diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 091570a..cb25a49 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -33,10 +33,9 @@ reviews: - path: "**/*.md" instructions: >- Focus on the contents of the docs and not on cosmetic things like markdown formatting. We - use markdown files for various docs including but not limited to the north star system - specification, SPEC.md, the plan for how to get there, ROADMAP.md, guidelines for AI - contributors, AGENTS.md, project overview and instructions for human contributors, - README.md. Think about the target audience of a document when deciding what comment to + use markdown files for various docs including specifications, roadmaps, guidelines for AI + contributors (AGENTS.md), and the project overview for human contributors (README.md). + Think about the target audience of a document when deciding what comment to leave. For specifications and designs, suggest potential product, architectural, and UI/UX improvements. For plans, suggest changes that would make things more parallelizable and deliverable-focused. For instructions, suggest better rules and guidelines and point out diff --git a/.envrc b/.envrc index 8a458d0..becf1fd 100644 --- a/.envrc +++ b/.envrc @@ -1,12 +1,11 @@ -if ! has nix_direnv_version || ! nix_direnv_version 3.0.4; then - source_url "https://raw.githubusercontent.com/nix-community/nix-direnv/3.0.4/direnvrc" "sha256-DzlYZ33mWF/Gs8DDeyjr8mnVmQGx7ASYqA5WlxwvBG4=" +if ! has nix_direnv_version || ! nix_direnv_version 3.0.5; then + URL=https://raw.githubusercontent.com/nix-community/nix-direnv/3.0.5/direnvrc + HASH=sha256-RuwIS+QKFj/T9M2TFXScjBsLR6V3A17YVoEW/Q6AZ1w= + source_url "$URL" "$HASH" fi -watch_file .env +watch_file *.nix +watch_file nix/*.nix watch_file flake.lock -watch_file flake.nix -if ! use flake . --impure --show-trace -then - echo "devenv could not be built. The devenv environment was not loaded. Make the necessary changes to devenv.nix and hit enter to try again." >&2 -fi +use flake . --impure --show-trace --accept-flake-config diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..20e6be6 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,10 @@ +## Motivation + + + +## Solution + + diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index fa8fc38..1e6da5b 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1,41 +1,115 @@ -name: "CI" +name: CI on: - workflow_dispatch: pull_request: - push: - branches: - - master + merge_group: + workflow_dispatch: + +# A new push to the same ref supersedes any run still in flight; master runs +# are never cancelled so the cache-warming pushes always complete. +concurrency: + group: ${{ github.repository }}-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/master' }} + +permissions: + contents: read + +# Nix is installed via nix-quick-install-action; the shared `data-cartel-public` +# Cachix (cachix-action) and the per-runner nix store (cache-nix-action) are +# warmed before any `nix` build, and built paths are pushed back to the Cachix. +# This replaces the DeterminateSystems installer + magic-nix-cache, whose hosted +# cache throttled CI with HTTP 418 responses. cachix-action is continue-on-error +# so a fork PR (no CACHIX_AUTH_TOKEN) skips the cache and still builds against +# cache.nixos.org without failing the job. jobs: - checks: - permissions: - checks: write - contents: read - id-token: write - packages: write - pull-requests: write - repository-projects: write - - runs-on: ${{ matrix.os }} - strategy: - matrix: - os: [ubuntu-latest] + hooks: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: nixbuild/nix-quick-install-action@2c9db80fb984ceb1bcaa77cdda3fdf8cfba92035 # v34 + with: + nix_version: "2.31.2" + nix_conf: | + accept-flake-config = true + fallback = true + access-tokens = github.com=${{ secrets.GITHUB_TOKEN }} + keep-env-derivations = true + keep-outputs = true + + - uses: cachix/cachix-action@ad2ddac53f961de1989924296a1f236fcfbaa4fc # v15 + continue-on-error: true + with: + name: data-cartel-public + authToken: ${{ secrets.CACHIX_AUTH_TOKEN }} + + - uses: nix-community/cache-nix-action@7df957e333c1e5da7721f60227dbba6d06080569 # v7 + with: + primary-key: nix-${{ runner.os }}-hooks-${{ hashFiles('**/*.nix', '**/flake.lock') }} + restore-prefixes-first-match: nix-${{ runner.os }}-hooks- + gc-max-store-size-linux: 5G + + - name: Pre-commit hooks + # Pure flake check -- runs the same hooks without the heavy devShell + run: nix build -L --log-format raw .#checks.x86_64-linux.git-hooks + + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: nixbuild/nix-quick-install-action@2c9db80fb984ceb1bcaa77cdda3fdf8cfba92035 # v34 + with: + nix_version: "2.31.2" + nix_conf: | + accept-flake-config = true + fallback = true + access-tokens = github.com=${{ secrets.GITHUB_TOKEN }} + keep-env-derivations = true + keep-outputs = true + + - uses: cachix/cachix-action@ad2ddac53f961de1989924296a1f236fcfbaa4fc # v15 + continue-on-error: true + with: + name: data-cartel-public + authToken: ${{ secrets.CACHIX_AUTH_TOKEN }} + + - uses: nix-community/cache-nix-action@7df957e333c1e5da7721f60227dbba6d06080569 # v7 + with: + primary-key: nix-${{ runner.os }}-rust-${{ hashFiles('**/*.nix', '**/flake.lock') }} + restore-prefixes-first-match: nix-${{ runner.os }}-rust- + gc-max-store-size-linux: 5G + + - name: Test + run: nix build -L --log-format raw .#checks.x86_64-linux.cargo-test + clippy: + runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 - - uses: DeterminateSystems/nix-installer-action@main - - uses: DeterminateSystems/magic-nix-cache-action@main - - uses: DeterminateSystems/flake-checker-action@main + - uses: actions/checkout@v4 - - name: Run Flake checks - run: nix -v flake check --show-trace --impure --accept-flake-config + - uses: nixbuild/nix-quick-install-action@2c9db80fb984ceb1bcaa77cdda3fdf8cfba92035 # v34 + with: + nix_version: "2.31.2" + nix_conf: | + accept-flake-config = true + fallback = true + access-tokens = github.com=${{ secrets.GITHUB_TOKEN }} + keep-env-derivations = true + keep-outputs = true - - name: cargo build - run: nix develop --impure --accept-flake-config -c cargo build --all-targets --locked + - uses: cachix/cachix-action@ad2ddac53f961de1989924296a1f236fcfbaa4fc # v15 + continue-on-error: true + with: + name: data-cartel-public + authToken: ${{ secrets.CACHIX_AUTH_TOKEN }} - - name: cargo test - run: nix develop --impure --accept-flake-config -c cargo test --all-targets --locked + - uses: nix-community/cache-nix-action@7df957e333c1e5da7721f60227dbba6d06080569 # v7 + with: + primary-key: nix-${{ runner.os }}-rust-${{ hashFiles('**/*.nix', '**/flake.lock') }} + restore-prefixes-first-match: nix-${{ runner.os }}-rust- + gc-max-store-size-linux: 5G - - name: cargo clippy - run: nix develop --impure --accept-flake-config -c cargo clippy --all-targets --locked -- -D warnings + - name: Clippy + run: nix build -L --log-format raw .#checks.x86_64-linux.cargo-clippy diff --git a/.github/workflows/templates.yaml b/.github/workflows/templates.yaml index 3ff49cb..3a4f0ad 100644 --- a/.github/workflows/templates.yaml +++ b/.github/workflows/templates.yaml @@ -7,23 +7,41 @@ on: branches: - master +concurrency: + group: ${{ github.repository }}-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/master' }} + jobs: templates: permissions: - checks: write contents: read - id-token: write - runs-on: ${{ matrix.os }} - strategy: - matrix: - os: [ubuntu-latest] + runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 - - uses: DeterminateSystems/nix-installer-action@main - - uses: DeterminateSystems/magic-nix-cache-action@main - - uses: DeterminateSystems/flake-checker-action@main + - uses: actions/checkout@v4 + + - uses: nixbuild/nix-quick-install-action@2c9db80fb984ceb1bcaa77cdda3fdf8cfba92035 # v34 + with: + nix_version: "2.31.2" + nix_conf: | + accept-flake-config = true + fallback = true + access-tokens = github.com=${{ secrets.GITHUB_TOKEN }} + keep-env-derivations = true + keep-outputs = true + + - uses: cachix/cachix-action@ad2ddac53f961de1989924296a1f236fcfbaa4fc # v15 + continue-on-error: true + with: + name: data-cartel-public + authToken: ${{ secrets.CACHIX_AUTH_TOKEN }} + + - uses: nix-community/cache-nix-action@7df957e333c1e5da7721f60227dbba6d06080569 # v7 + with: + primary-key: nix-${{ runner.os }}-templates-${{ hashFiles('**/*.nix', '**/flake.lock') }} + restore-prefixes-first-match: nix-${{ runner.os }}-templates- + gc-max-store-size-linux: 5G - name: Test rust template run: | @@ -32,7 +50,11 @@ jobs: nix flake init -t "${GITHUB_WORKSPACE}#rust" --accept-flake-config test -f Cargo.toml test -f flake.nix + test -f rust.nix test -f src/main.rs + test -f AGENTS.md + test -f clippy.toml + test -L CLAUDE.md nix develop --impure --accept-flake-config -c rustc --version - name: Test ci template diff --git a/.gitignore b/.gitignore index d0b8dc2..c02a30b 100644 --- a/.gitignore +++ b/.gitignore @@ -2,10 +2,22 @@ .devenv .direnv .pre-commit-config.yaml +result +result-* # Rust /target +# Generated agent skills (installed as symlinks on dev shell entry) +/.claude/skills/gitbutler +/.cursor/skills/gitbutler + # Misc .env *.local +*~ +.DS_Store +*.log +.worktrees/ +.tmp/ +.claude/skills/gitbutler diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..67c072f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,459 @@ +# AGENTS.md + +Rules and guidelines for AI agents working in this repository. Everything in +this document is a directive, not a suggestion. A new project started from this +template should replace the Project Direction section below with its own; the +rest of this file is the shared engineering standard and stays as written. + +--- + +## Project Direction + +> **TEMPLATE PLACEHOLDER -- replace this whole block.** +> +> State what this project is and what it is for, in two or three sentences. +> Then list the key architectural invariants an agent must never weaken -- +> the decisions that, if violated, make the change wrong regardless of whether +> it compiles and passes tests. Examples of the kind of thing that belongs +> here: what the source of truth is, which boundaries are one-way, which +> numeric representations are allowed, what is never allowed to reach a +> privileged subsystem. +> +> If the project keeps a spec, a roadmap, or a workflow doc, link them here +> and say which one is authoritative for behavior. Delete this blockquote once +> the section describes the real project. + +### Agent expectations + +- Follow the TTDD workflow: types first, failing test, implementation, refine. +- Honor the rules in this document for code style, testing, and quality gates. +- Edit code, tests, and configs in this repo. Humans own deploys, secrets, and + external systems outside git. +- Never relax quality checks (clippy, tests, lints) without explicit + permission. Ask if a check seems wrong; don't suppress. +- Don't substitute approaches, libraries, or tools without checking in. Scope + is whatever was asked, not whatever you'd prefer. +- When an issue is pointed out, fix it immediately. The user never sends + messages just for the sake of it. + +--- + +## Development Commands + +```bash +cargo check # Fast compilation verification +cargo nextest run # Run tests +cargo clippy # Linting +cargo fmt # Format code +``` + +While developing, run `cargo check` and `cargo nextest run` continuously to +verify types and behavior. Only once the implementation is complete run +`cargo clippy` and fix every warning, then `cargo fmt` before committing. + +**CRITICAL: Never use `cargo build` for verification.** Use `cargo check` +(faster) or `cargo nextest run` (more useful). Only use `cargo build` when you +genuinely need the binary. + +**Dependencies**: Always use `cargo add ` -- never manually edit +`Cargo.toml` versions. LLMs hallucinate version numbers. + +**Migrations** (projects that use sqlx): never hand-write migration files. Add +`sqlx-cli` to the dev shell and go through it (`sqlx migrate add `, +`sqlx migrate run`). + +### Environment + +- **Nix + direnv**: `direnv allow` activates the dev environment. +- All dependencies come from the Nix flake -- no `cargo install`, no + `brew install`, no ad-hoc installs outside the flake. If a tool is missing, + add it to the flake. + +### Version control + +Stacked PRs are the unit of review: one branch per PR, each based on its +predecessor, each small enough to review in one sitting. + +Write operations go through the GitButler CLI (`but`), provided in the dev +shell by the `but.nix` flake input -- not `git add`, `git commit`, `git push`, +`git checkout`, or `git rebase`. Read-only git inspection (`git status`, +`git log`, `git diff`) is fine. The gitbutler agent skill is installed into +`.claude/skills/gitbutler` and `.cursor/skills/gitbutler` on shell entry; read +it for the command reference and workflow. + +GitButler writes a stack-navigation footer into each PR body, but it drifts: +after a rebase, a branch add or remove, or a merge it goes stale or is missing +on PRs that were not opened through GitButler. Refresh every stacked PR's +footer with `nix run .#pr-stack-footer` after any operation that reshapes a +stack. + +--- + +## Workflow & Policies + +### TTDD (type-driven TDD) + +1. **Types first**: define the types, traits, and method signatures that model + the domain. Stub bodies with `todo!()`. +2. **Failing tests**: write tests that **compile** and fail. A build error is + not a failing test. +3. **Implementation**: write the logic that makes the tests pass. +4. **Refine**: broaden coverage, tighten types. + +Steps 1 and 2 interleave -- signatures often change to make a test compile. The +hard constraint: no behavioral logic before a compiling, failing test. + +For bug fixes, same shape: a test that compiles and fails by asserting the +correct behavior, then the fix. + +### PR titles and descriptions + +**Titles**: lowercase, imperative, concise. Describe the outcome, not the +mechanism. No prefixes like `feat:` or `fix:`. + +- Good: `reject orders that exceed the position limit` +- Good: `replace stringly-typed ids with newtypes` +- Bad: `Add validation helper for order sizing` +- Bad: `Refactor id handling to use newtype wrappers` + +**Descriptions**: follow `.github/PULL_REQUEST_TEMPLATE.md` -- two sections. + +- `## Motivation`: why the change is needed. The problem and the desired end + state, not the diff. Link the issue or ADR it advances. +- `## Solution`: how the PR solves it -- approach and key decisions, one line + per bullet. Note stack relationships, trade-offs, and follow-ups. + +**No self-promotion, ever.** No "Generated with " footers, no AI +attribution, no co-author trailers -- not in commits, PRs, or code. + +### Quality checks + +**NEVER disable or relax a quality check without explicit permission.** That +includes clippy allows (`#[allow(clippy::*)]`), compiler-warning allows +(`#[allow(dead_code)]`, `#[allow(unused)]`), lint config loosening, and test +coverage regressions. Fix the underlying code instead of suppressing. + +If fixing the code is impossible or clearly worse than suppressing (a genuine +false positive, or a lint that conflicts with project policy), STOP and ask. +Don't burn time on a convoluted workaround. When permission is granted, add a +comment explaining why the allow is necessary. + +### Documentation stays in lockstep with the code + +Every PR must leave the documentation true. Before handing off work, audit what +your change touched and update anything that went stale: `README.md`, this +file, any spec or roadmap the project keeps, subtree `AGENTS.md` files, and the +doc comments on the code you edited. + +If a doc has drifted and it is not in your change's path, fix it in the same PR +(preferred) or open a follow-up issue immediately. Stale documentation is a +bug: don't ship work that introduces it, don't ignore it when you see it. + +### When stuck + +If a fix doesn't work after three attempts, stop guessing and read the official +documentation for the thing you're fighting. + +--- + +## Code Style + +### Functional programming + +Prefer declarative, expression-oriented code: `map`, `filter`, `fold`, +`collect` over imperative loops; pure functions over side effects; +immutability by default; method chaining over intermediate variables. + +The smell to avoid: `let mut items = Vec::new(); for x in xs { items.push(..) }` +where `.map(..).collect()` would do. `mut` is fine in idiomatic contexts -- +`.scan()`, `.try_fold()`, builder patterns. + +### No boolean blindness + +Raw booleans obscure meaning at call sites. Prefer a discriminated union +(`enum ModalState { Open, Closed }`) or a named function (`open_modal()`) over +`set_is_open(true)`. + +### ASCII for code, Unicode for users + +The split is by **audience**, not by file type. + +**ASCII** (the default): code, comments, identifiers, type names, log +messages, commit subjects, PR titles, documentation prose, config files, and +developer-console output. Use `*` not a multiplication sign, `->` not an arrow, +`~` not an approximation sign, `--` not an em-dash, `beta` not a Greek letter. + +**Unicode** only where the audience is a user: UI text rendered in the app, CLI +output presented to a user, product-facing error messages, accessibility +strings, and UI strings quoted verbatim inside documentation. Inside those +quotes write the exact character the user sees; the prose around the quote +stays ASCII. + +A bulk find/replace of Unicode punctuation across the repo is not a safe +refactor -- it mangles user-facing strings and the docs that quote them. + +### Self-documenting code + +Documentation comments (docstrings, API docs) are good. Implementation comments +are a last resort -- refactor until the code is clear instead. + +### Descriptive names + +- Name what a thing IS. Avoid `result`, `data`, `value`, `item`. +- No single-letter variable names anywhere -- not in closures, locals, + parameters, or destructuring. `|r|` is unreadable; write `|rate|`. +- No abbreviations unless universally understood (`id`, `url`, `http`, `msg`, + `tx`). This includes import aliases: alias to the full name, not to initials. + +### Colocate types + +Keep types with the code that uses them, not in a separate file. + +### No hidden defaults + +Never add a default value (`#[serde(default)]`, `unwrap_or`, `Default` impls +for config) without being explicitly asked. Required configuration must fail +loudly when missing, not silently run on a value the user never chose. Ship an +example config file so new setups have a starting point. + +### Logging + +Use levels semantically: + +- **ERROR**: something failed and needs attention. +- **WARN**: something unexpected happened but the system recovered (retries + exhausted, fallback used). +- **INFO**: high-level lifecycle events only (service ready, graceful + shutdown). One or two lines per startup, not per component. +- **DEBUG**: operational detail useful for troubleshooting (component + initialized, request handled, config applied). +- **TRACE**: fine-grained execution flow for deep debugging. + +Message quality: + +- Log completion, not initiation: `"http server ready"`, not + `"starting http server"`. Never log both at the same level. +- Messages are grep-friendly and unique. No `"error occurred"`, + `"operation failed"`, `debug!("here")`, or `info!("initialized")` -- a log + line must be clear without reading the module path it came from. +- Context goes in structured fields, not interpolated strings: + `info!(port = config.port, "server ready")`, never + `info!("server ready on port {}", config.port)`. +- Never log credentials, tokens, or personal data. + +### Scripts + +Any script big enough to live in its own file MUST NOT be bash -- use nushell +(`.nu`, shebang `#!/usr/bin/env nu`). Bash is acceptable only for short inline +blocks (CI workflow `run:` steps, Makefile recipes). Nushell has structured +data, real error handling, and predictable quoting; bash files accumulate +footguns that nushell avoids by construction. + +--- + +## Rust Code Style + +### Package by feature, not by layer + +Organize by business domain, not by technical layer or language primitive. + +**FORBIDDEN file names**: `types.rs`, `error.rs`, `errors.rs`, `models.rs`, +`utils.rs`, `helpers.rs`, `impl.rs`, `traits.rs`, `structs.rs`, `enums.rs`, +`config.rs`, `constants.rs`, `common.rs`, `shared.rs`, `core.rs`. + +Each feature module holds all its related code: types, errors, logic. While the +project is small keep everything in `main.rs` or `lib.rs`; split only when +there are real domain boundaries. + +### Type modeling + +**Make invalid states unrepresentable.** This is non-negotiable. + +Never use `String` where the domain has a finite set of valid values: + +```rust +// FORBIDDEN: accepts "info", "debug", and also "banana" +struct Config { log_level: String } + +// CORRECT +enum LogLevel { Trace, Debug, Info, Warn, Error } +struct Config { log_level: LogLevel } +``` + +Use enums instead of fields that can contradict each other: + +```rust +// Bad +struct Order { status: String, order_id: Option, error: Option } + +// Good: each state carries exactly the data it needs +enum OrderStatus { + Pending, + Completed { order_id: OrderId }, + Failed { reason: FailureReason }, +} +``` + +**Parse, don't validate.** If a value exists, it is valid -- validation happens +once, at construction, through a smart constructor with a private inner field. +Parse external input (config, API responses) into domain types at the boundary; +never pass raw strings through the system. + +**Persistent IDs are newtypes.** Never a raw `String` or `&str` for an +identifier that is persisted or crosses an async boundary -- the type system +should make `load_portfolio(ingestion_id)` a compile error. + +Use typestate when a protocol must be enforced: encode "this has passed check +X" as a witness in the type, so the unchecked value cannot reach the operation +that requires the check. + +### Compile-time literals, never parsed + +Hardcoded values of parseable types use their compile-time macros rather than +runtime parsing -- for example `address!` / `b256!` / `fixed_bytes!` for alloy +byte types, `dec!` for `Decimal`. Runtime parsing (`FromStr`, `.parse()`) is +reserved for genuinely dynamic input at system boundaries. + +### Avoid deep nesting + +Keep function bodies, module structure, and tests flat. + +```rust +fn validate(data: Option<&Data>) -> Result<(), Error> { + let Some(data) = data else { return Err(Error::NoData) }; + if data.quantity <= 0 { return Err(Error::InvalidQuantity); } + Ok(()) +} +``` + +Early returns and `let-else` over nested `if let`. No modules inside modules. +No nested modules inside `mod tests` -- use descriptive test function names +instead. Nesting inside type definitions is the exception: an enum with struct +variants beats flattening into mutually exclusive optional fields. + +### Error handling + +- Use `?` and proper error types (thiserror). +- Never a `SomeError(String)` variant that throws type information away. +- Use `#[from]` to preserve error chains. Don't design error variants up front + -- write `?` where it belongs and let `cargo check` tell you which `#[from]` + variants you need. + +**`#[from]` variant names mirror the source error type, not the operation.** +`?` auto-converts every matching error, so an operation-specific name becomes a +lie the moment a second operation produces the same error type. + +- FORBIDDEN: `ReadConfig(#[from] std::io::Error)` +- CORRECT: `Io(#[from] std::io::Error)` +- FORBIDDEN: `ParseConfig(#[from] toml::de::Error)` +- CORRECT: `Toml(#[from] toml::de::Error)` + +**Never fabricate another crate's error.** Constructing +`std::io::Error::new(...)` to signal your own condition lies about the error's +origin and misleads whoever debugs it. Define your own variant. + +### Defensive programming + +Treat persisted state, external responses, configuration, arithmetic, and +cross-module inputs as capable of violating your assumptions. Enforce +invariants in types where possible, and at the narrowest boundary otherwise. + +An invariant violation returns a specific typed error. It never panics, never +silently coerces the value, never invents a fallback, and never continues with +partially trusted state. Every such check gets a regression test covering the +malformed or impossible shape alongside the valid path. + +Small custom macros are welcome when they remove genuinely mechanical +boilerplate and make the invariant easier to read at every call site. Keep the +domain operation, control flow, types, and error path visible; if understanding +the macro means reconstructing hidden behavior, write the explicit code. + +### Zero tolerance for panics in non-test code + +FORBIDDEN in production code: `unwrap()`, `expect()`, `panic!()`, +`unreachable!()`, `unimplemented!()`, indexing that can panic (`vec[i]` -- use +`.get(i)`), and unchecked arithmetic where overflow is possible. Enforce this +with workspace clippy lints. + +All of the above are fine inside `#[cfg(test)]`. + +`todo!()` is encouraged during the types-first stage of TTDD and must be gone +before the work is complete. Any `todo!()` in finished code is unacceptable. + +### Module organization + +Public API first, private helpers below -- consumers read the interface, not +the implementation, and diffs surface the important changes first. + +Use the most restrictive visibility that works: private over `pub(super)` over +`pub(crate)` over `pub`. Restrictive visibility lets the compiler find dead +code; `pub` blinds it. + +### Import organization + +Two groups, blank line between: external (`std`, `tokio`, `serde`), then +internal (`crate::`, `super::`). No function-level imports, except +enum-variant imports inside a function body. + +**No aliases to dodge name conflicts** -- use qualified paths, so meaning is +clear at the usage site instead of requiring a jump to the `use` block. + +**Tracing macros are unqualified.** `use tracing::error;` then +`error!(error = %err, "request failed")`, not `tracing::error!(...)`. + +--- + +## Testing + +### Testing pyramid + +More tests at the lower levels, fewer at the higher: + +1. **Property tests** (proptest) -- most numerous, for invariants. +2. **Unit tests** -- exhaustive edge cases, fast feedback. +3. **Integration tests** -- components working together, externals mocked. +4. **E2E tests** -- fewest, but required for full-system orchestration. + +The pyramid is about quantity, not avoidance. MANY property and unit tests, +SOME integration tests, a FEW e2e tests -- but e2e tests are mandatory for +verifying that async processes coordinate, that startup/shutdown and recovery +behave, and that flows spanning several components hold together. + +### E2E tests: strict definition + +E2E tests live in `tests/`, never in `src/`. A test is only e2e if it: + +1. Spins up the full service. +2. Uses ONLY the public API, as an external consumer would. +3. Mocks only truly external systems. +4. Asserts correctness through public responses. + +A test that reaches for internal types -- for setup or for verification -- is +not e2e. It belongs in `src/` as a unit or integration test. + +### Testing guidelines + +- Write the test before changing the logic. When testing existing code, don't + assume the current behavior is correct; it may be the bug. +- Tests assert CORRECT behavior. Never write a test that "documents" a known + gap by asserting the wrong result. +- Never test language features -- test business logic: + +```rust +// Bad: tests struct assignment, not our code +let request = Request { quantity: 100 }; +assert_eq!(request.quantity, 100); + +// Good: tests our validation logic +let result = validate_order(OrderRequest { quantity: -10 }); +assert!(matches!(result, Err(OrderError::InvalidQuantity))); +``` + +- Bug reproductions must exercise real code paths with realistic fixtures. A + test that hand-constructs invalid state and shows it fails proves only that + invalid things are invalid. Build the input through the same functions + production uses, then show the system produces the wrong result -- that is + the bug. +- Put context in the `assert!` message rather than debugging with `println!`. +- Cover happy paths in integration and e2e tests; cover edge cases in unit + tests. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/README.md b/README.md index f53d203..bfd20f6 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,8 @@ -# Rust Nix Flake Quickstart +# rust.nix + +A Nix flake scaffold for Rust projects: a devenv dev shell, crane build/test/clippy +derivations, the GitButler CLI and its agent skill, shared pre-commit hooks, and a CI +workflow that builds the checks by name. ## Use as a Nix flake template @@ -8,21 +12,53 @@ Full Rust dev shell + CI: nix flake init -t github:dataclique/rust.nix ``` -CI only — drops just `.github/workflows/ci.yaml` into a project that -already has a flake-based dev shell: +CI only -- drops just `.github/workflows/ci.yaml` into a project that already has a +flake-based dev shell: ``` sh nix flake init -t github:dataclique/rust.nix#ci ``` -Nix-only — drops just `flake.nix` and `.envrc` into an existing Rust -project, leaving `Cargo.toml` and `src/` alone. The flake consumes -`dataclique/rust.nix` as an input and re-exposes its dev shell: +Nix only -- drops `flake.nix` and `.envrc` into an existing Rust project, consuming this +repo as an input so the dev shell stays in sync instead of being copied: ``` sh nix flake init -t github:dataclique/rust.nix#nix ``` +## What you get + +- A devenv dev shell with the stable Rust toolchain from rust-overlay, cargo-nextest, + git, and the system deps most projects here need: cacert, openssl, pkg-config, + sqlite. +- Pre-commit hooks: nil, nixfmt, actionlint, taplo, rustfmt (`--edition 2024`), and + `deno fmt` on markdown. The markdown hook is disabled in interactive shells because + deno's V8 dependency has no reliable aarch64-darwin substitute, but it still runs in + CI through the `git-hooks` check. +- Crane derivations exposed as `packages.default` and as the `cargo-test` / + `cargo-clippy` checks. The check names are deliberately project-agnostic, so the CI + workflow works unchanged in any project built from this scaffold. +- The GitButler CLI (`but`) on PATH via the but.nix flake input, its agent skill + auto-installed into `.claude/skills/gitbutler` and `.cursor/skills/gitbutler` on shell + entry (gitignored), and `nix run .#pr-stack-footer` to refresh stacked-PR footers. +- `AGENTS.md` with the shared engineering conventions, and `CLAUDE.md` symlinked to it. + +## Starting a new project + +1. `direnv allow` to enter the dev shell. +2. Set the crate name in `Cargo.toml`, and the matching `pname` in `rust.nix`. +3. Rewrite the `## Project Direction` section of `AGENTS.md` to describe your project. +4. If you don't plan to re-expose templates from your project, delete the `templates` + flake output, the `ci-template-mirror` check, and the `templates/` directory. + +## Reusing the library from another flake + +A project that already has its own flake can add this repo as an input and call +`rust.nix.lib..mkRustPackages { root, pname, version, extraSrcDirs, extraEnv }` +instead of copying `rust.nix`, plus `mkDevShell { repoNotes, extraModules }` for the dev +shell. `extraSrcDirs` names directories the build or tests read that Cargo does not +treat as sources -- migrations, fixtures, golden data. + ## Prerequisites Install Nix @@ -37,7 +73,7 @@ Install Direnv nix -v flake install nixpkgs#direnv ``` -Hook Direnv to your shell, e.g. +Hook Direnv to your shell, e.g. ``` sh # For bash diff --git a/clippy.toml b/clippy.toml index 3909e5d..4d90be6 100644 --- a/clippy.toml +++ b/clippy.toml @@ -1,3 +1,4 @@ allow-unwrap-in-tests = true allow-expect-in-tests = true allow-indexing-slicing-in-tests = true +allow-panic-in-tests = true diff --git a/flake.lock b/flake.lock index f965898..d429d3d 100644 --- a/flake.lock +++ b/flake.lock @@ -1,57 +1,47 @@ { "nodes": { - "cachix": { + "but-nix": { "inputs": { - "devenv": [ - "devenv" - ], - "flake-compat": [ - "devenv", - "flake-compat" - ], - "git-hooks": [ - "devenv", - "git-hooks" - ], + "flake-utils": "flake-utils", "nixpkgs": [ - "devenv", "nixpkgs" ] }, "locked": { - "lastModified": 1774017633, - "narHash": "sha256-CWhnwL2M83/ItapPVeJqCevRoQttesYxJ1h0Mo6ZCXs=", - "owner": "cachix", - "repo": "cachix", - "rev": "e8be573b417f3daa3dd4cb9052178f848e0c9d1d", + "lastModified": 1785658433, + "narHash": "sha256-P/o/AEOTklxL1qXDtvxgID0YYfSPEs9JbUQN5eZO2q8=", + "owner": "dataclique", + "repo": "but.nix", + "rev": "a193504a5e5f5f30d5e07a832c8ca484b8cd18fe", "type": "github" }, "original": { - "owner": "cachix", - "ref": "latest", - "repo": "cachix", + "owner": "dataclique", + "repo": "but.nix", "type": "github" } }, - "cachix_2": { + "cachix": { "inputs": { "devenv": [ - "devenv", - "crate2nix" + "devenv" ], "flake-compat": [ "devenv", - "crate2nix" + "flake-compat" + ], + "git-hooks": [ + "devenv", + "git-hooks" ], - "git-hooks": "git-hooks", "nixpkgs": "nixpkgs" }, "locked": { - "lastModified": 1767714506, - "narHash": "sha256-WaTs0t1CxhgxbIuvQ97OFhDTVUGd1HA+KzLZUZBhe0s=", + "lastModified": 1777487137, + "narHash": "sha256-TuvKVBX60mqyMT6OB5JqVEh1YIWtFMR/igLCaCdC9tw=", "owner": "cachix", "repo": "cachix", - "rev": "894c649f0daaa38bbcfb21de64be47dfa7cd0ec9", + "rev": "a66a440c321d35f7193472c317f42a55ccd1cb93", "type": "github" }, "original": { @@ -61,50 +51,23 @@ "type": "github" } }, - "cachix_3": { - "inputs": { - "devenv": [ - "devenv", - "crate2nix", - "crate2nix_stable" - ], - "flake-compat": [ - "devenv", - "crate2nix", - "crate2nix_stable" - ], - "git-hooks": "git-hooks_2", - "nixpkgs": "nixpkgs_2" - }, + "crane": { "locked": { - "lastModified": 1767714506, - "narHash": "sha256-WaTs0t1CxhgxbIuvQ97OFhDTVUGd1HA+KzLZUZBhe0s=", - "owner": "cachix", - "repo": "cachix", - "rev": "894c649f0daaa38bbcfb21de64be47dfa7cd0ec9", + "lastModified": 1785782307, + "narHash": "sha256-MPaRdVkf6zZP5fCPxYCi8Dr4pZzgmXzg8T9nVEbp3Mw=", + "owner": "ipetkov", + "repo": "crane", + "rev": "2c71e194474d13de031d729b729c968ddbe3507f", "type": "github" }, "original": { - "owner": "cachix", - "ref": "latest", - "repo": "cachix", + "owner": "ipetkov", + "repo": "crane", "type": "github" } }, "crate2nix": { - "inputs": { - "cachix": "cachix_2", - "crate2nix_stable": "crate2nix_stable", - "devshell": "devshell_2", - "flake-compat": "flake-compat_2", - "flake-parts": "flake-parts_2", - "nix-test-runner": "nix-test-runner_2", - "nixpkgs": [ - "devenv", - "nixpkgs" - ], - "pre-commit-hooks": "pre-commit-hooks_2" - }, + "flake": false, "locked": { "lastModified": 1772186516, "narHash": "sha256-8s28pzmQ6TOIUzznwFibtW1CMieMUl1rYJIxoQYor58=", @@ -120,57 +83,29 @@ "type": "github" } }, - "crate2nix_stable": { - "inputs": { - "cachix": "cachix_3", - "crate2nix_stable": [ - "devenv", - "crate2nix", - "crate2nix_stable" - ], - "devshell": "devshell", - "flake-compat": "flake-compat", - "flake-parts": "flake-parts", - "nix-test-runner": "nix-test-runner", - "nixpkgs": "nixpkgs_3", - "pre-commit-hooks": "pre-commit-hooks" - }, - "locked": { - "lastModified": 1769627083, - "narHash": "sha256-SUuruvw1/moNzCZosHaa60QMTL+L9huWdsCBN6XZIic=", - "owner": "nix-community", - "repo": "crate2nix", - "rev": "7c33e664668faecf7655fa53861d7a80c9e464a2", - "type": "github" - }, - "original": { - "owner": "nix-community", - "ref": "0.15.0", - "repo": "crate2nix", - "type": "github" - } - }, "devenv": { "inputs": { "cachix": "cachix", "crate2nix": "crate2nix", - "flake-compat": "flake-compat_3", - "flake-parts": "flake-parts_3", + "flake-compat": "flake-compat", + "flake-parts": "flake-parts", "ghostty": "ghostty", "git-hooks": [ "git-hooks" ], "nix": "nix", "nixd": "nixd", - "nixpkgs": "nixpkgs_6", + "nixpkgs": [ + "nixpkgs" + ], "rust-overlay": "rust-overlay" }, "locked": { - "lastModified": 1777573317, - "narHash": "sha256-IqOdwdWBCf5eXNW7RGFb63Ql+ZnhHRPSTW0mxJYVOvg=", + "lastModified": 1786490576, + "narHash": "sha256-TH5K1TBi5YNUEEVXoPe99Wzy6XOxsuzkypEJofWc/fU=", "owner": "cachix", "repo": "devenv", - "rev": "da4531a7d9b79199b9e56802cb1006ce492c613b", + "rev": "5706f600b4b6799d5dade8602f85cfd2e060b2f3", "type": "github" }, "original": { @@ -179,101 +114,7 @@ "type": "github" } }, - "devshell": { - "inputs": { - "nixpkgs": [ - "devenv", - "crate2nix", - "crate2nix_stable", - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1768818222, - "narHash": "sha256-460jc0+CZfyaO8+w8JNtlClB2n4ui1RbHfPTLkpwhU8=", - "owner": "numtide", - "repo": "devshell", - "rev": "255a2b1725a20d060f566e4755dbf571bbbb5f76", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "devshell", - "type": "github" - } - }, - "devshell_2": { - "inputs": { - "nixpkgs": [ - "devenv", - "crate2nix", - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1768818222, - "narHash": "sha256-460jc0+CZfyaO8+w8JNtlClB2n4ui1RbHfPTLkpwhU8=", - "owner": "numtide", - "repo": "devshell", - "rev": "255a2b1725a20d060f566e4755dbf571bbbb5f76", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "devshell", - "type": "github" - } - }, - "fenix": { - "inputs": { - "nixpkgs": [ - "nixpkgs" - ], - "rust-analyzer-src": "rust-analyzer-src" - }, - "locked": { - "lastModified": 1777538343, - "narHash": "sha256-tW6Szt8/FmRTEi3KdB2TAwZZ3jEwjDD74zIL7uGLsgk=", - "owner": "nix-community", - "repo": "fenix", - "rev": "298b12d701ef0d12c0f2e4858d4208bee24d14e5", - "type": "github" - }, - "original": { - "owner": "nix-community", - "repo": "fenix", - "type": "github" - } - }, "flake-compat": { - "locked": { - "lastModified": 1733328505, - "narHash": "sha256-NeCCThCEP3eCl2l/+27kNNK7QrwZB1IJCrXfrbv5oqU=", - "rev": "ff81ac966bb2cae68946d5ed5fc4994f96d0ffec", - "revCount": 69, - "type": "tarball", - "url": "https://api.flakehub.com/f/pinned/edolstra/flake-compat/1.1.0/01948eb7-9cba-704f-bbf3-3fa956735b52/source.tar.gz" - }, - "original": { - "type": "tarball", - "url": "https://flakehub.com/f/edolstra/flake-compat/1.tar.gz" - } - }, - "flake-compat_2": { - "locked": { - "lastModified": 1733328505, - "narHash": "sha256-NeCCThCEP3eCl2l/+27kNNK7QrwZB1IJCrXfrbv5oqU=", - "rev": "ff81ac966bb2cae68946d5ed5fc4994f96d0ffec", - "revCount": 69, - "type": "tarball", - "url": "https://api.flakehub.com/f/pinned/edolstra/flake-compat/1.1.0/01948eb7-9cba-704f-bbf3-3fa956735b52/source.tar.gz" - }, - "original": { - "type": "tarball", - "url": "https://flakehub.com/f/edolstra/flake-compat/1.tar.gz" - } - }, - "flake-compat_3": { "flake": false, "locked": { "lastModified": 1767039857, @@ -289,23 +130,7 @@ "type": "github" } }, - "flake-compat_4": { - "flake": false, - "locked": { - "lastModified": 1761588595, - "narHash": "sha256-XKUZz9zewJNUj46b4AJdiRZJAvSZ0Dqj2BNfXvFlJC4=", - "owner": "edolstra", - "repo": "flake-compat", - "rev": "f387cd2afec9419c8ee37694406ca490c3f34ee5", - "type": "github" - }, - "original": { - "owner": "edolstra", - "repo": "flake-compat", - "type": "github" - } - }, - "flake-compat_5": { + "flake-compat_2": { "flake": false, "locked": { "lastModified": 1767039857, @@ -325,39 +150,15 @@ "inputs": { "nixpkgs-lib": [ "devenv", - "crate2nix", - "crate2nix_stable", - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1768135262, - "narHash": "sha256-PVvu7OqHBGWN16zSi6tEmPwwHQ4rLPU9Plvs8/1TUBY=", - "owner": "hercules-ci", - "repo": "flake-parts", - "rev": "80daad04eddbbf5a4d883996a73f3f542fa437ac", - "type": "github" - }, - "original": { - "owner": "hercules-ci", - "repo": "flake-parts", - "type": "github" - } - }, - "flake-parts_2": { - "inputs": { - "nixpkgs-lib": [ - "devenv", - "crate2nix", "nixpkgs" ] }, "locked": { - "lastModified": 1768135262, - "narHash": "sha256-PVvu7OqHBGWN16zSi6tEmPwwHQ4rLPU9Plvs8/1TUBY=", + "lastModified": 1778716662, + "narHash": "sha256-m1Yf0wZ8j1OHjTc2UwHwyQRSnNeSgLJOd7q5Y45hzi4=", "owner": "hercules-ci", "repo": "flake-parts", - "rev": "80daad04eddbbf5a4d883996a73f3f542fa437ac", + "rev": "f7c1a2d347e4c52d5fb8d10cb4d94b5884e546fb", "type": "github" }, "original": { @@ -366,28 +167,25 @@ "type": "github" } }, - "flake-parts_3": { + "flake-utils": { "inputs": { - "nixpkgs-lib": [ - "devenv", - "nixpkgs" - ] + "systems": "systems" }, "locked": { - "lastModified": 1775087534, - "narHash": "sha256-91qqW8lhL7TLwgQWijoGBbiD4t7/q75KTi8NxjVmSmA=", - "owner": "hercules-ci", - "repo": "flake-parts", - "rev": "3107b77cd68437b9a76194f0f7f9c55f2329ca5b", + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", "type": "github" }, "original": { - "owner": "hercules-ci", - "repo": "flake-parts", + "owner": "numtide", + "repo": "flake-utils", "type": "github" } }, - "flake-utils": { + "flake-utils_2": { "inputs": { "systems": "systems_2" }, @@ -406,105 +204,34 @@ } }, "ghostty": { - "inputs": { - "flake-compat": "flake-compat_4", - "home-manager": "home-manager", - "nixpkgs": "nixpkgs_4", - "systems": "systems", - "zig": "zig", - "zon2nix": "zon2nix" - }, + "flake": false, "locked": { - "lastModified": 1776365871, - "narHash": "sha256-lAFTUeJy7AT4V+t8/HlMM7O5z6W+G4eUhzRoh3ZdZu8=", - "owner": "cachix", + "lastModified": 1784602798, + "narHash": "sha256-298x90knBUWX5GHGXh2SKsAKvStjU2ri9UgOGoF79/8=", + "owner": "ghostty-org", "repo": "ghostty", - "rev": "d882f9106d15c213239b8916083835263d4fb9bc", + "rev": "88b4cd047fa627cdca6781bc7e7dc8b75a2cecb9", "type": "github" }, "original": { - "owner": "cachix", - "ref": "cachix-upstream", + "owner": "ghostty-org", "repo": "ghostty", "type": "github" } }, "git-hooks": { "inputs": { - "flake-compat": [ - "devenv", - "crate2nix", - "cachix", - "flake-compat" - ], - "gitignore": "gitignore", - "nixpkgs": [ - "devenv", - "crate2nix", - "cachix", - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1765404074, - "narHash": "sha256-+ZDU2d+vzWkEJiqprvV5PR26DVFN2vgddwG5SnPZcUM=", - "owner": "cachix", - "repo": "git-hooks.nix", - "rev": "2d6f58930fbcd82f6f9fd59fb6d13e37684ca529", - "type": "github" - }, - "original": { - "owner": "cachix", - "repo": "git-hooks.nix", - "type": "github" - } - }, - "git-hooks_2": { - "inputs": { - "flake-compat": [ - "devenv", - "crate2nix", - "crate2nix_stable", - "cachix", - "flake-compat" - ], - "gitignore": "gitignore_2", - "nixpkgs": [ - "devenv", - "crate2nix", - "crate2nix_stable", - "cachix", - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1765404074, - "narHash": "sha256-+ZDU2d+vzWkEJiqprvV5PR26DVFN2vgddwG5SnPZcUM=", - "owner": "cachix", - "repo": "git-hooks.nix", - "rev": "2d6f58930fbcd82f6f9fd59fb6d13e37684ca529", - "type": "github" - }, - "original": { - "owner": "cachix", - "repo": "git-hooks.nix", - "type": "github" - } - }, - "git-hooks_3": { - "inputs": { - "flake-compat": "flake-compat_5", - "gitignore": "gitignore_5", + "flake-compat": "flake-compat_2", "nixpkgs": [ "nixpkgs" ] }, "locked": { - "lastModified": 1776796298, - "narHash": "sha256-PcRvlWayisPSjd0UcRQbhG8Oqw78AcPE6x872cPRHN8=", + "lastModified": 1784288435, + "narHash": "sha256-ReRHaLgr/uVqdD8afFSn+myXIfpHeOhP0yYe0TJqAA8=", "owner": "cachix", "repo": "git-hooks.nix", - "rev": "3cfd774b0a530725a077e17354fbdb87ea1c4aad", + "rev": "43b3c1ab9d40fb1dbb008f451988a91e375825e9", "type": "github" }, "original": { @@ -513,145 +240,6 @@ "type": "github" } }, - "gitignore": { - "inputs": { - "nixpkgs": [ - "devenv", - "crate2nix", - "cachix", - "git-hooks", - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1709087332, - "narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=", - "owner": "hercules-ci", - "repo": "gitignore.nix", - "rev": "637db329424fd7e46cf4185293b9cc8c88c95394", - "type": "github" - }, - "original": { - "owner": "hercules-ci", - "repo": "gitignore.nix", - "type": "github" - } - }, - "gitignore_2": { - "inputs": { - "nixpkgs": [ - "devenv", - "crate2nix", - "crate2nix_stable", - "cachix", - "git-hooks", - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1709087332, - "narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=", - "owner": "hercules-ci", - "repo": "gitignore.nix", - "rev": "637db329424fd7e46cf4185293b9cc8c88c95394", - "type": "github" - }, - "original": { - "owner": "hercules-ci", - "repo": "gitignore.nix", - "type": "github" - } - }, - "gitignore_3": { - "inputs": { - "nixpkgs": [ - "devenv", - "crate2nix", - "crate2nix_stable", - "pre-commit-hooks", - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1709087332, - "narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=", - "owner": "hercules-ci", - "repo": "gitignore.nix", - "rev": "637db329424fd7e46cf4185293b9cc8c88c95394", - "type": "github" - }, - "original": { - "owner": "hercules-ci", - "repo": "gitignore.nix", - "type": "github" - } - }, - "gitignore_4": { - "inputs": { - "nixpkgs": [ - "devenv", - "crate2nix", - "pre-commit-hooks", - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1709087332, - "narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=", - "owner": "hercules-ci", - "repo": "gitignore.nix", - "rev": "637db329424fd7e46cf4185293b9cc8c88c95394", - "type": "github" - }, - "original": { - "owner": "hercules-ci", - "repo": "gitignore.nix", - "type": "github" - } - }, - "gitignore_5": { - "inputs": { - "nixpkgs": [ - "git-hooks", - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1709087332, - "narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=", - "owner": "hercules-ci", - "repo": "gitignore.nix", - "rev": "637db329424fd7e46cf4185293b9cc8c88c95394", - "type": "github" - }, - "original": { - "owner": "hercules-ci", - "repo": "gitignore.nix", - "type": "github" - } - }, - "home-manager": { - "inputs": { - "nixpkgs": [ - "devenv", - "ghostty", - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1770586272, - "narHash": "sha256-Ucci8mu8QfxwzyfER2DQDbvW9t1BnTUJhBmY7ybralo=", - "owner": "nix-community", - "repo": "home-manager", - "rev": "b1f916ba052341edc1f80d4b2399f1092a4873ca", - "type": "github" - }, - "original": { - "owner": "nix-community", - "repo": "home-manager", - "type": "github" - } - }, "nix": { "inputs": { "flake-compat": [ @@ -678,11 +266,11 @@ ] }, "locked": { - "lastModified": 1776511668, - "narHash": "sha256-g2KEBuHpc3a56c+jPcg0+w6LSuIj6f+zzdztLCOyIhc=", + "lastModified": 1786294306, + "narHash": "sha256-WcqKvA7f7TGrlDVd69T1UXUqVXJ+wfoRbO+mg5L7/Rc=", "owner": "cachix", "repo": "nix", - "rev": "42d4b7de21c15f28c568410f4383fa06a8458a40", + "rev": "59407321a92f7d34d4a53e38959294007c0bc37a", "type": "github" }, "original": { @@ -692,53 +280,24 @@ "type": "github" } }, - "nix-test-runner": { - "flake": false, - "locked": { - "lastModified": 1588761593, - "narHash": "sha256-FKJykltAN/g3eIceJl4SfDnnyuH2jHImhMrXS2KvGIs=", - "owner": "stoeffel", - "repo": "nix-test-runner", - "rev": "c45d45b11ecef3eb9d834c3b6304c05c49b06ca2", - "type": "github" - }, - "original": { - "owner": "stoeffel", - "repo": "nix-test-runner", - "type": "github" - } - }, - "nix-test-runner_2": { - "flake": false, - "locked": { - "lastModified": 1588761593, - "narHash": "sha256-FKJykltAN/g3eIceJl4SfDnnyuH2jHImhMrXS2KvGIs=", - "owner": "stoeffel", - "repo": "nix-test-runner", - "rev": "c45d45b11ecef3eb9d834c3b6304c05c49b06ca2", - "type": "github" - }, - "original": { - "owner": "stoeffel", - "repo": "nix-test-runner", - "type": "github" - } - }, "nixd": { "inputs": { "flake-parts": [ "devenv", "flake-parts" ], - "nixpkgs": "nixpkgs_5", + "nixpkgs": [ + "devenv", + "nixpkgs" + ], "treefmt-nix": "treefmt-nix" }, "locked": { - "lastModified": 1776341634, - "narHash": "sha256-L//ltP2o5+BnuK+KEulbi2gGeDpyyex6SkXLZcGQ/Ac=", + "lastModified": 1783935112, + "narHash": "sha256-IAQ14nteIKXAz4cd75UcZrsHGEVJ7QNrkUwX9rmeZ/Y=", "owner": "nix-community", "repo": "nixd", - "rev": "951e98e2025c47614f5249556ecf509b0ea35b51", + "rev": "a64cd33e53b316b6b092ea0a966640cd2309bf3d", "type": "github" }, "original": { @@ -749,11 +308,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1765186076, - "narHash": "sha256-hM20uyap1a0M9d344I692r+ik4gTMyj60cQWO+hAYP8=", + "lastModified": 1772624091, + "narHash": "sha256-QKyJ0QGWBn6r0invrMAK8dmJoBYWoOWy7lN+UHzW1jc=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "addf7cf5f383a3101ecfba091b98d0a1263dc9b8", + "rev": "80bdc1e5ce51f56b19791b52b2901187931f5353", "type": "github" }, "original": { @@ -763,213 +322,66 @@ "type": "github" } }, - "nixpkgs-src": { - "flake": false, - "locked": { - "lastModified": 1775888245, - "narHash": "sha256-nwASzrRDD1JBEu/o8ekKYEXm/oJW6EMCzCRdrwcLe90=", - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "13043924aaa7375ce482ebe2494338e058282925", - "type": "github" - }, - "original": { - "owner": "NixOS", - "ref": "nixpkgs-unstable", - "repo": "nixpkgs", - "type": "github" - } - }, "nixpkgs_2": { "locked": { - "lastModified": 1765186076, - "narHash": "sha256-hM20uyap1a0M9d344I692r+ik4gTMyj60cQWO+hAYP8=", - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "addf7cf5f383a3101ecfba091b98d0a1263dc9b8", - "type": "github" - }, - "original": { - "owner": "NixOS", - "ref": "nixos-unstable", - "repo": "nixpkgs", - "type": "github" - } - }, - "nixpkgs_3": { - "locked": { - "lastModified": 1769433173, - "narHash": "sha256-Gf1dFYgD344WZ3q0LPlRoWaNdNQq8kSBDLEWulRQSEs=", - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "13b0f9e6ac78abbbb736c635d87845c4f4bee51b", - "type": "github" - }, - "original": { - "owner": "NixOS", - "ref": "nixpkgs-unstable", - "repo": "nixpkgs", - "type": "github" - } - }, - "nixpkgs_4": { - "locked": { - "lastModified": 1772963539, - "narHash": "sha256-G4+9cEu8XSqEWYUB6iRgDfrg53av6yyRwAKhSeKbUVw=", - "rev": "9dcb002ca1690658be4a04645215baea8b95f31d", - "type": "tarball", - "url": "https://releases.nixos.org/nixos/unstable/nixos-26.05pre960399.9dcb002ca169/nixexprs.tar.xz" - }, - "original": { - "type": "tarball", - "url": "https://channels.nixos.org/nixos-unstable/nixexprs.tar.xz" - } - }, - "nixpkgs_5": { - "locked": { - "lastModified": 1772963539, - "narHash": "sha256-G4+9cEu8XSqEWYUB6iRgDfrg53av6yyRwAKhSeKbUVw=", - "rev": "9dcb002ca1690658be4a04645215baea8b95f31d", - "type": "tarball", - "url": "https://releases.nixos.org/nixos/unstable/nixos-26.05pre960399.9dcb002ca169/nixexprs.tar.xz" - }, - "original": { - "type": "tarball", - "url": "https://channels.nixos.org/nixos-unstable/nixexprs.tar.xz" - } - }, - "nixpkgs_6": { - "inputs": { - "nixpkgs-src": "nixpkgs-src" - }, - "locked": { - "lastModified": 1776771808, - "narHash": "sha256-FRpraDgknF5zoCYTi9CitoIaUYb/XGiXUuVqPg9AYB4=", - "owner": "cachix", - "repo": "devenv-nixpkgs", - "rev": "3a3d4ac6ea3dbf2534ef988086348b7e140b92ad", - "type": "github" - }, - "original": { - "owner": "cachix", - "ref": "rolling", - "repo": "devenv-nixpkgs", - "type": "github" - } - }, - "nixpkgs_7": { - "locked": { - "lastModified": 1777428379, - "narHash": "sha256-ypxFOeDz+CqADEQNL72haqGjvZQdBR5Vc7pyx2JDttI=", + "lastModified": 1786430034, + "narHash": "sha256-Vux08kA5PICwS2sViCMfwVLAHNoH8TkKAeBo25LjpMI=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "755f5aa91337890c432639c60b6064bb7fe67769", + "rev": "70cc4559b10a6062b05ff1af17e0add065ccaed9", "type": "github" }, "original": { "owner": "NixOS", - "ref": "nixos-25.11", + "ref": "nixos-26.05", "repo": "nixpkgs", "type": "github" } }, - "pre-commit-hooks": { + "root": { "inputs": { - "flake-compat": [ - "devenv", - "crate2nix", - "crate2nix_stable", - "flake-compat" - ], - "gitignore": "gitignore_3", - "nixpkgs": [ - "devenv", - "crate2nix", - "crate2nix_stable", - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1769069492, - "narHash": "sha256-Efs3VUPelRduf3PpfPP2ovEB4CXT7vHf8W+xc49RL/U=", - "owner": "cachix", - "repo": "pre-commit-hooks.nix", - "rev": "a1ef738813b15cf8ec759bdff5761b027e3e1d23", - "type": "github" - }, - "original": { - "owner": "cachix", - "repo": "pre-commit-hooks.nix", - "type": "github" + "but-nix": "but-nix", + "crane": "crane", + "devenv": "devenv", + "flake-utils": "flake-utils_2", + "git-hooks": "git-hooks", + "nixpkgs": "nixpkgs_2", + "rust-overlay": "rust-overlay_2" } }, - "pre-commit-hooks_2": { + "rust-overlay": { "inputs": { - "flake-compat": [ - "devenv", - "crate2nix", - "flake-compat" - ], - "gitignore": "gitignore_4", "nixpkgs": [ "devenv", - "crate2nix", "nixpkgs" ] }, "locked": { - "lastModified": 1769069492, - "narHash": "sha256-Efs3VUPelRduf3PpfPP2ovEB4CXT7vHf8W+xc49RL/U=", - "owner": "cachix", - "repo": "pre-commit-hooks.nix", - "rev": "a1ef738813b15cf8ec759bdff5761b027e3e1d23", - "type": "github" - }, - "original": { - "owner": "cachix", - "repo": "pre-commit-hooks.nix", - "type": "github" - } - }, - "root": { - "inputs": { - "devenv": "devenv", - "fenix": "fenix", - "flake-utils": "flake-utils", - "git-hooks": "git-hooks_3", - "nixpkgs": "nixpkgs_7" - } - }, - "rust-analyzer-src": { - "flake": false, - "locked": { - "lastModified": 1777505090, - "narHash": "sha256-0Hgm1PJpZXQDUvzr/KDKqoBNvBRzWSJ1cE/uTFY8gTE=", - "owner": "rust-lang", - "repo": "rust-analyzer", - "rev": "34f1c5f1c600f3373589868e9416a0236ccfb07d", + "lastModified": 1782875958, + "narHash": "sha256-5eqDcnBjb1424HRQdnhuhNOBZguq1Z2tqSa2OMF/m2c=", + "owner": "oxalica", + "repo": "rust-overlay", + "rev": "13139aefa973f3d96c60c0fbab801de058ae25ca", "type": "github" }, "original": { - "owner": "rust-lang", - "ref": "nightly", - "repo": "rust-analyzer", + "owner": "oxalica", + "repo": "rust-overlay", "type": "github" } }, - "rust-overlay": { + "rust-overlay_2": { "inputs": { "nixpkgs": [ - "devenv", "nixpkgs" ] }, "locked": { - "lastModified": 1776741231, - "narHash": "sha256-k9G98qzn+7npROUaks8VqCFm7cFtEG8ulQLBBo5lItg=", + "lastModified": 1786507911, + "narHash": "sha256-w5aZRLbiu7H6TqsYXVMdRKg0S4DRaJpxyqxx86AwxVk=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "02061303f7c4c964f7b4584dabd9e985b4cd442b", + "rev": "39db48099ad16834af7e27485a4babf9c28b3897", "type": "github" }, "original": { @@ -979,7 +391,6 @@ } }, "systems": { - "flake": false, "locked": { "lastModified": 1681028828, "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", @@ -1018,11 +429,11 @@ ] }, "locked": { - "lastModified": 1775636079, - "narHash": "sha256-pc20NRoMdiar8oPQceQT47UUZMBTiMdUuWrYu2obUP0=", + "lastModified": 1780220602, + "narHash": "sha256-eynAfOmbmxJnkp7YewvCEbShNnnYJ9gLLqkzsYtBPeM=", "owner": "numtide", "repo": "treefmt-nix", - "rev": "790751ff7fd3801feeaf96d7dc416a8d581265ba", + "rev": "db947814a175b7ca6ded66e21383d938df01c227", "type": "github" }, "original": { @@ -1030,85 +441,6 @@ "repo": "treefmt-nix", "type": "github" } - }, - "zig": { - "inputs": { - "flake-compat": [ - "devenv", - "ghostty", - "flake-compat" - ], - "nixpkgs": [ - "devenv", - "ghostty", - "nixpkgs" - ], - "systems": [ - "devenv", - "ghostty", - "systems" - ] - }, - "locked": { - "lastModified": 1773145353, - "narHash": "sha256-dE8zx8WA54TRmFFQBvA48x/sXGDTP7YaDmY6nNKMAYw=", - "owner": "mitchellh", - "repo": "zig-overlay", - "rev": "8666155d83bf792956a7c40915508e6d4b2b8716", - "type": "github" - }, - "original": { - "owner": "mitchellh", - "repo": "zig-overlay", - "type": "github" - } - }, - "zig_2": { - "inputs": { - "nixpkgs": [ - "devenv", - "ghostty", - "zon2nix", - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1776208985, - "narHash": "sha256-IOuRFpbeQ9jSk54OURX5yvjoC759ujgSNjkMKpChdDA=", - "ref": "refs/heads/main", - "rev": "e8ee348125247e7bd74932cc42ac92df90961d5b", - "revCount": 1666, - "type": "git", - "url": "https://codeberg.org/jcollie/zig-overlay.git" - }, - "original": { - "type": "git", - "url": "https://codeberg.org/jcollie/zig-overlay.git" - } - }, - "zon2nix": { - "inputs": { - "nixpkgs": [ - "devenv", - "ghostty", - "nixpkgs" - ], - "zig": "zig_2" - }, - "locked": { - "lastModified": 1776269939, - "narHash": "sha256-tOGsI1d1Xk1PYapQJ/ByG0utbWXJasIna/fUib+/b5A=", - "owner": "jcollie", - "repo": "zon2nix", - "rev": "cc467a77c2ebcd9aab84024196abfc37eaf1007d", - "type": "github" - }, - "original": { - "owner": "jcollie", - "ref": "main", - "repo": "zon2nix", - "type": "github" - } } }, "root": "root", diff --git a/flake.nix b/flake.nix index 9606e39..98f4451 100644 --- a/flake.nix +++ b/flake.nix @@ -1,16 +1,27 @@ { + description = "Rust project scaffold: devenv shell, crane builds, GitButler stacks, and CI"; + inputs = { - nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11"; + nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05"; flake-utils.url = "github:numtide/flake-utils"; git-hooks.url = "github:cachix/git-hooks.nix"; git-hooks.inputs.nixpkgs.follows = "nixpkgs"; devenv.url = "github:cachix/devenv"; - devenv.inputs.git-hooks.follows = "git-hooks"; + devenv.inputs = { + nixpkgs.follows = "nixpkgs"; + git-hooks.follows = "git-hooks"; + }; + + rust-overlay.url = "github:oxalica/rust-overlay"; + rust-overlay.inputs.nixpkgs.follows = "nixpkgs"; - fenix.url = "github:nix-community/fenix"; - fenix.inputs.nixpkgs.follows = "nixpkgs"; + crane.url = "github:ipetkov/crane"; + + # The GitButler CLI (`but`) and its agent skill, shared across repos. + but-nix.url = "github:dataclique/but.nix"; + but-nix.inputs.nixpkgs.follows = "nixpkgs"; }; outputs = @@ -19,7 +30,9 @@ nixpkgs, flake-utils, git-hooks, - fenix, + rust-overlay, + crane, + but-nix, ... }@inputs: flake-utils.lib.eachDefaultSystem ( @@ -27,23 +40,46 @@ let pkgs = import nixpkgs { inherit system; - overlays = [ fenix.overlays.default ]; + overlays = [ rust-overlay.overlays.default ]; + }; + + rustToolchain = pkgs.rust-bin.stable.latest.default; + craneLib = (crane.mkLib pkgs).overrideToolchain rustToolchain; + butLib = but-nix.lib.${system}; + + lib = import ./nix/lib.nix { + inherit + pkgs + rustToolchain + craneLib + butLib + inputs + ; }; - toolchain = fenix.packages.${system}.default; - lib = import ./nix/lib.nix { inherit pkgs toolchain inputs; }; + rustPkgs = import ./rust.nix { inherit (lib) mkRustPackages; }; in { inherit lib; devShells.default = lib.mkDevShell { }; - packages.devenv-up = self.devShells.${system}.default.config.procfileScript; + packages = { + default = rustPkgs.package; + rust-nix = rustPkgs.package; + inherit (butLib) pr-stack-footer; + }; + # `cargo-test` and `cargo-clippy` are deliberately project-agnostic + # names: the CI workflow shipped by `templates.ci` builds them by + # name, so it works unchanged in any project using this scaffold. checks = { - pre-commit = git-hooks.lib.${system}.run { - src = ./.; - inherit (lib) hooks; + cargo-test = rustPkgs.test; + cargo-clippy = rustPkgs.clippy; + + git-hooks = git-hooks.lib.${system}.run { + src = self; + hooks = lib.hooksForChecks; }; # Enforce that the workflow shipped by `templates.ci` is @@ -70,20 +106,33 @@ rust = { path = ./.; - description = "Rust dev shell (devenv + fenix + pre-commit) with CI"; + description = "Rust scaffold: devenv shell, crane builds, GitButler stacks, CI"; welcomeText = '' - # Rust + Nix template + # Rust + Nix scaffold Next steps: 1. `direnv allow` (or `nix develop --impure`) to enter the dev shell. - 2. `cargo run` to verify the toolchain. - 3. Edit `Cargo.toml` to set your crate name. - - Optional cleanup: this flake inherits a `templates` output - that re-exposes the project as a sub-template. If you don't - plan to re-expose templates from your project, you can - delete the `templates` block from `flake.nix`. It's - otherwise harmless. + 2. Set your crate name in `Cargo.toml`, and the matching `pname` + in `rust.nix`. + 3. `cargo run` to verify the toolchain. + 4. Rewrite the `## Project Direction` section of `AGENTS.md` to + describe your project. `CLAUDE.md` is a symlink to it. + + What you get: + - A devenv shell with the stable Rust toolchain, cargo-nextest, + and pre-commit hooks (nil, nixfmt, actionlint, taplo, rustfmt). + - `nix build` / `.#checks..cargo-test` / + `.#checks..cargo-clippy` -- crane derivations that CI + builds by name. + - The GitButler CLI (`but`) on PATH, its agent skill installed + into `.claude/skills` and `.cursor/skills` on shell entry, and + `nix run .#pr-stack-footer` to refresh stacked-PR footers. + + Optional cleanup: this flake inherits a `templates` output that + re-exposes the project as a sub-template, along with the + `ci-template-mirror` check and the `templates/` directory that + check reads. If you don't plan to re-expose templates from your + project, delete all three. ''; }; @@ -93,9 +142,14 @@ welcomeText = '' # CI-only template - Drops `.github/workflows/ci.yaml` into your project. Assumes - your flake exposes a `devShells.default` that provides - `cargo` and `clippy`. + Drops `.github/workflows/ci.yaml` into your project. It builds + `.#checks.x86_64-linux.git-hooks`, `.#checks.x86_64-linux.cargo-test`, + and `.#checks.x86_64-linux.cargo-clippy`, so your flake needs to + expose those three checks -- which it does if you started from the + `rust` template. + + The Cachix step is `continue-on-error`, so a fork PR without + `CACHIX_AUTH_TOKEN` still builds against cache.nixos.org. ''; }; @@ -107,18 +161,30 @@ Drops `flake.nix` and `.envrc` into an existing Rust project. The flake consumes `dataclique/rust.nix` as an input and - re-exposes its dev shell as `devShells.default`. + re-exposes its dev shell as `devShells.default` -- so you get + the toolchain, `but`, and the pre-commit hooks without copying + any of it. Next steps: 1. `direnv allow` (or `nix develop --impure`) to enter the dev shell. 2. `cargo build` to verify the toolchain. + + To build your crate with crane too, call + `rust-nix.lib..mkRustPackages { root = ./.; pname = "..."; }` + and expose the result as `packages` and `checks`. ''; }; }; }; nixConfig = { - extra-trusted-public-keys = "devenv.cachix.org-1:w1cLUi8dv3hnoSPGAuibQv+f9TZLr6cv/Hm9XgU50cw="; - extra-substituters = "https://devenv.cachix.org"; + extra-substituters = [ + "https://devenv.cachix.org" + "https://nix-community.cachix.org" + ]; + extra-trusted-public-keys = [ + "devenv.cachix.org-1:w1cLUi8dv3hnoSPGAuibQv+f9TZLr6cv/Hm9XgU50cw=" + "nix-community.cachix.org-1:mB9FSh9qf2dCimDSUo8Zy7bkq5CX+/rkCWyvRCYg3Fs=" + ]; }; } diff --git a/nix/lib.nix b/nix/lib.nix index 3bef0b3..3d0c44c 100644 --- a/nix/lib.nix +++ b/nix/lib.nix @@ -1,48 +1,233 @@ { pkgs, - toolchain, + rustToolchain, + craneLib, + butLib, inputs, }: + let + inherit (pkgs) lib; + + # System libraries every Rust project here ends up needing: TLS for HTTP + # clients, sqlite for the embedded store, pkg-config to find both. + deps = with pkgs; [ + cacert + openssl.dev + pkg-config + sqlite.dev + ]; + hooks = { + # Nix + nil.enable = true; + nixfmt.enable = true; + + # GitHub Actions -- this repo ships workflows, so they get linted too. actionlint.enable = true; + + # TOML taplo.enable = true; - nixfmt.enable = true; + # Markdown + denofmt = { + enable = true; + name = "denofmt"; + entry = "${pkgs.deno}/bin/deno fmt"; + files = "\\.md$"; + pass_filenames = true; + }; + + # Rust -- rustfmt is invoked directly rather than through `cargo fmt`, + # which resolves the cargo-fmt shim through PATH; the hook runner does + # not carry it. rustfmt = { enable = true; - packageOverrides = { inherit (toolchain) cargo rustfmt; }; + entry = "${rustToolchain}/bin/rustfmt --edition 2024"; + files = "\\.rs$"; + pass_filenames = true; }; }; + # Deno's V8 dependency has no reliable aarch64-darwin substitute and can + # take hours to build locally. Markdown formatting still gates CI through + # `hooksForChecks`; entering an interactive shell must not compile V8. + hooksForShells = hooks // { + denofmt = hooks.denofmt // { + enable = false; + }; + }; + + hooksForChecks = hooks; + + # Build the `package` / `test` / `clippy` derivations for a Cargo project + # with crane. `root` is the directory holding Cargo.toml -- pass `./.` from + # a file at the project root. + # + # `extraSrcDirs` names directories that are not Cargo sources but that the + # build or tests read (migrations, fixtures, golden data). Anything not + # listed is filtered out, so a change to it does not invalidate the build. + mkRustPackages = + { + root, + pname, + version ? "0.1.0", + extraSrcDirs ? [ ], + extraEnv ? { }, + extraNativeBuildInputs ? [ ], + extraBuildInputs ? [ ], + }: + let + keepExtraDir = + path: + lib.any ( + dir: baseNameOf path == dir || lib.hasPrefix (toString (root + "/${dir}")) path + ) extraSrcDirs; + + src = lib.cleanSourceWith { + src = root; + filter = path: type: craneLib.filterCargoSources path type || keepExtraDir path; + }; + + # Cargo manifests only, so the dependency derivation's hash changes + # when dependencies change and not when sources do. + depsSrc = lib.cleanSourceWith { + src = root; + filter = + path: type: + type == "directory" || baseNameOf path == "Cargo.toml" || baseNameOf path == "Cargo.lock"; + }; + + cargoVendorDir = craneLib.vendorCargoDeps { + src = depsSrc; + cargoLock = root + "/Cargo.lock"; + }; + + commonArgs = { + inherit + pname + version + src + cargoVendorDir + ; + + nativeBuildInputs = [ pkgs.pkg-config ] ++ extraNativeBuildInputs; + + buildInputs = [ + pkgs.openssl + pkgs.sqlite + ] + ++ lib.optionals pkgs.stdenv.hostPlatform.isDarwin [ pkgs.apple-sdk_15 ] + ++ extraBuildInputs; + + RUSTFLAGS = "-D warnings"; + + # Compile/test env for sqlx and reqwest inside the nix sandbox. + # SQLX_OFFLINE is set as a derivation env var, not only in + # .cargo/config.toml, because crane's buildDepsOnly vendors the + # dependency crates from a manifest-only source tree (depsSrc) that + # omits .cargo/config.toml. Without the env var, compile-time + # `sqlx::query!` macros connect to DATABASE_URL instead of their + # bundled `.sqlx/` caches and fail against the empty sandbox + # database. Inert in a project that does not use sqlx. + DATABASE_URL = "sqlite::memory:"; + SQLX_OFFLINE = "true"; + SSL_CERT_FILE = "${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt"; + NIX_SSL_CERT_FILE = "${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt"; + } + // extraEnv; + + cargoArtifacts = craneLib.buildDepsOnly (commonArgs // { src = depsSrc; }); + in + { + package = craneLib.buildPackage ( + commonArgs + // { + inherit cargoArtifacts; + doCheck = true; + } + ); + + # CI check derivations -- lighter than buildPackage (no final link step). + test = craneLib.cargoTest (commonArgs // { inherit cargoArtifacts; }); + + clippy = craneLib.cargoClippy ( + commonArgs + // { + inherit cargoArtifacts; + cargoClippyExtraArgs = "--all-targets -- -D clippy::all"; + } + ); + }; + + # Conventions spliced into the gitbutler agent skill installed on shell + # entry. A project that diverges from these should pass its own + # `repoNotes` to `mkDevShell`. + defaultRepoNotes = '' + ## This Repository + + - **Pre-commit hooks:** a commit runs nil, nixfmt, actionlint, taplo, and + rustfmt. A commit that trips a formatter fails; re-stage and retry. + - **Commit messages:** lowercase, imperative, and about the outcome. No + `feat:` / `fix:` prefixes, no AI attribution or "Generated with" footers. + - **Branch names:** `/`, where type is one of + `feat`, `fix`, `adr`, `docs`. + - **Stack footers:** run `nix run .#pr-stack-footer` after any operation + that reshapes a stack -- GitButler does not refresh the footer on a + no-op push, so it goes stale after a rebase, merge, or branch change. + + ''; + devenvModule = { ... }: { - packages = with pkgs; pkgs.lib.optionals stdenv.isDarwin [ libiconv ]; + packages = deps ++ [ + pkgs.git + pkgs.cargo-nextest + ]; - languages.rust = { - enable = true; - inherit toolchain; + languages = { + nix.enable = true; + + rust = { + enable = true; + toolchain.rustc = rustToolchain; + toolchain.cargo = rustToolchain; + toolchain.rustfmt = rustToolchain; + toolchain.clippy = rustToolchain; + }; }; + git-hooks.hooks = hooksForShells; difftastic.enable = true; - git-hooks = { inherit hooks; }; + cachix.enable = true; }; mkDevShell = { + repoNotes ? defaultRepoNotes, extraModules ? [ ], }: inputs.devenv.lib.mkShell { inherit inputs pkgs; - modules = [ devenvModule ] ++ extraModules; + modules = [ + devenvModule + (butLib.devenvModule { inherit repoNotes; }) + ] + ++ extraModules; }; in { inherit - toolchain + deps hooks + hooksForShells + hooksForChecks + mkRustPackages + defaultRepoNotes devenvModule mkDevShell ; + + toolchain = rustToolchain; } diff --git a/rust.nix b/rust.nix new file mode 100644 index 0000000..532e0a2 --- /dev/null +++ b/rust.nix @@ -0,0 +1,16 @@ +{ mkRustPackages }: + +# The crane build for this project. `mkRustPackages` lives in nix/lib.nix and +# is re-exported as `lib..mkRustPackages`, so a project consuming this +# flake as an input can call it instead of copying this file. +# +# Add directories the build or tests read but Cargo does not consider sources +# (migrations, fixtures, golden data) to `extraSrcDirs`: +# +# extraSrcDirs = [ "migrations" "fixtures" ]; + +mkRustPackages { + root = ./.; + pname = "rust-nix"; + version = "0.1.0"; +} diff --git a/templates/ci/.github/workflows/ci.yaml b/templates/ci/.github/workflows/ci.yaml index fa8fc38..1e6da5b 100644 --- a/templates/ci/.github/workflows/ci.yaml +++ b/templates/ci/.github/workflows/ci.yaml @@ -1,41 +1,115 @@ -name: "CI" +name: CI on: - workflow_dispatch: pull_request: - push: - branches: - - master + merge_group: + workflow_dispatch: + +# A new push to the same ref supersedes any run still in flight; master runs +# are never cancelled so the cache-warming pushes always complete. +concurrency: + group: ${{ github.repository }}-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/master' }} + +permissions: + contents: read + +# Nix is installed via nix-quick-install-action; the shared `data-cartel-public` +# Cachix (cachix-action) and the per-runner nix store (cache-nix-action) are +# warmed before any `nix` build, and built paths are pushed back to the Cachix. +# This replaces the DeterminateSystems installer + magic-nix-cache, whose hosted +# cache throttled CI with HTTP 418 responses. cachix-action is continue-on-error +# so a fork PR (no CACHIX_AUTH_TOKEN) skips the cache and still builds against +# cache.nixos.org without failing the job. jobs: - checks: - permissions: - checks: write - contents: read - id-token: write - packages: write - pull-requests: write - repository-projects: write - - runs-on: ${{ matrix.os }} - strategy: - matrix: - os: [ubuntu-latest] + hooks: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: nixbuild/nix-quick-install-action@2c9db80fb984ceb1bcaa77cdda3fdf8cfba92035 # v34 + with: + nix_version: "2.31.2" + nix_conf: | + accept-flake-config = true + fallback = true + access-tokens = github.com=${{ secrets.GITHUB_TOKEN }} + keep-env-derivations = true + keep-outputs = true + + - uses: cachix/cachix-action@ad2ddac53f961de1989924296a1f236fcfbaa4fc # v15 + continue-on-error: true + with: + name: data-cartel-public + authToken: ${{ secrets.CACHIX_AUTH_TOKEN }} + + - uses: nix-community/cache-nix-action@7df957e333c1e5da7721f60227dbba6d06080569 # v7 + with: + primary-key: nix-${{ runner.os }}-hooks-${{ hashFiles('**/*.nix', '**/flake.lock') }} + restore-prefixes-first-match: nix-${{ runner.os }}-hooks- + gc-max-store-size-linux: 5G + + - name: Pre-commit hooks + # Pure flake check -- runs the same hooks without the heavy devShell + run: nix build -L --log-format raw .#checks.x86_64-linux.git-hooks + + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: nixbuild/nix-quick-install-action@2c9db80fb984ceb1bcaa77cdda3fdf8cfba92035 # v34 + with: + nix_version: "2.31.2" + nix_conf: | + accept-flake-config = true + fallback = true + access-tokens = github.com=${{ secrets.GITHUB_TOKEN }} + keep-env-derivations = true + keep-outputs = true + + - uses: cachix/cachix-action@ad2ddac53f961de1989924296a1f236fcfbaa4fc # v15 + continue-on-error: true + with: + name: data-cartel-public + authToken: ${{ secrets.CACHIX_AUTH_TOKEN }} + + - uses: nix-community/cache-nix-action@7df957e333c1e5da7721f60227dbba6d06080569 # v7 + with: + primary-key: nix-${{ runner.os }}-rust-${{ hashFiles('**/*.nix', '**/flake.lock') }} + restore-prefixes-first-match: nix-${{ runner.os }}-rust- + gc-max-store-size-linux: 5G + + - name: Test + run: nix build -L --log-format raw .#checks.x86_64-linux.cargo-test + clippy: + runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 - - uses: DeterminateSystems/nix-installer-action@main - - uses: DeterminateSystems/magic-nix-cache-action@main - - uses: DeterminateSystems/flake-checker-action@main + - uses: actions/checkout@v4 - - name: Run Flake checks - run: nix -v flake check --show-trace --impure --accept-flake-config + - uses: nixbuild/nix-quick-install-action@2c9db80fb984ceb1bcaa77cdda3fdf8cfba92035 # v34 + with: + nix_version: "2.31.2" + nix_conf: | + accept-flake-config = true + fallback = true + access-tokens = github.com=${{ secrets.GITHUB_TOKEN }} + keep-env-derivations = true + keep-outputs = true - - name: cargo build - run: nix develop --impure --accept-flake-config -c cargo build --all-targets --locked + - uses: cachix/cachix-action@ad2ddac53f961de1989924296a1f236fcfbaa4fc # v15 + continue-on-error: true + with: + name: data-cartel-public + authToken: ${{ secrets.CACHIX_AUTH_TOKEN }} - - name: cargo test - run: nix develop --impure --accept-flake-config -c cargo test --all-targets --locked + - uses: nix-community/cache-nix-action@7df957e333c1e5da7721f60227dbba6d06080569 # v7 + with: + primary-key: nix-${{ runner.os }}-rust-${{ hashFiles('**/*.nix', '**/flake.lock') }} + restore-prefixes-first-match: nix-${{ runner.os }}-rust- + gc-max-store-size-linux: 5G - - name: cargo clippy - run: nix develop --impure --accept-flake-config -c cargo clippy --all-targets --locked -- -D warnings + - name: Clippy + run: nix build -L --log-format raw .#checks.x86_64-linux.cargo-clippy diff --git a/templates/nix/.envrc b/templates/nix/.envrc index de012d0..becf1fd 100644 --- a/templates/nix/.envrc +++ b/templates/nix/.envrc @@ -1,12 +1,11 @@ -if ! has nix_direnv_version || ! nix_direnv_version 3.0.4; then - source_url "https://raw.githubusercontent.com/nix-community/nix-direnv/3.0.4/direnvrc" "sha256-DzlYZ33mWF/Gs8DDeyjr8mnVmQGx7ASYqA5WlxwvBG4=" +if ! has nix_direnv_version || ! nix_direnv_version 3.0.5; then + URL=https://raw.githubusercontent.com/nix-community/nix-direnv/3.0.5/direnvrc + HASH=sha256-RuwIS+QKFj/T9M2TFXScjBsLR6V3A17YVoEW/Q6AZ1w= + source_url "$URL" "$HASH" fi -watch_file .env +watch_file *.nix +watch_file nix/*.nix watch_file flake.lock -watch_file flake.nix -if ! use flake . --impure --show-trace -then - echo "devenv could not be built. The devenv environment was not loaded. Make the necessary changes to flake.nix and hit enter to try again." >&2 -fi +use flake . --impure --show-trace --accept-flake-config diff --git a/templates/nix/flake.nix b/templates/nix/flake.nix index 0fa399c..f1b78a5 100644 --- a/templates/nix/flake.nix +++ b/templates/nix/flake.nix @@ -1,8 +1,10 @@ { inputs = { - nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11"; + nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05"; flake-utils.url = "github:numtide/flake-utils"; + rust-nix.url = "github:dataclique/rust.nix"; + rust-nix.inputs.nixpkgs.follows = "nixpkgs"; }; outputs = @@ -12,12 +14,39 @@ rust-nix, ... }: - flake-utils.lib.eachDefaultSystem (system: { - devShells.default = rust-nix.devShells.${system}.default; - }); + flake-utils.lib.eachDefaultSystem ( + system: + let + rustNixLib = rust-nix.lib.${system}; + in + { + # The scaffold's dev shell as-is: Rust toolchain, cargo-nextest, + # `but`, and the shared pre-commit hooks. + devShells.default = rust-nix.devShells.${system}.default; + + # To build this crate with crane as well, replace the line above with + # a `rustNixLib.mkDevShell { }` call and uncomment the block below, + # setting `pname` to your crate name. + # + # packages = let + # rustPkgs = rustNixLib.mkRustPackages { + # root = ./.; + # pname = "my-crate"; + # }; + # in { + # default = rustPkgs.package; + # }; + } + ); nixConfig = { - extra-trusted-public-keys = "devenv.cachix.org-1:w1cLUi8dv3hnoSPGAuibQv+f9TZLr6cv/Hm9XgU50cw="; - extra-substituters = "https://devenv.cachix.org"; + extra-substituters = [ + "https://devenv.cachix.org" + "https://nix-community.cachix.org" + ]; + extra-trusted-public-keys = [ + "devenv.cachix.org-1:w1cLUi8dv3hnoSPGAuibQv+f9TZLr6cv/Hm9XgU50cw=" + "nix-community.cachix.org-1:mB9FSh9qf2dCimDSUo8Zy7bkq5CX+/rkCWyvRCYg3Fs=" + ]; }; }