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#[derive(Clone, Debug, PartialEq, Eq)]
85pub enum RepoStopReason {
86 None,
88 MaxCards,
90 Timeout,
92 Terminated,
94 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#[derive(Clone, Debug, PartialEq, Eq)]
114pub struct PerFileScanStats {
115 pub file: PathBuf,
117 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 pub partial: bool,
134 pub stop_reason: RepoStopReason,
137 pub cap: Option<usize>,
139 pub file_timings: Option<Vec<PerFileScanStats>>,
145 pub output_bytes: Option<u64>,
155}
156
157pub 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 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 pub diff_scoped_files: BTreeSet<PathBuf>,
262 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#[derive(Clone, Debug, Default)]
353pub struct ScanCost {
354 pub elapsed_ms: u64,
357 pub output_bytes_total: u64,
361}
362
363#[derive(Clone, Debug, Default)]
370pub struct Provenance {
371 pub root_abs: Option<String>,
374 pub base_sha: Option<String>,
376 pub head_sha: Option<String>,
378 pub diff_path: Option<String>,
380 pub diff_sha256: Option<String>,
382 pub generated_at: String,
384 pub dirty_worktree: Option<bool>,
386}
387
388impl Provenance {
389 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
403pub fn bless_fixture_card_goldens(names: &[&str]) -> Result<Vec<PathBuf>, String> {
409 json::bless_fixture_card_goldens(names)
410}
411
412pub 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
423pub 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
436pub 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
448pub fn render_fixture_surface(fixture: &str, surface: &str) -> Result<String, String> {
454 json::render_fixture_surface(fixture, surface)
455}
456
457pub 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
473pub 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
513pub 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
536pub fn render_gate_manifest(output: &AnalyzeOutput) -> String {
543 gate_manifest::render(output)
544}
545
546pub fn render_gate_manifest_repo(output: &AnalyzeOutput, report_filename: &str) -> String {
561 gate_manifest::render_repo(output, report_filename)
562}
563
564pub fn render_usefulness_telemetry(output: &AnalyzeOutput) -> String {
572 usefulness_telemetry::render(output)
573}
574
575pub 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
648pub 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 let queried_suffix = queried_display
673 .strip_prefix(&root_display)
674 .map(|rest| rest.trim_start_matches('/'))
675 .unwrap_or(&queried_display);
676
677 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#[derive(Clone, Debug, PartialEq, Eq)]
705pub struct BaselineInitResult {
706 pub captured: usize,
708 pub ledger_existed: bool,
710 pub ledger_path: PathBuf,
712 pub snapshot_path: PathBuf,
714 pub cards: Vec<ReviewCard>,
717}
718
719pub 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 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 let review_after = review_after
757 .map(ToOwned::to_owned)
758 .unwrap_or_else(default_review_after_date);
759
760 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
803pub 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 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 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 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 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
882fn 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
896fn default_review_after_date() -> String {
898 compute_review_after_date()
903}
904
905fn compute_review_after_date() -> String {
906 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 let future_secs = secs + 365 * 24 * 3600;
914 unix_secs_to_iso_date(future_secs)
916}
917
918pub(crate) fn unix_secs_to_iso_datetime_utc(secs: u64) -> String {
925 let date = unix_secs_to_iso_date(secs);
926 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 let days = secs / 86400;
937 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}