AGENTS.md — working on Quirl
Canonical Quirl project documentation synced from AGENTS.md.
Quirl is a Rust workspace implementing a shell with typed data pipelines and an embedded, sandboxed Lua 5.4 extension runtime. This file captures the project-specific rules that generic Rust knowledge won't give you.
Engineering priorities: safety, performance, developer experience
In that order. This is Quirl's adaptation of TigerStyle, not a verbatim copy: rules written for a fixed-memory database must be translated to a general-purpose Rust shell.
- Safety means preserving user data, terminal state, child-process lifecycle, capability boundaries, and deterministic resource limits.
- Performance means predictable latency and memory use, especially for interactive paths, streams, untrusted input, and extension callbacks.
- Developer experience means precise names, small APIs, actionable errors, reproducible tests, and one obvious source of truth.
Prefer the design that advances all three. Feature gaps are acceptable; known correctness, security, resource-leak, or terminal-corruption defects in the changed path are not "future cleanup." Fix them or keep the feature out.
For changes involving concurrency, process lifecycle, persistence, protocols, or untrusted execution, write down the failure model and invariants before implementation. Ask what can go wrong at every boundary, including partial success and cleanup failure.
Bound everything that can grow or wait
Every operation driven by user, filesystem, process, or extension input needs an explicit limit or a demonstrated streaming bound:
- bytes retained and bytes scanned;
- collection, queue, recursion, and nesting depth;
- records, fields, files, processes, and pipeline stages;
- instructions, wall time, retries, and callback duration.
Enforce bounds where work enters the owning crate, fail early with a
ShellError carrying ErrorCode::ResourceLimit, and include the configured
limit plus observed usage in error context when safe. A long-running event loop
is allowed only when each turn has bounded work and cancellation is observable.
Avoid recursion on untrusted or attacker-controlled structure. Use an explicit stack with a depth limit. Recursion over a small compile-time-bounded domain is acceptable when the bound is obvious and tested.
Assertions, validation, and invariants
Distinguish programmer errors from expected operating errors:
- Invalid source, configuration, protocol data, I/O, resource exhaustion, and
extension behavior are operating errors. Validate them and return
ShellErrorat shell-effect boundaries; a core-independent foundation parser may instead return an inert domain diagnostic for its consumer to map. Never panic on user-controlled input. - Impossible internal states are programmer errors. Encode them with types
first, then use
assert!ordebug_assert!where an executable invariant materially improves review and testing. Do not use assertions as a substitute for handling a reachable error. - Assert or validate both the positive space and the negative boundary. If a writer guarantees a property, validate it again at the reader when crossing persistence, process, plugin, or protocol boundaries.
- Split unrelated compound assertions so failures identify the violated invariant precisely.
- Check compile-time relationships among protocol sizes, limits, and constants when Rust can express the check clearly.
Tests must exercise valid inputs, invalid inputs, and transitions from valid to invalid. Fault paths must prove cleanup as well as the returned error.
Explicit control flow and resource ownership
- Prefer direct, structured control flow and a small number of domain-shaped abstractions. Every abstraction must make ownership, bounds, or invariants easier to see.
- Keep variables in the smallest useful scope. Validate and transform values close to where they are consumed to reduce time-of-check/time-of-use gaps.
- Centralize branching and state transitions in the owning function; push bounded iteration and pure calculation into focused helpers. Leaf helpers should not mutate distant state.
- Aim for functions that fit on one screen, roughly 70 lines or fewer. Refactor longer functions unless keeping a state machine or transaction together is clearer and safer; document that reason when it is not obvious.
- Prefer positive conditions and exhaustive
matchexpressions. Break dense boolean expressions into named predicates or branches when a reviewer cannot readily enumerate the cases. - Acquire resources into RAII guards immediately. Partial initialization must unwind safely: close descriptors, reap children, restore terminal modes, remove temporary files, and preserve the original error.
- Signal handlers and asynchronous observers record state only. Run Lua, adapters, hooks, and user callbacks at explicit safe points, never while a process graph, job entry, terminal handoff, or persisted record is only partially committed.
Use fixed-width integers for persisted, serialized, protocol, process, and
resource-limit fields. Use usize for in-memory indexing and Rust collection
APIs; convert at a checked boundary rather than with unchecked as casts.
Names, comments, and API shape
- Choose precise nouns and verbs; avoid abbreviations unless they are standard domain terms. Do not overload one name with context-dependent meanings.
- Put units and qualifiers in names when the type alone is ambiguous, for
example
timeout_ms,output_bytes_max, orprocess_count. - Use parameter structs when multiple same-typed arguments can be swapped or when boolean/optional arguments would make call sites unclear.
- Pass correctness- and security-relevant options explicitly. Do not rely on a dependency's default timeout, capacity, permissions, standard library, or follow-symlink behavior.
- Comments explain why an invariant, ordering, or unusual mechanism exists. Tests with non-obvious setup also explain their goal and method. Comments are complete, maintained sentences rather than restatements of the code.
- Order files for a top-down first read: public contract and principal control flow before implementation details, with tests last.
Documentation is part of the interface
Documentation is a required correctness property, not optional follow-up work.
Every public Rust crate, module, type, field, variant, constant, trait, and
function must have //! or /// documentation that explains its contract.
Document invariants, units, resource bounds, side effects, errors, and security
assumptions where they matter; do not merely restate the identifier. The
workspace denies missing_docs, and cargo xtask check builds all Rustdoc with
warnings denied, so undocumented or broken public APIs cannot land.
Rustdoc describes the Rust API. User- and AI-facing command documentation has a
separate runtime source of truth: Catalog::builtin() supplies help,
completion, quirl describe, quirl doc, LSP, MCP catalog data, and
quirl agent; HOST_API supplies the Lua SDK and AI capability catalog. Keep
those records complete when behavior changes. Never copy command contracts into
a parallel documentation table or assume Rust /// comments are available via
runtime reflection. See docs/documentation-system.md.
Use rustfmt; do not hand-align code against the formatter. Workspace warnings
and Clippy lints are part of the design contract, not optional polish.
Performance and dependencies
For hot paths or potentially large inputs, make a rough resource sketch before implementation: expected and maximum bytes, allocations, syscalls, processes, and latency. Optimize architecture and asymptotic behavior first; use benchmarks to justify micro-optimizations and retain regression evidence for important claims.
Keep control-plane work such as configuration, catalog construction, and extension discovery out of per-keystroke and per-row data paths. Batch I/O and cross-thread communication when it improves bounds and latency without delaying cancellation or interactive feedback.
Dependencies are a supply-chain, compile-time, binary-size, and maintenance cost. Prefer the standard library and existing workspace dependencies. A new dependency must have a concrete owner, justify why a small local implementation is less safe or maintainable, disable unnecessary features, and preserve the crate layering. Do not add a dependency for trivial convenience.
Architecture: respect the layering
Dependency direction is strict and one-way, codified in ADR 0016. The ADR contains the complete allowed edge table; in summary:
quirl-catalog,quirl-core, andquirl-syntaxare foundation peers with no dependencies on other Quirl crates.- Contract, data, Lua, picker, plugin, process, LSP, and UI crates use only the
inward Quirl edges listed in ADR 0016. In particular,
quirl-pickermay depend on core, andquirl-processmay depend on core and syntax. quirl-cliis the sole product composition root and the only product crate permitted to assemble every layer.- Native Quirl process execution and reusable child-lifecycle containment
belong to
quirl-process; core retains passive capability and outcome values.
When adding functionality, put it in the lowest crate that can own it, and never invert an arrow to make something compile.
One operating error type at effectful boundaries
Fallible cross-crate service and shell-effect paths return
Result<T, quirl_core::ShellError>.
ShellError carries an ErrorCode, message, labels, context, and help, and
derives Serialize so --format json works for free.
Core-independent foundation parsers may return owned, inert diagnostics to
avoid inverting the crate graph. Execution, I/O, and persistence consumers map
those diagnostics to ShellError while preserving message, span, help, and
source identity. Read-only presentation adapters may map them directly to their
protocol's diagnostic value. Do not use this exception for operating failures.
- Do not introduce
anyhow, new error enums, orthiserrorderives. Errors are hand-built for serialization control. - Map new failure domains onto an existing
ErrorCode, or extend the enum incrates/quirl-core/src/error.rsif genuinely new. - Every error must render well both as JSON and through
quirl_ui::render_error. Write thehelptext; a diagnostic without a suggested fix is half done. clippy::unwrap_usedandclippy::expect_usedare denied by workspace lints (tests are exempt viaclippy.toml). Rare true invariants such as mutex poisoning may carry a targeted#[allow]with a reason.
The Lua boundary is a security boundary
All Lua embedding lives in quirl-lua. Rules that must hold:
- Every VM runs under a
LuaPolicy(memory limit, instruction budget, wall deadline, cancellation). Never create an unrestrictedmlua::Lua. - The stdlib is restricted (
TABLE|STRING|MATH|UTF8);io,os,debug,require, andpackagestay removed. Do not re-expose them. - Values crossing Lua → Rust are deserialized into typed structs with
#[serde(deny_unknown_fields)]and then validated. Never consume rawmlua::Valuein higher crates; convert at the boundary. - A misbehaving script must fail with a
ShellError(Lua,Validation, orResourceLimit) — it must never panic the host or hang the session.
Generated artifacts: edit the source, not the output
- The Lua SDK (LuaLS stubs, JSON schema, Markdown docs) is generated from the
single
HOST_APItable inquirl-lua. To change the host API, editHOST_API, then runcargo xtask sdkto regeneratedocs/quirl.lua(a test asserts the checked-in file matchessdk_lua()exactly). Never hand-editdocs/quirl.lua. - Command metadata (help, completions, docs, AI export) comes from
Catalog::builtin()inquirl-catalog. New commands and flags are added there once — never hardcode help strings or completion lists elsewhere. - Rust API documentation is generated directly from
//!and///comments bycargo xtask docs; never hand-edit files undertarget/doc.
Workspace hygiene
- Toolchain is pinned to Rust 1.88 via
rust-toolchain.toml; don't use features from newer compilers. spikes/directories are intentionally separate Cargo workspaces so that mutually exclusive engine features (Luau, QuickJS, …) never unify with the shell's Lua 5.4 build. Never add spikes as workspace members or import their dependencies intocrates/.quirl-benchis research tooling (publish = false), not product code.- Unsafe Rust is prohibited in
crates/except for the privatecfg(windows)Job Object FFI wrapper inquirl-processsanctioned by ADR 0016. Every unsafe block there requires a local safety explanation; expanding that audited boundary requires a new ADR and security review. - No feature flags on the main crates; keep it that way unless an ADR says otherwise.
Testing
- Tests live in-crate as
#[cfg(test)] mod testsin the same file — no separatetests/directories. Follow that pattern. - Name tests as behavior sentences in snake_case, e.g.
instruction_budget_stops_runaway_code. cargo xtask testruns workspace tests, bounded seeded C1 differential cases against available Bash/Zsh references, and guest-side Lua tests. Replay a generated failure with its reported--seedand--cases; never introduce a wall-clock seed inside a test.- Sandbox changes need adversarial tests: prove the budget, limit, or restriction actually trips.
- Follow
docs/testing-strategy.mdfor contract, fault, PTY, guest-runtime, and release evidence.
Process
cargo xtask checkis the canonical local quality gate and must pass before every commit. It includes the workspace Rustdoc build and missing-public-docs enforcement. The only CI workflow is the bounded daily Bash/Zsh simulation swarm; other local Cargo tasks deliberately remain local while project traffic is low.- Conventional commits (
feat,fix,docs,refactor,chore,bench), present tense, optionally scoped, e.g.feat(lua): add completion budgets. - Significant design choices go through an ADR in
docs/decisions/. ADR 0001 is the standing contract: Lua is the only extension language, Rust validates everything at the boundary. ADR 0016 fixes the crate dependency and runtime-ownership graph.docs/language-design.mdis the product specification; its §13 delivery sequence and acceptance gates define what each phase must prove.
xtask command conventions
- Use the stable workspace
xshelldependency,Shell, andcmd!for linear development-task orchestration. Set the workspace directory once on theShell, interpolate arguments instead of formatting a command string, and letxshellreject nonzero statuses. - Keep
std::process::Commandfor subprocess boundaries that require exact byte capture, custom stdin/stdout ownership, polling, deadlines, process-group containment, cancellation, or deliberate nonzero-status inspection. The compatibility simulator is the reference for this case. - Do not invoke a platform shell merely to join commands or expand arguments.
Express sequencing in Rust and pass every dynamic argument through
cmd!interpolation so it cannot become shell syntax. - Keep filesystem transactions in Rust.
xshellimproves command orchestration; it does not replace atomic writes, explicit bounds, RAII cleanup, or structured error context. - Quirl is a prototype moving fast: prefer extending the existing patterns
(
ShellError,HOST_API,Catalog::builtin,LuaPolicy) over inventing parallel mechanisms. Breaking changes are fine; drift is not.