Plan: Pipeline Decomposition (Post mod.rs Refactor)¶
Status: Proposed Date: 2026-02-08 Predecessor: mod.rs refactor (done — mod.rs now 46 lines, dispatch in own file) RFC: RFC-001-dx-ux-governance.md, Wave B1/B2 Constraint: No behavior changes, no output-contract changes,
cargo test --workspacegreen after each step.
Problem¶
The mod.rs extraction succeeded (1173 → 46 lines), but created a new concentration point. pipeline.rs (557 lines) now combines 5 distinct concerns that were previously mixed in mod.rs. Additionally, run.rs and ci.rs contain identical error-handling and report-timing patterns.
Verified Findings¶
F1 — pipeline.rs is a new god-module (557 lines, 5 concerns)¶
| Lines | Concern | Target |
|---|---|---|
| 11–91 | PipelineInput + from_run/from_ci | stays in pipeline.rs |
| 93–96 | PipelineError enum | → pipeline_error.rs |
| 123–310 | execute_pipeline() | stays in pipeline.rs |
| 312–334 | write_error_artifacts() | → reporting.rs |
| 336–364 | build_summary_from_artifacts() | → reporting.rs |
| 366–429 | build_performance_metrics() | → reporting.rs |
| 431–441 | print_pipeline_summary() | → reporting.rs |
| 443–454 | maybe_export_baseline() | → reporting.rs |
| 456–557 | Tests (performance metrics) | → reporting.rs |
After extraction: pipeline.rs drops from 557 → ~250 lines (input mapping + execution only).
F2 — Identical error-handling block in run.rs and ci.rs¶
Lines 17–31 in both files are identical 15-line match blocks:
// run.rs:17 and ci.rs:17 — identical
let execution = match execution {
Ok(ok) => ok,
Err(PipelineError::Classified { run_error }) => {
let reason = reason_code_from_run_error(&run_error)
.unwrap_or(ReasonCode::ECfgParse);
return write_error_artifacts(reason, run_error.message, ...);
}
Err(PipelineError::Fatal(err)) => return Err(err),
};
Fix: Method on PipelineError that encapsulates this mapping.
F3 — Duplicate elapsed_ms() definitions¶
Two identical helper functions: - pipeline.rs:114–121 (7 lines, with bounds check) - profile.rs:488–490 (1-liner, inline min())
Plus two inline occurrences of the same pattern: - run.rs:53: report_start.elapsed().as_millis().min(u128::from(u64::MAX)) as u64 - ci.rs:104: identical
Fix: One shared elapsed_ms() in a tiny util, used everywhere.
F4 — Repetitive PipelineError::Classified construction (10 instances)¶
All 10 follow the same pattern in execute_pipeline():
Fix: Small constructor methods on PipelineError (cfg_err, missing_cfg, invalid_args, from_run_error).
F5 — Report-timing + summary rebuild duplication¶
Both run.rs and ci.rs: 1. Create report_start = Instant::now() 2. Write output formats (run.json, summary, etc.) 3. Measure report_ms = elapsed(report_start) 4. Rebuild summary with build_summary_from_artifacts(..., Some(report_ms)) 5. Write final summary
The difference: ci.rs additionally writes JUnit, SARIF, OTel, PR comment. Both need the same summary finalization pattern.
Fix: finalize_and_write_summary() helper in reporting.rs that takes the pre-built summary and writes with timing.
Target Structure¶
commands/
mod.rs (46 lines) — unchanged
dispatch.rs (54 lines) — unchanged
pipeline.rs (~250 lines) — PipelineInput + execute_pipeline only
pipeline_error.rs (~50 lines) — PipelineError + constructor methods + error→reason mapping
reporting.rs (~200 lines) — summary building, error artifacts, console output, perf metrics
reporting_tests.rs (~100 lines) — performance metric tests (from pipeline.rs)
run.rs (~40 lines) — thin: input → pipeline → report
ci.rs (~140 lines) — thin: input → pipeline → CI outputs → report
run_output.rs (445 lines) — unchanged
runner_builder.rs (262 lines) — unchanged
Alternatively, reporting_tests.rs can stay as #[cfg(test)] mod tests inside reporting.rs.
Steps¶
Step 1: Extract pipeline_error.rs¶
Move from pipeline.rs: - PipelineError enum (lines 93–96) - elapsed_ms() helper (lines 114–121) — make pub(crate)
Add constructor methods:
impl PipelineError {
pub(crate) fn cfg_parse(path: impl Into<String>, msg: impl Into<String>) -> Self {
Self::Classified {
run_error: RunError::config_parse(Some(path.into()), msg.into()),
}
}
pub(crate) fn missing_cfg(path: impl Into<String>, msg: impl Into<String>) -> Self {
Self::Classified {
run_error: RunError::missing_config(path.into(), msg.into()),
}
}
pub(crate) fn invalid_args(msg: impl Into<String>) -> Self {
Self::Classified {
run_error: RunError::invalid_args(msg.into()),
}
}
pub(crate) fn from_run_error(run_error: RunError) -> Self {
Self::Classified { run_error }
}
/// Map pipeline error to exit code + write error artifacts.
/// Shared between run.rs and ci.rs.
pub(crate) fn into_exit_code(
self,
version: ExitCodeVersion,
verify_enabled: bool,
run_json_path: &Path,
) -> anyhow::Result<i32> {
match self {
Self::Classified { run_error } => {
let reason = reason_code_from_run_error(&run_error)
.unwrap_or(ReasonCode::ECfgParse);
write_error_artifacts(reason, run_error.message, version, verify_enabled, run_json_path)
}
Self::Fatal(err) => Err(err),
}
}
}
Update pipeline.rs: Replace 10 verbose PipelineError::Classified { run_error: RunError::... } constructions with one-liner constructors.
Update run.rs and ci.rs: Replace 15-line match block with:
let execution = match execute_pipeline(&input, legacy_mode).await {
Ok(ok) => ok,
Err(e) => return e.into_exit_code(version, !args.no_verify, &run_json_path),
};
Verification: - cargo build -p assay-cli - cargo test -p assay-cli - Identical behavior: error exit codes and run.json/summary.json content unchanged
Step 2: Extract reporting.rs¶
Move from pipeline.rs: - write_error_artifacts() (lines 312–334) - build_summary_from_artifacts() (lines 336–364) - build_performance_metrics() (lines 366–429) - print_pipeline_summary() (lines 431–441) - maybe_export_baseline() (lines 443–454) - #[cfg(test)] mod tests (lines 456–557)
All functions remain pub(crate). Imports from run_output and assay_core::report.
Update pipeline.rs: Remove moved functions, add use super::reporting::* where needed (only write_error_artifacts if referenced by pipeline_error.rs).
Update run.rs and ci.rs: Change imports from super::pipeline:: to super::reporting:: for summary/printing/baseline functions.
Verification: - cargo build -p assay-cli - cargo test -p assay-cli (especially performance metric tests)
Step 3: Deduplicate elapsed_ms()¶
- Remove
elapsed_ms()frompipeline.rs(moved topipeline_error.rsin step 1) - Remove
elapsed_ms()fromprofile.rs:488–490 - Both import from
pipeline_error::elapsed_ms - Replace inline occurrences in
run.rs:53andci.rs:104withelapsed_ms(report_start)
Verification: - cargo build -p assay-cli - cargo test -p assay-cli -p assay-evidence
Step 4: Summary finalization helper (optional, only if Step 1–3 feel clean)¶
If after steps 1–3 the report-timing pattern in run.rs and ci.rs still feels duplicated, extract:
// reporting.rs
pub(crate) fn finalize_summary(
base_summary: Summary,
report_start: Instant,
summary_path: &Path,
) -> anyhow::Result<()> {
let report_ms = elapsed_ms(report_start);
let summary = base_summary.with_report_ms(report_ms);
write_summary(&summary, summary_path)
}
Decision gate: Only do this if run.rs and ci.rs still have >5 lines of identical summary-writing code after steps 1–3. If the duplication is small (3–4 lines), leave it — premature abstraction.
What This Does NOT Change¶
run_output.rs(445 lines) — separate concern (outcome decision + run.json formatting), no overlap with pipelinerunner_builder.rs(262 lines) — separate concern (Runner construction), no overlap- Any output contract (
run.json,summary.json, SARIF, JUnit) - Any exit code behavior
- Any test behavior
Verification Checklist¶
-
cargo build -p assay-clicompiles after each step -
cargo test -p assay-clipasses after each step (especiallyperformance_metrics_*tests) -
cargo clippy -p assay-cli -- -D warningsclean after each step -
pipeline.rs< 260 lines after step 2 -
run.rs< 50 lines,ci.rs< 150 lines after step 1 - No
elapsed_msduplication after step 3 - Zero
PipelineError::Classified { run_error: RunError::verbose constructions after step 1 -
run.rsandci.rserror handling blocks < 5 lines each after step 1 -
cargo test --workspacegreen
Risk Assessment¶
| Risk | Mitigation |
|---|---|
| Import churn breaks replay.rs | replay.rs uses super::run_output:: not super::pipeline:: — not affected |
| Tests break during move | Move tests with their functions, run after each step |
| Circular imports between pipeline_error.rs and reporting.rs | pipeline_error.rs only depends on assay_core::errors::RunError + exit_codes, not on reporting |
| Over-extraction | Step 4 has explicit decision gate — skip if duplication is small |
Line Count Budget¶
| File | Before | After | Delta |
|---|---|---|---|
| pipeline.rs | 557 | ~250 | -307 |
| pipeline_error.rs | — | ~50 | +50 |
| reporting.rs | — | ~300 | +300 |
| run.rs | 64 | ~40 | -24 |
| ci.rs | 207 | ~140 | -67 |
| profile.rs | 654 | 651 | -3 |
| Net | 1482 | ~1431 | -51 |
Net line reduction is small — this is about separation of concerns, not line golf.