Skip to main content

unsafe_review_core/
api.rs

1use crate::analysis::{pipeline, receipts};
2use crate::domain::{CardId, ReviewCard};
3use crate::input::workspace;
4use crate::output::{
5    agent, badges, comment_plan, confirmation, gate_manifest, human, json, lsp, markdown, outcome,
6    policy_report, receipt_audit, repair_queue, sarif, usefulness_telemetry, witness_plan,
7};
8use crate::policy::SnapshotCoverage;
9use crate::util::path_display;
10use std::collections::{BTreeMap, BTreeSet};
11use std::path::{Path, PathBuf};
12
13#[derive(Clone, Debug, PartialEq, Eq)]
14pub enum Scope {
15    Diff,
16    Repo,
17}
18
19#[derive(Clone, Debug, PartialEq, Eq)]
20pub enum AnalysisMode {
21    Instant,
22    Draft,
23    Ready,
24    Repo,
25}
26
27impl AnalysisMode {
28    pub fn as_str(&self) -> &'static str {
29        match self {
30            Self::Instant => "instant",
31            Self::Draft => "draft",
32            Self::Ready => "ready",
33            Self::Repo => "repo",
34        }
35    }
36}
37
38#[derive(Clone, Debug, PartialEq, Eq)]
39pub enum PolicyMode {
40    Advisory,
41    NoNewDebt,
42    Blocking,
43}
44
45impl PolicyMode {
46    pub fn as_str(&self) -> &'static str {
47        match self {
48            Self::Advisory => "advisory",
49            Self::NoNewDebt => "no-new-debt",
50            Self::Blocking => "blocking",
51        }
52    }
53}
54
55#[derive(Clone, Debug, PartialEq, Eq)]
56pub enum DiffSource {
57    NoneRepoScan,
58    Text(String),
59    File(PathBuf),
60}
61
62#[derive(Clone, Debug, PartialEq, Eq)]
63pub enum RepoScanPhase {
64    Discovering,
65    Scanning,
66    Complete,
67}
68
69impl RepoScanPhase {
70    pub fn as_str(&self) -> &'static str {
71        match self {
72            Self::Discovering => "discovering",
73            Self::Scanning => "scanning",
74            Self::Complete => "complete",
75        }
76    }
77}
78
79/// Why a repo scan stopped short of scanning every discovered file.
80///
81/// A `Complete` scan has `stop_reason: None` (or equivalently `"none"`
82/// in the JSON sidecar).  Every other variant indicates a bounded-but-partial
83/// run; `completed` stays `false` for all partial variants.
84#[derive(Clone, Debug, PartialEq, Eq)]
85pub enum RepoStopReason {
86    /// Scan ran to completion — every in-scope file was read.
87    None,
88    /// `--max-cards N` was reached; scanning stopped after `N` cards were emitted.
89    MaxCards,
90    /// `--timeout-seconds N` elapsed while the scan was in progress.
91    Timeout,
92    /// A unix signal (SIGTERM / SIGINT) interrupted the scan.
93    Terminated,
94    /// The scan did not complete due to an analysis or report-write error
95    /// (anything that is not a timeout, signal, or cap).
96    Error,
97}
98
99impl RepoStopReason {
100    pub fn as_str(&self) -> &'static str {
101        match self {
102            Self::None => "none",
103            Self::MaxCards => "max_cards",
104            Self::Timeout => "timeout",
105            Self::Terminated => "terminated",
106            Self::Error => "error",
107        }
108    }
109}
110
111/// Per-file scan timing entry.  Diagnostic only — not a coverage claim,
112/// proof, UB-free, Miri-clean, site-execution, or performance guarantee.
113#[derive(Clone, Debug, PartialEq, Eq)]
114pub struct PerFileScanStats {
115    /// Repository-relative path of the scanned file.
116    pub file: PathBuf,
117    /// Wall-clock milliseconds spent scanning this file (parse + site detection).
118    pub scan_ms: u64,
119}
120
121#[derive(Clone, Debug, PartialEq, Eq)]
122pub struct RepoScanStatus {
123    pub schema_version: String,
124    pub phase: RepoScanPhase,
125    pub elapsed_ms: u64,
126    pub files_discovered: usize,
127    pub files_scanned: usize,
128    pub cards_found: usize,
129    pub last_path: Option<PathBuf>,
130    pub completed: bool,
131    /// Whether this is a partial (bounded) scan result.
132    /// `true` for max-cards, timeout, and signal-terminated scans.
133    pub partial: bool,
134    /// The reason the scan stopped.  `None` for a complete scan, or one of the
135    /// named stop reasons for a bounded/interrupted scan.
136    pub stop_reason: RepoStopReason,
137    /// The configured card cap when `stop_reason == MaxCards`; `None` otherwise.
138    pub cap: Option<usize>,
139    /// Per-file timing breakdown for diagnostic use.  Present only when the
140    /// scan covered fewer than [`FILE_TIMINGS_CAP`] files; `None` for large
141    /// scans (cap honesty: the field is absent, not silently truncated).
142    /// This is a **diagnostic aperture only** — not a coverage claim, proof,
143    /// UB-free, Miri-clean, site-execution, or performance guarantee.
144    pub file_timings: Option<Vec<PerFileScanStats>>,
145    /// Total bytes written to the output artifact(s) for this run.  `Some`
146    /// only after the final report file is successfully written; `None` for
147    /// in-progress, error, timeout, signal-terminated, and capped states where
148    /// no final artifact was produced.
149    ///
150    /// This is a **diagnostic aperture only** — it measures the disk footprint
151    /// of this run's output, not the files scanned.  It is not a coverage
152    /// claim, proof, UB-free, Miri-clean, site-execution, or performance
153    /// guarantee.
154    pub output_bytes: Option<u64>,
155}
156
157/// Maximum number of files for which per-file timing is collected.
158/// Scans touching more files omit `file_timings` entirely rather than
159/// silently emitting a partial list (truncation honesty).
160pub const FILE_TIMINGS_CAP: usize = 100;
161
162#[derive(Clone, Debug, Default, PartialEq, Eq)]
163pub struct DiscoveryOptions {
164    pub include: Vec<String>,
165    pub exclude: Vec<String>,
166    pub respect_gitignore: bool,
167    pub large_repo_ignores: bool,
168    pub max_files: Option<usize>,
169}
170
171impl DiscoveryOptions {
172    pub fn repo_defaults() -> Self {
173        Self {
174            respect_gitignore: true,
175            large_repo_ignores: true,
176            ..Self::default()
177        }
178    }
179}
180
181#[derive(Clone, Debug)]
182pub struct AnalyzeInput {
183    pub root: PathBuf,
184    pub scope: Scope,
185    pub diff: DiffSource,
186    pub mode: AnalysisMode,
187    pub policy: PolicyMode,
188    pub include_unchanged_tests: bool,
189    pub max_cards: Option<usize>,
190}
191
192impl Default for AnalyzeInput {
193    fn default() -> Self {
194        Self {
195            root: PathBuf::from("."),
196            scope: Scope::Diff,
197            diff: DiffSource::NoneRepoScan,
198            mode: AnalysisMode::Draft,
199            policy: PolicyMode::Advisory,
200            include_unchanged_tests: true,
201            max_cards: None,
202        }
203    }
204}
205
206#[derive(Clone, Debug, Default)]
207pub struct Summary {
208    pub rust_files: usize,
209    pub changed_files: usize,
210    pub changed_rust_files: usize,
211    pub changed_non_rust_files: usize,
212    pub unsafe_sites: usize,
213    pub cards: usize,
214    pub open_actionable_gaps: usize,
215    pub contract_missing: usize,
216    pub guard_missing: usize,
217    pub guarded_unwitnessed: usize,
218    pub unsafe_unreached: usize,
219    pub requires_loom: usize,
220    pub miri_unsupported: usize,
221    pub static_unknown: usize,
222    /// Coverage movement counts (SPEC-0030).
223    ///
224    /// `new_gaps`      — open actionable cards not in the baseline ledger.
225    /// `worsened_gaps` — baseline cards whose coverage regressed (requires a saved coverage
226    ///                   snapshot; always 0 until `baseline init` authoring lands).
227    /// `improved_gaps` — baseline cards whose evidence coverage improved (at least one slot
228    ///                   advanced and no slot regressed; requires a saved coverage snapshot;
229    ///                   always 0 until `baseline init` authoring lands).
230    ///                   Precedence: worsened > improved > inherited.  A card is only counted
231    ///                   improved if it is not already counted worsened.
232    ///                   An improved card is still advisory, still open, still present — it is
233    ///                   NOT resolved, NOT safe, NOT UB-free, NOT Miri-clean, and NOT a
234    ///                   site-execution claim.
235    /// `resolved_gaps` — baseline ledger entries whose card is no longer present.
236    /// `inherited_gaps`— baseline-known cards still open and unchanged.
237    ///
238    /// On a diff-scoped run `new_gaps` is constrained to changed-line sites;
239    /// on a repo-mode run it counts all open actionable non-baseline gaps.
240    pub new_gaps: usize,
241    pub worsened_gaps: usize,
242    pub improved_gaps: usize,
243    pub resolved_gaps: usize,
244    pub inherited_gaps: usize,
245}
246
247#[derive(Clone, Debug)]
248pub struct AnalyzeOutput {
249    pub schema_version: String,
250    pub tool: String,
251    pub root: PathBuf,
252    pub scope: Scope,
253    pub mode: AnalysisMode,
254    pub policy: PolicyMode,
255    pub summary: Summary,
256    pub cards: Vec<ReviewCard>,
257    /// On a diff-scoped run, the set of candidate files that were scanned.
258    /// Empty for full-scan (repo-mode) runs.  Used to distinguish "baseline
259    /// card resolved because we scanned its file and it is gone" from "baseline
260    /// card not present because its file was out of diff scope" (SPEC-0030).
261    pub diff_scoped_files: BTreeSet<PathBuf>,
262    /// Per-card coverage snapshot loaded from `policy/unsafe-review-baseline-snapshot.toml`.
263    ///
264    /// Output renderers use this to project the per-card `baseline_state`/`outcome_movement`
265    /// from the same slot-level comparison the summary uses (SPEC-0030 §single-truth).
266    /// Empty when no snapshot file exists (no worsened/improved projection possible).
267    pub coverage_snapshot: BTreeMap<String, SnapshotCoverage>,
268}
269
270#[derive(Clone, Debug, PartialEq, Eq)]
271pub struct ReviewCardConfirmationProjection {
272    pub hypothesis_to_confirm: String,
273    pub build_this_first: String,
274    pub minimal_repro_steps: Vec<String>,
275    pub minimal_repro_limitation: String,
276    pub confirmation_step: String,
277}
278
279#[derive(Clone, Debug)]
280pub struct RepoScanEvent {
281    pub status: RepoScanStatus,
282    pub partial_output: Option<AnalyzeOutput>,
283}
284
285pub fn analyze(input: AnalyzeInput) -> Result<AnalyzeOutput, String> {
286    pipeline::analyze(input)
287}
288
289pub fn analyze_with_discovery(
290    input: AnalyzeInput,
291    discovery: DiscoveryOptions,
292) -> Result<AnalyzeOutput, String> {
293    pipeline::analyze_with_discovery(input, discovery)
294}
295
296pub fn analyze_with_discovery_and_progress<F>(
297    input: AnalyzeInput,
298    discovery: DiscoveryOptions,
299    progress: F,
300) -> Result<AnalyzeOutput, String>
301where
302    F: FnMut(&RepoScanStatus) -> Result<(), String>,
303{
304    pipeline::analyze_with_discovery_and_progress(input, discovery, progress)
305}
306
307pub fn analyze_with_discovery_and_repo_events<F>(
308    input: AnalyzeInput,
309    discovery: DiscoveryOptions,
310    events: F,
311) -> Result<AnalyzeOutput, String>
312where
313    F: FnMut(&RepoScanEvent) -> Result<(), String>,
314{
315    pipeline::analyze_with_discovery_and_repo_events(input, discovery, events)
316}
317
318pub fn discover_repo_files(
319    root: PathBuf,
320    discovery: DiscoveryOptions,
321) -> Result<Vec<PathBuf>, String> {
322    workspace::discover_rust_files(&root, &discovery)
323}
324
325pub fn validate_witness_receipts(root: PathBuf) -> Result<usize, String> {
326    receipts::validate_receipts(&root)
327}
328
329pub fn audit_witness_receipts(input: AnalyzeInput) -> Result<ReceiptAuditReport, String> {
330    let output = pipeline::analyze_without_receipts(input)?;
331    receipts::audit_receipts(&output)
332}
333
334pub fn evaluate_policy_report(mut input: AnalyzeInput) -> Result<PolicyReport, String> {
335    input.policy = PolicyMode::Advisory;
336    let output = pipeline::analyze(input)?;
337    policy_report::evaluate(&output)
338}
339
340pub fn evaluate_policy_report_from_output(output: &AnalyzeOutput) -> Result<PolicyReport, String> {
341    policy_report::evaluate(output)
342}
343
344/// Run cost aperture measured by the CLI emit layer and injected into
345/// `usefulness-telemetry.json` (SPEC-0038 §scan_cost).
346///
347/// Core must not measure wall time — this struct carries the two cost fields
348/// that only the CLI layer can observe.
349///
350/// Diagnostic only — not a coverage claim, proof, UB-free, Miri-clean,
351/// site-execution, or performance guarantee.
352#[derive(Clone, Debug, Default)]
353pub struct ScanCost {
354    /// Wall-clock milliseconds from before `analyze()` through the last artifact
355    /// write, measured in the CLI emit layer.
356    pub elapsed_ms: u64,
357    /// Total bytes written across all output artifacts for this run, accumulated
358    /// in the CLI emit layer.  The telemetry file itself is excluded (it is
359    /// rendered before its own bytes are known).
360    pub output_bytes_total: u64,
361}
362
363/// Traceable evidence metadata that the CLI layer assembles from argv, git, and the
364/// filesystem before calling the JSON renderer.
365///
366/// This is "traceable evidence metadata", not proof: the fields identify the inputs
367/// used to produce an artifact so that two runs against different diffs cannot emit
368/// byte-identical clean receipts, but they do not prove correctness or memory safety.
369#[derive(Clone, Debug, Default)]
370pub struct Provenance {
371    /// Absolute path of the resolved workspace root (additive alongside the existing
372    /// relative `root` field which remains unchanged for compatibility).
373    pub root_abs: Option<String>,
374    /// Resolved base commit SHA (when `--base` was supplied and git resolution succeeded).
375    pub base_sha: Option<String>,
376    /// Resolved HEAD commit SHA (when `--base` was supplied and git resolution succeeded).
377    pub head_sha: Option<String>,
378    /// Path of the diff file (when `--diff <file>` was supplied).
379    pub diff_path: Option<String>,
380    /// SHA-256 hex digest of the diff file content (when `--diff <file>` was supplied).
381    pub diff_sha256: Option<String>,
382    /// RFC3339 UTC timestamp at which the artifact was generated.
383    pub generated_at: String,
384    /// Whether the working tree had uncommitted changes when the tool ran (None = git unavailable).
385    pub dirty_worktree: Option<bool>,
386}
387
388impl Provenance {
389    /// Build a minimal provenance block stamped with the current UTC time.
390    pub fn new_now() -> Self {
391        use std::time::{SystemTime, UNIX_EPOCH};
392        let secs = SystemTime::now()
393            .duration_since(UNIX_EPOCH)
394            .map(|d| d.as_secs())
395            .unwrap_or(0);
396        Self {
397            generated_at: unix_secs_to_iso_datetime_utc(secs),
398            ..Self::default()
399        }
400    }
401}
402
403/// Regenerate `expected.cards.json` for each named fixture (or all registered
404/// fixtures if `names` is empty), always writing LF line endings.
405///
406/// Called by `cargo run -p xtask -- bless-goldens [fixture ...]`.
407/// Does not execute witnesses or assess soundness.
408pub fn bless_fixture_card_goldens(names: &[&str]) -> Result<Vec<PathBuf>, String> {
409    json::bless_fixture_card_goldens(names)
410}
411
412/// Runtime-root-aware variant of [`bless_fixture_card_goldens`].
413///
414/// This is used by `xtask` so a reused shared target directory cannot make a
415/// cached binary read or write fixtures from the checkout where it was compiled.
416pub fn bless_fixture_card_goldens_from_workspace(
417    workspace: &Path,
418    names: &[&str],
419) -> Result<Vec<PathBuf>, String> {
420    json::bless_fixture_card_goldens_from_workspace(workspace, names)
421}
422
423/// Regenerate surface goldens (`expected.lsp.json`, `expected.repair-queue.json`)
424/// for a single named fixture, writing LF line endings.
425///
426/// `surfaces` must contain only `"lsp"`, `"repair-queue"`, or
427/// `"comment-plan"`. Paths inside the rendered JSON are normalised to relative
428/// form so goldens are byte-stable.
429pub fn bless_fixture_surface_goldens(
430    fixture: &str,
431    surfaces: &[&str],
432) -> Result<Vec<PathBuf>, String> {
433    json::bless_fixture_surface_goldens(fixture, surfaces)
434}
435
436/// Runtime-root-aware variant of [`bless_fixture_surface_goldens`].
437///
438/// This is used by `xtask` so a reused shared target directory cannot make a
439/// cached binary read fixtures from the checkout where it was compiled.
440pub fn bless_fixture_surface_goldens_from_workspace(
441    workspace: &Path,
442    fixture: &str,
443    surfaces: &[&str],
444) -> Result<Vec<PathBuf>, String> {
445    json::bless_fixture_surface_goldens_from_workspace(workspace, fixture, surfaces)
446}
447
448/// Render a single surface for a fixture as the bless path would write it
449/// (normalised, LF-terminated) without writing a file.
450///
451/// Used by `check-fixture-surface-parity` to produce the reference text for
452/// diffing against the committed golden.
453pub fn render_fixture_surface(fixture: &str, surface: &str) -> Result<String, String> {
454    json::render_fixture_surface(fixture, surface)
455}
456
457/// Runtime-root-aware variant of [`render_fixture_surface`].
458///
459/// This is used by `xtask` so parity and determinism checks render from the
460/// active workspace root selected at runtime.
461pub fn render_fixture_surface_from_workspace(
462    workspace: &Path,
463    fixture: &str,
464    surface: &str,
465) -> Result<String, String> {
466    json::render_fixture_surface_from_workspace(workspace, fixture, surface)
467}
468
469pub fn render_json(output: &AnalyzeOutput) -> String {
470    json::render(output)
471}
472
473/// Render the JSON analyze artifact with attached traceable evidence metadata.
474///
475/// The `provenance` block is inserted as a nested object in the output.
476/// `tool_version` also appears top-level beside `tool` for consumer grep-ability.
477pub fn render_json_with_provenance(output: &AnalyzeOutput, provenance: &Provenance) -> String {
478    json::render_with_provenance(output, provenance)
479}
480
481pub fn render_human(output: &AnalyzeOutput) -> String {
482    human::render(output)
483}
484
485pub fn render_markdown(output: &AnalyzeOutput) -> String {
486    markdown::render(output)
487}
488
489pub fn render_pr_summary(output: &AnalyzeOutput) -> String {
490    markdown::render_pr_summary(output)
491}
492
493pub fn render_github_summary(output: &AnalyzeOutput) -> String {
494    markdown::render_github_summary(output)
495}
496
497pub fn render_sarif(output: &AnalyzeOutput) -> String {
498    sarif::render(output)
499}
500
501pub fn render_comment_plan(output: &AnalyzeOutput) -> String {
502    comment_plan::render(output)
503}
504
505pub fn render_lsp(output: &AnalyzeOutput) -> String {
506    lsp::render(output)
507}
508
509pub fn project_editor(output: &AnalyzeOutput) -> lsp::EditorProjection {
510    lsp::project_editor(output)
511}
512
513/// Render the rich hover markdown for a single [`ReviewCard`] as the live LSP
514/// server would produce it.
515///
516/// The returned string is the same content that `lsp.json` embeds in its
517/// `hovers[].contents` field: obligations, evidence state (contract / guard /
518/// reach / witness), hazard families, verify commands, witness route, handoff
519/// commands, and the advisory trust boundary.
520///
521/// This is **advisory evidence only**: no memory-safety proof, no UB-free
522/// status, no Miri-clean status, and not a site-execution claim unless a
523/// matching witness receipt says so.
524pub fn render_lsp_hover(card: &ReviewCard) -> String {
525    lsp::render_hover(card)
526}
527
528pub fn render_witness_plan(output: &AnalyzeOutput) -> String {
529    witness_plan::render(output)
530}
531
532pub fn render_repair_queue(output: &AnalyzeOutput) -> String {
533    repair_queue::render(output)
534}
535
536/// Render the `unsafe-review-gate.json` routing manifest (SPEC-0034).
537///
538/// The manifest is a thin index over the artifacts a `first-pr`/`repo` run
539/// produced, plus the SPEC-0030 movement summary.  It is fully deterministic —
540/// it carries no timestamp or wall-time field — so it is safe to include in
541/// byte-compared goldens or reproducibility rails.
542pub fn render_gate_manifest(output: &AnalyzeOutput) -> String {
543    gate_manifest::render(output)
544}
545
546/// Render `unsafe-review-gate.json` for a `repo` run (SPEC-0034 parity).
547///
548/// Repo mode writes a single output file rather than a bundle directory, so
549/// the first-pr-specific artifact pointers (`pr_summary`, `sarif`, `lsp`, …)
550/// are absent — they were not emitted and must not be faked (SPEC-0034:
551/// "Missing optional artifacts are omitted, not faked.").
552///
553/// `report_filename` is the basename of the `--out` file (e.g. `"repo.json"`);
554/// it becomes the `artifacts.cards` pointer so downstream consumers can locate
555/// the ReviewCard dataset relative to the manifest.
556///
557/// The `status` field is always `"advisory"` — the manifest carries posture,
558/// never a merge verdict, not proof, not UB-free, not Miri-clean, not a
559/// site-execution claim.
560pub fn render_gate_manifest_repo(output: &AnalyzeOutput, report_filename: &str) -> String {
561    gate_manifest::render_repo(output, report_filename)
562}
563
564/// Render the `usefulness-telemetry.json` low-noise usefulness telemetry artifact (SPEC-0038).
565///
566/// This is a pure projection from `AnalyzeOutput` — no new analysis.
567/// Diagnostic operational usefulness only: not calibrated precision/recall,
568/// not accuracy measurement, not memory-safety proof, not UB-free status,
569/// not Miri-clean status, not a site-execution claim, not a gate, and not
570/// a merge verdict.
571pub fn render_usefulness_telemetry(output: &AnalyzeOutput) -> String {
572    usefulness_telemetry::render(output)
573}
574
575/// Render `usefulness-telemetry.json` with CLI-layer scan cost injected
576/// (SPEC-0038 §scan_cost).
577///
578/// The `cost` argument carries `elapsed_ms` and `output_bytes_total` measured
579/// in the CLI emit layer — fields that core cannot compute itself (core must
580/// not measure wall time).  When `cost` is `None` the `scan_cost` section is
581/// omitted and the output is identical to `render_usefulness_telemetry`.
582pub fn render_usefulness_telemetry_with_cost(
583    output: &AnalyzeOutput,
584    cost: Option<&ScanCost>,
585) -> String {
586    usefulness_telemetry::render_with_cost(output, cost)
587}
588
589pub fn project_review_card_confirmation(card: &ReviewCard) -> ReviewCardConfirmationProjection {
590    let minimal_repro = confirmation::minimal_repro(card);
591    ReviewCardConfirmationProjection {
592        hypothesis_to_confirm: confirmation::hypothesis_to_confirm(card),
593        build_this_first: confirmation::build_this_first(card).summary,
594        minimal_repro_steps: minimal_repro.steps().to_vec(),
595        minimal_repro_limitation: minimal_repro.limitation().to_string(),
596        confirmation_step: confirmation::confirmation_step(card),
597    }
598}
599
600pub fn render_badge_jsons(output: &AnalyzeOutput) -> (String, String) {
601    badges::render(output)
602}
603
604pub fn compare_outcome_json(before_json: &str, after_json: &str) -> Result<OutcomeReport, String> {
605    outcome::compare_json(before_json, after_json)
606}
607
608pub fn render_outcome_json(report: &OutcomeReport) -> String {
609    outcome::render_json(report)
610}
611
612pub fn render_outcome_markdown(report: &OutcomeReport) -> String {
613    outcome::render_markdown(report)
614}
615
616pub fn render_receipt_audit_json(report: &ReceiptAuditReport) -> String {
617    receipt_audit::render_json(report)
618}
619
620pub fn render_receipt_audit_markdown(report: &ReceiptAuditReport) -> String {
621    receipt_audit::render_markdown(report)
622}
623
624pub fn render_policy_report_json(report: &PolicyReport) -> String {
625    policy_report::render_json(report)
626}
627
628pub fn render_policy_report_markdown(report: &PolicyReport) -> String {
629    policy_report::render_markdown(report)
630}
631
632pub fn explain_card(output: &AnalyzeOutput, id: &CardId) -> Option<String> {
633    output
634        .cards
635        .iter()
636        .find(|card| &card.id == id)
637        .map(markdown::render_card_detail)
638}
639
640pub fn collect_context(output: &AnalyzeOutput, id: &CardId) -> Option<String> {
641    output
642        .cards
643        .iter()
644        .find(|card| &card.id == id)
645        .map(|card| agent::render_with_output(output, card))
646}
647
648/// Render a `file_range_scan` envelope for SPEC-0033.
649///
650/// Collects packets for all cards whose unsafe site overlaps `file:line_start-line_end`
651/// (1-based, both endpoints inclusive).  If `changed_only` is `true`, further
652/// restricts to cards whose `baseline_state` is `new` or `worsened` (SPEC-0030).
653///
654/// File paths are matched by normalizing both to forward-slash display form,
655/// then checking whether the card's site file ends with the queried fragment so
656/// callers may pass either root-relative or short relative paths (e.g. `src/lib.rs`).
657/// The `root` parameter is used to strip a leading root prefix from the queried path
658/// before the suffix comparison.
659pub fn collect_context_range(
660    output: &AnalyzeOutput,
661    root: &Path,
662    file: &Path,
663    line_start: u32,
664    line_end: u32,
665    changed_only: bool,
666) -> String {
667    let queried_display = path_display(file);
668    let root_display = path_display(root);
669
670    // Strip the workspace root prefix from the queried path so that callers
671    // can use either a short relative path ("src/lib.rs") or an absolute one.
672    let queried_suffix = queried_display
673        .strip_prefix(&root_display)
674        .map(|rest| rest.trim_start_matches('/'))
675        .unwrap_or(&queried_display);
676
677    // Pre-filter to the requested file; range + changed-only filtering happens
678    // inside render_range_scan.
679    let file_cards: Vec<&ReviewCard> = output
680        .cards
681        .iter()
682        .filter(|card| {
683            let card_file = path_display(&card.site.location.file);
684            card_file == queried_display
685                || card_file == queried_suffix
686                || card_file.ends_with(&format!("/{queried_suffix}"))
687        })
688        .collect();
689
690    let statuses = comment_plan::card_statuses(output);
691    agent::render_range_scan(
692        queried_display,
693        line_start,
694        line_end,
695        changed_only,
696        &file_cards,
697        &output.schema_version,
698        &statuses,
699        &output.coverage_snapshot,
700    )
701}
702
703/// Result returned by `baseline_init` summarizing what was captured.
704#[derive(Clone, Debug, PartialEq, Eq)]
705pub struct BaselineInitResult {
706    /// Number of open actionable cards captured as baseline entries.
707    pub captured: usize,
708    /// Whether the baseline ledger file already existed before this run.
709    pub ledger_existed: bool,
710    /// Path to the baseline ledger written.
711    pub ledger_path: PathBuf,
712    /// Path to the coverage snapshot written.
713    pub snapshot_path: PathBuf,
714    /// The open actionable cards captured, in scan order.
715    /// Used by the CLI to display a debt scope listing for brownfield adoption.
716    pub cards: Vec<ReviewCard>,
717}
718
719/// `baseline init` (SPEC-0030): scan the repo for open actionable cards, capture each
720/// card's identity and coverage state, and write both the baseline ledger and the coverage
721/// snapshot.  Idempotent — re-running overwrites with a fresh snapshot of the current state.
722///
723/// The honest default `reason` and `review_after` are set to record pre-existing debt only;
724/// no card is marked safe, reviewed, or UB-free.
725///
726/// `review_after` defaults to one year from today's date.
727pub fn baseline_init(
728    root: &Path,
729    out: Option<&Path>,
730    review_after: Option<&str>,
731) -> Result<BaselineInitResult, String> {
732    use crate::domain::coverage::CoverageBlock;
733    use crate::policy::{
734        LedgerEntry, SnapshotCoverage, merge_and_write_baseline_ledger, write_coverage_snapshot,
735    };
736    use std::collections::BTreeMap;
737
738    let ledger_path = out
739        .map(Path::to_path_buf)
740        .unwrap_or_else(|| root.join("policy/unsafe-review-baseline.toml"));
741    let snapshot_path = baseline_snapshot_path(&ledger_path);
742    let ledger_existed = ledger_path.is_file();
743
744    // Run a full repo scan to get all current cards.
745    let output = pipeline::analyze(AnalyzeInput {
746        root: root.to_path_buf(),
747        scope: Scope::Repo,
748        diff: DiffSource::NoneRepoScan,
749        mode: AnalysisMode::Repo,
750        policy: PolicyMode::Advisory,
751        include_unchanged_tests: true,
752        max_cards: None,
753    })?;
754
755    // Determine review_after date (required by ledger validator).
756    let review_after = review_after
757        .map(ToOwned::to_owned)
758        .unwrap_or_else(default_review_after_date);
759
760    // Collect open actionable cards.
761    let mut ledger_entries: Vec<LedgerEntry> = Vec::new();
762    let mut snapshot_entries: BTreeMap<String, SnapshotCoverage> = BTreeMap::new();
763    let mut actionable_cards: Vec<ReviewCard> = Vec::new();
764
765    for card in &output.cards {
766        if card.class.is_actionable() {
767            ledger_entries.push(LedgerEntry {
768                card_id: card.id.0.clone(),
769                owner: "baseline-init".to_string(),
770                reason: "captured by `baseline init`; pre-existing debt, not reviewed as safe"
771                    .to_string(),
772                evidence: "baseline-init: captured by baseline init; pre-existing debt".to_string(),
773                review_after: Some(review_after.clone()),
774                expires: None,
775            });
776            let block = CoverageBlock::derive(card);
777            snapshot_entries.insert(
778                card.id.0.clone(),
779                SnapshotCoverage {
780                    contract_coverage: block.contract_coverage.as_str().to_string(),
781                    guard_coverage: block.guard_coverage.as_str().to_string(),
782                    test_reach_coverage: block.test_reach_coverage.as_str().to_string(),
783                    witness_receipt_coverage: block.witness_receipt_coverage.as_str().to_string(),
784                },
785            );
786            actionable_cards.push(card.clone());
787        }
788    }
789
790    let captured = ledger_entries.len();
791    merge_and_write_baseline_ledger(&ledger_path, &ledger_entries)?;
792    write_coverage_snapshot(&snapshot_path, &snapshot_entries)?;
793
794    Ok(BaselineInitResult {
795        captured,
796        ledger_existed,
797        ledger_path,
798        snapshot_path,
799        cards: actionable_cards,
800    })
801}
802
803/// `baseline add` (SPEC-0030): add or update a single baseline entry (plus its snapshot state)
804/// by re-analyzing the repo, finding the card matching `card_id`, and recording its current
805/// coverage state.
806///
807/// Returns `Err` if the card cannot be found in the current scan.
808pub fn baseline_add(
809    root: &Path,
810    card_id: &str,
811    owner: &str,
812    reason: &str,
813    evidence: &str,
814    review_after: Option<&str>,
815    out: Option<&Path>,
816) -> Result<(), String> {
817    use crate::domain::coverage::CoverageBlock;
818    use crate::policy::{
819        LedgerEntry, SnapshotCoverage, load_coverage_snapshot, merge_and_write_baseline_ledger,
820        write_coverage_snapshot,
821    };
822    use std::collections::BTreeMap;
823
824    let ledger_path = out
825        .map(Path::to_path_buf)
826        .unwrap_or_else(|| root.join("policy/unsafe-review-baseline.toml"));
827    let snapshot_path = baseline_snapshot_path(&ledger_path);
828
829    // Run a full repo scan.
830    let output = pipeline::analyze(AnalyzeInput {
831        root: root.to_path_buf(),
832        scope: Scope::Repo,
833        diff: DiffSource::NoneRepoScan,
834        mode: AnalysisMode::Repo,
835        policy: PolicyMode::Advisory,
836        include_unchanged_tests: true,
837        max_cards: None,
838    })?;
839
840    // Find the specific card.
841    let card = output
842        .cards
843        .iter()
844        .find(|card| card.id.0 == card_id)
845        .ok_or_else(|| format!("card `{card_id}` not found in current repo scan"))?;
846
847    let review_after = review_after
848        .map(ToOwned::to_owned)
849        .unwrap_or_else(default_review_after_date);
850
851    let entry = LedgerEntry {
852        card_id: card_id.to_string(),
853        owner: owner.to_string(),
854        reason: reason.to_string(),
855        evidence: evidence.to_string(),
856        review_after: Some(review_after),
857        expires: None,
858    };
859
860    // Update the snapshot.
861    let mut snapshot = load_coverage_snapshot(&snapshot_path)?;
862    let block = CoverageBlock::derive(card);
863    snapshot.insert(
864        card_id.to_string(),
865        SnapshotCoverage {
866            contract_coverage: block.contract_coverage.as_str().to_string(),
867            guard_coverage: block.guard_coverage.as_str().to_string(),
868            test_reach_coverage: block.test_reach_coverage.as_str().to_string(),
869            witness_receipt_coverage: block.witness_receipt_coverage.as_str().to_string(),
870        },
871    );
872
873    // Sort snapshot to BTreeMap (already sorted).
874    let sorted_snapshot: BTreeMap<String, SnapshotCoverage> = snapshot.into_iter().collect();
875
876    merge_and_write_baseline_ledger(&ledger_path, &[entry])?;
877    write_coverage_snapshot(&snapshot_path, &sorted_snapshot)?;
878
879    Ok(())
880}
881
882/// Derive the coverage snapshot path from the baseline ledger path: the snapshot is written
883/// as a sibling `<ledger-stem>-snapshot.toml`. The default ledger
884/// `policy/unsafe-review-baseline.toml` keeps producing
885/// `policy/unsafe-review-baseline-snapshot.toml`, and a custom `--out` keeps both files
886/// together instead of writing the snapshot into the scanned `--root` (which would edit a
887/// repo unsafe-review only promised to read).
888fn baseline_snapshot_path(ledger_path: &Path) -> PathBuf {
889    let stem = ledger_path
890        .file_stem()
891        .map(|stem| stem.to_string_lossy().into_owned())
892        .unwrap_or_else(|| "unsafe-review-baseline".to_string());
893    ledger_path.with_file_name(format!("{stem}-snapshot.toml"))
894}
895
896/// Default `review_after` date: one year from today (ISO 8601 YYYY-MM-DD).
897fn default_review_after_date() -> String {
898    // Use a fixed date offset from June 2026 (the current date per context).
899    // We can't use std::time for date arithmetic without chrono, so we hardcode
900    // a safe one-year increment from a known epoch base.
901    // This is called only in baseline authoring, not in analysis; precision is not critical.
902    compute_review_after_date()
903}
904
905fn compute_review_after_date() -> String {
906    // Use SystemTime to get the current date offset by ~365 days.
907    use std::time::{SystemTime, UNIX_EPOCH};
908    let secs = SystemTime::now()
909        .duration_since(UNIX_EPOCH)
910        .map(|d| d.as_secs())
911        .unwrap_or(0);
912    // Approximate: add 365 days worth of seconds.
913    let future_secs = secs + 365 * 24 * 3600;
914    // Convert to a YYYY-MM-DD string using a simple algorithm.
915    unix_secs_to_iso_date(future_secs)
916}
917
918/// Extend the date-only helper to a full RFC3339 UTC timestamp (e.g. `2026-06-07T21:30:00Z`).
919///
920/// The time portion is always `T00:00:00Z` (midnight UTC) because we only have
921/// second-level granularity and already discard the sub-day remainder in the
922/// date calculation.  For a provenance `generated_at` field this is sufficient
923/// — the date binds the artifact to a calendar day without requiring chrono.
924pub(crate) fn unix_secs_to_iso_datetime_utc(secs: u64) -> String {
925    let date = unix_secs_to_iso_date(secs);
926    // Compute HH:MM:SS from the remaining seconds in the day.
927    let remainder = secs % 86400;
928    let hh = remainder / 3600;
929    let mm = (remainder % 3600) / 60;
930    let ss = remainder % 60;
931    format!("{date}T{hh:02}:{mm:02}:{ss:02}Z")
932}
933
934fn unix_secs_to_iso_date(secs: u64) -> String {
935    // Days since Unix epoch.
936    let days = secs / 86400;
937    // Gregorian calendar calculation.
938    let mut remaining_days = days;
939    let mut year = 1970u32;
940    loop {
941        let days_in_year = if is_leap_year(year) { 366 } else { 365 };
942        if remaining_days < days_in_year {
943            break;
944        }
945        remaining_days -= days_in_year;
946        year += 1;
947    }
948    let mut month = 1u32;
949    loop {
950        let days_in_month = days_in_month(year, month);
951        if remaining_days < days_in_month {
952            break;
953        }
954        remaining_days -= days_in_month;
955        month += 1;
956    }
957    let day = remaining_days + 1;
958    format!("{year:04}-{month:02}-{day:02}")
959}
960
961fn is_leap_year(year: u32) -> bool {
962    year.is_multiple_of(400) || (year.is_multiple_of(4) && !year.is_multiple_of(100))
963}
964
965fn days_in_month(year: u32, month: u32) -> u64 {
966    match month {
967        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
968        4 | 6 | 9 | 11 => 30,
969        2 => {
970            if is_leap_year(year) {
971                29
972            } else {
973                28
974            }
975        }
976        _ => 30,
977    }
978}
979
980pub use outcome::OutcomeReport;
981pub use policy_report::PolicyReport;
982pub use receipts::ReceiptAuditReport;
983
984#[cfg(test)]
985mod tests {
986    use super::*;
987
988    #[test]
989    fn analysis_mode_strings_cover_every_variant() {
990        assert_eq!(AnalysisMode::Instant.as_str(), "instant");
991        assert_eq!(AnalysisMode::Draft.as_str(), "draft");
992        assert_eq!(AnalysisMode::Ready.as_str(), "ready");
993        assert_eq!(AnalysisMode::Repo.as_str(), "repo");
994    }
995
996    #[test]
997    fn policy_mode_strings_cover_every_variant() {
998        assert_eq!(PolicyMode::Advisory.as_str(), "advisory");
999        assert_eq!(PolicyMode::NoNewDebt.as_str(), "no-new-debt");
1000        assert_eq!(PolicyMode::Blocking.as_str(), "blocking");
1001    }
1002
1003    #[test]
1004    fn analyze_input_default_is_advisory_diff_draft_with_unchanged_tests() {
1005        let input = AnalyzeInput::default();
1006
1007        assert_eq!(input.root, PathBuf::from("."));
1008        assert_eq!(input.scope, Scope::Diff);
1009        assert_eq!(input.diff, DiffSource::NoneRepoScan);
1010        assert_eq!(input.mode, AnalysisMode::Draft);
1011        assert_eq!(input.policy, PolicyMode::Advisory);
1012        assert!(input.include_unchanged_tests);
1013        assert_eq!(input.max_cards, None);
1014    }
1015
1016    #[test]
1017    fn baseline_snapshot_path_keeps_default_canonical_location() {
1018        let ledger = Path::new("repo/policy/unsafe-review-baseline.toml");
1019        assert_eq!(
1020            baseline_snapshot_path(ledger),
1021            PathBuf::from("repo/policy/unsafe-review-baseline-snapshot.toml")
1022        );
1023    }
1024
1025    #[test]
1026    fn baseline_snapshot_path_follows_custom_out_as_sibling() {
1027        let ledger = Path::new("elsewhere/bun-baseline.toml");
1028        assert_eq!(
1029            baseline_snapshot_path(ledger),
1030            PathBuf::from("elsewhere/bun-baseline-snapshot.toml")
1031        );
1032    }
1033
1034    #[test]
1035    fn baseline_snapshot_path_handles_extension_less_out() {
1036        let ledger = Path::new("elsewhere/baseline");
1037        assert_eq!(
1038            baseline_snapshot_path(ledger),
1039            PathBuf::from("elsewhere/baseline-snapshot.toml")
1040        );
1041    }
1042}