bdd — spec-driven BDD/TDD CLI

CI Coverage Latest release Clippy

One native binary for the whole spec-driven loop (spec → Gherkin → RED → GREEN → REFACTOR) with an embedded MCP server that keeps the seven tdd-workflow-server tool contracts.

Full command manual: every command, subcommand, and flag with in-depth examples, searchable — davidparry.github.io/tdd-bdd-agentic/manual. Source lives in manual/src; rebuild with mdbook build cli/manual from the repository root (cargo install mdbook once). The built book is committed under docs/manual/ so GitHub Pages serves it.

The core theme

The requirements spec is the source of truth, and the discipline is enforced by tooling, not by convention. Everything else follows from that one idea:

The CLI grew out of a talk and hands-on class that teaches spec-driven development with BDD and TDD — this repository is that workshop (see ../student-follow-along.md). The class walks students through the loop manually against the Java tdd-workflow-server; this binary automates the same loop with byte-identical tool replies, so the lesson and the tool never drift apart.

How it differs from the closest projects

Stated as facts about what each tool does and does not do:

No existing tool combines a machine-validated spec with a wording refinement gate, real Cucumber across JVM, JavaScript/TypeScript, .NET, and Rust projects, an enforced TDD state machine, typed mutations with no file/shell escape hatches, an embedded MCP server with a frozen tool contract, and a local-only LLM — in one native binary. That combination is why this exists.

Status

Every roadmap phase through greenfield mode has landed, with clean architecture and full test coverage throughout:

Ollama model

Generation talks to a local Ollama instance. The model this CLI is developed and run against is qwen3-coder-next:latest — a coding model:

ollama pull qwen3-coder-next:latest
bdd model use qwen3-coder-next:latest

Your mileage will vary with a different model. A stronger coding model may draft, generate, and implement better; a model trained for chat, general knowledge, or work other than development will typically produce weaker specs, step definitions, tests, and production code. The CLI will use whatever Ollama has installed (or fall back to templates if none).

Interactive shell

Bare bdd prints the help and, when run in a terminal, opens an interactive shell so the loop never needs the bdd prefix retyped:

$ bdd
...help...

  ╭──────────────────────────────────╮
  │                                  ▼
  │    > bdd  v0.2.5                 │
  │    spec → RED → GREEN → REFACTOR │
  ▲                                  │
  ╰──────────────────────────────────╯

Model set for this session: qwen3-coder-next:latest (not saved - keep it with: bdd model use qwen3-coder-next:latest).
bdd> spec list
bdd> test
bdd> state
bdd> exit
Session over - 3 commands run.

The banner is the CLI mark in ASCII — the red→green cycle looping around the prompt — with the compiled-in version.

The shell announces the model status on startup: the configured model if one is set; otherwise the first installed Ollama model, borrowed for this session only (nothing is written until you run bdd model use <name>). When Ollama is unreachable it says to install it from ollama.com, and when no models are pulled it gives the exact command (ollama pull qwen3-coder-next:latest) — generation falls back to deterministic templates either way. See Ollama model for why that name, and why another model will change the quality of generated work.

On a brand-new project the shell notices and offers the loop directly: when this is the first session in the root (no .bdd-history yet), a model is ready, and there is no requirements/requirements.json, it asks "It appears you are in a greenfield - start with the greenfield command now? [y/N]"y runs bdd greenfield on the spot, anything else drops to the prompt.

Supported target languages

Ecosystem BDD framework Marker files Runtime probed
Java Cucumber-JVM pom.xml, build.gradle, build.gradle.kts java
JavaScript Cucumber-JS package.json node
TypeScript Cucumber-JS package.json + tsconfig.json node
.NET Reqnroll (SpecFlow's successor) *.csproj, *.sln dotnet
Rust cucumber-rs Cargo.toml cargo

Architecture

Clean architecture; the dependency rule points inward, and only the composition roots (main.rs, the MCP delivery in src/mcp.rs, and the greenfield orchestrator in src/greenfield.rs) name concrete adapters:

Layer Module Contents
Domain src/domain/ Requirement model, spec validator, wording refiner, TDD state machine, language detection, Gherkin feature model, step discovery, generation templates, scaffolds. Pure logic, no IO.
Ports src/ports.rs Traits the inner layers depend on: SpecRepository, FeatureFiles, FeatureCatalog, ChangeStore, Prompter, StateStore, TestRunner, LlmGenerator, ModelCatalog, ModelStore, ProjectFiles, SourceFiles, ScaffoldWriter, RuntimeProbe, InteractiveShell.
Application src/application/ Use-case services (SpecService, SpecMutationService, ScenarioService, ChangeService, TddService, GenerationService, InitService, ModelService, InspectService) composed via constructor injection. The interactive shell loop lives in src/repl.rs.
Adapters src/adapters/ Filesystem spec/feature/staging/state/source access, the four test runners (Maven, cucumber-js, dotnet, cargo), Ollama HTTP catalog and generator, TOML config store, console prompter, rustyline shell with the persistent .bdd-history, runtime probe.

Building

Requires stable Rust (edition 2024, rust-version = 1.97; pinned via rust-toolchain.toml). All commands run from this cli/ directory.

Dev builds

Fast compile, debug assertions on — the everyday loop:

cargo build
./target/debug/bdd --help
./target/debug/bdd --root .. spec validate   # against the workshop repo

The profile picks the output directory: plain cargo build writes target/debug/bdd, only cargo build --release writes target/release/bdd. Flags like --all-targets add compile targets (tests, benches), not profiles — after a cargo clean, a release binary exists only once you build with --release.

Release builds (standalone executable)

Optimized, self-contained native binary:

cargo build --release
./target/release/bdd --help

Or build and install onto your PATH in one step:

cargo install --path .    # drops bdd into ~/.cargo/bin/

Distribution model: one native executable per OS/architecture, bundling the Gherkin parser, MCP server, validators, and adapters. The target project still needs its own toolchain — a JDK for Cucumber-JVM, Node.js for Cucumber-JS, the .NET SDK for Reqnroll, the Rust toolchain for cucumber-rs. The CLI never installs runtimes or dependencies during a run.

Production releases (CI)

Releases are built by dist (cargo-dist) through the generated .github/workflows/release.yml. Pushing a version tag builds, packages, checksums, and attaches everything to a GitHub Release. scripts/release.sh automates the whole cut — it runs the test suite, bumps the version in cli/Cargo.toml (patch by default, or pass an exact version), syncs Cargo.lock, folds the change into the branch's single squashed commit, and pushes the tag:

scripts/release.sh          # bump the patch version and release
scripts/release.sh 0.2.5    # ship this version when Cargo.toml is already there

If cli/Cargo.toml already has the version you want to ship, pass it explicitly so the script does not patch-bump again.

To trigger a production build by hand instead, push a tag matching the version in cli/Cargo.toml (the tag is what starts the Release workflow):

git tag v<MAJOR.MINOR.PATCH> && git push origin v<MAJOR.MINOR.PATCH>

# example, with cli/Cargo.toml at version = "0.2.5":
git tag v0.2.5 && git push origin v0.2.5

Tag the commit you want released only after it is pushed, and make sure the version has not been released before — dist matches the tag against cli/Cargo.toml and publishes the GitHub Release from it.

The version reported by bdd -V is compiled in, so a binary built before a bump keeps reporting the old number until it is rebuilt (cargo build --release) or reinstalled from the new release.

Each release carries binaries for macOS (Apple Silicon and Intel), Linux (x86_64 and arm64), and Windows (x86_64), plus shell and PowerShell installers. The configuration lives in dist-workspace.toml at the repository root (it points at this cli/ workspace); after changing it, run dist generate from the repository root to regenerate the workflow.

The shell installer places the binary in $CARGO_HOME/bin (usually ~/.cargo/bin/bdd) and writes an install receipt to ~/.config/bdd-cli/bdd-cli-receipt.json.

Uninstalling

Each release also ships bdd-cli-uninstaller.sh (source: scripts/bdd-cli-uninstaller.sh). It reads the install receipt, removes the installed binaries and the receipt, and warns about anything it deliberately leaves alone (the shared ~/.cargo/env PATH hook, which rustup also uses):

curl -LsSf https://github.com/davidparry/tdd-bdd-agentic/releases/latest/download/bdd-cli-uninstaller.sh | sh -s -- -y

Drop the -y to get a confirmation prompt listing what will be removed before anything is deleted.

Tests

cargo test                   # everything: unit + cucumber
cargo test --lib             # unit tests only
cargo test --test cucumber   # spec-driven cucumber scenarios only
cargo llvm-cov --ignore-filename-regex 'main\.rs' --summary-only

Two documented exclusions: - main.rs is the composition root: it only parses arguments and wires adapters into services, so exercising it needs spawned-binary integration tests that would duplicate what the Cucumber suite already proves through the same services. It is excluded from the metric. A side effect is that generic services instantiated only by main.rs (with the real filesystem adapters) show a few residual "missed" instantiations in the per-file table; the Uncovered Lines list (--show-missing-lines) is the ground truth. - read_line/tell in adapters/readline_shell.rs are terminal glue that needs a real tty; everything mappable behind them (map_readline, history persistence) is unit-tested.

Accepted architecture trade-offs (reviewed, kept as-is): - greenfield.rs and mcp.rs construct filesystem adapters directly — they are composition roots like main.rs, wiring the same services onto a different delivery mechanism. - workshop_layout() hard-codes the Java kata paths so the frozen get_requirement tool stays byte-identical to the Java server. - The feature-file surface has two ports (FeatureFiles for existence and tag checks, FeatureCatalog for parsing) because the spec validator and the readers genuinely need different capabilities.

Contributing

This project practices what it preaches — changes are spec-driven and test-first:

  1. Start with the spec. New behavior begins as a scenario in tests/features/*.feature (and unit tests beside the module). Watch it fail (RED), implement the simplest thing that passes (GREEN), then refactor on a green bar.
  2. Respect the dependency rule. Domain code takes no IO and imports nothing from adapters/; anything the inner layers need from the outside world enters through a trait in src/ports.rs. Only the composition roots — main.rs, mcp.rs, and greenfield.rs — may name concrete adapter types.
  3. Keep the tool contracts frozen. The seven adopted tools (list_requirements, get_requirement, validate_spec, refine_requirement, run_tests, get_tdd_state, start_refactor) must stay byte-identical to the Java tdd-workflow-server — reply strings included. The Java sources under ../mcp-server/ are the reference; the unit tests here are the conformance suite.
  4. Never expose escape hatches. No write_file, run_shell, install_dependency, or arbitrary-path tools. Mutations go through typed, validated tools only.
  5. Before opening a PR:
cargo test && cargo clippy --all-targets && cargo fmt --check

The roadmap phases through greenfield mode — foundation (read tools, MCP transport) → controlled authoring (staged mutations) → Java support + TDD state → JavaScript/TypeScript → .NET → Rust → greenfield mode — have all landed; hardening (security, packaging) is the open phase.

Greenfield mode flow

bdd greenfield runs the whole creation order from an empty directory, consulting the human at exactly two moments — the wording of the driving spec, and the review of generated tests before they are committed.

flowchart TD subgraph auto0 [CLI automated - phase 0] scaffold["Scaffold: build files, Cucumber runner,
empty spec, .bdd-mcp.toml config"] end subgraph human1 [Human input - phase 1: the driving spec] describe["Human describes what to build in plain words"] split["Model splits the description into requirement
proposals (title, story, criteria); human picks one"] draft["Wizard walks each field with the proposal
pre-filled - Enter accepts, typing replaces"] vloop["validate_spec + refine_requirement loop,
findings shown, human rewords"] approve{"Human approves wording"} describe --> split --> draft --> vloop --> approve end subgraph auto2 [CLI automated - phases 2 and 3] gherkin["scenario_add from acceptance criteria,
tagged with requirement id"] stepdefs["step_definition_create for undefined steps"] unit["unit_test_create from criteria"] gherkin --> stepdefs --> unit end ask{"Assertion derivable?"} humanUnit["Human supplies the assertion"] subgraph auto4 [CLI automated - phase 4] skeleton["Compile-only production skeleton"] redRun["run_tests -> RED"] skeleton --> redRun end implement["Enter: the model attempts the implementation
(a number buys that many hands-off attempts;
or the developer implements by hand)"] subgraph auto5 [CLI automated - close the loop] greenRun["run_tests -> GREEN"] refactorGate["start_refactor -> run_tests still GREEN"] markDone["requirement_mark_implemented"] greenRun --> refactorGate --> markDone end scaffold --> describe approve --> gherkin unit --> ask ask -->|yes| skeleton ask -->|no| humanUnit --> skeleton redRun --> implement --> greenRun markDone -->|"next requirement"| describe

Guard rails: greenfield mode checks the target language's runtime up front and offers authoring-only mode when it is missing (it never installs one), and it resolves the LLM model first — with no model configured it borrows the first installed Ollama model for the session (persist a choice any time with bdd model use <name>).

The description step needs a resolved model; without one (or when the description is left blank, the model is unreachable, or its reply holds no complete requirement) drafting falls back to the manual prompts — title, story, criteria — unchanged. A proposal only qualifies when it arrives complete: title, story, and at least one Given/When/Then criterion. When the description holds several requirements, the wizard lists their titles, you pick one to start with, and the rest are named for later runs.

Notable dependencies

License

AGPL-3.0 — see LICENSE.