1use std::{
4 collections::BTreeMap,
5 fmt, fs,
6 io::{self, Write},
7 path::{Path, PathBuf},
8 process::ExitCode,
9 time::{SystemTime, UNIX_EPOCH},
10};
11
12use anyhow::Result;
13use serde::{Deserialize, Deserializer, Serialize, de};
14use thiserror::Error;
15
16use crate::cli;
17
18pub const MACHINE_LEDGER_FILE: &str = "ledger.jsonl";
19pub const MARKDOWN_LEDGER_FILE: &str = "ledger.md";
20
21#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
22pub struct LedgerEntry {
23 pub commit_sha: String,
24 pub verdict: Verdict,
25 pub disposition: Disposition,
26 pub claim: String,
27 pub evidence: Vec<String>,
28 pub reviewer: ReviewerConfig,
29 #[serde(default)]
30 pub summary: String,
31 pub findings: Vec<String>,
32 #[serde(default)]
33 pub structured_findings: Vec<StructuredFinding>,
34 #[serde(default)]
35 pub next_steps: Vec<String>,
36 #[serde(default)]
37 pub raw_reviewer_output: String,
38 pub resolution: Option<Resolution>,
39 pub created_at_unix: u64,
40 pub updated_at_unix: u64,
41}
42
43impl LedgerEntry {
44 pub fn new(
45 commit_sha: impl Into<String>,
46 verdict: Verdict,
47 claim: impl Into<String>,
48 evidence: Vec<String>,
49 reviewer: ReviewerConfig,
50 findings: Vec<String>,
51 ) -> Self {
52 Self::new_at(
53 commit_sha,
54 verdict,
55 claim,
56 evidence,
57 reviewer,
58 findings,
59 unix_now(),
60 )
61 }
62
63 pub fn new_at(
64 commit_sha: impl Into<String>,
65 verdict: Verdict,
66 claim: impl Into<String>,
67 evidence: Vec<String>,
68 reviewer: ReviewerConfig,
69 findings: Vec<String>,
70 timestamp: u64,
71 ) -> Self {
72 let disposition = match verdict {
73 Verdict::Pass => Disposition::Resolved,
74 Verdict::Reject => Disposition::Open,
75 };
76
77 Self {
78 commit_sha: commit_sha.into(),
79 verdict,
80 disposition,
81 claim: claim.into(),
82 evidence,
83 reviewer,
84 summary: String::new(),
85 findings,
86 structured_findings: Vec::new(),
87 next_steps: Vec::new(),
88 raw_reviewer_output: String::new(),
89 resolution: None,
90 created_at_unix: timestamp,
91 updated_at_unix: timestamp,
92 }
93 }
94
95 pub fn with_structured_review(
96 mut self,
97 summary: impl Into<String>,
98 structured_findings: Vec<StructuredFinding>,
99 next_steps: Vec<String>,
100 raw_reviewer_output: impl Into<String>,
101 ) -> Self {
102 self.summary = summary.into();
103 self.structured_findings = structured_findings;
104 self.next_steps = next_steps;
105 self.raw_reviewer_output = raw_reviewer_output.into();
106 self
107 }
108
109 pub fn is_unresolved_rejection(&self) -> bool {
110 self.verdict == Verdict::Reject && self.disposition == Disposition::Open
111 }
112}
113
114#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
115#[serde(rename_all = "UPPERCASE")]
116pub enum Verdict {
117 Pass,
118 Reject,
119}
120
121impl fmt::Display for Verdict {
122 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
123 match self {
124 Self::Pass => formatter.write_str("PASS"),
125 Self::Reject => formatter.write_str("REJECT"),
126 }
127 }
128}
129
130#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
131#[serde(rename_all = "kebab-case")]
132pub enum FindingSeverity {
133 Critical,
134 High,
135 Medium,
136 Low,
137}
138
139impl fmt::Display for FindingSeverity {
140 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
141 match self {
142 Self::Critical => formatter.write_str("critical"),
143 Self::High => formatter.write_str("high"),
144 Self::Medium => formatter.write_str("medium"),
145 Self::Low => formatter.write_str("low"),
146 }
147 }
148}
149
150#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
151pub struct StructuredFinding {
152 pub severity: FindingSeverity,
153 pub title: String,
154 pub body: String,
155 pub file: String,
156 pub line_start: u32,
157 pub line_end: u32,
158 #[serde(deserialize_with = "deserialize_confidence")]
159 pub confidence: u8,
160 pub recommendation: String,
161}
162
163impl StructuredFinding {
164 pub fn display_line(&self) -> String {
165 format!(
166 "{} [{}:{}-{}] {}: {} Recommendation: {} Confidence: {}%",
167 self.severity,
168 self.file,
169 self.line_start,
170 self.line_end,
171 self.title,
172 self.body,
173 self.recommendation,
174 self.confidence
175 )
176 }
177}
178
179fn deserialize_confidence<'de, D>(deserializer: D) -> Result<u8, D::Error>
188where
189 D: Deserializer<'de>,
190{
191 struct ConfidenceVisitor;
192
193 impl<'de> de::Visitor<'de> for ConfidenceVisitor {
194 type Value = u8;
195
196 fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
197 formatter.write_str("an integer percent 0..=100 or a normalized float 0.0..=1.0")
198 }
199
200 fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E>
201 where
202 E: de::Error,
203 {
204 Ok(clamp_percent(value))
205 }
206
207 fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E>
208 where
209 E: de::Error,
210 {
211 Ok(u64::try_from(value).map_or(0, clamp_percent))
213 }
214
215 fn visit_f64<E>(self, value: f64) -> Result<Self::Value, E>
216 where
217 E: de::Error,
218 {
219 Ok(confidence_from_f64(value))
220 }
221
222 fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
223 where
224 E: de::Error,
225 {
226 Ok(value.trim().parse::<f64>().map_or(0, confidence_from_f64))
230 }
231 }
232
233 deserializer.deserialize_any(ConfidenceVisitor)
234}
235
236fn clamp_percent(value: u64) -> u8 {
238 value.min(100) as u8
239}
240
241fn confidence_from_f64(value: f64) -> u8 {
247 if value.is_nan() {
248 return 0;
249 }
250
251 let percent = if (0.0..=1.0).contains(&value) {
252 value * 100.0
253 } else {
254 value
255 };
256
257 if percent <= 0.0 {
258 0
259 } else if percent >= 100.0 {
260 100
261 } else {
262 percent.round() as u8
263 }
264}
265
266#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
267#[serde(rename_all = "kebab-case")]
268pub enum Disposition {
269 Open,
270 Resolved,
271 Waived,
272}
273
274impl fmt::Display for Disposition {
275 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
276 match self {
277 Self::Open => formatter.write_str("open"),
278 Self::Resolved => formatter.write_str("resolved"),
279 Self::Waived => formatter.write_str("waived"),
280 }
281 }
282}
283
284#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
285pub struct ReviewerConfig {
286 pub harness: String,
287 pub model: String,
288 pub allow_same_model: bool,
289}
290
291impl ReviewerConfig {
292 pub fn new(
293 harness: impl Into<String>,
294 model: impl Into<String>,
295 allow_same_model: bool,
296 ) -> Self {
297 Self {
298 harness: harness.into(),
299 model: model.into(),
300 allow_same_model,
301 }
302 }
303}
304
305#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
306pub struct Resolution {
307 pub kind: ResolutionKind,
308 pub reason: String,
309 pub resolved_at_unix: u64,
310}
311
312#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
313#[serde(rename_all = "kebab-case")]
314pub enum ResolutionKind {
315 Resolved,
316 Waived,
317}
318
319#[derive(Clone, Debug, Default, Eq, PartialEq)]
320pub struct LedgerStats {
321 pub total: usize,
322 pub pass: usize,
323 pub reject: usize,
324 pub unresolved: usize,
325 pub resolved: usize,
326 pub waived: usize,
327}
328
329#[derive(Clone, Debug)]
330pub struct LedgerStore {
331 root: PathBuf,
332}
333
334impl LedgerStore {
335 pub fn new(root: impl Into<PathBuf>) -> Self {
336 Self { root: root.into() }
337 }
338
339 pub fn machine_path(&self) -> PathBuf {
340 self.root.join(MACHINE_LEDGER_FILE)
341 }
342
343 pub fn markdown_path(&self) -> PathBuf {
344 self.root.join(MARKDOWN_LEDGER_FILE)
345 }
346
347 pub fn append_entry(&self, entry: &LedgerEntry) -> Result<(), LedgerError> {
348 fs::create_dir_all(&self.root)?;
349 let mut file = fs::OpenOptions::new()
350 .create(true)
351 .append(true)
352 .open(self.machine_path())?;
353 serde_json::to_writer(&mut file, entry)?;
354 writeln!(file)?;
355 self.render_markdown_mirror()?;
356 Ok(())
357 }
358
359 pub fn read_history(&self) -> Result<Vec<LedgerEntry>, LedgerError> {
360 let path = self.machine_path();
361 let contents = match fs::read_to_string(&path) {
362 Ok(contents) => contents,
363 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
364 Err(error) => return Err(error.into()),
365 };
366
367 contents
368 .lines()
369 .filter(|line| !line.trim().is_empty())
370 .map(|line| serde_json::from_str(line).map_err(LedgerError::from))
371 .collect()
372 }
373
374 pub fn latest_entries(&self) -> Result<Vec<LedgerEntry>, LedgerError> {
375 let mut by_sha = BTreeMap::new();
376 for entry in self.read_history()? {
377 by_sha.insert(entry.commit_sha.clone(), entry);
378 }
379 Ok(by_sha.into_values().collect())
380 }
381
382 pub fn show(&self, sha: &str) -> Result<LedgerEntry, LedgerError> {
383 self.latest_entries()?
384 .into_iter()
385 .find(|entry| entry.commit_sha == sha)
386 .ok_or_else(|| LedgerError::NotFound {
387 sha: sha.to_owned(),
388 })
389 }
390
391 pub fn unresolved_rejections(&self) -> Result<Vec<LedgerEntry>, LedgerError> {
392 Ok(self
393 .latest_entries()?
394 .into_iter()
395 .filter(LedgerEntry::is_unresolved_rejection)
396 .collect())
397 }
398
399 pub fn resolve(&self, sha: &str) -> Result<LedgerEntry, LedgerError> {
400 self.transition_rejection(
401 sha,
402 Disposition::Resolved,
403 ResolutionKind::Resolved,
404 "resolved",
405 )
406 }
407
408 pub fn waive(&self, sha: &str, reason: &str) -> Result<LedgerEntry, LedgerError> {
409 if reason.trim().is_empty() {
410 return Err(LedgerError::EmptyWaiverReason);
411 }
412
413 self.transition_rejection(sha, Disposition::Waived, ResolutionKind::Waived, reason)
414 }
415
416 pub fn stats(&self) -> Result<LedgerStats, LedgerError> {
417 let mut stats = LedgerStats::default();
418 for entry in self.latest_entries()? {
419 stats.total += 1;
420 match entry.verdict {
421 Verdict::Pass => stats.pass += 1,
422 Verdict::Reject => stats.reject += 1,
423 }
424 match entry.disposition {
425 Disposition::Open => {
426 if entry.verdict == Verdict::Reject {
427 stats.unresolved += 1;
428 }
429 }
430 Disposition::Resolved => stats.resolved += 1,
431 Disposition::Waived => stats.waived += 1,
432 }
433 }
434 Ok(stats)
435 }
436
437 pub fn render_markdown_mirror(&self) -> Result<(), LedgerError> {
438 fs::create_dir_all(&self.root)?;
439 let markdown = render_markdown(&self.latest_entries()?, &self.stats()?);
440 fs::write(self.markdown_path(), markdown)?;
441 Ok(())
442 }
443
444 fn transition_rejection(
445 &self,
446 sha: &str,
447 disposition: Disposition,
448 kind: ResolutionKind,
449 reason: &str,
450 ) -> Result<LedgerEntry, LedgerError> {
451 let mut entry = self.show(sha)?;
452 if !entry.is_unresolved_rejection() {
453 return Err(LedgerError::NoOpenRejection {
454 sha: sha.to_owned(),
455 });
456 }
457
458 let timestamp = unix_now();
459 entry.disposition = disposition;
460 entry.resolution = Some(Resolution {
461 kind,
462 reason: reason.trim().to_owned(),
463 resolved_at_unix: timestamp,
464 });
465 entry.updated_at_unix = timestamp;
466 self.append_entry(&entry)?;
467 Ok(entry)
468 }
469}
470
471#[derive(Debug, Error)]
472pub enum LedgerError {
473 #[error("ledger IO failed: {0}")]
474 Io(#[from] io::Error),
475 #[error("ledger JSON failed: {0}")]
476 Json(#[from] serde_json::Error),
477 #[error("ledger entry not found for commit {sha}")]
478 NotFound { sha: String },
479 #[error("commit {sha} has no open rejection")]
480 NoOpenRejection { sha: String },
481 #[error("waive requires a non-empty reason")]
482 EmptyWaiverReason,
483}
484
485pub fn run(args: cli::LedgerArgs, state_dir: &Path) -> Result<ExitCode> {
486 let store = LedgerStore::new(state_dir);
487 match args.command {
488 cli::LedgerCommand::List => {
489 print_unresolved(&store.unresolved_rejections()?);
490 }
491 cli::LedgerCommand::Show { sha } => {
492 let entry = store.show(&sha)?;
493 print_entry(&entry);
494 }
495 cli::LedgerCommand::Resolve { sha } => {
496 let entry = store.resolve(&sha)?;
497 println!("resolved {}", entry.commit_sha);
498 }
499 cli::LedgerCommand::Waive { sha, reason } => {
500 let entry = store.waive(&sha, &reason)?;
501 println!("waived {}", entry.commit_sha);
502 }
503 cli::LedgerCommand::Stats => print_stats(&store.stats()?),
504 }
505
506 Ok(ExitCode::SUCCESS)
507}
508
509fn render_markdown(entries: &[LedgerEntry], stats: &LedgerStats) -> String {
510 let mut output = String::new();
511 output.push_str("# Truth Mirror Ledger\n\n");
512 output.push_str("## Summary\n\n");
513 output.push_str(&format!("- Total: {}\n", stats.total));
514 output.push_str(&format!("- PASS: {}\n", stats.pass));
515 output.push_str(&format!("- REJECT: {}\n", stats.reject));
516 output.push_str(&format!("- Unresolved rejections: {}\n", stats.unresolved));
517 output.push_str(&format!("- Resolved: {}\n", stats.resolved));
518 output.push_str(&format!("- Waived: {}\n\n", stats.waived));
519 output.push_str("## Entries\n");
520
521 if entries.is_empty() {
522 output.push_str("\nNo ledger entries.\n");
523 return output;
524 }
525
526 for entry in entries {
527 output.push_str(&format!(
528 "\n### {} - {} - {}\n\n",
529 entry.commit_sha, entry.verdict, entry.disposition
530 ));
531 output.push_str(&format!("- Claim: {}\n", entry.claim));
532 output.push_str(&format!(
533 "- Evidence: {}\n",
534 if entry.evidence.is_empty() {
535 "none".to_owned()
536 } else {
537 entry.evidence.join(", ")
538 }
539 ));
540 output.push_str(&format!(
541 "- Reviewer: {}/{} (allow_same_model={})\n",
542 entry.reviewer.harness, entry.reviewer.model, entry.reviewer.allow_same_model
543 ));
544 if !entry.summary.trim().is_empty() {
545 output.push_str(&format!("- Summary: {}\n", entry.summary));
546 }
547
548 if entry.findings.is_empty() {
549 output.push_str("- Findings: none\n");
550 } else if !entry.structured_findings.is_empty() {
551 output.push_str("- Findings:\n");
552 for finding in &entry.structured_findings {
553 output.push_str(&format!(" - {}\n", finding.display_line()));
554 }
555 } else {
556 output.push_str("- Findings:\n");
557 for finding in &entry.findings {
558 output.push_str(&format!(" - {}\n", finding));
559 }
560 }
561
562 if !entry.next_steps.is_empty() {
563 output.push_str("- Next steps:\n");
564 for step in &entry.next_steps {
565 output.push_str(&format!(" - {}\n", step));
566 }
567 }
568
569 if !entry.raw_reviewer_output.trim().is_empty() {
570 output.push_str("- Raw reviewer output:\n\n```json\n");
571 output.push_str(entry.raw_reviewer_output.trim());
572 output.push_str("\n```\n");
573 }
574
575 if let Some(resolution) = &entry.resolution {
576 output.push_str(&format!(
577 "- Resolution: {:?} - {}\n",
578 resolution.kind, resolution.reason
579 ));
580 }
581 }
582
583 output
584}
585
586fn print_unresolved(entries: &[LedgerEntry]) {
587 if entries.is_empty() {
588 println!("No unresolved rejected commits.");
589 return;
590 }
591
592 for entry in entries {
593 println!(
594 "{} {} {} {}",
595 entry.commit_sha, entry.verdict, entry.disposition, entry.claim
596 );
597 }
598}
599
600fn print_entry(entry: &LedgerEntry) {
601 println!("commit: {}", entry.commit_sha);
602 println!("verdict: {}", entry.verdict);
603 println!("disposition: {}", entry.disposition);
604 println!("claim: {}", entry.claim);
605 println!("evidence: {}", entry.evidence.join(", "));
606 println!(
607 "reviewer: {}/{} allow_same_model={}",
608 entry.reviewer.harness, entry.reviewer.model, entry.reviewer.allow_same_model
609 );
610 if !entry.summary.trim().is_empty() {
611 println!("summary: {}", entry.summary);
612 }
613 if entry.findings.is_empty() {
614 println!("findings: none");
615 } else if !entry.structured_findings.is_empty() {
616 println!("findings:");
617 for finding in &entry.structured_findings {
618 println!("- {}", finding.display_line());
619 }
620 } else {
621 println!("findings:");
622 for finding in &entry.findings {
623 println!("- {finding}");
624 }
625 }
626 if !entry.next_steps.is_empty() {
627 println!("next_steps:");
628 for step in &entry.next_steps {
629 println!("- {step}");
630 }
631 }
632 if !entry.raw_reviewer_output.trim().is_empty() {
633 println!("raw_reviewer_output:");
634 println!("{}", entry.raw_reviewer_output.trim());
635 }
636}
637
638fn print_stats(stats: &LedgerStats) {
639 println!("total={}", stats.total);
640 println!("pass={}", stats.pass);
641 println!("reject={}", stats.reject);
642 println!("unresolved={}", stats.unresolved);
643 println!("resolved={}", stats.resolved);
644 println!("waived={}", stats.waived);
645}
646
647fn unix_now() -> u64 {
648 SystemTime::now()
649 .duration_since(UNIX_EPOCH)
650 .map_or(0, |duration| duration.as_secs())
651}
652
653#[cfg(test)]
654mod tests {
655 use std::fs;
656
657 use proptest::prelude::*;
658
659 use super::{
660 Disposition, FindingSeverity, LedgerEntry, LedgerStore, ResolutionKind, ReviewerConfig,
661 StructuredFinding, Verdict,
662 };
663
664 fn reviewer() -> ReviewerConfig {
665 ReviewerConfig::new("claude", "claude-opus-4-1", false)
666 }
667
668 fn rejected_entry(sha: &str) -> LedgerEntry {
669 LedgerEntry::new_at(
670 sha,
671 Verdict::Reject,
672 "CLAIM: thing | verified: cargo test | evidence: tests:cargo-test",
673 vec!["tests:cargo-test".to_owned()],
674 reviewer(),
675 vec!["claim was unsupported".to_owned()],
676 100,
677 )
678 }
679
680 fn structured_finding() -> StructuredFinding {
681 StructuredFinding {
682 severity: FindingSeverity::High,
683 title: "missing proof".to_owned(),
684 body: "The evidence pointer does not prove the claim.".to_owned(),
685 file: "src/lib.rs".to_owned(),
686 line_start: 7,
687 line_end: 9,
688 confidence: 95,
689 recommendation: "Add executable evidence before claiming completion.".to_owned(),
690 }
691 }
692
693 #[test]
694 fn append_and_read_latest_entries() {
695 let temp = tempfile::tempdir().unwrap();
696 let store = LedgerStore::new(temp.path());
697 store.append_entry(&rejected_entry("abc123")).unwrap();
698
699 let entries = store.latest_entries().unwrap();
700
701 assert_eq!(entries.len(), 1);
702 assert_eq!(entries[0].commit_sha, "abc123");
703 assert!(entries[0].is_unresolved_rejection());
704 }
705
706 #[test]
707 fn markdown_mirror_renders_summary_and_findings() {
708 let temp = tempfile::tempdir().unwrap();
709 let store = LedgerStore::new(temp.path());
710 store.append_entry(&rejected_entry("abc123")).unwrap();
711
712 let markdown = fs::read_to_string(store.markdown_path()).unwrap();
713
714 assert!(markdown.contains("# Truth Mirror Ledger"));
715 assert!(markdown.contains("Unresolved rejections: 1"));
716 assert!(markdown.contains("claim was unsupported"));
717 }
718
719 #[test]
720 fn markdown_mirror_renders_structured_review_provenance() {
721 let temp = tempfile::tempdir().unwrap();
722 let store = LedgerStore::new(temp.path());
723 let entry = rejected_entry("abc123").with_structured_review(
724 "The claim is not proven.",
725 vec![structured_finding()],
726 vec!["Run cargo test.".to_owned()],
727 r#"{"verdict":"REJECT"}"#,
728 );
729 store.append_entry(&entry).unwrap();
730
731 let markdown = fs::read_to_string(store.markdown_path()).unwrap();
732
733 assert!(markdown.contains("The claim is not proven."));
734 assert!(markdown.contains("high [src/lib.rs:7-9] missing proof"));
735 assert!(markdown.contains("Run cargo test."));
736 assert!(markdown.contains(r#"{"verdict":"REJECT"}"#));
737 }
738
739 #[test]
740 fn resolve_clears_unresolved_rejection() {
741 let temp = tempfile::tempdir().unwrap();
742 let store = LedgerStore::new(temp.path());
743 store.append_entry(&rejected_entry("abc123")).unwrap();
744
745 let resolved = store.resolve("abc123").unwrap();
746
747 assert_eq!(resolved.disposition, Disposition::Resolved);
748 assert_eq!(
749 resolved.resolution.as_ref().unwrap().kind,
750 ResolutionKind::Resolved
751 );
752 assert!(store.unresolved_rejections().unwrap().is_empty());
753 assert_eq!(store.read_history().unwrap().len(), 2);
754 }
755
756 #[test]
757 fn waive_records_reason_and_clears_unresolved_rejection() {
758 let temp = tempfile::tempdir().unwrap();
759 let store = LedgerStore::new(temp.path());
760 store.append_entry(&rejected_entry("abc123")).unwrap();
761
762 let waived = store.waive("abc123", "Ramiro approved exception").unwrap();
763
764 assert_eq!(waived.disposition, Disposition::Waived);
765 assert_eq!(
766 waived.resolution.as_ref().unwrap().reason,
767 "Ramiro approved exception"
768 );
769 assert!(store.unresolved_rejections().unwrap().is_empty());
770 }
771
772 #[test]
773 fn stats_counts_latest_dispositions() {
774 let temp = tempfile::tempdir().unwrap();
775 let store = LedgerStore::new(temp.path());
776 store.append_entry(&rejected_entry("abc123")).unwrap();
777 store
778 .append_entry(&LedgerEntry::new_at(
779 "def456",
780 Verdict::Pass,
781 "CLAIM: pass | verified: cargo test | evidence: tests:cargo-test",
782 vec!["tests:cargo-test".to_owned()],
783 reviewer(),
784 Vec::new(),
785 100,
786 ))
787 .unwrap();
788 store.waive("abc123", "accepted risk").unwrap();
789
790 let stats = store.stats().unwrap();
791
792 assert_eq!(stats.total, 2);
793 assert_eq!(stats.pass, 1);
794 assert_eq!(stats.reject, 1);
795 assert_eq!(stats.unresolved, 0);
796 assert_eq!(stats.waived, 1);
797 }
798
799 fn confidence_of(confidence: serde_json::Value) -> u8 {
802 let value = serde_json::json!({
803 "severity": "high",
804 "title": "missing proof",
805 "body": "The cited evidence does not prove the claim.",
806 "file": "src/lib.rs",
807 "line_start": 1,
808 "line_end": 1,
809 "confidence": confidence,
810 "recommendation": "Provide executable evidence.",
811 });
812 serde_json::from_value::<StructuredFinding>(value)
813 .expect("a finding must never fail to deserialize over confidence formatting")
814 .confidence
815 }
816
817 #[test]
818 fn confidence_accepts_codex_normalized_floats() {
819 assert_eq!(confidence_of(serde_json::json!(0.95)), 95);
821 assert_eq!(confidence_of(serde_json::json!(0.9)), 90);
822 assert_eq!(confidence_of(serde_json::json!(0.86)), 86);
823 }
824
825 #[test]
826 fn confidence_accepts_integer_percent() {
827 assert_eq!(confidence_of(serde_json::json!(86)), 86);
828 assert_eq!(confidence_of(serde_json::json!(0)), 0);
829 assert_eq!(confidence_of(serde_json::json!(100)), 100);
830 }
831
832 #[test]
833 fn confidence_normalizes_or_clamps_but_never_drops_the_verdict() {
834 assert_eq!(confidence_of(serde_json::json!(95.5)), 96); assert_eq!(confidence_of(serde_json::json!(140)), 100); assert_eq!(confidence_of(serde_json::json!(-3)), 0); assert_eq!(confidence_of(serde_json::json!(-0.2)), 0); assert_eq!(confidence_of(serde_json::json!("0.86")), 86); assert_eq!(confidence_of(serde_json::json!("nonsense")), 0); }
841
842 proptest! {
843 #[test]
844 fn confidence_deserialization_is_total_over_all_finite_floats(value in -1000.0f64..1000.0) {
845 let confidence = confidence_of(serde_json::json!(value));
847 prop_assert!(confidence <= 100);
848 }
849
850 #[test]
851 fn append_read_preserves_unresolved_rejection_semantics(sha in "[a-f0-9]{7,40}") {
852 let temp = tempfile::tempdir().unwrap();
853 let store = LedgerStore::new(temp.path());
854 store.append_entry(&rejected_entry(&sha)).unwrap();
855
856 let entries = store.latest_entries().unwrap();
857 prop_assert_eq!(entries.len(), 1);
858 prop_assert_eq!(entries[0].commit_sha.as_str(), sha.as_str());
859 prop_assert!(entries[0].is_unresolved_rejection());
860 }
861 }
862}