cargo-crap4rust 0.7.0

Cargo subcommand for computing CRAP scores across Rust crates
Documentation
# Architecture

How a `cargo crap4rust` invocation actually flows through the code. This is
a map of what exists today, not a decision record — see `docs/ADRs/` for the
"why" behind the shapes described here, and `docs/FORMULA.md` for how the
scores themselves are computed.

---

## Pipeline

```
Args (clap)
  → Config::from_args(args)
    → App
        resolver:          Box<dyn PackageResolver>    — resolves Cargo packages to score
        discovery:         Box<dyn FunctionDiscovery>   — finds functions inside each package
        coverage_provider: Box<dyn CoverageProvider>    — provides coverage data for them
        scorer:            Box<dyn Scorer>              — turns both into scored reports
        reporter:          Box<dyn Reporter>             — renders the final report
      → App::run()
          1. resolver.resolve(&config) -> Vec<PackageContext>
          2. coverage_provider.provide(&config, &packages) -> Vec<CoverageRecord>
             (generates coverage via `cargo llvm-cov` first if --coverage
             was not given; either way, records are not checked for
             emptiness yet)
          3. discovery.discover(&package) -> Vec<SourceFunction>, once per
             package, accumulated; bail if the total is empty
          4. bail if the coverage records collected in (2) are empty
          5. CoverageIndex::from_records(records); bail if not a single
             discovered function matches any record by file + line span
          6. scorer.score_functions(functions, &coverage_index, &config)
             -> Vec<FunctionReport>, sorted by crap_score descending
          7. scorer.project_metrics(&reports, &config) -> ProjectMetrics
          8. reporter.render(&ProjectReport { .. }, &config)
          9. Ok(report.exit_code(&config))
```

The four bail points (empty functions, empty coverage, no matches, plus
whatever error `resolve`/`provide`/`discover` themselves return) all
surface as `Result::Err` from `App::run()`, printed by `main.rs` as
`error: {message}` with exit code `2` — distinct from a *successful* run
that nonetheless found crappy functions and exits `1`.

`App` (`app.rs`) is the only place that wires concrete types to trait
objects — everywhere else in the codebase depends on the `traits/`
interfaces, never on `CargoPackageResolver`/`SourceFunctionDiscovery`/
`LlvmCovProvider`/`DefaultScorer`/`StdoutReporter` directly. See
`docs/ADRs/ADR-DynDispatchAppOverGenerics.md` for why these are `Box<dyn
Trait>` fields rather than generic parameters.

## The five injected seams (`traits/`)

| Trait | Concrete impl | Responsibility |
|---|---|---|
| `PackageResolver` | `CargoPackageResolver` (`cargo_package_resolver.rs`) | Run `cargo_metadata`, select the requested (or, for a multi-member workspace with none requested, every) package, and build each one's `PackageContext` — manifest dir, workspace root, and resolved source roots. |
| `FunctionDiscovery` | `SourceFunctionDiscovery` (`source_function_discovery.rs`) | Walk a single package's source roots and return every production `SourceFunction` found, complexity already computed. |
| `CoverageProvider` | `LlvmCovProvider` (`llvm_cov_provider.rs`) | Resolve a coverage JSON path (generating one via `cargo llvm-cov` if none was given) and parse it into `CoverageRecord`s. |
| `Scorer` | `DefaultScorer` (`default_scorer.rs`) | Turn discovered functions plus a `CoverageIndex` into scored, sorted `FunctionReport`s, and fold those into project-level `ProjectMetrics`. |
| `Reporter` | `StdoutReporter` (`stdout_reporter.rs`) | Render a `ProjectReport` as human-readable text or JSON. |

Every trait's real implementation keeps its underlying logic as plain,
directly-unit-testable functions in the same file (`resolve_packages`,
`discover_functions`, `ensure_coverage_path`/`load_coverage_records`) —
the trait `impl` itself is a thin delegator, not a rewrite. `tests/app_tests.rs`
is the seam these five traits exist for: it builds `App` via
`App::with_deps()` with fakes standing in for whichever one dependency a
given test wants to fail or return edge-case data from, without touching a
real Cargo workspace, filesystem, or `cargo llvm-cov` subprocess for the
other four.

## Per-package function discovery: `FileWalker` and `TestModuleRegistry`

`SourceFunctionDiscovery::discover` delegates to `FileWalker::process_source_root`,
which runs in two phases per source root:

1. **`collect_parsed_files`** — walk every `.rs` file under the source root,
   apply the path-based selection rules (`is_selected_relative_file`,
   `is_selected_source_file`, `is_excluded_relative_file` — `examples/`,
   `benches/`, `build.rs`, and, unless `--include-test-targets`, `tests/`),
   read and `syn::parse_file` every file that survives, and return the
   whole `Vec<(PathBuf, syn::File)>` — nothing is scored yet.
2. **`TestModuleRegistry::build`** runs once over that same in-memory set,
   resolving every file-based `#[cfg(test)] mod name;` declaration (inline
   `mod { ... }` blocks are recursed into for nested declarations) to the
   file it would compile to, before any file's functions are recorded. The
   main loop then skips any parsed file the registry resolved to, and
   `visit_items`/`visit_item`/`visit_module` walk everything else, each
   *returning* the `Vec<SourceFunction>` it found rather than writing into a
   shared accumulator. See `docs/ADRs/ADR-CrossFileTestModuleExclusion.md`
   for why this needs to be a project-wide pass rather than a per-file
   check, and its scope boundary (single-level, non-recursive across
   files).

Both phases read every candidate file's contents once; `TestModuleRegistry`
and the actual function walk both operate on the same already-parsed
`syn::File`, not a second read-and-reparse.

## Per-impl-block discovery: `ImplCollector`

`ImplCollector` (`impl_collector.rs`) handles the one case `FileWalker`'s
own AST walk delegates out: an `impl Type { ... }` block's methods, each
qualified with the type's own name (`impl_type_name`) so `Foo::bar` and
`Baz::bar` are reported as distinct functions even when their method names
collide. `is_test_attrs`/`is_test_path` (also in this file) recognize
`#[test]` and `#[cfg(test)]` — by trailing path segment, so
`#[tokio::test]`-style attributes are caught too — and are shared by both
`FileWalker` and `ImplCollector` so a test function is excluded identically
whether it is a free function, a method, or (via `TestModuleRegistry`) an
entire file-based submodule.

## Data model

| Type | Scope | Carries |
|---|---|---|
| `PackageContext` | one resolved Cargo package | name, manifest dir, workspace root, resolved source roots, and the `include_test_targets`/`exclude_paths` settings that applied when it was built |
| `SourceFunction` | one discovered function | qualified name, file path (both a normalized `path_key` for coverage matching and a human-readable `relative_file`), `[line, end_line]` span, and its own `complexity` |
| `CoverageRecord` | one llvm-cov region (post-merge) | `path_key`, `line`, `covered_regions`/`total_regions` |
| `FunctionReport` | one scored function | everything `SourceFunction` carries, plus `coverage`, `crap_score`, and `verdict` |
| `ProjectMetrics` | one `App::run()` invocation | `total_functions`, `crappy_functions`, `crappy_percent`, project-level `verdict` |
| `ProjectReport` | one `App::run()` invocation | `scope_name` (every scored package's name, joined) plus every field `ProjectMetrics` carries, plus the full `Vec<FunctionReport>` |

`ProjectReport` is what `Reporter::render` turns into either the
human-readable table or the `--output-format json` output — it is the
single shape both output modes are projections of, and the same shape
`ProjectReport::exit_code` reads to decide the process's exit code.

## CLI layer

`Args` (`cli.rs`, `clap::Parser`) parses argv into flags; `Config`
(`config.rs`) is the plain-data form `App` actually consumes, built once via
`Config::from_args(args)`. `main.rs` strips a leading `crap4rust` argument
(present when invoked as `cargo crap4rust ...` rather than the raw binary)
and `lib.rs::run()`/`run_from_args()` are thin entry points — all real logic
lives in `App` and below.

## Related

- `docs/FORMULA.md` — how `cognitive_complexity`, the CRAP score, and
  project-level verdicts are actually computed.
- `docs/ADRs/` — why `App` is shaped this way, why classification is
  structural rather than type-resolved, why file-based test submodules need
  their own registry, and why duplicate coverage records have to merge
  order-independently.
- `docs/ROADMAP.md` — what's shipped and what's planned next.