Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Plan Conventions & Best Practices

Reference document for writing Nexus implementation plans. Covers mise task usage, integration test standards, and plan document structure.


1. mise Tasks Over Raw Cargo

Plans MUST use mise run <task> instead of raw cargo commands. This ensures all developers and CI run the same commands, and makes it possible to add pre/post hooks or change underlying tooling without updating every plan.

Current mise tasks

TaskDescriptionUnderlying command
buildBuild all crates (debug)cargo build
build:releaseBuild all crates (release)cargo build --release
testRun all workspace testscargo test --workspace
test:unitRun unit tests only (no integration)cargo test --workspace --lib
test:libRun tests for nexus-libcargo test -p nexus-lib
test:nexusdRun tests for nexusdcargo test -p nexusd
test:nexusctlRun tests for nexusctlcargo test -p nexusctl
runRun nexusd (debug build)cargo run -p nexusd --
run:nexusctlRun nexusctl (debug build)cargo run -p nexusctl --
checkCheck all crates for errorscargo check --workspace
clippyRun clippy lintscargo clippy --workspace
install:nexusd:debugInstall nexusd debug build + systemd unit(copy + systemctl)
install:nexusd:releaseInstall nexusd release build + systemd unit(copy + systemctl)
install:nexusctl:debugInstall nexusctl debug build(copy)
install:nexusctl:releaseInstall nexusctl release build(copy)
install:debugInstall all binaries (debug)depends on above
install:releaseInstall all binaries (release)depends on above

Command mapping for plan authors

What you want to doWrong (raw cargo)Right (mise)
Build everythingcargo buildmise run build
Build for releasecargo build --releasemise run build:release
Run all testscargo test --workspacemise run test
Run nexus-lib testscargo test -p nexus-libmise run test:lib
Run nexusd testscargo test -p nexusdmise run test:nexusd
Run nexusctl testscargo test -p nexusctlmise run test:nexusctl
Run unit tests onlycargo test --workspace --libmise run test:unit
Check for errorscargo check --workspacemise run check
Run clippycargo clippy --workspacemise run clippy
Start nexusdcargo run -p nexusd --mise run run
Start nexusctlcargo run -p nexusctl --mise run run:nexusctl

Exception: targeted test runs

When running a specific test by name during TDD (e.g., verifying a single failing test), raw cargo is acceptable inline because there is no mise task for arbitrary test filters:

# Acceptable in TDD steps:
cargo test -p nexus-lib store::sqlite::tests::open_creates_new_database

If a plan repeatedly runs the same filtered test command, consider whether a new mise task would help. The threshold: if 3+ plans would use the same filter, add a task.

When to add new mise tasks

Add a new task to .mise.toml when:

  • A plan needs a command that will be reused across multiple steps/plans
  • The command involves multiple chained operations (build + copy + reload)
  • The command has flags that are easy to get wrong

Do NOT add a task for:

  • One-off manual verification steps
  • Targeted test runs with specific test name filters

Proposed missing tasks

TaskDescriptionWhen to add
test:integrationRun only integration testscargo test --workspace --test '*'
fmtFormat all codecargo fmt --all
fmt:checkCheck formatting without modifyingcargo fmt --all -- --check

2. Integration Test Standards

The problem today

Both nexusd/tests/daemon.rs and nexusctl/tests/cli.rs contain duplicated boilerplate:

  1. Daemon startupCommand::new(binary).env(...).arg(...).spawn()
  2. Readiness polling – loop 50 times, sleep 100ms, try HTTP GET
  3. Graceful shutdownsignal::kill(Pid::from_raw(...), SIGTERM)
  4. Port allocation – hardcoded ports (9600 for nexusd, 9601 for nexusctl)
  5. Temp DB pathtempfile::tempdir().join("test.db")
  6. Temp config – write YAML to temp file with custom listen address

This boilerplate will grow with every new integration test added in steps 4-10.

Proposed shared test harness

A TestDaemon helper that lives in nexus-lib as a test utility, gated behind a test-support feature flag so it doesn’t affect production builds.

Location: nexus-lib/src/test_support.rs (exported only when #[cfg(test)] or feature test-support is active)

Sketch:

#![allow(unused)]
fn main() {
// nexus-lib/src/test_support.rs

use std::net::TcpListener;
use std::path::{Path, PathBuf};
use std::process::{Child, Command};
use std::time::Duration;
use nix::sys::signal::{self, Signal};
use nix::unistd::Pid;

/// A running nexusd instance for integration tests.
/// Stops the daemon on drop.
pub struct TestDaemon {
    child: Child,
    pub addr: String,
    pub port: u16,
    pub db_path: PathBuf,
    _tmp_dir: tempfile::TempDir,
    _config_dir: tempfile::TempDir,
}

impl TestDaemon {
    /// Start a daemon on a random available port with a temp database.
    /// Polls until the health endpoint responds or timeout (5s).
    pub async fn start() -> Self {
        Self::start_with_binary(Self::find_nexusd_binary()).await
    }

    /// Start with an explicit binary path (for cross-package tests).
    pub async fn start_with_binary(binary: PathBuf) -> Self {
        let port = free_port();
        let addr = format!("127.0.0.1:{port}");

        let tmp_dir = tempfile::tempdir().expect("failed to create temp dir");
        let db_path = tmp_dir.path().join("test.db");

        let config_dir = tempfile::tempdir().expect("failed to create config dir");
        let config_path = config_dir.path().join("nexus.yaml");
        let config_yaml = format!("api:\n  listen: \"{addr}\"");
        std::fs::write(&config_path, config_yaml)
            .expect("failed to write test config");

        let child = Command::new(&binary)
            .env("RUST_LOG", "info")
            .arg("--config")
            .arg(&config_path)
            .arg("--db")
            .arg(&db_path)
            .spawn()
            .unwrap_or_else(|e| panic!("failed to start {}: {e}", binary.display()));

        let daemon = TestDaemon {
            child,
            addr: addr.clone(),
            port,
            db_path,
            _tmp_dir: tmp_dir,
            _config_dir: config_dir,
        };

        daemon.wait_ready().await;
        daemon
    }

    pub fn health_url(&self) -> String {
        format!("http://{}/v1/health", self.addr)
    }

    async fn wait_ready(&self) {
        let client = reqwest::Client::new();
        for _ in 0..50 {
            tokio::time::sleep(Duration::from_millis(100)).await;
            if client.get(self.health_url()).send().await.is_ok() {
                return;
            }
        }
        panic!("daemon did not become ready within 5 seconds on {}", self.addr);
    }

    /// Find the nexusd binary relative to the test binary location.
    fn find_nexusd_binary() -> PathBuf {
        let mut path = std::env::current_exe().expect("cannot get test binary path");
        path.pop(); // remove test-<hash>
        path.pop(); // remove deps
        path.join("nexusd")
    }
}

impl Drop for TestDaemon {
    fn drop(&mut self) {
        let _ = signal::kill(
            Pid::from_raw(self.child.id() as i32),
            Signal::SIGTERM,
        );
        let _ = self.child.wait();
    }
}

/// Find a free TCP port by binding to port 0 and reading the assigned port.
fn free_port() -> u16 {
    let listener = TcpListener::bind("127.0.0.1:0")
        .expect("failed to bind to port 0");
    listener.local_addr().unwrap().port()
}
}

Port allocation strategy

Current approach (hardcoded ports) – fragile. Tests fail when ports collide or are in use.

Recommended approach (port 0) – bind a TcpListener to 127.0.0.1:0, read the assigned port, drop the listener, then pass the port to the daemon config. There is a small TOCTOU window where another process could grab the port between the listener drop and the daemon bind, but this is extremely unlikely in practice and far better than hardcoded ports.

This eliminates all port conflict issues when cargo test --workspace runs test binaries in parallel.

Temp DB and config handling

  • Every integration test MUST use tempfile::tempdir() for its database. Never use the real $XDG_STATE_HOME path.
  • Every integration test that needs a non-default listen address MUST write a temp config file with the test port.
  • The TestDaemon helper handles both automatically.
  • Temp directories are cleaned up on drop via tempfile::TempDir.

Where the harness lives

Option A (recommended): nexus-lib with a test-support feature

# nexus-lib/Cargo.toml
[features]
test-support = ["dep:tempfile", "dep:nix", "dep:reqwest"]

[dependencies]
tempfile = { version = "3", optional = true }
nix = { version = "0.30", features = ["signal"], optional = true }
reqwest = { version = "0.12", features = ["json"], optional = true }

Both nexusd and nexusctl add:

[dev-dependencies]
nexus-lib = { path = "../nexus-lib", features = ["test-support"] }

Why not a separate crate: A nexus-test-support crate adds workspace complexity for minimal benefit. The feature flag keeps test utilities out of production builds while avoiding a new crate.

Usage after adoption

#![allow(unused)]
fn main() {
// nexusd/tests/daemon.rs
use nexus_lib::test_support::TestDaemon;

#[tokio::test]
async fn daemon_starts_serves_health_and_stops() {
    let daemon = TestDaemon::start().await;

    let client = reqwest::Client::new();
    let resp = client.get(daemon.health_url()).send().await.unwrap();
    assert_eq!(resp.status(), 200);

    let body: serde_json::Value = resp.json().await.unwrap();
    assert_eq!(body["status"], "ok");
    assert!(body["database"]["table_count"].is_number());
    assert!(daemon.db_path.exists());
    // daemon stops on drop
}
}
#![allow(unused)]
fn main() {
// nexusctl/tests/cli.rs
use nexus_lib::test_support::TestDaemon;

#[tokio::test]
async fn status_when_daemon_running() {
    let daemon = TestDaemon::start().await;

    let output = Command::new(env!("CARGO_BIN_EXE_nexusctl"))
        .args(["--daemon", &daemon.addr, "status"])
        .output()
        .unwrap();

    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(output.status.success());
    assert!(stdout.contains("ok"));
    // daemon stops on drop
}
}

Migration path

This harness does not need to exist before step 4. The current tests work. Introduce TestDaemon as part of step 4 (VM Records) when the third integration test file is added, then backfill the existing tests.


3. Plan Document Conventions

Standard structure

Every plan document MUST follow this structure. Sections marked (required) cannot be omitted.

# Step N: <Title> -- Implementation Plan        (required)

> **For Claude:** REQUIRED SUB-SKILL: ...       (required)

**Goal:** <one sentence>                         (required)

**Architecture:** <paragraph>                    (required)

**Tech Stack (additions to existing):**          (required if adding deps)
- `crate_name` version -- purpose

**XDG Directory Layout:**                        (if new paths are introduced)

---

## Task 1: <Title>                               (required, repeat per task)

**Files:**                                       (required)
- Create: `path/to/file`
- Modify: `path/to/file`

**Step 1: Write the failing test**               (TDD tasks)
**Step 2: Run tests to verify they fail**
**Step 3: Implement**
**Step 4: Run tests to verify they pass**
**Step 5: Commit**

---

## Verification Checklist                        (required)

- [ ] `mise run build` succeeds
- [ ] `mise run test` passes
- [ ] ...

TDD flow conventions

For tasks that add new functionality with testable behavior:

  1. Write the failing test first. Include the full test code in the plan.
  2. Show the run command. Use mise tasks (see Section 1). For targeted test runs, raw cargo with a filter is acceptable.
  3. State the expected failure. E.g., “FAIL – Config type does not exist yet.”
  4. Implement the code. Include the full implementation.
  5. Show the verification command. Same command as step 2.
  6. State the expected success. E.g., “All 4 tests PASS.”
  7. Commit.

For tasks where TDD doesn’t apply (systemd unit files, config wiring, mise task updates):

  1. Implement.
  2. Show verification. Manual smoke test or build command.
  3. State the expected result.
  4. Commit.

Commit message conventions

Format: <type>(<scope>): <description>

Types:

  • feat – new functionality
  • fix – bug fix
  • test – adding or updating tests (no production code change)
  • chore – build system, tooling, mise tasks, CI
  • refactor – code restructuring with no behavior change
  • docs – documentation only

Scope: the crate name (nexusd, nexusctl, nexus-lib) or mise for task changes. Omit scope for cross-cutting changes.

Examples:

feat(nexus-lib): add Config type with YAML loading and defaults
feat(nexusd): add GET /v1/health endpoint
test(nexusd): add integration test for daemon lifecycle
chore(mise): add nexusctl to build and install tasks
feat(nexus-lib): add NexusClient HTTP client for daemon API
test: update integration tests for database initialization

Verification checklist conventions

Every plan MUST end with a verification checklist. Include:

  1. Build verification: mise run build succeeds with no warnings
  2. Test verification: mise run test – all tests pass
  3. Lint verification: mise run clippy – no warnings (from step 3 onward)
  4. Functional verification: manual smoke tests for each user-visible feature added
  5. Negative verification: behavior when things go wrong (missing config, daemon not running, etc.)

Use - [ ] checkboxes. The implementer checks these off as they complete the plan.

Updating the progress tracker

The codex has a progress dashboard at src/tracker/progress.md. Update it when a step changes status. Four edits are needed:

  1. Kanban board — move the step card to the correct column (Complete / In Progress / Pending).
  2. Pie chart — update the counts to match.
  3. Details table — update the Status column and add a plan link if a new plan was written.
  4. Header line — update the “N of 10 steps complete” count.

Example: moving step 4 from Pending to In Progress:

  In Progress
-   No active steps
+   Step 4: VM Records CRUD

  Pending
-   Step 4: VM Records CRUD
    Step 5: btrfs Workspaces

Update the tracker at these points in the workflow:

  • When starting a step — move from Pending to In Progress
  • When completing a step — move from In Progress to Complete, add plan link to details table
  • When abandoning a step — remove from the board, note in the Issues section

Rebuild the codex after changes: cd ~/Work/WorkFort/codex && mise run build

Feasibility assessments

Plans that introduce new dependencies, external APIs, or system-level interfaces go through a technical feasibility assessment before being committed. The assessment is a temporary document — a code review for the plan — that is deleted after its findings are incorporated. See Assessment Conventions for the full process.

Key rules:

  • All MUST FIX items from the assessment MUST be incorporated into the plan before committing.
  • The plan is committed as a single commit with all corrections already applied.
  • Plans MUST NOT contain links or references to individual feasibility assessments.

Working directory

All commands in plans assume the working directory is ~/Work/WorkFort/nexus/ (the Rust workspace root). If a command needs a different directory, state it explicitly. Prefer absolute paths in cd commands:

# Good
cd ~/Work/WorkFort/nexus && mise run test

# Avoid
cd nexus && cargo test

File paths

Always use paths relative to the workspace root in the Files section:

**Files:**
- Create: `nexus/nexus-lib/src/store/schema.rs`
- Modify: `nexus/nexusd/src/main.rs`

In code blocks (Cargo.toml, Rust files), include a comment with the file path on the first line:

#![allow(unused)]
fn main() {
// nexus/nexusd/src/api.rs
use axum::{Json, Router, routing::get};
}
# nexus/nexus-lib/Cargo.toml
[package]
name = "nexus-lib"