Development
This page captures the working rules for changing Graft. It is mainly for contributors and release preparation.
Core workflow
For every issue:
- read the issue and related docs or code
- confirm scope before editing
- use one branch per issue
- keep changes focused
- update tests and docs with the code
- run the relevant checks
- note follow-up work in the issue or pull request
Keep implementation decisions aligned with the main architecture:
TOML → CLI → JSON stdout → NixOS/Home Manager modules → Quadlet .container
The CLI owns defaults, validation, dependency resolution, and semantic decisions. The Nix modules should stay dumb materialisers.
Quadlet renderer checklist
Apply this checklist whenever adding or changing a rendered Quadlet field.
Before implementation, decide and document:
- whether the field is already in the TOML schema
- the resolved JSON shape
- omitted behaviour
- empty list or map behaviour, if applicable
- invalid empty or whitespace-only values
- control-character handling
- ordering: sorted output or user order
- literal passthrough versus parser/policy
- whether NixOS and Home Manager must render the same output
- whether runtime verification is needed after merge
During implementation, update:
- Rust resolver tests
- NixOS module rendering
- Home Manager module rendering
- Nix module-eval assertions in
flake.nix docs/reference.mdexamples/reference.toml- other manual pages when the user-facing behaviour changes
Ordering policy
Choose ordering deliberately.
Map-like values with unstable source order should be sorted for deterministic output. Current example:
config.container.environment→ sorted by key
List-like or precedence-sensitive values should preserve user order. Current examples:
config.container.environmentFileconfig.network.publishconfig.filesystem.volumes
Document the choice in docs/reference.md.
Literal passthrough policy
Some upstream syntaxes are broad and already validated by Podman, Quadlet, or systemd. Do not accidentally replace those syntaxes with a narrow parser.
For broad syntaxes, prefer line-safe passthrough:
- reject empty or whitespace-only values when the field is present
- reject control characters
- render mechanically with field-appropriate renderer escaping
- do not add a parser, allowlist, or policy without a dedicated issue
Current examples:
PublishPort=valuesVolume=strings assembled from TOML parts- systemd service timing values such as
RestartSec=
If an implementation repeatedly says “out of scope”, “not yet”, or “no parser yet”, update Non-goals and deferred scope or link a tracking issue.
Matrix tests
Fields with valid combinations need matrix tests. Cover both valid combinations and impossible combinations.
Examples:
- volume rendering:
target,source:target,source:target:mode - invalid volume rendering:
modewithoutsource - service rendering: restart-only, timing-only, restart plus timing
- container identity: user-only, invalid group-only, user plus group, neither
Prefer small resolver tests for semantic combinations and module-eval assertions for generated Quadlet text.
Documentation parity
Resolver rules must be mirrored in user-facing docs.
If the resolver rejects or requires something, update:
- Rust tests
docs/reference.mdexamples/reference.toml
If the behaviour affects generated Quadlet output, also update
docs/quadlet.md. If the behaviour changes the visible project scope, update
README.md, docs/index.md, docs/overview.md, or docs/roadmap.md as
appropriate.
Runtime verification
Local unit/module tests prove evaluation and rendering. Runtime-sensitive features should also be validated after merge through a real NixOS → Quadlet → Podman path.
Run privileged runtime checks on a local test machine where the operator can approve privilege escalation directly. Do not require maintainer-specific sessions, sockets, or other local-only workflow details in the public manual.
Runtime test output should check the generated unit text and the Podman runtime state where possible.
Dead-code and module-boundary hygiene
The baseline already has several hard gates for unused or dead code:
- Rust warnings, including compiler dead-code warnings, via clippy with
-D warnings -D clippy::pedantic - unused Rust dependencies via
cargo machete - orphaned Rust source files via
cargo modules orphansfor the library and standalonegraft-pausebinary - unused Nix code via
deadnix --fail - missing Rust test coverage via
cargo llvm-cov --fail-under-lines 80
Do not add unstable or noisy hygiene gates without a focused design issue.
cargo-udeps currently requires nightly-only rustc flags in this environment,
so track it as an advisory/local-only candidate in #96 and the later local
quality workflow in #23. Treat tokei/scc output as refactor signals, not CI
thresholds; resolve.rs splitting is tracked in #97. Public API usage remains a
manual visibility review until a low-noise tool path is proven; track that in
issue #98.
Standard checks
For Rust changes:
nix develop .#ci -c bash -lc 'cd crates/graft && cargo fmt --check && mkdir -p target/nextest && cargo nextest run --profile ci && cargo test --doc'
nix develop .#ci -c bash -lc 'cd crates/graft && cargo clippy --all-targets -- -D warnings -D clippy::pedantic'
nix develop .#ci -c bash -lc 'cd crates/graft && cargo machete'
nix develop .#ci -c bash -lc 'cd crates/graft && NO_COLOR=1 cargo modules orphans --lib && NO_COLOR=1 cargo modules orphans --bin graft-pause'
The nextest run writes JUnit test results to
crates/graft/target/nextest/ci/junit.xml for Codecov uploads.
Generate Rust coverage locally and enforce the 80% line threshold:
nix develop .#ci -c bash -lc '
set -euo pipefail
cd crates/graft
mkdir -p target/coverage
export LLVM_COV="$(command -v llvm-cov)"
export LLVM_PROFDATA="$(command -v llvm-profdata)"
cargo llvm-cov --workspace --all-features --fail-under-lines 80 --lcov --output-path target/coverage/lcov.info
'
For dependency security and policy checks:
nix develop .#ci -c bash -lc 'cd crates/graft && cargo-audit audit'
nix develop .#ci -c cargo deny --manifest-path crates/graft/Cargo.toml check --config deny.toml
For secret scanning, copy tracked files to a temporary directory so ignored local files stay out of scope:
nix develop .#ci -c bash -lc '
set -euo pipefail
scan_root=$(mktemp -d)
cleanup() {
rm -rf "${scan_root}"
}
trap cleanup EXIT
git ls-files -z | tar --null --files-from=- -cf - | tar -xf - -C "${scan_root}"
gitleaks dir --no-banner --no-color --redact "${scan_root}"
'
For workflow changes:
nix develop .#ci -c actionlint
nix develop .#ci -c zizmor --no-progress --color never --min-confidence high .github/workflows/*.yml .github/actions/setup-nix/action.yml
For Nix/module/docs changes:
nix develop .#ci -c bash -lc 'git ls-files "*.nix" -z | xargs -0 nixfmt --check'
nix develop .#ci -c bash -lc 'git ls-files "*.toml" -z | xargs -0 taplo format --check'
nix develop .#ci -c bash -lc 'git ls-files "*.toml" -z | xargs -0 taplo lint --no-schema'
nix develop .#ci -c bash -lc 'git ls-files "*.md" -z | xargs -0 markdownlint-cli2 --config .markdownlint.jsonc'
nix develop .#ci -c statix check .
nix develop .#ci -c deadnix --fail .
nix build \
.#checks.x86_64-linux.nixos-module-eval \
.#checks.x86_64-linux.home-manager-module-eval \
--print-out-paths
nix flake check
nix develop .#ci -c mdbook build
git diff --check
The module-eval checks use IFD, so build them explicitly. nix flake check
may omit them and must not be the only Nix module gate.