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#[derive(Clone, Debug, PartialEq, Eq)]
95pub enum RepoStopReason {
96 None,
98 MaxCards,
100 Timeout,
102 Terminated,
104 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#[derive(Clone, Debug, PartialEq, Eq)]
124pub struct PerFileScanStats {
125 pub file: PathBuf,
127 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 pub partial: bool,
144 pub stop_reason: RepoStopReason,
147 pub cap: Option<usize>,
149 pub file_timings: Option<Vec<PerFileScanStats>>,
155 pub output_bytes: Option<u64>,
165}
166
167pub 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 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 pub scan_capped: bool,
264 pub card_cap: Option<usize>,
267}
268
269impl Summary {
270 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 pub diff_scoped_files: BTreeSet<PathBuf>,
309 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#[derive(Clone, Debug, Default)]
400pub struct ScanCost {
401 pub elapsed_ms: u64,
404 pub output_bytes_total: u64,
408}
409
410#[derive(Clone, Debug, Default)]
417pub struct Provenance {
418 pub root_abs: Option<String>,
421 pub base_sha: Option<String>,
423 pub head_sha: Option<String>,
425 pub diff_path: Option<String>,
427 pub diff_sha256: Option<String>,
429 pub generated_at: String,
431 pub dirty_worktree: Option<bool>,
433}
434
435impl Provenance {
436 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
450pub fn bless_fixture_card_goldens(names: &[&str]) -> Result<Vec<PathBuf>, String> {
456 json::bless_fixture_card_goldens(names)
457}
458
459pub 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
470pub 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
483pub 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
495pub fn render_fixture_surface(fixture: &str, surface: &str) -> Result<String, String> {
501 json::render_fixture_surface(fixture, surface)
502}
503
504pub 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
520pub 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
564pub fn project_editor_diagnostics(output: &AnalyzeOutput) -> Vec<lsp::EditorDiagnostic> {
567 lsp::project_editor_diagnostics(output)
568}
569
570pub fn project_actionable_editor_diagnostics(output: &AnalyzeOutput) -> Vec<lsp::EditorDiagnostic> {
572 lsp::project_actionable_editor_diagnostics(output)
573}
574
575pub 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
598pub fn render_gate_manifest(output: &AnalyzeOutput) -> String {
605 gate_manifest::render(output)
606}
607
608pub fn render_gate_manifest_repo(output: &AnalyzeOutput, report_filename: &str) -> String {
623 gate_manifest::render_repo(output, report_filename)
624}
625
626pub fn render_usefulness_telemetry(output: &AnalyzeOutput) -> String {
634 usefulness_telemetry::render(output)
635}
636
637pub 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
710pub 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 let queried_suffix = queried_display
735 .strip_prefix(&root_display)
736 .map(|rest| rest.trim_start_matches('/'))
737 .unwrap_or(&queried_display);
738
739 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#[derive(Clone, Debug, PartialEq, Eq)]
767pub struct BaselineInitResult {
768 pub captured: usize,
770 pub ledger_existed: bool,
772 pub ledger_path: PathBuf,
774 pub snapshot_path: PathBuf,
776 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
787pub 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
806pub 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 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 let review_after = review_after
845 .map(ToOwned::to_owned)
846 .unwrap_or_else(default_review_after_date);
847
848 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
891pub 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 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 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 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 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
970pub 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 let ledger_path = root.join("policy/unsafe-review-baseline.toml");
994 let ledger_entries = load_baseline_entries_lenient(&ledger_path)?;
995 let strict_baseline_error = load_ledger_entries(&ledger_path, LedgerKind::Baseline).err();
1004
1005 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 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 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: ¤t_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
1078pub 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
1108fn 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
1122fn default_review_after_date() -> String {
1124 compute_review_after_date()
1129}
1130
1131fn compute_review_after_date() -> String {
1132 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 let future_secs = secs + 365 * 24 * 3600;
1140 unix_secs_to_iso_date(future_secs)
1142}
1143
1144pub(crate) fn unix_secs_to_iso_datetime_utc(secs: u64) -> String {
1151 let date = unix_secs_to_iso_date(secs);
1152 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 let days = secs / 86400;
1163 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 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 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 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 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 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}