Skip to main content

unsafe_review_core/
api.rs

1use crate::analysis::{pipeline, receipts};
2use crate::domain::{CardId, ReviewCard};
3use crate::freshness::AnalysisIdentity;
4use crate::input::workspace;
5use crate::output::{
6    agent, badges, comment_plan, confirmation, gate_manifest, human, json, lsp, markdown, outcome,
7    policy_report, receipt_audit, repair_queue, sarif, usefulness_telemetry, witness_plan,
8};
9use crate::policy::SnapshotCoverage;
10use crate::util::path_display;
11use std::collections::{BTreeMap, BTreeSet};
12use std::path::{Path, PathBuf};
13
14#[derive(Clone, Debug, PartialEq, Eq)]
15pub enum Scope {
16    Diff,
17    Repo,
18}
19
20impl Scope {
21    pub fn as_str(&self) -> &'static str {
22        match self {
23            Self::Diff => "diff",
24            Self::Repo => "repo",
25        }
26    }
27}
28
29#[derive(Clone, Debug, PartialEq, Eq)]
30pub enum AnalysisMode {
31    Instant,
32    Draft,
33    Ready,
34    Repo,
35}
36
37impl AnalysisMode {
38    pub fn as_str(&self) -> &'static str {
39        match self {
40            Self::Instant => "instant",
41            Self::Draft => "draft",
42            Self::Ready => "ready",
43            Self::Repo => "repo",
44        }
45    }
46}
47
48#[derive(Clone, Debug, PartialEq, Eq)]
49pub enum PolicyMode {
50    Advisory,
51    NoNewDebt,
52    Blocking,
53}
54
55impl PolicyMode {
56    pub fn as_str(&self) -> &'static str {
57        match self {
58            Self::Advisory => "advisory",
59            Self::NoNewDebt => "no-new-debt",
60            Self::Blocking => "blocking",
61        }
62    }
63}
64
65#[derive(Clone, Debug, PartialEq, Eq)]
66pub enum DiffSource {
67    NoneRepoScan,
68    Text(String),
69    File(PathBuf),
70}
71
72#[derive(Clone, Debug, PartialEq, Eq)]
73pub enum RepoScanPhase {
74    Discovering,
75    Scanning,
76    Complete,
77}
78
79impl RepoScanPhase {
80    pub fn as_str(&self) -> &'static str {
81        match self {
82            Self::Discovering => "discovering",
83            Self::Scanning => "scanning",
84            Self::Complete => "complete",
85        }
86    }
87}
88
89/// Why a repo scan stopped short of scanning every discovered file.
90///
91/// A `Complete` scan has `stop_reason: None` (or equivalently `"none"`
92/// in the JSON sidecar).  Every other variant indicates a bounded-but-partial
93/// run; `completed` stays `false` for all partial variants.
94#[derive(Clone, Debug, PartialEq, Eq)]
95pub enum RepoStopReason {
96    /// Scan ran to completion — every in-scope file was read.
97    None,
98    /// `--max-cards N` was reached; scanning stopped after `N` cards were emitted.
99    MaxCards,
100    /// `--timeout-seconds N` elapsed while the scan was in progress.
101    Timeout,
102    /// A unix signal (SIGTERM / SIGINT) interrupted the scan.
103    Terminated,
104    /// The scan did not complete due to an analysis or report-write error
105    /// (anything that is not a timeout, signal, or cap).
106    Error,
107}
108
109impl RepoStopReason {
110    pub fn as_str(&self) -> &'static str {
111        match self {
112            Self::None => "none",
113            Self::MaxCards => "max_cards",
114            Self::Timeout => "timeout",
115            Self::Terminated => "terminated",
116            Self::Error => "error",
117        }
118    }
119}
120
121/// Per-file scan timing entry.  Diagnostic only — not a coverage claim,
122/// proof, UB-free, Miri-clean, site-execution, or performance guarantee.
123#[derive(Clone, Debug, PartialEq, Eq)]
124pub struct PerFileScanStats {
125    /// Repository-relative path of the scanned file.
126    pub file: PathBuf,
127    /// Wall-clock milliseconds spent scanning this file (parse + site detection).
128    pub scan_ms: u64,
129}
130
131#[derive(Clone, Debug, PartialEq, Eq)]
132pub struct RepoScanStatus {
133    pub schema_version: String,
134    pub phase: RepoScanPhase,
135    pub elapsed_ms: u64,
136    pub files_discovered: usize,
137    pub files_scanned: usize,
138    pub cards_found: usize,
139    pub last_path: Option<PathBuf>,
140    pub completed: bool,
141    /// Whether this is a partial (bounded) scan result.
142    /// `true` for max-cards, timeout, and signal-terminated scans.
143    pub partial: bool,
144    /// The reason the scan stopped.  `None` for a complete scan, or one of the
145    /// named stop reasons for a bounded/interrupted scan.
146    pub stop_reason: RepoStopReason,
147    /// The configured card cap when `stop_reason == MaxCards`; `None` otherwise.
148    pub cap: Option<usize>,
149    /// Per-file timing breakdown for diagnostic use.  Present only when the
150    /// scan covered fewer than [`FILE_TIMINGS_CAP`] files; `None` for large
151    /// scans (cap honesty: the field is absent, not silently truncated).
152    /// This is a **diagnostic aperture only** — not a coverage claim, proof,
153    /// UB-free, Miri-clean, site-execution, or performance guarantee.
154    pub file_timings: Option<Vec<PerFileScanStats>>,
155    /// Total bytes written to the output artifact(s) for this run.  `Some`
156    /// only after the final report file is successfully written; `None` for
157    /// in-progress, error, timeout, signal-terminated, and capped states where
158    /// no final artifact was produced.
159    ///
160    /// This is a **diagnostic aperture only** — it measures the disk footprint
161    /// of this run's output, not the files scanned.  It is not a coverage
162    /// claim, proof, UB-free, Miri-clean, site-execution, or performance
163    /// guarantee.
164    pub output_bytes: Option<u64>,
165}
166
167/// Maximum number of files for which per-file timing is collected.
168/// Scans touching more files omit `file_timings` entirely rather than
169/// silently emitting a partial list (truncation honesty).
170pub const FILE_TIMINGS_CAP: usize = 100;
171
172#[derive(Clone, Debug, Default, PartialEq, Eq)]
173pub struct DiscoveryOptions {
174    pub include: Vec<String>,
175    pub exclude: Vec<String>,
176    pub respect_gitignore: bool,
177    pub large_repo_ignores: bool,
178    pub max_files: Option<usize>,
179}
180
181impl DiscoveryOptions {
182    pub fn repo_defaults() -> Self {
183        Self {
184            respect_gitignore: true,
185            large_repo_ignores: true,
186            ..Self::default()
187        }
188    }
189}
190
191#[derive(Clone, Debug)]
192pub struct AnalyzeInput {
193    pub root: PathBuf,
194    pub scope: Scope,
195    pub diff: DiffSource,
196    pub mode: AnalysisMode,
197    pub policy: PolicyMode,
198    pub include_unchanged_tests: bool,
199    pub max_cards: Option<usize>,
200}
201
202impl Default for AnalyzeInput {
203    fn default() -> Self {
204        Self {
205            root: PathBuf::from("."),
206            scope: Scope::Diff,
207            diff: DiffSource::NoneRepoScan,
208            mode: AnalysisMode::Draft,
209            policy: PolicyMode::Advisory,
210            include_unchanged_tests: true,
211            max_cards: None,
212        }
213    }
214}
215
216#[derive(Clone, Debug, Default)]
217pub struct Summary {
218    pub rust_files: usize,
219    pub changed_files: usize,
220    pub changed_rust_files: usize,
221    pub changed_non_rust_files: usize,
222    pub unsafe_sites: usize,
223    pub cards: usize,
224    pub open_actionable_gaps: usize,
225    pub contract_missing: usize,
226    pub guard_missing: usize,
227    pub guarded_unwitnessed: usize,
228    pub unsafe_unreached: usize,
229    pub requires_loom: usize,
230    pub miri_unsupported: usize,
231    pub static_unknown: usize,
232    /// Coverage movement counts (SPEC-0030).
233    ///
234    /// `new_gaps`      — open actionable cards not in the baseline ledger.
235    /// `worsened_gaps` — baseline cards whose coverage regressed (requires a saved coverage
236    ///                   snapshot; always 0 until `baseline init` authoring lands).
237    /// `improved_gaps` — baseline cards whose evidence coverage improved (at least one slot
238    ///                   advanced and no slot regressed; requires a saved coverage snapshot;
239    ///                   always 0 until `baseline init` authoring lands).
240    ///                   Precedence: worsened > improved > inherited.  A card is only counted
241    ///                   improved if it is not already counted worsened.
242    ///                   An improved card is still advisory, still open, still present — it is
243    ///                   NOT resolved, NOT safe, NOT UB-free, NOT Miri-clean, and NOT a
244    ///                   site-execution claim.
245    /// `resolved_gaps` — baseline ledger entries whose card is no longer present.
246    /// `inherited_gaps`— baseline-known cards still open and unchanged.
247    ///
248    /// On a diff-scoped run `new_gaps` is constrained to changed-line sites;
249    /// on a repo-mode run it counts all open actionable non-baseline gaps.
250    pub new_gaps: usize,
251    pub worsened_gaps: usize,
252    pub improved_gaps: usize,
253    pub resolved_gaps: usize,
254    pub inherited_gaps: usize,
255    /// True when the scan emitted fewer cards than it discovered because the
256    /// `max_cards` cap was exceeded and spread-selection dropped the remainder
257    /// (SPEC-0035 `stop_reason=max_cards`).
258    ///
259    /// This is *projected* from the pipeline's cap decision, never re-derived by
260    /// a consumer from `unsafe_sites > cards` (registry FM8, project-vs-re-derive).
261    /// Every count in this summary is understated while it is true, so any surface
262    /// that renders a count must also disclose the cap.
263    pub scan_capped: bool,
264    /// The `max_cards` value in effect when `scan_capped` is true; `None` on an
265    /// uncapped run.
266    pub card_cap: Option<usize>,
267}
268
269impl Summary {
270    /// One-line disclosure for a capped scan, or `None` when the scan was complete.
271    ///
272    /// Single source of the wording so the terminal, PR summary, and witness plan
273    /// cannot drift apart. It reports coverage of the emitted card set only: it is
274    /// not a memory-safety, UB-free, Miri-clean, site-execution, or
275    /// precision/recall claim, and a complete scan is not one either.
276    pub fn capped_scan_notice(&self) -> Option<String> {
277        if !self.scan_capped {
278            return None;
279        }
280        let cap = match self.card_cap {
281            Some(cap) => format!("--max-cards {cap}"),
282            None => "the card cap".to_string(),
283        };
284        Some(format!(
285            "Partial scan: {} of {} discovered unsafe sites are shown ({cap}). \
286             Every count above is capped, not a complete inventory — rerun without \
287             the cap to see the rest.",
288            self.cards, self.unsafe_sites
289        ))
290    }
291}
292
293#[derive(Clone, Debug)]
294pub struct AnalyzeOutput {
295    pub analysis_identity: AnalysisIdentity,
296    pub schema_version: String,
297    pub tool: String,
298    pub root: PathBuf,
299    pub scope: Scope,
300    pub mode: AnalysisMode,
301    pub policy: PolicyMode,
302    pub summary: Summary,
303    pub cards: Vec<ReviewCard>,
304    /// On a diff-scoped run, the set of candidate files that were scanned.
305    /// Empty for full-scan (repo-mode) runs.  Used to distinguish "baseline
306    /// card resolved because we scanned its file and it is gone" from "baseline
307    /// card not present because its file was out of diff scope" (SPEC-0030).
308    pub diff_scoped_files: BTreeSet<PathBuf>,
309    /// Per-card coverage snapshot loaded from `policy/unsafe-review-baseline-snapshot.toml`.
310    ///
311    /// Output renderers use this to project the per-card `baseline_state`/`outcome_movement`
312    /// from the same slot-level comparison the summary uses (SPEC-0030 §single-truth).
313    /// Empty when no snapshot file exists (no worsened/improved projection possible).
314    pub coverage_snapshot: BTreeMap<String, SnapshotCoverage>,
315}
316
317#[derive(Clone, Debug, PartialEq, Eq)]
318pub struct ReviewCardConfirmationProjection {
319    pub hypothesis_to_confirm: String,
320    pub build_this_first: String,
321    pub minimal_repro_steps: Vec<String>,
322    pub minimal_repro_limitation: String,
323    pub confirmation_step: String,
324}
325
326#[derive(Clone, Debug)]
327pub struct RepoScanEvent {
328    pub status: RepoScanStatus,
329    pub partial_output: Option<AnalyzeOutput>,
330}
331
332pub fn analyze(input: AnalyzeInput) -> Result<AnalyzeOutput, String> {
333    pipeline::analyze(input)
334}
335
336pub fn analyze_with_discovery(
337    input: AnalyzeInput,
338    discovery: DiscoveryOptions,
339) -> Result<AnalyzeOutput, String> {
340    pipeline::analyze_with_discovery(input, discovery)
341}
342
343pub fn analyze_with_discovery_and_progress<F>(
344    input: AnalyzeInput,
345    discovery: DiscoveryOptions,
346    progress: F,
347) -> Result<AnalyzeOutput, String>
348where
349    F: FnMut(&RepoScanStatus) -> Result<(), String>,
350{
351    pipeline::analyze_with_discovery_and_progress(input, discovery, progress)
352}
353
354pub fn analyze_with_discovery_and_repo_events<F>(
355    input: AnalyzeInput,
356    discovery: DiscoveryOptions,
357    events: F,
358) -> Result<AnalyzeOutput, String>
359where
360    F: FnMut(&RepoScanEvent) -> Result<(), String>,
361{
362    pipeline::analyze_with_discovery_and_repo_events(input, discovery, events)
363}
364
365pub fn discover_repo_files(
366    root: PathBuf,
367    discovery: DiscoveryOptions,
368) -> Result<Vec<PathBuf>, String> {
369    workspace::discover_rust_files(&root, &discovery)
370}
371
372pub fn validate_witness_receipts(root: PathBuf) -> Result<usize, String> {
373    receipts::validate_receipts(&root)
374}
375
376pub fn audit_witness_receipts(input: AnalyzeInput) -> Result<ReceiptAuditReport, String> {
377    let output = pipeline::analyze_without_receipts(input)?;
378    receipts::audit_receipts(&output)
379}
380
381pub fn evaluate_policy_report(mut input: AnalyzeInput) -> Result<PolicyReport, String> {
382    input.policy = PolicyMode::Advisory;
383    let output = pipeline::analyze(input)?;
384    policy_report::evaluate(&output)
385}
386
387pub fn evaluate_policy_report_from_output(output: &AnalyzeOutput) -> Result<PolicyReport, String> {
388    policy_report::evaluate(output)
389}
390
391/// Run cost aperture measured by the CLI emit layer and injected into
392/// `usefulness-telemetry.json` (SPEC-0038 §scan_cost).
393///
394/// Core must not measure wall time — this struct carries the two cost fields
395/// that only the CLI layer can observe.
396///
397/// Diagnostic only — not a coverage claim, proof, UB-free, Miri-clean,
398/// site-execution, or performance guarantee.
399#[derive(Clone, Debug, Default)]
400pub struct ScanCost {
401    /// Wall-clock milliseconds from before `analyze()` through the last artifact
402    /// write, measured in the CLI emit layer.
403    pub elapsed_ms: u64,
404    /// Total bytes written across all output artifacts for this run, accumulated
405    /// in the CLI emit layer.  The telemetry file itself is excluded (it is
406    /// rendered before its own bytes are known).
407    pub output_bytes_total: u64,
408}
409
410/// Traceable evidence metadata that the CLI layer assembles from argv, git, and the
411/// filesystem before calling the JSON renderer.
412///
413/// This is "traceable evidence metadata", not proof: the fields identify the inputs
414/// used to produce an artifact so that two runs against different diffs cannot emit
415/// byte-identical clean receipts, but they do not prove correctness or memory safety.
416#[derive(Clone, Debug, Default)]
417pub struct Provenance {
418    /// Absolute path of the resolved workspace root (additive alongside the existing
419    /// relative `root` field which remains unchanged for compatibility).
420    pub root_abs: Option<String>,
421    /// Resolved base commit SHA (when `--base` was supplied and git resolution succeeded).
422    pub base_sha: Option<String>,
423    /// Resolved HEAD commit SHA (when `--base` was supplied and git resolution succeeded).
424    pub head_sha: Option<String>,
425    /// Path of the diff file (when `--diff <file>` was supplied).
426    pub diff_path: Option<String>,
427    /// SHA-256 hex digest of the diff file content (when `--diff <file>` was supplied).
428    pub diff_sha256: Option<String>,
429    /// RFC3339 UTC timestamp at which the artifact was generated.
430    pub generated_at: String,
431    /// Whether the working tree had uncommitted changes when the tool ran (None = git unavailable).
432    pub dirty_worktree: Option<bool>,
433}
434
435impl Provenance {
436    /// Build a minimal provenance block stamped with the current UTC time.
437    pub fn new_now() -> Self {
438        use std::time::{SystemTime, UNIX_EPOCH};
439        let secs = SystemTime::now()
440            .duration_since(UNIX_EPOCH)
441            .map(|d| d.as_secs())
442            .unwrap_or(0);
443        Self {
444            generated_at: unix_secs_to_iso_datetime_utc(secs),
445            ..Self::default()
446        }
447    }
448}
449
450/// Regenerate `expected.cards.json` for each named fixture (or all registered
451/// fixtures if `names` is empty), always writing LF line endings.
452///
453/// Called by `cargo run -p xtask -- bless-goldens [fixture ...]`.
454/// Does not execute witnesses or assess soundness.
455pub fn bless_fixture_card_goldens(names: &[&str]) -> Result<Vec<PathBuf>, String> {
456    json::bless_fixture_card_goldens(names)
457}
458
459/// Runtime-root-aware variant of [`bless_fixture_card_goldens`].
460///
461/// This is used by `xtask` so a reused shared target directory cannot make a
462/// cached binary read or write fixtures from the checkout where it was compiled.
463pub fn bless_fixture_card_goldens_from_workspace(
464    workspace: &Path,
465    names: &[&str],
466) -> Result<Vec<PathBuf>, String> {
467    json::bless_fixture_card_goldens_from_workspace(workspace, names)
468}
469
470/// Regenerate surface goldens (`expected.lsp.json`, `expected.repair-queue.json`)
471/// for a single named fixture, writing LF line endings.
472///
473/// `surfaces` must contain only `"lsp"`, `"repair-queue"`, or
474/// `"comment-plan"`. Paths inside the rendered JSON are normalised to relative
475/// form so goldens are byte-stable.
476pub fn bless_fixture_surface_goldens(
477    fixture: &str,
478    surfaces: &[&str],
479) -> Result<Vec<PathBuf>, String> {
480    json::bless_fixture_surface_goldens(fixture, surfaces)
481}
482
483/// Runtime-root-aware variant of [`bless_fixture_surface_goldens`].
484///
485/// This is used by `xtask` so a reused shared target directory cannot make a
486/// cached binary read fixtures from the checkout where it was compiled.
487pub fn bless_fixture_surface_goldens_from_workspace(
488    workspace: &Path,
489    fixture: &str,
490    surfaces: &[&str],
491) -> Result<Vec<PathBuf>, String> {
492    json::bless_fixture_surface_goldens_from_workspace(workspace, fixture, surfaces)
493}
494
495/// Render a single surface for a fixture as the bless path would write it
496/// (normalised, LF-terminated) without writing a file.
497///
498/// Used by `check-fixture-surface-parity` to produce the reference text for
499/// diffing against the committed golden.
500pub fn render_fixture_surface(fixture: &str, surface: &str) -> Result<String, String> {
501    json::render_fixture_surface(fixture, surface)
502}
503
504/// Runtime-root-aware variant of [`render_fixture_surface`].
505///
506/// This is used by `xtask` so parity and determinism checks render from the
507/// active workspace root selected at runtime.
508pub fn render_fixture_surface_from_workspace(
509    workspace: &Path,
510    fixture: &str,
511    surface: &str,
512) -> Result<String, String> {
513    json::render_fixture_surface_from_workspace(workspace, fixture, surface)
514}
515
516pub fn render_json(output: &AnalyzeOutput) -> String {
517    json::render(output)
518}
519
520/// Render the JSON analyze artifact with attached traceable evidence metadata.
521///
522/// The `provenance` block is inserted as a nested object in the output.
523/// `tool_version` also appears top-level beside `tool` for consumer grep-ability.
524pub fn render_json_with_provenance(output: &AnalyzeOutput, provenance: &Provenance) -> String {
525    json::render_with_provenance(output, provenance)
526}
527
528pub fn render_human(output: &AnalyzeOutput) -> String {
529    human::render(output)
530}
531
532pub fn render_human_short(output: &AnalyzeOutput) -> String {
533    human::render_short(output)
534}
535
536pub fn render_markdown(output: &AnalyzeOutput) -> String {
537    markdown::render(output)
538}
539
540pub fn render_pr_summary(output: &AnalyzeOutput) -> String {
541    markdown::render_pr_summary(output)
542}
543
544pub fn render_github_summary(output: &AnalyzeOutput) -> String {
545    markdown::render_github_summary(output)
546}
547
548pub fn render_sarif(output: &AnalyzeOutput) -> String {
549    sarif::render(output)
550}
551
552pub fn render_comment_plan(output: &AnalyzeOutput) -> String {
553    comment_plan::render(output)
554}
555
556pub fn render_lsp(output: &AnalyzeOutput) -> String {
557    lsp::render(output)
558}
559
560pub fn project_editor(output: &AnalyzeOutput) -> lsp::EditorProjection {
561    lsp::project_editor(output)
562}
563
564/// Project only the canonical editor diagnostics without constructing hovers
565/// or code actions that a caller does not need.
566pub fn project_editor_diagnostics(output: &AnalyzeOutput) -> Vec<lsp::EditorDiagnostic> {
567    lsp::project_editor_diagnostics(output)
568}
569
570/// Project only actionable canonical editor diagnostics for live transport.
571pub fn project_actionable_editor_diagnostics(output: &AnalyzeOutput) -> Vec<lsp::EditorDiagnostic> {
572    lsp::project_actionable_editor_diagnostics(output)
573}
574
575/// Render the rich hover markdown for a single [`ReviewCard`] as the live LSP
576/// server would produce it.
577///
578/// The returned string is the same content that `lsp.json` embeds in its
579/// `hovers[].contents` field: obligations, evidence state (contract / guard /
580/// reach / witness), hazard families, verify commands, witness route, handoff
581/// commands, and the advisory trust boundary.
582///
583/// This is **advisory evidence only**: no memory-safety proof, no UB-free
584/// status, no Miri-clean status, and not a site-execution claim unless a
585/// matching witness receipt says so.
586pub fn render_lsp_hover(card: &ReviewCard) -> String {
587    lsp::render_hover(card)
588}
589
590pub fn render_witness_plan(output: &AnalyzeOutput) -> String {
591    witness_plan::render(output)
592}
593
594pub fn render_repair_queue(output: &AnalyzeOutput) -> String {
595    repair_queue::render(output)
596}
597
598/// Render the `unsafe-review-gate.json` routing manifest (SPEC-0034).
599///
600/// The manifest is a thin index over the artifacts a `first-pr`/`repo` run
601/// produced, plus the SPEC-0030 movement summary.  It is fully deterministic —
602/// it carries no timestamp or wall-time field — so it is safe to include in
603/// byte-compared goldens or reproducibility rails.
604pub fn render_gate_manifest(output: &AnalyzeOutput) -> String {
605    gate_manifest::render(output)
606}
607
608/// Render `unsafe-review-gate.json` for a `repo` run (SPEC-0034 parity).
609///
610/// Repo mode writes a single output file rather than a bundle directory, so
611/// the first-pr-specific artifact pointers (`pr_summary`, `sarif`, `lsp`, …)
612/// are absent — they were not emitted and must not be faked (SPEC-0034:
613/// "Missing optional artifacts are omitted, not faked.").
614///
615/// `report_filename` is the basename of the `--out` file (e.g. `"repo.json"`);
616/// it becomes the `artifacts.cards` pointer so downstream consumers can locate
617/// the ReviewCard dataset relative to the manifest.
618///
619/// The `status` field is always `"advisory"` — the manifest carries posture,
620/// never a merge verdict, not proof, not UB-free, not Miri-clean, not a
621/// site-execution claim.
622pub fn render_gate_manifest_repo(output: &AnalyzeOutput, report_filename: &str) -> String {
623    gate_manifest::render_repo(output, report_filename)
624}
625
626/// Render the `usefulness-telemetry.json` low-noise usefulness telemetry artifact (SPEC-0038).
627///
628/// This is a pure projection from `AnalyzeOutput` — no new analysis.
629/// Diagnostic operational usefulness only: not calibrated precision/recall,
630/// not accuracy measurement, not memory-safety proof, not UB-free status,
631/// not Miri-clean status, not a site-execution claim, not a gate, and not
632/// a merge verdict.
633pub fn render_usefulness_telemetry(output: &AnalyzeOutput) -> String {
634    usefulness_telemetry::render(output)
635}
636
637/// Render `usefulness-telemetry.json` with CLI-layer scan cost injected
638/// (SPEC-0038 §scan_cost).
639///
640/// The `cost` argument carries `elapsed_ms` and `output_bytes_total` measured
641/// in the CLI emit layer — fields that core cannot compute itself (core must
642/// not measure wall time).  When `cost` is `None` the `scan_cost` section is
643/// omitted and the output is identical to `render_usefulness_telemetry`.
644pub fn render_usefulness_telemetry_with_cost(
645    output: &AnalyzeOutput,
646    cost: Option<&ScanCost>,
647) -> String {
648    usefulness_telemetry::render_with_cost(output, cost)
649}
650
651pub fn project_review_card_confirmation(card: &ReviewCard) -> ReviewCardConfirmationProjection {
652    let minimal_repro = confirmation::minimal_repro(card);
653    ReviewCardConfirmationProjection {
654        hypothesis_to_confirm: confirmation::hypothesis_to_confirm(card),
655        build_this_first: confirmation::build_this_first(card).summary,
656        minimal_repro_steps: minimal_repro.steps().to_vec(),
657        minimal_repro_limitation: minimal_repro.limitation().to_string(),
658        confirmation_step: confirmation::confirmation_step(card),
659    }
660}
661
662pub fn render_badge_jsons(output: &AnalyzeOutput) -> (String, String) {
663    badges::render(output)
664}
665
666pub fn compare_outcome_json(before_json: &str, after_json: &str) -> Result<OutcomeReport, String> {
667    outcome::compare_json(before_json, after_json)
668}
669
670pub fn render_outcome_json(report: &OutcomeReport) -> String {
671    outcome::render_json(report)
672}
673
674pub fn render_outcome_markdown(report: &OutcomeReport) -> String {
675    outcome::render_markdown(report)
676}
677
678pub fn render_receipt_audit_json(report: &ReceiptAuditReport) -> String {
679    receipt_audit::render_json(report)
680}
681
682pub fn render_receipt_audit_markdown(report: &ReceiptAuditReport) -> String {
683    receipt_audit::render_markdown(report)
684}
685
686pub fn render_policy_report_json(report: &PolicyReport) -> String {
687    policy_report::render_json(report)
688}
689
690pub fn render_policy_report_markdown(report: &PolicyReport) -> String {
691    policy_report::render_markdown(report)
692}
693
694pub fn explain_card(output: &AnalyzeOutput, id: &CardId) -> Option<String> {
695    output
696        .cards
697        .iter()
698        .find(|card| &card.id == id)
699        .map(markdown::render_card_detail)
700}
701
702pub fn collect_context(output: &AnalyzeOutput, id: &CardId) -> Option<String> {
703    output
704        .cards
705        .iter()
706        .find(|card| &card.id == id)
707        .map(|card| agent::render_with_output(output, card))
708}
709
710/// Render a `file_range_scan` envelope for SPEC-0033.
711///
712/// Collects packets for all cards whose unsafe site overlaps `file:line_start-line_end`
713/// (1-based, both endpoints inclusive).  If `changed_only` is `true`, further
714/// restricts to cards whose `baseline_state` is `new` or `worsened` (SPEC-0030).
715///
716/// File paths are matched by normalizing both to forward-slash display form,
717/// then checking whether the card's site file ends with the queried fragment so
718/// callers may pass either root-relative or short relative paths (e.g. `src/lib.rs`).
719/// The `root` parameter is used to strip a leading root prefix from the queried path
720/// before the suffix comparison.
721pub fn collect_context_range(
722    output: &AnalyzeOutput,
723    root: &Path,
724    file: &Path,
725    line_start: u32,
726    line_end: u32,
727    changed_only: bool,
728) -> String {
729    let queried_display = path_display(file);
730    let root_display = path_display(root);
731
732    // Strip the workspace root prefix from the queried path so that callers
733    // can use either a short relative path ("src/lib.rs") or an absolute one.
734    let queried_suffix = queried_display
735        .strip_prefix(&root_display)
736        .map(|rest| rest.trim_start_matches('/'))
737        .unwrap_or(&queried_display);
738
739    // Pre-filter to the requested file; range + changed-only filtering happens
740    // inside render_range_scan.
741    let file_cards: Vec<&ReviewCard> = output
742        .cards
743        .iter()
744        .filter(|card| {
745            let card_file = path_display(&card.site.location.file);
746            card_file == queried_display
747                || card_file == queried_suffix
748                || card_file.ends_with(&format!("/{queried_suffix}"))
749        })
750        .collect();
751
752    let statuses = comment_plan::card_statuses(output);
753    agent::render_range_scan_with_output(
754        output,
755        queried_display,
756        line_start,
757        line_end,
758        changed_only,
759        &file_cards,
760        None,
761        &statuses,
762    )
763}
764
765/// Result returned by `baseline_init` summarizing what was captured.
766#[derive(Clone, Debug, PartialEq, Eq)]
767pub struct BaselineInitResult {
768    /// Number of open actionable cards captured as baseline entries.
769    pub captured: usize,
770    /// Whether the baseline ledger file already existed before this run.
771    pub ledger_existed: bool,
772    /// Path to the baseline ledger written.
773    pub ledger_path: PathBuf,
774    /// Path to the coverage snapshot written.
775    pub snapshot_path: PathBuf,
776    /// The open actionable cards captured, in scan order.
777    /// Used by the CLI to display a debt scope listing for brownfield adoption.
778    pub cards: Vec<ReviewCard>,
779}
780
781struct BaselineInitPlan {
782    result: BaselineInitResult,
783    ledger_entries: Vec<crate::policy::LedgerEntry>,
784    snapshot_entries: BTreeMap<String, crate::policy::SnapshotCoverage>,
785}
786
787/// `baseline init` (SPEC-0030): scan the repo for open actionable cards, capture each
788/// card's identity and coverage state, and write both the baseline ledger and the coverage
789/// snapshot.  Idempotent — re-running overwrites with a fresh snapshot of the current state.
790///
791/// The honest default `reason` and `review_after` are set to record pre-existing debt only;
792/// no card is marked safe, reviewed, or UB-free.
793///
794/// `review_after` defaults to one year from today's date.
795pub fn baseline_init(
796    root: &Path,
797    out: Option<&Path>,
798    review_after: Option<&str>,
799) -> Result<BaselineInitResult, String> {
800    let plan = baseline_init_plan(root, out, review_after)?;
801    crate::policy::merge_and_write_baseline_ledger(&plan.result.ledger_path, &plan.ledger_entries)?;
802    crate::policy::write_coverage_snapshot(&plan.result.snapshot_path, &plan.snapshot_entries)?;
803    Ok(plan.result)
804}
805
806/// Preview the baseline entries that `baseline init` would author without writing files.
807/// The returned result is the same plan used by the applying command, preserving one
808/// source of truth for card selection and output paths.
809pub fn baseline_init_preview(
810    root: &Path,
811    out: Option<&Path>,
812    review_after: Option<&str>,
813) -> Result<BaselineInitResult, String> {
814    Ok(baseline_init_plan(root, out, review_after)?.result)
815}
816
817fn baseline_init_plan(
818    root: &Path,
819    out: Option<&Path>,
820    review_after: Option<&str>,
821) -> Result<BaselineInitPlan, String> {
822    use crate::domain::coverage::CoverageBlock;
823    use crate::policy::{LedgerEntry, SnapshotCoverage};
824    use std::collections::BTreeMap;
825
826    let ledger_path = out
827        .map(Path::to_path_buf)
828        .unwrap_or_else(|| root.join("policy/unsafe-review-baseline.toml"));
829    let snapshot_path = baseline_snapshot_path(&ledger_path);
830    let ledger_existed = ledger_path.is_file();
831
832    // Run a full repo scan to get all current cards.
833    let output = pipeline::analyze(AnalyzeInput {
834        root: root.to_path_buf(),
835        scope: Scope::Repo,
836        diff: DiffSource::NoneRepoScan,
837        mode: AnalysisMode::Repo,
838        policy: PolicyMode::Advisory,
839        include_unchanged_tests: true,
840        max_cards: None,
841    })?;
842
843    // Determine review_after date (required by ledger validator).
844    let review_after = review_after
845        .map(ToOwned::to_owned)
846        .unwrap_or_else(default_review_after_date);
847
848    // Collect open actionable cards.
849    let mut ledger_entries: Vec<LedgerEntry> = Vec::new();
850    let mut snapshot_entries: BTreeMap<String, SnapshotCoverage> = BTreeMap::new();
851    let mut actionable_cards: Vec<ReviewCard> = Vec::new();
852
853    for card in &output.cards {
854        if card.class.is_actionable() {
855            ledger_entries.push(LedgerEntry {
856                card_id: card.id.0.clone(),
857                owner: "baseline-init".to_string(),
858                reason: "captured by `baseline init`; pre-existing debt, not reviewed as safe"
859                    .to_string(),
860                evidence: "baseline-init: captured by baseline init; pre-existing debt".to_string(),
861                review_after: Some(review_after.clone()),
862                expires: None,
863            });
864            let block = CoverageBlock::derive(card);
865            snapshot_entries.insert(
866                card.id.0.clone(),
867                SnapshotCoverage {
868                    contract_coverage: block.contract_coverage.as_str().to_string(),
869                    guard_coverage: block.guard_coverage.as_str().to_string(),
870                    test_reach_coverage: block.test_reach_coverage.as_str().to_string(),
871                    witness_receipt_coverage: block.witness_receipt_coverage.as_str().to_string(),
872                },
873            );
874            actionable_cards.push(card.clone());
875        }
876    }
877
878    Ok(BaselineInitPlan {
879        result: BaselineInitResult {
880            captured: ledger_entries.len(),
881            ledger_existed,
882            ledger_path,
883            snapshot_path,
884            cards: actionable_cards,
885        },
886        ledger_entries,
887        snapshot_entries,
888    })
889}
890
891/// `baseline add` (SPEC-0030): add or update a single baseline entry (plus its snapshot state)
892/// by re-analyzing the repo, finding the card matching `card_id`, and recording its current
893/// coverage state.
894///
895/// Returns `Err` if the card cannot be found in the current scan.
896pub fn baseline_add(
897    root: &Path,
898    card_id: &str,
899    owner: &str,
900    reason: &str,
901    evidence: &str,
902    review_after: Option<&str>,
903    out: Option<&Path>,
904) -> Result<(), String> {
905    use crate::domain::coverage::CoverageBlock;
906    use crate::policy::{
907        LedgerEntry, SnapshotCoverage, load_coverage_snapshot, merge_and_write_baseline_ledger,
908        write_coverage_snapshot,
909    };
910    use std::collections::BTreeMap;
911
912    let ledger_path = out
913        .map(Path::to_path_buf)
914        .unwrap_or_else(|| root.join("policy/unsafe-review-baseline.toml"));
915    let snapshot_path = baseline_snapshot_path(&ledger_path);
916
917    // Run a full repo scan.
918    let output = pipeline::analyze(AnalyzeInput {
919        root: root.to_path_buf(),
920        scope: Scope::Repo,
921        diff: DiffSource::NoneRepoScan,
922        mode: AnalysisMode::Repo,
923        policy: PolicyMode::Advisory,
924        include_unchanged_tests: true,
925        max_cards: None,
926    })?;
927
928    // Find the specific card.
929    let card = output
930        .cards
931        .iter()
932        .find(|card| card.id.0 == card_id)
933        .ok_or_else(|| format!("card `{card_id}` not found in current repo scan"))?;
934
935    let review_after = review_after
936        .map(ToOwned::to_owned)
937        .unwrap_or_else(default_review_after_date);
938
939    let entry = LedgerEntry {
940        card_id: card_id.to_string(),
941        owner: owner.to_string(),
942        reason: reason.to_string(),
943        evidence: evidence.to_string(),
944        review_after: Some(review_after),
945        expires: None,
946    };
947
948    // Update the snapshot.
949    let mut snapshot = load_coverage_snapshot(&snapshot_path)?;
950    let block = CoverageBlock::derive(card);
951    snapshot.insert(
952        card_id.to_string(),
953        SnapshotCoverage {
954            contract_coverage: block.contract_coverage.as_str().to_string(),
955            guard_coverage: block.guard_coverage.as_str().to_string(),
956            test_reach_coverage: block.test_reach_coverage.as_str().to_string(),
957            witness_receipt_coverage: block.witness_receipt_coverage.as_str().to_string(),
958        },
959    );
960
961    // Sort snapshot to BTreeMap (already sorted).
962    let sorted_snapshot: BTreeMap<String, SnapshotCoverage> = snapshot.into_iter().collect();
963
964    merge_and_write_baseline_ledger(&ledger_path, &[entry])?;
965    write_coverage_snapshot(&snapshot_path, &sorted_snapshot)?;
966
967    Ok(())
968}
969
970/// `baseline status` (issue #1893): classify every baseline ledger entry, plus every
971/// currently open actionable card the ledger does not represent, into the ten SPEC-0030
972/// baseline-health buckets. Read-only — runs a full repo scan and reads policy files;
973/// writes nothing.
974///
975/// Degrades instead of failing outright when the baseline ledger itself fails the
976/// analyzer's strict per-entry validation (bad card_id shape, or missing
977/// owner/reason/evidence) — implemented in `baseline_status_with_date`; see also
978/// [`crate::policy::baseline_health::BaselineHealthReport::card_scan_error`].
979pub fn baseline_status(root: &Path) -> Result<BaselineHealthReport, String> {
980    let today = policy_report::current_utc_date()?;
981    baseline_status_with_date(root, &today)
982}
983
984fn baseline_status_with_date(root: &Path, today: &str) -> Result<BaselineHealthReport, String> {
985    use crate::policy::{
986        LedgerKind, baseline_health, is_expired, load_baseline_entries_lenient,
987        load_coverage_snapshot, load_ledger_entries,
988    };
989
990    // Load the baseline ledger leniently FIRST (cheap, no repo scan) so its result is
991    // available before attempting the full-repo card scan below (issue #1893 review
992    // finding).
993    let ledger_path = root.join("policy/unsafe-review-baseline.toml");
994    let ledger_entries = load_baseline_entries_lenient(&ledger_path)?;
995    // Capture the strict loader's exact failure before the repo scan. On Windows the
996    // analyzer's file discovery can normalize the root while loading policy, so
997    // comparing a rendered path substring is not reliable (mixed `/` and `\\`
998    // separators can make the same path compare unequal). The lenient read above
999    // already proved the ledger is syntactically usable for per-entry diagnosis; the
1000    // same strict-loader error from `pipeline::analyze`, after separator normalization,
1001    // therefore identifies precisely the degraded health case this command is allowed
1002    // to tolerate. Every other analyzer error remains fatal.
1003    let strict_baseline_error = load_ledger_entries(&ledger_path, LedgerKind::Baseline).err();
1004
1005    // `pipeline::analyze` loads `PolicyState` internally, which uses the *strict*
1006    // ledger loader for the baseline file — the exact per-entry validation
1007    // `identity_unmatched` exists to tolerate. A baseline ledger entry with a bad
1008    // card_id shape or missing owner/reason/evidence (real TOML, just failing that
1009    // strict per-entry check) would otherwise abort the repo scan entirely, defeating
1010    // the corrupt-ledger-diagnosis purpose of this command before it could ever report
1011    // `identity_unmatched` for the offending row. Since `load_baseline_entries_lenient`
1012    // just proved the file itself parses (only the strict per-entry checks differ),
1013    // treat that specific failure as "current-card data unavailable" and still produce
1014    // a report — every other failure (a genuinely unparseable ledger, a broken
1015    // suppression ledger, a source-scan error, and so on) still fails `baseline_status`
1016    // outright, same as before.
1017    let analyze_result = pipeline::analyze(AnalyzeInput {
1018        root: root.to_path_buf(),
1019        scope: Scope::Repo,
1020        diff: DiffSource::NoneRepoScan,
1021        mode: AnalysisMode::Repo,
1022        policy: PolicyMode::Advisory,
1023        include_unchanged_tests: true,
1024        max_cards: None,
1025    });
1026    //
1027    // The degrade branch keys off equality with the strict baseline-loader failure
1028    // captured above, normalizing only path separators. This keeps the exception
1029    // fail-closed: a source scan, suppression-ledger, or other analyzer error cannot be
1030    // mistaken for the diagnosable baseline-entry failure.
1031    let (current_cards, card_scan_error) = match analyze_result {
1032        Ok(output) => (output.cards, None),
1033        Err(err)
1034            if strict_baseline_error
1035                .as_deref()
1036                .is_some_and(|expected| expected.replace('\\', "/") == err.replace('\\', "/")) =>
1037        {
1038            (Vec::new(), Some(err))
1039        }
1040        Err(err) => return Err(err),
1041    };
1042
1043    // Only currently-active (non-expired) suppressions count as `suppression_overlap`
1044    // (issue #1893 review finding): an expired suppression is already surfaced as its
1045    // own ledger-health problem (`policy report`'s `expired_suppressions`), and folding
1046    // it into `suppression_overlap` too would double-report the same stale entry under
1047    // two different labels. Reuses the canonical expiry predicate — no second expiry
1048    // model. Note this does not affect `new_unbaselined`: the core analyzer already
1049    // classifies any card matching *any* suppression entry (active or expired) as
1050    // `Suppressed`, which is not actionable, before `baseline_health` ever sees it.
1051    let suppression_path = root.join("policy/unsafe-review-suppressions.toml");
1052    let suppression_ids: BTreeSet<String> =
1053        load_ledger_entries(&suppression_path, LedgerKind::Suppression)?
1054            .into_iter()
1055            .filter(|entry| !is_expired(entry.expires.as_deref(), today))
1056            .map(|entry| entry.card_id)
1057            .collect();
1058
1059    let snapshot_path = baseline_snapshot_path(&ledger_path);
1060    let (snapshot, snapshot_load_error) = match load_coverage_snapshot(&snapshot_path) {
1061        Ok(map) => (Some(map), None),
1062        Err(err) => (None, Some(err)),
1063    };
1064
1065    let input = baseline_health::BaselineHealthInput {
1066        today,
1067        current_cards: &current_cards,
1068        ledger_entries: &ledger_entries,
1069        suppression_ids: &suppression_ids,
1070        snapshot: snapshot.as_ref(),
1071        snapshot_load_error: snapshot_load_error.as_deref(),
1072    };
1073    let mut report = baseline_health::classify(&input);
1074    report.card_scan_error = card_scan_error;
1075    Ok(report)
1076}
1077
1078/// `baseline refresh --dry-run` (issue #1893): build the deterministic per-entry action
1079/// plan from the same classification as `baseline_status`. Writes nothing; there is no
1080/// apply mode (SPEC-0030 non-goal — a future apply command would be separately
1081/// approved, explicit, idempotent, and refuse to overwrite a changed ledger).
1082pub fn baseline_refresh_preview(root: &Path) -> Result<BaselineRefreshPlan, String> {
1083    let report = baseline_status(root)?;
1084    Ok(crate::policy::baseline_health::build_refresh_plan(&report))
1085}
1086
1087pub fn render_baseline_status_json(report: &BaselineHealthReport) -> String {
1088    crate::output::baseline_health::render_status_json(report)
1089}
1090
1091pub fn render_baseline_status_human(report: &BaselineHealthReport) -> String {
1092    crate::output::baseline_health::render_status_human(report)
1093}
1094
1095pub fn render_baseline_refresh_json(plan: &BaselineRefreshPlan) -> String {
1096    crate::output::baseline_health::render_refresh_json(plan)
1097}
1098
1099pub fn render_baseline_refresh_human(plan: &BaselineRefreshPlan) -> String {
1100    crate::output::baseline_health::render_refresh_human(plan)
1101}
1102
1103pub use crate::policy::baseline_health::{
1104    BaselineHealthCounts, BaselineHealthEntry, BaselineHealthReport, BaselineRefreshPlan,
1105    HealthBucket, RefreshAction, RefreshPlanEntry, RefreshPlanSummary,
1106};
1107
1108/// Derive the coverage snapshot path from the baseline ledger path: the snapshot is written
1109/// as a sibling `<ledger-stem>-snapshot.toml`. The default ledger
1110/// `policy/unsafe-review-baseline.toml` keeps producing
1111/// `policy/unsafe-review-baseline-snapshot.toml`, and a custom `--out` keeps both files
1112/// together instead of writing the snapshot into the scanned `--root` (which would edit a
1113/// repo unsafe-review only promised to read).
1114fn baseline_snapshot_path(ledger_path: &Path) -> PathBuf {
1115    let stem = ledger_path
1116        .file_stem()
1117        .map(|stem| stem.to_string_lossy().into_owned())
1118        .unwrap_or_else(|| "unsafe-review-baseline".to_string());
1119    ledger_path.with_file_name(format!("{stem}-snapshot.toml"))
1120}
1121
1122/// Default `review_after` date: one year from today (ISO 8601 YYYY-MM-DD).
1123fn default_review_after_date() -> String {
1124    // Use a fixed date offset from June 2026 (the current date per context).
1125    // We can't use std::time for date arithmetic without chrono, so we hardcode
1126    // a safe one-year increment from a known epoch base.
1127    // This is called only in baseline authoring, not in analysis; precision is not critical.
1128    compute_review_after_date()
1129}
1130
1131fn compute_review_after_date() -> String {
1132    // Use SystemTime to get the current date offset by ~365 days.
1133    use std::time::{SystemTime, UNIX_EPOCH};
1134    let secs = SystemTime::now()
1135        .duration_since(UNIX_EPOCH)
1136        .map(|d| d.as_secs())
1137        .unwrap_or(0);
1138    // Approximate: add 365 days worth of seconds.
1139    let future_secs = secs + 365 * 24 * 3600;
1140    // Convert to a YYYY-MM-DD string using a simple algorithm.
1141    unix_secs_to_iso_date(future_secs)
1142}
1143
1144/// Extend the date-only helper to a full RFC3339 UTC timestamp (e.g. `2026-06-07T21:30:00Z`).
1145///
1146/// The time portion is always `T00:00:00Z` (midnight UTC) because we only have
1147/// second-level granularity and already discard the sub-day remainder in the
1148/// date calculation.  For a provenance `generated_at` field this is sufficient
1149/// — the date binds the artifact to a calendar day without requiring chrono.
1150pub(crate) fn unix_secs_to_iso_datetime_utc(secs: u64) -> String {
1151    let date = unix_secs_to_iso_date(secs);
1152    // Compute HH:MM:SS from the remaining seconds in the day.
1153    let remainder = secs % 86400;
1154    let hh = remainder / 3600;
1155    let mm = (remainder % 3600) / 60;
1156    let ss = remainder % 60;
1157    format!("{date}T{hh:02}:{mm:02}:{ss:02}Z")
1158}
1159
1160fn unix_secs_to_iso_date(secs: u64) -> String {
1161    // Days since Unix epoch.
1162    let days = secs / 86400;
1163    // Gregorian calendar calculation.
1164    let mut remaining_days = days;
1165    let mut year = 1970u32;
1166    loop {
1167        let days_in_year = if is_leap_year(year) { 366 } else { 365 };
1168        if remaining_days < days_in_year {
1169            break;
1170        }
1171        remaining_days -= days_in_year;
1172        year += 1;
1173    }
1174    let mut month = 1u32;
1175    loop {
1176        let days_in_month = days_in_month(year, month);
1177        if remaining_days < days_in_month {
1178            break;
1179        }
1180        remaining_days -= days_in_month;
1181        month += 1;
1182    }
1183    let day = remaining_days + 1;
1184    format!("{year:04}-{month:02}-{day:02}")
1185}
1186
1187fn is_leap_year(year: u32) -> bool {
1188    year.is_multiple_of(400) || (year.is_multiple_of(4) && !year.is_multiple_of(100))
1189}
1190
1191fn days_in_month(year: u32, month: u32) -> u64 {
1192    match month {
1193        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
1194        4 | 6 | 9 | 11 => 30,
1195        2 => {
1196            if is_leap_year(year) {
1197                29
1198            } else {
1199                28
1200            }
1201        }
1202        _ => 30,
1203    }
1204}
1205
1206pub use outcome::OutcomeReport;
1207pub use policy_report::PolicyReport;
1208pub use receipts::ReceiptAuditReport;
1209
1210#[cfg(test)]
1211mod tests {
1212    use super::*;
1213    use crate::domain::coverage::CoverageBlock;
1214    use crate::policy::{LedgerKind, load_coverage_snapshot, load_ledger_entries};
1215    use std::fs;
1216    use std::time::{SystemTime, UNIX_EPOCH};
1217
1218    #[test]
1219    fn analysis_mode_strings_cover_every_variant() {
1220        assert_eq!(AnalysisMode::Instant.as_str(), "instant");
1221        assert_eq!(AnalysisMode::Draft.as_str(), "draft");
1222        assert_eq!(AnalysisMode::Ready.as_str(), "ready");
1223        assert_eq!(AnalysisMode::Repo.as_str(), "repo");
1224    }
1225
1226    #[test]
1227    fn policy_mode_strings_cover_every_variant() {
1228        assert_eq!(PolicyMode::Advisory.as_str(), "advisory");
1229        assert_eq!(PolicyMode::NoNewDebt.as_str(), "no-new-debt");
1230        assert_eq!(PolicyMode::Blocking.as_str(), "blocking");
1231    }
1232
1233    #[test]
1234    fn analyze_input_default_is_advisory_diff_draft_with_unchanged_tests() {
1235        let input = AnalyzeInput::default();
1236
1237        assert_eq!(input.root, PathBuf::from("."));
1238        assert_eq!(input.scope, Scope::Diff);
1239        assert_eq!(input.diff, DiffSource::NoneRepoScan);
1240        assert_eq!(input.mode, AnalysisMode::Draft);
1241        assert_eq!(input.policy, PolicyMode::Advisory);
1242        assert!(input.include_unchanged_tests);
1243        assert_eq!(input.max_cards, None);
1244    }
1245
1246    #[test]
1247    fn baseline_snapshot_path_keeps_default_canonical_location() {
1248        let ledger = Path::new("repo/policy/unsafe-review-baseline.toml");
1249        assert_eq!(
1250            baseline_snapshot_path(ledger),
1251            PathBuf::from("repo/policy/unsafe-review-baseline-snapshot.toml")
1252        );
1253    }
1254
1255    #[test]
1256    fn baseline_snapshot_path_follows_custom_out_as_sibling() {
1257        let ledger = Path::new("elsewhere/bun-baseline.toml");
1258        assert_eq!(
1259            baseline_snapshot_path(ledger),
1260            PathBuf::from("elsewhere/bun-baseline-snapshot.toml")
1261        );
1262    }
1263
1264    #[test]
1265    fn baseline_snapshot_path_handles_extension_less_out() {
1266        let ledger = Path::new("elsewhere/baseline");
1267        assert_eq!(
1268            baseline_snapshot_path(ledger),
1269            PathBuf::from("elsewhere/baseline-snapshot.toml")
1270        );
1271    }
1272
1273    #[test]
1274    fn baseline_add_persists_exact_coverage_block_snapshot_for_canonical_identity()
1275    -> Result<(), String> {
1276        // Advisory parity proof for issue #2122: baseline_add is a ReviewCard
1277        // consumer. It must resolve the exact canonical id from the current repo
1278        // scan and persist the four CoverageBlock::derive slots without
1279        // reclassification. This is ledger/snapshot evidence, not a safety,
1280        // UB-free, Miri-clean, or site-execution claim.
1281        let root = unique_temp_dir("baseline-add-parity-success")?;
1282        fs::create_dir_all(&root).map_err(|err| format!("create temp root failed: {err}"))?;
1283        install_fixture_repo(&root, "raw_pointer_alignment")?;
1284
1285        let output = pipeline::analyze(AnalyzeInput {
1286            root: root.clone(),
1287            scope: Scope::Repo,
1288            diff: DiffSource::NoneRepoScan,
1289            mode: AnalysisMode::Repo,
1290            policy: PolicyMode::Advisory,
1291            include_unchanged_tests: true,
1292            max_cards: None,
1293        })?;
1294        let card_id = output
1295            .cards
1296            .first()
1297            .ok_or("fixture should emit at least one card")?
1298            .id
1299            .0
1300            .clone();
1301        let source_card = output
1302            .cards
1303            .iter()
1304            .find(|card| card.id.0 == card_id)
1305            .ok_or("selected card disappeared")?
1306            .clone();
1307        let expected_block = CoverageBlock::derive(&source_card);
1308
1309        baseline_add(
1310            &root,
1311            &card_id,
1312            "triage-owner",
1313            "pre-existing debt; not reviewed as safe",
1314            "baseline-add parity proof",
1315            Some("2027-08-10"),
1316            None,
1317        )?;
1318
1319        let ledger_path = root.join("policy/unsafe-review-baseline.toml");
1320        let snapshot_path = baseline_snapshot_path(&ledger_path);
1321        let ledger_entries = load_ledger_entries(&ledger_path, LedgerKind::Baseline)?;
1322        expect_eq("ledger entry count", ledger_entries.len(), 1)?;
1323        let ledger_entry = &ledger_entries[0];
1324        expect_eq(
1325            "ledger card_id",
1326            ledger_entry.card_id.as_str(),
1327            card_id.as_str(),
1328        )?;
1329        expect_eq("ledger owner", ledger_entry.owner.as_str(), "triage-owner")?;
1330        expect_eq(
1331            "ledger reason",
1332            ledger_entry.reason.as_str(),
1333            "pre-existing debt; not reviewed as safe",
1334        )?;
1335        expect_eq(
1336            "ledger evidence",
1337            ledger_entry.evidence.as_str(),
1338            "baseline-add parity proof",
1339        )?;
1340        expect_eq(
1341            "ledger review_after",
1342            ledger_entry.review_after.as_deref(),
1343            Some("2027-08-10"),
1344        )?;
1345        // Advisory ledger fields stay as ledger metadata; classification stays on the card.
1346        expect_eq(
1347            "ledger owner does not reclassify ReviewCard class",
1348            ledger_entry.owner.as_str() != source_card.class.as_str(),
1349            true,
1350        )?;
1351
1352        let snapshot = load_coverage_snapshot(&snapshot_path)?;
1353        let stored = snapshot
1354            .get(&card_id)
1355            .ok_or_else(|| format!("snapshot missing card_id {card_id}"))?;
1356        expect_eq(
1357            "contract_coverage parity",
1358            stored.contract_coverage.as_str(),
1359            expected_block.contract_coverage.as_str(),
1360        )?;
1361        expect_eq(
1362            "guard_coverage parity",
1363            stored.guard_coverage.as_str(),
1364            expected_block.guard_coverage.as_str(),
1365        )?;
1366        expect_eq(
1367            "test_reach_coverage parity",
1368            stored.test_reach_coverage.as_str(),
1369            expected_block.test_reach_coverage.as_str(),
1370        )?;
1371        expect_eq(
1372            "witness_receipt_coverage parity",
1373            stored.witness_receipt_coverage.as_str(),
1374            expected_block.witness_receipt_coverage.as_str(),
1375        )?;
1376
1377        // Updating the same card keeps the same snapshot derivation but overwrites
1378        // ledger metadata (owner/reason/evidence are ledger fields, not ReviewCard fields).
1379        baseline_add(
1380            &root,
1381            &card_id,
1382            "second-owner",
1383            "updated reason; still debt",
1384            "second evidence",
1385            Some("2027-09-01"),
1386            None,
1387        )?;
1388        let ledger_entries_2 = load_ledger_entries(&ledger_path, LedgerKind::Baseline)?;
1389        expect_eq("ledger entry count after update", ledger_entries_2.len(), 1)?;
1390        expect_eq(
1391            "ledger owner after update",
1392            ledger_entries_2[0].owner.as_str(),
1393            "second-owner",
1394        )?;
1395        let snapshot_2 = load_coverage_snapshot(&snapshot_path)?;
1396        let stored_2 = snapshot_2
1397            .get(&card_id)
1398            .ok_or_else(|| format!("snapshot missing after update {card_id}"))?;
1399        expect_eq(
1400            "contract_coverage stable after ledger update",
1401            stored_2.contract_coverage.as_str(),
1402            expected_block.contract_coverage.as_str(),
1403        )?;
1404        expect_eq(
1405            "witness_receipt_coverage stable after ledger update",
1406            stored_2.witness_receipt_coverage.as_str(),
1407            expected_block.witness_receipt_coverage.as_str(),
1408        )?;
1409
1410        fs::remove_dir_all(&root).map_err(|err| format!("remove temp root failed: {err}"))?;
1411        Ok(())
1412    }
1413
1414    #[test]
1415    fn baseline_add_missing_identity_fails_without_mutating_ledger_or_snapshot()
1416    -> Result<(), String> {
1417        // Advisory parity proof: a missing identity must fail closed with an
1418        // error naming the requested id, and must not mutate existing ledger or
1419        // snapshot state. No safety or reviewed-as-safe claim.
1420        let root = unique_temp_dir("baseline-add-missing-no-mutate")?;
1421        fs::create_dir_all(&root).map_err(|err| format!("create temp root failed: {err}"))?;
1422        install_fixture_repo(&root, "raw_pointer_alignment")?;
1423
1424        let output = pipeline::analyze(AnalyzeInput {
1425            root: root.clone(),
1426            scope: Scope::Repo,
1427            diff: DiffSource::NoneRepoScan,
1428            mode: AnalysisMode::Repo,
1429            policy: PolicyMode::Advisory,
1430            include_unchanged_tests: true,
1431            max_cards: None,
1432        })?;
1433        let real_id = output
1434            .cards
1435            .first()
1436            .ok_or("fixture should emit at least one card")?
1437            .id
1438            .0
1439            .clone();
1440
1441        baseline_add(
1442            &root,
1443            &real_id,
1444            "owner-a",
1445            "existing debt",
1446            "evidence-a",
1447            Some("2027-08-10"),
1448            None,
1449        )?;
1450
1451        let ledger_path = root.join("policy/unsafe-review-baseline.toml");
1452        let snapshot_path = baseline_snapshot_path(&ledger_path);
1453        let ledger_before = fs::read_to_string(&ledger_path)
1454            .map_err(|err| format!("read ledger before failed: {err}"))?;
1455        let snapshot_before = fs::read_to_string(&snapshot_path)
1456            .map_err(|err| format!("read snapshot before failed: {err}"))?;
1457
1458        let missing_id = "UR-missing-fixture-src-lib-rs-owner-operation-unknown-c999";
1459        let err = baseline_add(
1460            &root,
1461            missing_id,
1462            "owner-b",
1463            "reason-b",
1464            "evidence-b",
1465            Some("2027-08-10"),
1466            None,
1467        )
1468        .err()
1469        .ok_or_else(|| "baseline_add with missing id should fail".to_string())?;
1470        if !err.contains(missing_id) {
1471            return Err(format!(
1472                "missing-id error should name the requested id: actual={err:?}, expected fragment={missing_id:?}"
1473            ));
1474        }
1475        if !err.contains("not found in current repo scan") {
1476            return Err(format!(
1477                "missing-id error should mention not found in current repo scan: actual={err:?}"
1478            ));
1479        }
1480
1481        let ledger_after = fs::read_to_string(&ledger_path)
1482            .map_err(|err| format!("read ledger after failed: {err}"))?;
1483        let snapshot_after = fs::read_to_string(&snapshot_path)
1484            .map_err(|err| format!("read snapshot after failed: {err}"))?;
1485        expect_eq(
1486            "ledger unchanged after missing-id failure",
1487            ledger_after,
1488            ledger_before,
1489        )?;
1490        expect_eq(
1491            "snapshot unchanged after missing-id failure",
1492            snapshot_after,
1493            snapshot_before,
1494        )?;
1495
1496        // A missing id must also fail when no ledger exists yet (fresh repo),
1497        // without creating one.
1498        let fresh_root = unique_temp_dir("baseline-add-missing-fresh")?;
1499        fs::create_dir_all(&fresh_root)
1500            .map_err(|err| format!("create fresh root failed: {err}"))?;
1501        install_fixture_repo(&fresh_root, "raw_pointer_alignment")?;
1502        let missing_err = baseline_add(
1503            &fresh_root,
1504            missing_id,
1505            "owner-c",
1506            "reason-c",
1507            "evidence-c",
1508            Some("2027-08-10"),
1509            None,
1510        )
1511        .err()
1512        .ok_or_else(|| "fresh missing-id baseline_add should fail".to_string())?;
1513        if !missing_err.contains(missing_id) {
1514            return Err(format!(
1515                "fresh missing-id error should name the requested id: actual={missing_err:?}"
1516            ));
1517        }
1518        let fresh_ledger = fresh_root.join("policy/unsafe-review-baseline.toml");
1519        let fresh_snapshot = baseline_snapshot_path(&fresh_ledger);
1520        if fresh_ledger.exists() {
1521            return Err("fresh missing-id must not create a ledger file".to_string());
1522        }
1523        if fresh_snapshot.exists() {
1524            return Err("fresh missing-id must not create a snapshot file".to_string());
1525        }
1526
1527        fs::remove_dir_all(&root).map_err(|err| format!("remove temp root failed: {err}"))?;
1528        fs::remove_dir_all(&fresh_root)
1529            .map_err(|err| format!("remove fresh root failed: {err}"))?;
1530        Ok(())
1531    }
1532
1533    fn unique_temp_dir(prefix: &str) -> Result<PathBuf, String> {
1534        let nanos = SystemTime::now()
1535            .duration_since(UNIX_EPOCH)
1536            .map_err(|err| format!("system clock before UNIX_EPOCH: {err}"))?
1537            .as_nanos();
1538        let pid = std::process::id();
1539        Ok(std::env::temp_dir().join(format!("{prefix}-{pid}-{nanos}")))
1540    }
1541
1542    fn install_fixture_repo(root: &Path, fixture: &str) -> Result<(), String> {
1543        let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
1544        let fixture_root = manifest_dir.join("../../fixtures").join(fixture);
1545        if !fixture_root.is_dir() {
1546            return Err(format!("fixture not found: {}", fixture_root.display()));
1547        }
1548        copy_dir_recursive(&fixture_root.join("src"), &root.join("src"))?;
1549        let cargo_src = fixture_root.join("Cargo.toml");
1550        let cargo_dst = root.join("Cargo.toml");
1551        if cargo_src.is_file() {
1552            fs::copy(&cargo_src, &cargo_dst)
1553                .map_err(|err| format!("copy Cargo.toml failed: {err}"))?;
1554        }
1555        Ok(())
1556    }
1557
1558    fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<(), String> {
1559        fs::create_dir_all(dst).map_err(|err| format!("create {} failed: {err}", dst.display()))?;
1560        for entry in
1561            fs::read_dir(src).map_err(|err| format!("read_dir {} failed: {err}", src.display()))?
1562        {
1563            let entry = entry.map_err(|err| format!("read_dir entry failed: {err}"))?;
1564            let file_type = entry
1565                .file_type()
1566                .map_err(|err| format!("file_type failed: {err}"))?;
1567            let src_path = entry.path();
1568            let dst_path = dst.join(entry.file_name());
1569            if file_type.is_dir() {
1570                copy_dir_recursive(&src_path, &dst_path)?;
1571            } else if file_type.is_file() {
1572                fs::copy(&src_path, &dst_path)
1573                    .map_err(|err| format!("copy {} failed: {err}", src_path.display()))?;
1574            }
1575        }
1576        Ok(())
1577    }
1578
1579    fn expect_eq<T>(context: &str, actual: T, expected: T) -> Result<(), String>
1580    where
1581        T: std::fmt::Debug + PartialEq,
1582    {
1583        if actual == expected {
1584            Ok(())
1585        } else {
1586            Err(format!(
1587                "{context} mismatch: actual={actual:?}, expected={expected:?}"
1588            ))
1589        }
1590    }
1591}