Skip to main content

truth_mirror/
claim.rs

1//! CLAIM parsing and deterministic pre-commit gate.
2
3use std::fmt;
4
5use thiserror::Error;
6
7pub const DEFAULT_FAKE_MARKERS: &[&str] = &["mock-as-real", "TODO-as-done"];
8
9#[derive(Clone, Debug, Eq, PartialEq)]
10pub struct Claim {
11    pub what: String,
12    pub verification: String,
13    pub evidence: Vec<EvidenceRef>,
14}
15
16impl Claim {
17    pub fn new(
18        what: impl Into<String>,
19        verification: impl Into<String>,
20        evidence: Vec<EvidenceRef>,
21    ) -> Result<Self, ClaimError> {
22        let claim = Self {
23            what: normalize_field(what.into()),
24            verification: normalize_field(verification.into()),
25            evidence,
26        };
27        claim.validate()?;
28        Ok(claim)
29    }
30
31    pub fn parse(input: &str) -> Result<Self, ClaimError> {
32        let line = input
33            .lines()
34            .map(str::trim)
35            .find(|line| line.starts_with("CLAIM:"))
36            .ok_or(ClaimError::MissingClaim)?;
37
38        Self::parse_line(line)
39    }
40
41    pub fn parse_line(line: &str) -> Result<Self, ClaimError> {
42        let mut segments = line.split('|').map(str::trim);
43        let claim_segment = segments
44            .next()
45            .and_then(|segment| segment.strip_prefix("CLAIM:"))
46            .ok_or(ClaimError::MissingClaim)?;
47
48        let mut verification = None;
49        let mut evidence = Vec::new();
50
51        for segment in segments {
52            if let Some(value) = field_value(segment, &["verified", "verification", "how"]) {
53                verification = Some(normalize_field(value.to_owned()));
54                continue;
55            }
56
57            if let Some(value) = field_value(segment, &["evidence", "evidence-pointer"]) {
58                for item in value.split(',') {
59                    evidence.push(EvidenceRef::parse(item)?);
60                }
61            }
62        }
63
64        Self::new(
65            claim_segment,
66            verification.ok_or(ClaimError::MissingVerification)?,
67            evidence,
68        )
69    }
70
71    pub fn to_line(&self) -> String {
72        let evidence = self
73            .evidence
74            .iter()
75            .map(EvidenceRef::as_str)
76            .collect::<Vec<_>>()
77            .join(", ");
78
79        format!(
80            "CLAIM: {} | verified: {} | evidence: {}",
81            self.what, self.verification, evidence
82        )
83    }
84
85    fn validate(&self) -> Result<(), ClaimError> {
86        if self.what.is_empty() {
87            return Err(ClaimError::EmptyWhat);
88        }
89
90        if self.verification.is_empty() {
91            return Err(ClaimError::MissingVerification);
92        }
93
94        if self.evidence.is_empty() {
95            return Err(ClaimError::MissingEvidence);
96        }
97
98        Ok(())
99    }
100}
101
102#[derive(Clone, Debug, Eq, PartialEq)]
103pub struct EvidenceRef(String);
104
105impl EvidenceRef {
106    pub fn parse(value: &str) -> Result<Self, ClaimError> {
107        let value = normalize_field(value.to_owned());
108        if value.is_empty() {
109            return Err(ClaimError::MissingEvidence);
110        }
111
112        let normalized = value.to_ascii_lowercase();
113        if matches!(
114            normalized.as_str(),
115            "none" | "n/a" | "na" | "todo" | "tbd" | "later" | "missing"
116        ) || !looks_like_pointer(&value)
117        {
118            return Err(ClaimError::InvalidEvidence { value });
119        }
120
121        Ok(Self(value))
122    }
123
124    pub fn as_str(&self) -> &str {
125        &self.0
126    }
127}
128
129impl fmt::Display for EvidenceRef {
130    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
131        self.0.fmt(formatter)
132    }
133}
134
135#[derive(Clone, Debug, Eq, Error, PartialEq)]
136pub enum ClaimError {
137    #[error("missing CLAIM: line")]
138    MissingClaim,
139    #[error("CLAIM: what field is empty")]
140    EmptyWhat,
141    #[error("CLAIM: missing verified field")]
142    MissingVerification,
143    #[error("CLAIM: missing evidence pointer")]
144    MissingEvidence,
145    #[error("CLAIM: invalid evidence pointer {value:?}")]
146    InvalidEvidence { value: String },
147}
148
149#[derive(Clone, Debug, Eq, Error, PartialEq)]
150pub enum GateFailure {
151    #[error("missing CLAIM: line")]
152    MissingClaim,
153    #[error("completion wording lacks evidence pointer for word {word:?}")]
154    CompletionWithoutEvidence { word: String },
155    #[error("{0}")]
156    InvalidClaim(#[from] ClaimError),
157    #[error("fake marker {marker:?} found at diff line {line}")]
158    FakeMarker { marker: String, line: usize },
159}
160
161pub fn evaluate_commit_message(
162    commit_message: &str,
163    claim_file: Option<&str>,
164    diff: Option<&str>,
165    fake_markers: &[String],
166) -> Result<Claim, GateFailure> {
167    let claim_source = if commit_message
168        .lines()
169        .any(|line| line.trim().starts_with("CLAIM:"))
170    {
171        commit_message
172    } else {
173        claim_file.ok_or(GateFailure::MissingClaim)?
174    };
175
176    let completion_word = completion_word(commit_message);
177    let claim = Claim::parse(claim_source).map_err(|error| match (&error, completion_word) {
178        (ClaimError::MissingEvidence | ClaimError::InvalidEvidence { .. }, Some(word)) => {
179            GateFailure::CompletionWithoutEvidence {
180                word: word.to_owned(),
181            }
182        }
183        _ => GateFailure::InvalidClaim(error),
184    })?;
185
186    if let Some(diff) = diff
187        && let Some(marker) = first_fake_marker(diff, fake_markers)
188    {
189        return Err(marker);
190    }
191
192    Ok(claim)
193}
194
195pub fn first_fake_marker(diff: &str, fake_markers: &[String]) -> Option<GateFailure> {
196    let markers = normalized_markers(fake_markers);
197    for (index, line) in diff.lines().enumerate() {
198        if line.starts_with("+++") || line.starts_with("---") {
199            continue;
200        }
201
202        let line_lower = line.to_ascii_lowercase();
203        if let Some(marker) = markers
204            .iter()
205            .find(|marker| line_lower.contains(marker.normalized.as_str()))
206        {
207            return Some(GateFailure::FakeMarker {
208                marker: marker.original.clone(),
209                line: index + 1,
210            });
211        }
212    }
213
214    None
215}
216
217struct Marker {
218    original: String,
219    normalized: String,
220}
221
222fn normalized_markers(fake_markers: &[String]) -> Vec<Marker> {
223    let source: Vec<String> = if fake_markers.is_empty() {
224        DEFAULT_FAKE_MARKERS
225            .iter()
226            .map(|marker| (*marker).to_owned())
227            .collect()
228    } else {
229        fake_markers.to_vec()
230    };
231
232    source
233        .into_iter()
234        .filter(|marker| !marker.trim().is_empty())
235        .map(|marker| Marker {
236            normalized: marker.trim().to_ascii_lowercase(),
237            original: marker.trim().to_owned(),
238        })
239        .collect()
240}
241
242fn completion_word(input: &str) -> Option<&'static str> {
243    const WORDS: &[&str] = &[
244        "done",
245        "complete",
246        "completed",
247        "verified",
248        "fixed",
249        "passing",
250    ];
251
252    input
253        .split(|character: char| !character.is_ascii_alphanumeric())
254        .find_map(|word| {
255            let normalized = word.to_ascii_lowercase();
256            WORDS
257                .iter()
258                .copied()
259                .find(|candidate| *candidate == normalized)
260        })
261}
262
263fn field_value<'a>(segment: &'a str, names: &[&str]) -> Option<&'a str> {
264    let (name, value) = segment.split_once(':')?;
265    names
266        .iter()
267        .any(|candidate| name.trim().eq_ignore_ascii_case(candidate))
268        .then_some(value.trim())
269}
270
271fn looks_like_pointer(value: &str) -> bool {
272    let lower = value.to_ascii_lowercase();
273    lower.contains("://")
274        || [
275            "file:",
276            "path:",
277            "log:",
278            "test:",
279            "tests:",
280            "screenshot:",
281            "artifact:",
282            "ci:",
283            "bead:",
284            "openspec:",
285            "commit:",
286        ]
287        .iter()
288        .any(|prefix| lower.starts_with(prefix))
289        || value.contains('/')
290        || value.contains('.')
291}
292
293fn normalize_field(value: String) -> String {
294    value.split_whitespace().collect::<Vec<_>>().join(" ")
295}
296
297#[cfg(test)]
298mod tests {
299    use proptest::prelude::*;
300
301    use super::{
302        Claim, ClaimError, EvidenceRef, GateFailure, evaluate_commit_message, first_fake_marker,
303    };
304
305    #[test]
306    fn parses_claim_line_with_evidence() {
307        let claim = Claim::parse(
308            "feat: thing\n\nCLAIM: add parser | verified: cargo test | evidence: tests:cargo-test",
309        )
310        .unwrap();
311
312        assert_eq!(claim.what, "add parser");
313        assert_eq!(claim.verification, "cargo test");
314        assert_eq!(claim.evidence[0].as_str(), "tests:cargo-test");
315    }
316
317    #[test]
318    fn rejects_missing_claim() {
319        let error = Claim::parse("feat: thing").unwrap_err();
320
321        assert_eq!(error, ClaimError::MissingClaim);
322    }
323
324    #[test]
325    fn rejects_missing_evidence() {
326        let error = Claim::parse("CLAIM: complete parser | verified: cargo test").unwrap_err();
327
328        assert_eq!(error, ClaimError::MissingEvidence);
329    }
330
331    #[test]
332    fn reports_completion_word_without_evidence() {
333        let error = evaluate_commit_message(
334            "feat: parser\n\nCLAIM: complete parser | verified: cargo test",
335            None,
336            None,
337            &[],
338        )
339        .unwrap_err();
340
341        assert_eq!(
342            error,
343            GateFailure::CompletionWithoutEvidence {
344                word: "complete".to_owned()
345            }
346        );
347    }
348
349    #[test]
350    fn accepts_claim_file_fallback() {
351        let claim = evaluate_commit_message(
352            "feat: parser",
353            Some("CLAIM: add parser | verified: cargo test | evidence: tests:cargo-test"),
354            None,
355            &[],
356        )
357        .unwrap();
358
359        assert_eq!(claim.what, "add parser");
360    }
361
362    #[test]
363    fn finds_default_fake_marker_with_location() {
364        let error = first_fake_marker("diff --git a/x b/x\n+ TODO-as-done", &[]).unwrap();
365
366        assert_eq!(
367            error,
368            GateFailure::FakeMarker {
369                marker: "TODO-as-done".to_owned(),
370                line: 2
371            }
372        );
373    }
374
375    #[test]
376    fn configured_fake_marker_overrides_defaults() {
377        let markers = vec!["pretend-pass".to_owned()];
378        let error = first_fake_marker("+ pretend-pass", &markers).unwrap();
379
380        assert_eq!(
381            error,
382            GateFailure::FakeMarker {
383                marker: "pretend-pass".to_owned(),
384                line: 1
385            }
386        );
387    }
388
389    proptest! {
390        #[test]
391        fn claim_roundtrip_preserves_semantic_fields(
392            what in "[A-Za-z0-9][A-Za-z0-9 _./:-]{0,48}",
393            verification in "[A-Za-z0-9][A-Za-z0-9 _./:-]{0,48}",
394            evidence_suffix in "[a-z0-9][a-z0-9_-]{0,24}",
395        ) {
396            let evidence = EvidenceRef::parse(&format!("tests:{evidence_suffix}")).unwrap();
397            let claim = Claim::new(what, verification, vec![evidence]).unwrap();
398
399            let parsed = Claim::parse(&claim.to_line()).unwrap();
400
401            prop_assert_eq!(parsed, claim);
402        }
403    }
404}