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/// Default evidence-pointer prefixes that make a CLAIM's evidence look real.
10pub const DEFAULT_EVIDENCE_PATTERNS: &[&str] = &[
11    "file:",
12    "path:",
13    "log:",
14    "test:",
15    "tests:",
16    "screenshot:",
17    "artifact:",
18    "ci:",
19    "bead:",
20    "openspec:",
21    "commit:",
22];
23
24/// Diff paths excluded from the fake-marker scan: documentation and specs mention
25/// markers to *describe* them, not to fake behavior. Matched by prefix or suffix.
26pub const DEFAULT_MARKER_IGNORE_PATHS: &[&str] = &[".md", "openspec/", "docs/"];
27
28/// Resolved deterministic-gate policy (markers, evidence patterns, ignore paths).
29#[derive(Clone, Debug, Eq, PartialEq)]
30pub struct GatePolicy {
31    pub fake_markers: Vec<String>,
32    pub evidence_patterns: Vec<String>,
33    pub marker_ignore_paths: Vec<String>,
34}
35
36impl Default for GatePolicy {
37    fn default() -> Self {
38        Self {
39            fake_markers: owned(DEFAULT_FAKE_MARKERS),
40            evidence_patterns: owned(DEFAULT_EVIDENCE_PATTERNS),
41            marker_ignore_paths: owned(DEFAULT_MARKER_IGNORE_PATHS),
42        }
43    }
44}
45
46fn owned(values: &[&str]) -> Vec<String> {
47    values.iter().map(|value| (*value).to_owned()).collect()
48}
49
50#[derive(Clone, Debug, Eq, PartialEq)]
51pub struct Claim {
52    pub what: String,
53    pub verification: String,
54    pub evidence: Vec<EvidenceRef>,
55}
56
57impl Claim {
58    pub fn new(
59        what: impl Into<String>,
60        verification: impl Into<String>,
61        evidence: Vec<EvidenceRef>,
62    ) -> Result<Self, ClaimError> {
63        let claim = Self {
64            what: normalize_field(what.into()),
65            verification: normalize_field(verification.into()),
66            evidence,
67        };
68        claim.validate()?;
69        Ok(claim)
70    }
71
72    pub fn parse(input: &str) -> Result<Self, ClaimError> {
73        Self::parse_with(input, DEFAULT_EVIDENCE_PATTERNS)
74    }
75
76    /// Parse a CLAIM, accepting the given evidence-pointer patterns (in addition
77    /// to the built-in heuristics).
78    pub fn parse_with<S: AsRef<str>>(input: &str, patterns: &[S]) -> Result<Self, ClaimError> {
79        let paragraph = claim_paragraph(input).ok_or(ClaimError::MissingClaim)?;
80        Self::parse_line_with(&paragraph, patterns)
81    }
82
83    pub fn parse_line(line: &str) -> Result<Self, ClaimError> {
84        Self::parse_line_with(line, DEFAULT_EVIDENCE_PATTERNS)
85    }
86
87    pub fn parse_line_with<S: AsRef<str>>(line: &str, patterns: &[S]) -> Result<Self, ClaimError> {
88        let body = line
89            .trim()
90            .strip_prefix("CLAIM:")
91            .ok_or(ClaimError::MissingClaim)?;
92        let fields = claim_fields(body, patterns);
93        let claim_segment = fields.first().map_or(body, |field| &body[..field.start]);
94
95        let mut verification = None;
96        let mut evidence = Vec::new();
97        let mut evidence_error = None;
98
99        for field in fields {
100            match field.kind {
101                ClaimFieldKind::Verification => {
102                    let field_value = normalize_field(field.value.to_owned());
103                    if !field_value.is_empty() && verification.is_none() {
104                        verification = Some(field_value);
105                    }
106                }
107                ClaimFieldKind::Evidence => {
108                    for item in field
109                        .value
110                        .split(',')
111                        .map(str::trim)
112                        .filter(|item| !item.is_empty())
113                    {
114                        match EvidenceRef::parse_with(item, patterns) {
115                            Ok(parsed) => evidence.push(parsed),
116                            Err(error) => {
117                                if evidence_error.is_none() {
118                                    evidence_error = Some(error);
119                                }
120                            }
121                        }
122                    }
123                }
124            }
125        }
126
127        let what = normalize_field(trim_claim_text(claim_segment).to_owned());
128        if what.is_empty() {
129            return Err(ClaimError::EmptyWhat);
130        }
131        if let Some(error) = evidence_error {
132            return Err(error);
133        }
134        if evidence.is_empty() {
135            return Err(ClaimError::MissingEvidence);
136        }
137        let verification = verification.ok_or(ClaimError::MissingVerification)?;
138        if verification.is_empty() {
139            return Err(ClaimError::MissingVerification);
140        }
141
142        Ok(Self {
143            what,
144            verification,
145            evidence,
146        })
147    }
148
149    pub fn to_line(&self) -> String {
150        let evidence = self
151            .evidence
152            .iter()
153            .map(EvidenceRef::as_str)
154            .collect::<Vec<_>>()
155            .join(", ");
156
157        format!(
158            "CLAIM: {} | verified: {} | evidence: {}",
159            self.what, self.verification, evidence
160        )
161    }
162
163    fn validate(&self) -> Result<(), ClaimError> {
164        if self.what.is_empty() {
165            return Err(ClaimError::EmptyWhat);
166        }
167
168        if self.verification.is_empty() {
169            return Err(ClaimError::MissingVerification);
170        }
171
172        if self.evidence.is_empty() {
173            return Err(ClaimError::MissingEvidence);
174        }
175
176        Ok(())
177    }
178}
179
180/// Return the one CLAIM trailer paragraph from a commit message. Git trailers
181/// are commonly wrapped by editors and agents, so continuation lines belong to
182/// the claim until a blank line or a new, non-claim trailer begins.
183fn claim_paragraph(input: &str) -> Option<String> {
184    let mut lines = input.lines();
185    let first = lines.find(|line| line.trim_start().starts_with("CLAIM:"))?;
186    let mut paragraph = first.trim().to_owned();
187
188    for line in lines {
189        let line = line.trim();
190        if line.is_empty() || starts_next_trailer(line) {
191            break;
192        }
193        paragraph.push(' ');
194        paragraph.push_str(line);
195    }
196
197    Some(paragraph)
198}
199
200/// A trailer key is capitalized (for example `Co-authored-by:`), while CLAIM
201/// fields such as `verified:` and `evidence:` remain lowercase continuations.
202fn starts_next_trailer(line: &str) -> bool {
203    let Some((key, _)) = line.split_once(':') else {
204        return false;
205    };
206    let key = key.trim();
207    if [
208        "evidence-pointer",
209        "verification",
210        "verified",
211        "evidence",
212        "how",
213    ]
214    .iter()
215    .any(|field| key.eq_ignore_ascii_case(field))
216    {
217        return false;
218    }
219    !key.is_empty()
220        && key
221            .bytes()
222            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b' '))
223        && key.bytes().any(|byte| byte.is_ascii_uppercase())
224}
225
226#[derive(Clone, Debug, Eq, PartialEq)]
227pub struct EvidenceRef(String);
228
229impl EvidenceRef {
230    pub fn parse(value: &str) -> Result<Self, ClaimError> {
231        Self::parse_with(value, DEFAULT_EVIDENCE_PATTERNS)
232    }
233
234    pub fn parse_with<S: AsRef<str>>(value: &str, patterns: &[S]) -> Result<Self, ClaimError> {
235        let value = normalize_field(value.to_owned());
236        if value.is_empty() {
237            return Err(ClaimError::MissingEvidence);
238        }
239
240        let normalized = value.to_ascii_lowercase();
241        if matches!(
242            normalized.as_str(),
243            "none" | "n/a" | "na" | "todo" | "tbd" | "later" | "missing"
244        ) || !looks_like_pointer(&value, patterns)
245        {
246            return Err(ClaimError::InvalidEvidence { value });
247        }
248
249        Ok(Self(value))
250    }
251
252    pub fn as_str(&self) -> &str {
253        &self.0
254    }
255}
256
257impl fmt::Display for EvidenceRef {
258    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
259        self.0.fmt(formatter)
260    }
261}
262
263#[derive(Clone, Debug, Eq, Error, PartialEq)]
264pub enum ClaimError {
265    #[error("missing CLAIM: line")]
266    MissingClaim,
267    #[error("CLAIM: what field is empty")]
268    EmptyWhat,
269    #[error("CLAIM: missing verified field")]
270    MissingVerification,
271    #[error("CLAIM: missing evidence pointer")]
272    MissingEvidence,
273    #[error("CLAIM: invalid evidence pointer {value:?}")]
274    InvalidEvidence { value: String },
275}
276
277#[derive(Clone, Debug, Eq, Error, PartialEq)]
278pub enum GateFailure {
279    #[error("missing CLAIM: line")]
280    MissingClaim,
281    #[error(
282        "completion wording lacks evidence pointer for word {word:?}; example: CLAIM: change behavior | verified: cargo test | evidence: tests:cargo-test"
283    )]
284    CompletionWithoutEvidence { word: String },
285    #[error("{0}")]
286    InvalidClaim(#[from] ClaimError),
287    #[error("fake marker {marker:?} found at diff line {line}")]
288    FakeMarker { marker: String, line: usize },
289}
290
291pub fn evaluate_commit_message(
292    commit_message: &str,
293    claim_file: Option<&str>,
294    diff: Option<&str>,
295    policy: &GatePolicy,
296) -> Result<Claim, GateFailure> {
297    let has_claim_line = commit_message
298        .lines()
299        .any(|line| line.trim().starts_with("CLAIM:"));
300
301    let claim_source = if has_claim_line {
302        commit_message
303    } else {
304        // No CLAIM line in the message.  Before bailing with MissingClaim, check
305        // whether body prose contains a completion word — if so, surface a more
306        // actionable CompletionWithoutEvidence hint.
307        match claim_file {
308            Some(source) => source,
309            None => {
310                return Err(match completion_word_outside_claim(commit_message) {
311                    Some(word) => GateFailure::CompletionWithoutEvidence {
312                        word: word.to_owned(),
313                    },
314                    None => GateFailure::MissingClaim,
315                });
316            }
317        }
318    };
319
320    // When a CLAIM line is structurally present, surface the underlying
321    // ClaimError directly — naming the exact problem (missing evidence, invalid
322    // pointer value, etc.) so the author can fix it without guessing.
323    //
324    // CompletionWithoutEvidence is reserved for the no-claim-line path above,
325    // where body prose with a completion word signals a forgotten CLAIM block.
326    let claim = Claim::parse_with(claim_source, &policy.evidence_patterns)
327        .map_err(GateFailure::InvalidClaim)?;
328
329    if let Some(diff) = diff
330        && let Some(marker) =
331            first_fake_marker(diff, &policy.fake_markers, &policy.marker_ignore_paths)
332    {
333        return Err(marker);
334    }
335
336    Ok(claim)
337}
338
339/// Whether a new-side diff path is excluded from the fake-marker scan.
340fn path_is_ignored<S: AsRef<str>>(path: &str, ignore_paths: &[S]) -> bool {
341    ignore_paths.iter().any(|ignore| {
342        let ignore = ignore.as_ref();
343        !ignore.is_empty() && (path.starts_with(ignore) || path.ends_with(ignore))
344    })
345}
346
347pub fn first_fake_marker<S: AsRef<str>>(
348    diff: &str,
349    fake_markers: &[String],
350    ignore_paths: &[S],
351) -> Option<GateFailure> {
352    let markers = normalized_markers(fake_markers);
353    let mut ignored_file = false;
354    for (index, line) in diff.lines().enumerate() {
355        // Track the current file from its `+++ b/<path>` header so documentation
356        // and spec files (which mention markers to describe them) are skipped.
357        if let Some(rest) = line.strip_prefix("+++ ") {
358            let path = rest.strip_prefix("b/").unwrap_or(rest);
359            ignored_file = path_is_ignored(path, ignore_paths);
360            continue;
361        }
362
363        // Only lines the commit actually INTRODUCES count. Context and removed
364        // lines are not this commit's doing — flagging them would trip on any
365        // change made near an unrelated pre-existing marker (including a source
366        // file that legitimately *defines* the marker token).
367        let Some(added) = line.strip_prefix('+') else {
368            continue;
369        };
370        if added.starts_with("++") || ignored_file {
371            continue;
372        }
373
374        let line_lower = added.to_ascii_lowercase();
375        if let Some(marker) = markers
376            .iter()
377            .find(|marker| line_lower.contains(marker.normalized.as_str()))
378        {
379            return Some(GateFailure::FakeMarker {
380                marker: marker.original.clone(),
381                line: index + 1,
382            });
383        }
384    }
385
386    None
387}
388
389struct Marker {
390    original: String,
391    normalized: String,
392}
393
394fn normalized_markers(fake_markers: &[String]) -> Vec<Marker> {
395    let source: Vec<String> = if fake_markers.is_empty() {
396        DEFAULT_FAKE_MARKERS
397            .iter()
398            .map(|marker| (*marker).to_owned())
399            .collect()
400    } else {
401        fake_markers.to_vec()
402    };
403
404    source
405        .into_iter()
406        .filter(|marker| !marker.trim().is_empty())
407        .map(|marker| Marker {
408            normalized: marker.trim().to_ascii_lowercase(),
409            original: marker.trim().to_owned(),
410        })
411        .collect()
412}
413
414/// Scan only lines that are NOT the CLAIM line itself for completion words.
415///
416/// This prevents body prose ("the fixed seed order", "verified assumptions")
417/// from triggering a gate failure when a structurally valid CLAIM is present.
418/// This function is used only when no CLAIM line was found, so all lines are
419/// scanned as a fallback signal that the author forgot to include evidence.
420fn completion_word_outside_claim(input: &str) -> Option<&'static str> {
421    const WORDS: &[&str] = &[
422        "done",
423        "complete",
424        "completed",
425        "verified",
426        "fixed",
427        "passing",
428    ];
429
430    input
431        .lines()
432        .filter(|line| !line.trim().starts_with("CLAIM:"))
433        .flat_map(|line| line.split(|character: char| !character.is_ascii_alphanumeric()))
434        .find_map(|word| {
435            let normalized = word.to_ascii_lowercase();
436            WORDS
437                .iter()
438                .copied()
439                .find(|candidate| *candidate == normalized)
440        })
441}
442
443fn looks_like_pointer<S: AsRef<str>>(value: &str, patterns: &[S]) -> bool {
444    let lower = value.to_ascii_lowercase();
445    lower.contains("://")
446        || patterns
447            .iter()
448            .any(|prefix| lower.starts_with(&prefix.as_ref().to_ascii_lowercase()))
449        || value.contains('/')
450        || value.contains('.')
451}
452
453fn normalize_field(value: String) -> String {
454    value.split_whitespace().collect::<Vec<_>>().join(" ")
455}
456
457#[derive(Clone, Copy, Debug, Eq, PartialEq)]
458enum ClaimFieldKind {
459    Verification,
460    Evidence,
461}
462
463#[derive(Clone, Copy, Debug, Eq, PartialEq)]
464struct ClaimField<'a> {
465    kind: ClaimFieldKind,
466    start: usize,
467    value: &'a str,
468}
469
470#[derive(Clone, Copy, Debug, Eq, PartialEq)]
471struct FieldTag {
472    kind: ClaimFieldKind,
473    start: usize,
474    value_start: usize,
475}
476
477fn claim_fields<'a, S: AsRef<str>>(input: &'a str, patterns: &[S]) -> Vec<ClaimField<'a>> {
478    if input.contains('|') {
479        delimited_claim_fields(input, patterns)
480    } else {
481        loose_claim_fields(input, patterns)
482    }
483}
484
485fn delimited_claim_fields<'a, S: AsRef<str>>(
486    input: &'a str,
487    patterns: &[S],
488) -> Vec<ClaimField<'a>> {
489    let mut fields = Vec::new();
490    let mut segments = Vec::new();
491    let mut segment_start = 0;
492    let mut seen_delimiter = false;
493    // The text before the first `|` is the summary (`what`); only later
494    // pipe-delimited segments are parsed for named fields.
495    for (index, character) in input.char_indices() {
496        if character != '|' {
497            continue;
498        }
499        if seen_delimiter {
500            segments.push((segment_start, index));
501        }
502        seen_delimiter = true;
503        segment_start = index + character.len_utf8();
504    }
505    if seen_delimiter {
506        segments.push((segment_start, input.len()));
507    }
508    let segment_has_evidence = segments
509        .iter()
510        .copied()
511        .map(|(start, end)| segment_starts_with_field(input, start, end, ClaimFieldKind::Evidence))
512        .collect::<Vec<_>>();
513    let any_segment_has_evidence = segment_has_evidence
514        .iter()
515        .any(|has_evidence| *has_evidence);
516
517    for (segment_index, (start, end)) in segments.iter().copied().enumerate() {
518        let other_segment_has_evidence =
519            any_segment_has_evidence && !segment_has_evidence[segment_index];
520        fields.extend(segment_claim_fields(
521            input,
522            start,
523            end,
524            other_segment_has_evidence,
525            patterns,
526        ));
527    }
528    fields
529}
530
531fn segment_claim_fields<'a, S: AsRef<str>>(
532    input: &'a str,
533    segment_start: usize,
534    segment_end: usize,
535    other_segment_has_evidence: bool,
536    patterns: &[S],
537) -> Vec<ClaimField<'a>> {
538    let Some(segment) = input.get(segment_start..segment_end) else {
539        return Vec::new();
540    };
541    if other_segment_has_evidence
542        && let Some(tag) = segment_starting_field_tag(segment, ClaimFieldKind::Verification)
543    {
544        return vec![ClaimField {
545            kind: tag.kind,
546            start: segment_start + tag.start,
547            value: &segment[tag.value_start..],
548        }];
549    }
550    loose_claim_fields(segment, patterns)
551        .into_iter()
552        .map(|field| ClaimField {
553            kind: field.kind,
554            start: segment_start + field.start,
555            value: field.value,
556        })
557        .collect()
558}
559
560fn segment_starts_with_field(
561    input: &str,
562    segment_start: usize,
563    segment_end: usize,
564    kind: ClaimFieldKind,
565) -> bool {
566    input
567        .get(segment_start..segment_end)
568        .and_then(|segment| segment_starting_field_tag(segment, kind))
569        .is_some()
570}
571
572fn segment_starting_field_tag(segment: &str, kind: ClaimFieldKind) -> Option<FieldTag> {
573    let trimmed_start = segment.len() - segment.trim_start().len();
574    field_tags(segment)
575        .into_iter()
576        .find(|tag| tag.kind == kind && tag.start == trimmed_start)
577}
578
579fn loose_claim_fields<'a, S: AsRef<str>>(input: &'a str, patterns: &[S]) -> Vec<ClaimField<'a>> {
580    let tags = field_tags(input);
581    // Loose CLAIMs may mention field-like words in the summary/prose. Prefer the
582    // latest verification/evidence pair whose evidence looks pointer-like, then
583    // anchor on the first tag's kind, then fall back to adjacent mixed tags.
584    let mut fields = complete_field_pair(input, &tags, patterns).unwrap_or_else(|| {
585        [
586            tags.iter()
587                .copied()
588                .rfind(|tag| tag.kind == ClaimFieldKind::Verification),
589            tags.iter()
590                .copied()
591                .rfind(|tag| tag.kind == ClaimFieldKind::Evidence),
592        ]
593        .into_iter()
594        .flatten()
595        .collect::<Vec<_>>()
596    });
597    fields.sort_by_key(|tag| tag.start);
598
599    fields
600        .iter()
601        .enumerate()
602        .map(|(index, tag)| {
603            let end = fields.get(index + 1).map_or(input.len(), |next| next.start);
604            ClaimField {
605                kind: tag.kind,
606                start: tag.start,
607                value: trim_field_value(&input[tag.value_start..end]),
608            }
609        })
610        .collect()
611}
612
613fn complete_field_pair<S: AsRef<str>>(
614    input: &str,
615    tags: &[FieldTag],
616    patterns: &[S],
617) -> Option<Vec<FieldTag>> {
618    if let Some(pair) = later_complete_verification_evidence_pair(input, tags, patterns) {
619        return Some(pair);
620    }
621
622    if let Some(first) = tags.first().copied() {
623        match first.kind {
624            ClaimFieldKind::Verification => {
625                if let Some(evidence) = tags
626                    .iter()
627                    .copied()
628                    .rfind(|tag| tag.kind == ClaimFieldKind::Evidence)
629                {
630                    return Some(vec![first, evidence]);
631                }
632            }
633            ClaimFieldKind::Evidence => {
634                if let Some(verification) = tags
635                    .iter()
636                    .copied()
637                    .find(|tag| tag.kind == ClaimFieldKind::Verification)
638                {
639                    if !evidence_tag_value_looks_like_pointer(
640                        input,
641                        first,
642                        verification.start,
643                        patterns,
644                    ) && let Some(evidence) = tags.iter().copied().rfind(|tag| {
645                        tag.kind == ClaimFieldKind::Evidence && tag.start > verification.start
646                    }) {
647                        return Some(vec![verification, evidence]);
648                    }
649                    return Some(vec![first, verification]);
650                }
651            }
652        }
653    }
654
655    tags.windows(2).find_map(|window| {
656        let first = window[0];
657        let second = window[1];
658        (first.kind != second.kind).then(|| vec![first, second])
659    })
660}
661
662fn later_complete_verification_evidence_pair<S: AsRef<str>>(
663    input: &str,
664    tags: &[FieldTag],
665    patterns: &[S],
666) -> Option<Vec<FieldTag>> {
667    tags.windows(2)
668        .enumerate()
669        .rev()
670        .find_map(|(index, window)| {
671            let verification = window[0];
672            let evidence = window[1];
673            let evidence_end = tags.get(index + 2).map_or(input.len(), |tag| tag.start);
674            (verification.kind == ClaimFieldKind::Verification
675                && evidence.kind == ClaimFieldKind::Evidence
676                && evidence_tag_value_looks_like_pointer(input, evidence, evidence_end, patterns))
677            .then(|| vec![verification, evidence])
678        })
679}
680
681fn evidence_tag_value_looks_like_pointer<S: AsRef<str>>(
682    input: &str,
683    tag: FieldTag,
684    end: usize,
685    patterns: &[S],
686) -> bool {
687    input
688        .get(tag.value_start..end)
689        .map(trim_field_value)
690        .is_some_and(|value| looks_like_pointer(value, patterns))
691}
692
693fn field_tags(input: &str) -> Vec<FieldTag> {
694    const FIELD_NAMES_WITH_HOW: &[(&str, ClaimFieldKind)] = &[
695        ("evidence-pointer", ClaimFieldKind::Evidence),
696        ("verification", ClaimFieldKind::Verification),
697        ("verified", ClaimFieldKind::Verification),
698        ("evidence", ClaimFieldKind::Evidence),
699        ("how", ClaimFieldKind::Verification),
700    ];
701
702    collect_field_tags(input, FIELD_NAMES_WITH_HOW)
703}
704
705fn collect_field_tags(input: &str, field_names: &[(&str, ClaimFieldKind)]) -> Vec<FieldTag> {
706    let mut tags = Vec::new();
707    for (index, _) in input.char_indices() {
708        if !field_boundary_before(input, index) {
709            continue;
710        }
711        if let Some(tag) = field_tag_at(input, index, field_names) {
712            tags.push(tag);
713        }
714    }
715    tags
716}
717
718fn field_tag_at(
719    input: &str,
720    index: usize,
721    field_names: &[(&str, ClaimFieldKind)],
722) -> Option<FieldTag> {
723    for (name, kind) in field_names {
724        let after_name = index + name.len();
725        if input
726            .get(index..after_name)
727            .is_some_and(|candidate| candidate.eq_ignore_ascii_case(name))
728            && input
729                .get(after_name..)
730                .is_some_and(|remaining| remaining.starts_with(':'))
731        {
732            return Some(FieldTag {
733                kind: *kind,
734                start: index,
735                value_start: after_name + 1,
736            });
737        }
738    }
739    None
740}
741
742fn field_boundary_before(input: &str, index: usize) -> bool {
743    index == 0
744        || input[..index]
745            .chars()
746            .next_back()
747            .is_some_and(|character| character.is_whitespace() || matches!(character, '|' | ';'))
748}
749
750fn trim_field_value(value: &str) -> &str {
751    value
752        .trim_matches(|character: char| character.is_whitespace() || matches!(character, '|' | ';'))
753}
754
755fn trim_claim_text(value: &str) -> &str {
756    value.trim_matches(|character: char| {
757        character.is_whitespace() || matches!(character, '|' | ';' | ',')
758    })
759}
760
761#[cfg(test)]
762mod tests {
763    use proptest::prelude::*;
764
765    use super::{
766        Claim, ClaimError, EvidenceRef, GateFailure, GatePolicy, evaluate_commit_message,
767        first_fake_marker,
768    };
769
770    /// No ignore paths — every added line is scanned.
771    const NO_IGNORE: &[&str] = &[];
772
773    #[test]
774    fn parses_claim_line_with_evidence() {
775        let claim = Claim::parse(
776            "feat: thing\n\nCLAIM: add parser | verified: cargo test | evidence: tests:cargo-test",
777        )
778        .unwrap();
779
780        assert_eq!(claim.what, "add parser");
781        assert_eq!(claim.verification, "cargo test");
782        assert_eq!(claim.evidence[0].as_str(), "tests:cargo-test");
783    }
784
785    #[test]
786    fn parses_wrapped_claim_paragraph_until_the_next_trailer() {
787        let claim = Claim::parse(
788            "fix: preserve review queue\n\nCLAIM: continue draining after a poisoned review item\n  Verified: cargo test --workspace\n  Evidence: tests:reviewer-drain,\n    logs:target/test-results/reviewer.log\n\nCo-authored-by: Example Reviewer <reviewer@example.invalid>",
789        )
790        .unwrap();
791
792        assert_eq!(claim.what, "continue draining after a poisoned review item");
793        assert_eq!(claim.verification, "cargo test --workspace");
794        assert_eq!(claim.evidence.len(), 2);
795        assert_eq!(claim.evidence[0].as_str(), "tests:reviewer-drain");
796        assert_eq!(
797            claim.evidence[1].as_str(),
798            "logs:target/test-results/reviewer.log"
799        );
800    }
801
802    #[test]
803    fn parses_named_claim_fields_without_pipe_delimiters() {
804        let claim = Claim::parse(
805            "feat: thing\n\nCLAIM: add parser verified: cargo test evidence: tests:cargo-test",
806        )
807        .unwrap();
808
809        assert_eq!(claim.what, "add parser");
810        assert_eq!(claim.verification, "cargo test");
811        assert_eq!(claim.evidence[0].as_str(), "tests:cargo-test");
812    }
813
814    #[test]
815    fn parses_how_as_verification_alias() {
816        let claim = Claim::parse(
817            "feat: thing\n\nCLAIM: add parser | how: cargo test | evidence: tests:claim",
818        )
819        .unwrap();
820
821        assert_eq!(claim.what, "add parser");
822        assert_eq!(claim.verification, "cargo test");
823        assert_eq!(claim.evidence[0].as_str(), "tests:claim");
824    }
825
826    #[test]
827    fn loose_claim_parses_how_as_verification_alias() {
828        let claim =
829            Claim::parse("feat: thing\n\nCLAIM: add parser how: cargo test evidence: tests:claim")
830                .unwrap();
831
832        assert_eq!(claim.what, "add parser");
833        assert_eq!(claim.verification, "cargo test");
834        assert_eq!(claim.evidence[0].as_str(), "tests:claim");
835    }
836
837    #[test]
838    fn loose_claim_accepts_evidence_before_verification() {
839        let claim = Claim::parse(
840            "feat: thing\n\nCLAIM: add parser evidence: tests:claim verified: cargo test",
841        )
842        .unwrap();
843
844        assert_eq!(claim.what, "add parser");
845        assert_eq!(claim.verification, "cargo test");
846        assert_eq!(claim.evidence[0].as_str(), "tests:claim");
847    }
848
849    #[test]
850    fn comma_separated_evidence_ignores_blank_items() {
851        let claim = Claim::parse(
852            "feat: thing\n\nCLAIM: add parser | verified: cargo test | evidence: tests:unit, , tests:integration,",
853        )
854        .unwrap();
855
856        assert_eq!(claim.evidence.len(), 2);
857        assert_eq!(claim.evidence[0].as_str(), "tests:unit");
858        assert_eq!(claim.evidence[1].as_str(), "tests:integration");
859    }
860
861    #[test]
862    fn loose_claim_preserves_field_like_summary_text() {
863        let claim = Claim::parse(
864            "feat: thing\n\nCLAIM: add evidence: parser verified: cargo test evidence: tests:claim",
865        )
866        .unwrap();
867
868        assert_eq!(claim.what, "add evidence: parser");
869        assert_eq!(claim.verification, "cargo test");
870        assert_eq!(claim.evidence[0].as_str(), "tests:claim");
871    }
872
873    #[test]
874    fn loose_claim_preserves_verified_label_in_summary_text() {
875        let claim = Claim::parse(
876            "feat: thing\n\nCLAIM: add verified: docs verified: cargo test evidence: tests:claim",
877        )
878        .unwrap();
879
880        assert_eq!(claim.what, "add verified: docs");
881        assert_eq!(claim.verification, "cargo test");
882        assert_eq!(claim.evidence[0].as_str(), "tests:claim");
883    }
884
885    #[test]
886    fn loose_claim_uses_custom_evidence_patterns_for_summary_disambiguation() {
887        let claim = Claim::parse_with(
888            "feat: thing\n\nCLAIM: add verified: docs verified: cargo test evidence: jira:PROJ-42",
889            &["jira:"],
890        )
891        .unwrap();
892
893        assert_eq!(claim.what, "add verified: docs");
894        assert_eq!(claim.verification, "cargo test");
895        assert_eq!(claim.evidence[0].as_str(), "jira:PROJ-42");
896    }
897
898    #[test]
899    fn loose_claim_parses_how_after_field_like_summary_text() {
900        let claim = Claim::parse(
901            "feat: thing\n\nCLAIM: add verified: docs how: cargo test evidence: tests:x",
902        )
903        .unwrap();
904
905        assert_eq!(claim.what, "add verified: docs");
906        assert_eq!(claim.verification, "cargo test");
907        assert_eq!(claim.evidence[0].as_str(), "tests:x");
908    }
909
910    #[test]
911    fn pipe_delimited_claim_accepts_mixed_named_fields_in_segment() {
912        let claim = Claim::parse(
913            "feat: thing\n\nCLAIM: add parser | verified: cargo test evidence: tests:claim",
914        )
915        .unwrap();
916
917        assert_eq!(claim.what, "add parser");
918        assert_eq!(claim.verification, "cargo test");
919        assert_eq!(claim.evidence[0].as_str(), "tests:claim");
920    }
921
922    #[test]
923    fn loose_claim_keeps_evidence_when_verification_mentions_evidence() {
924        let claim = Claim::parse(
925            "feat: thing\n\nCLAIM: add parser evidence: tests:claim verified: reviewed evidence: output",
926        )
927        .unwrap();
928
929        assert_eq!(claim.what, "add parser");
930        assert_eq!(claim.verification, "reviewed evidence: output");
931        assert_eq!(claim.evidence[0].as_str(), "tests:claim");
932    }
933
934    #[test]
935    fn pipe_delimited_claim_keeps_later_evidence_when_verification_mentions_evidence() {
936        let claim = Claim::parse(
937            "feat: thing\n\nCLAIM: add parser | verified: reviewed evidence: output | evidence: tests:claim",
938        )
939        .unwrap();
940
941        assert_eq!(claim.what, "add parser");
942        assert_eq!(claim.verification, "reviewed evidence: output");
943        assert_eq!(claim.evidence[0].as_str(), "tests:claim");
944    }
945
946    #[test]
947    fn loose_claim_keeps_later_evidence_when_verification_mentions_evidence() {
948        let claim = Claim::parse(
949            "feat: thing\n\nCLAIM: add parser verified: reviewed evidence: output evidence: tests:claim",
950        )
951        .unwrap();
952
953        assert_eq!(claim.what, "add parser");
954        assert_eq!(claim.verification, "reviewed evidence: output");
955        assert_eq!(claim.evidence[0].as_str(), "tests:claim");
956    }
957
958    #[test]
959    fn pipe_delimited_claim_keeps_earlier_evidence_when_verification_mentions_evidence() {
960        let claim = Claim::parse(
961            "feat: thing\n\nCLAIM: add parser | evidence: tests:claim | verified: reviewed evidence: output",
962        )
963        .unwrap();
964
965        assert_eq!(claim.what, "add parser");
966        assert_eq!(claim.verification, "reviewed evidence: output");
967        assert_eq!(claim.evidence[0].as_str(), "tests:claim");
968    }
969
970    #[test]
971    fn duplicate_verification_aliases_ignore_empty_duplicate_in_any_order() {
972        let messages = [
973            "feat: thing\n\nCLAIM: fix parser | verified: cargo test | verification: | evidence: tests:x",
974            "feat: thing\n\nCLAIM: fix parser | verification: | verified: cargo test | evidence: tests:x",
975        ];
976
977        for message in messages {
978            let claim = Claim::parse(message).unwrap();
979            assert_eq!(claim.verification, "cargo test");
980            assert_eq!(claim.evidence[0].as_str(), "tests:x");
981        }
982    }
983
984    #[test]
985    fn duplicate_empty_verification_aliases_still_reject_as_missing() {
986        let error = Claim::parse(
987            "feat: thing\n\nCLAIM: fix parser | verification: | verified: | evidence: tests:x",
988        )
989        .unwrap_err();
990
991        assert_eq!(error, ClaimError::MissingVerification);
992    }
993
994    #[test]
995    fn pipe_delimited_claim_keeps_field_like_words_in_summary() {
996        let claim = Claim::parse(
997            "feat: thing\n\nCLAIM: add evidence: parser | verified: cargo test | evidence: tests:cargo-test",
998        )
999        .unwrap();
1000
1001        assert_eq!(claim.what, "add evidence: parser");
1002        assert_eq!(claim.verification, "cargo test");
1003        assert_eq!(claim.evidence[0].as_str(), "tests:cargo-test");
1004    }
1005
1006    #[test]
1007    fn pipe_delimited_claim_treats_first_segment_as_summary() {
1008        let claim = Claim::parse(
1009            "feat: thing\n\nCLAIM: evidence: parser | verified: cargo test | evidence: tests:cargo-test",
1010        )
1011        .unwrap();
1012
1013        assert_eq!(claim.what, "evidence: parser");
1014        assert_eq!(claim.verification, "cargo test");
1015        assert_eq!(claim.evidence[0].as_str(), "tests:cargo-test");
1016    }
1017
1018    #[test]
1019    fn pipe_delimited_claim_preserves_semicolon_in_evidence_value() {
1020        let claim = Claim::parse(
1021            "feat: thing\n\nCLAIM: add parser | verified: cargo test | evidence: tests:unit; tests:integration",
1022        )
1023        .unwrap();
1024
1025        assert_eq!(claim.what, "add parser");
1026        assert_eq!(claim.evidence[0].as_str(), "tests:unit; tests:integration");
1027    }
1028
1029    #[test]
1030    fn pipe_delimited_claim_keeps_field_like_words_in_verification() {
1031        let claim = Claim::parse(
1032            "feat: thing\n\nCLAIM: add parser | verified: describe how: it works | evidence: tests:cargo-test",
1033        )
1034        .unwrap();
1035
1036        assert_eq!(claim.what, "add parser");
1037        assert_eq!(claim.verification, "describe how: it works");
1038        assert_eq!(claim.evidence[0].as_str(), "tests:cargo-test");
1039    }
1040
1041    #[test]
1042    fn rejects_missing_claim() {
1043        let error = Claim::parse("feat: thing").unwrap_err();
1044
1045        assert_eq!(error, ClaimError::MissingClaim);
1046    }
1047
1048    #[test]
1049    fn rejects_missing_evidence() {
1050        let error = Claim::parse("CLAIM: complete parser | verified: cargo test").unwrap_err();
1051
1052        assert_eq!(error, ClaimError::MissingEvidence);
1053    }
1054
1055    #[test]
1056    fn reports_completion_word_without_evidence_when_no_claim_line() {
1057        // No CLAIM line at all + body prose with completion word → CompletionWithoutEvidence.
1058        let error = evaluate_commit_message(
1059            "feat: complete the parser",
1060            None,
1061            None,
1062            &GatePolicy::default(),
1063        )
1064        .unwrap_err();
1065
1066        assert_eq!(
1067            error,
1068            GateFailure::CompletionWithoutEvidence {
1069                word: "complete".to_owned()
1070            }
1071        );
1072    }
1073
1074    #[test]
1075    fn claim_line_with_missing_evidence_reports_invalid_claim_not_completion_word() {
1076        // A CLAIM line IS present but evidence is missing → surface the underlying
1077        // ClaimError so the author knows *what* failed, not a misleading completion-word hint.
1078        let error = evaluate_commit_message(
1079            "feat: parser\n\nCLAIM: complete parser | verified: cargo test",
1080            None,
1081            None,
1082            &GatePolicy::default(),
1083        )
1084        .unwrap_err();
1085
1086        assert_eq!(
1087            error,
1088            GateFailure::InvalidClaim(ClaimError::MissingEvidence)
1089        );
1090    }
1091
1092    #[test]
1093    fn claim_line_with_non_pointer_evidence_names_offending_value() {
1094        // Finding 2 regression: InvalidEvidence carries the bad value; the error must
1095        // name it verbatim so the author can fix it without guessing.
1096        let error = evaluate_commit_message(
1097            "feat: parser\n\nCLAIM: add things | verified: manual | evidence: not-a-pointer",
1098            None,
1099            None,
1100            &GatePolicy::default(),
1101        )
1102        .unwrap_err();
1103
1104        assert_eq!(
1105            error,
1106            GateFailure::InvalidClaim(ClaimError::InvalidEvidence {
1107                value: "not-a-pointer".to_owned(),
1108            })
1109        );
1110    }
1111
1112    #[test]
1113    fn equivalent_completion_claim_shapes_get_stable_evidence_failure() {
1114        // These all have a CLAIM line present but evidence missing/invalid.
1115        // After Finding 2 fix they report InvalidClaim, not CompletionWithoutEvidence.
1116        let messages = [
1117            "feat: parser\n\nCLAIM: complete parser | verified: cargo test",
1118            "feat: parser\n\nCLAIM: complete parser verified: cargo test",
1119        ];
1120
1121        for message in messages {
1122            let error =
1123                evaluate_commit_message(message, None, None, &GatePolicy::default()).unwrap_err();
1124            assert_eq!(
1125                error,
1126                GateFailure::InvalidClaim(ClaimError::MissingEvidence),
1127                "unexpected error for message: {message:?}"
1128            );
1129        }
1130    }
1131
1132    #[test]
1133    fn equivalent_invalid_evidence_shapes_get_stable_evidence_failure() {
1134        let messages = [
1135            "feat: parser\n\nCLAIM: complete parser | evidence: missing | verified: cargo test",
1136            "feat: parser\n\nCLAIM: complete parser verified: cargo test evidence: missing",
1137            "feat: parser\n\nCLAIM: complete parser | evidence: missing",
1138        ];
1139
1140        for message in messages {
1141            let error =
1142                evaluate_commit_message(message, None, None, &GatePolicy::default()).unwrap_err();
1143            assert_eq!(
1144                error,
1145                GateFailure::InvalidClaim(ClaimError::InvalidEvidence {
1146                    value: "missing".to_owned(),
1147                })
1148            );
1149        }
1150    }
1151
1152    #[test]
1153    fn subject_completion_word_does_not_mask_claim_evidence_error() {
1154        // Finding 2: even when the subject contains a completion word ("fixed"),
1155        // a structurally-present CLAIM line means the underlying ClaimError is
1156        // surfaced directly.  CompletionWithoutEvidence is only for the no-CLAIM case.
1157        let error = evaluate_commit_message(
1158            "fix: fixed seed order\n\nCLAIM: add parser | verified: cargo test",
1159            None,
1160            None,
1161            &GatePolicy::default(),
1162        )
1163        .unwrap_err();
1164
1165        assert_eq!(
1166            error,
1167            GateFailure::InvalidClaim(ClaimError::MissingEvidence)
1168        );
1169    }
1170
1171    #[test]
1172    fn later_claim_completion_word_does_not_mask_first_claim_evidence_error() {
1173        let error = evaluate_commit_message(
1174            "feat: parser\n\nCLAIM: add parser | verified: cargo test\nCLAIM: complete parser | verified: cargo test | evidence: tests:later",
1175            None,
1176            None,
1177            &GatePolicy::default(),
1178        )
1179        .unwrap_err();
1180
1181        assert_eq!(
1182            error,
1183            GateFailure::InvalidClaim(ClaimError::MissingEvidence)
1184        );
1185    }
1186
1187    #[test]
1188    fn body_after_first_claim_does_not_mask_first_claim_evidence_error() {
1189        let error = evaluate_commit_message(
1190            "feat: parser\n\nCLAIM: add parser | verified: cargo test\n\nverified later in prose",
1191            None,
1192            None,
1193            &GatePolicy::default(),
1194        )
1195        .unwrap_err();
1196
1197        assert_eq!(
1198            error,
1199            GateFailure::InvalidClaim(ClaimError::MissingEvidence)
1200        );
1201    }
1202
1203    #[test]
1204    fn valid_claim_with_completion_words_in_body_prose_passes() {
1205        let claim = evaluate_commit_message(
1206            "fix: parser\n\nThis body says the parser is fixed and verified.\n\nCLAIM: add parser | verified: cargo test | evidence: tests:x",
1207            None,
1208            None,
1209            &GatePolicy::default(),
1210        )
1211        .unwrap();
1212
1213        assert_eq!(claim.what, "add parser");
1214        assert_eq!(claim.verification, "cargo test");
1215        assert_eq!(claim.evidence[0].as_str(), "tests:x");
1216    }
1217
1218    #[test]
1219    fn invalid_evidence_is_not_masked_by_verified_field_label() {
1220        let error = evaluate_commit_message(
1221            "fix: seed order\n\nCLAIM: add parser | verified: cargo test | evidence: Depot job 393s6c1ff6 failure log",
1222            None,
1223            None,
1224            &GatePolicy::default(),
1225        )
1226        .unwrap_err();
1227
1228        assert_eq!(
1229            error,
1230            GateFailure::InvalidClaim(ClaimError::InvalidEvidence {
1231                value: "Depot job 393s6c1ff6 failure log".to_owned(),
1232            })
1233        );
1234    }
1235
1236    #[test]
1237    fn accepts_claim_file_fallback() {
1238        let claim = evaluate_commit_message(
1239            "feat: parser",
1240            Some("CLAIM: add parser | verified: cargo test | evidence: tests:cargo-test"),
1241            None,
1242            &GatePolicy::default(),
1243        )
1244        .unwrap();
1245
1246        assert_eq!(claim.what, "add parser");
1247    }
1248
1249    #[test]
1250    fn custom_evidence_pattern_is_accepted() {
1251        let policy = GatePolicy {
1252            evidence_patterns: vec!["jira:".to_owned()],
1253            ..GatePolicy::default()
1254        };
1255        let claim = evaluate_commit_message(
1256            "chore: thing\n\nCLAIM: do thing | verified: manual | evidence: jira:PROJ-42",
1257            None,
1258            None,
1259            &policy,
1260        )
1261        .unwrap();
1262
1263        assert_eq!(claim.evidence[0].as_str(), "jira:PROJ-42");
1264    }
1265
1266    #[test]
1267    fn body_prose_with_completion_word_does_not_reject_valid_claim() {
1268        // Finding 3 regression: body text "the fixed seed order" must not trigger a
1269        // gate failure when a fully valid CLAIM line with pointer evidence is present.
1270        let claim = evaluate_commit_message(
1271            "fix: seed order\n\nThis commit fixed the seed order so tests run deterministically.\n\nCLAIM: fix seed order | verified: cargo test | evidence: tests:cargo-test",
1272            None,
1273            None,
1274            &GatePolicy::default(),
1275        )
1276        .unwrap();
1277
1278        assert_eq!(claim.what, "fix seed order");
1279    }
1280
1281    #[test]
1282    fn marker_in_ignored_doc_path_is_not_flagged() {
1283        // Same added marker line, but under a doc path in the diff → skipped.
1284        let marker = ["mock", "as", "real"].join("-");
1285        let diff = format!("diff --git a/docs/x.md b/docs/x.md\n+++ b/docs/x.md\n+ {marker}");
1286
1287        let policy = GatePolicy::default();
1288        assert!(
1289            first_fake_marker(&diff, &policy.fake_markers, &policy.marker_ignore_paths).is_none()
1290        );
1291
1292        // Under a code path, the same line IS flagged.
1293        let code_diff = format!("diff --git a/src/x.rs b/src/x.rs\n+++ b/src/x.rs\n+ {marker}");
1294        assert!(
1295            first_fake_marker(
1296                &code_diff,
1297                &policy.fake_markers,
1298                &policy.marker_ignore_paths
1299            )
1300            .is_some()
1301        );
1302    }
1303
1304    #[test]
1305    fn finds_default_fake_marker_with_location() {
1306        let done = ["TODO", "as", "done"].join("-");
1307        let diff = format!("diff --git a/x b/x\n+ {done}");
1308        let error = first_fake_marker(&diff, &[], NO_IGNORE).unwrap();
1309
1310        assert_eq!(
1311            error,
1312            GateFailure::FakeMarker {
1313                marker: "TODO-as-done".to_owned(),
1314                line: 2
1315            }
1316        );
1317    }
1318
1319    #[test]
1320    fn context_and_removed_lines_do_not_trip_fake_marker() {
1321        // Built at runtime so the literal marker token never appears in this file
1322        // (truth-mirror's own gate would otherwise flag this very line).
1323        let marker = ["mock", "as", "real"].join("-");
1324        // A context line (space prefix) and a removed line (-) that merely mention
1325        // the marker must not be flagged; only added (+) content counts.
1326        let diff = format!(
1327            "diff --git a/x b/x\n const MARKERS = [\"{marker}\"];\n- old_line_with {marker}\n+ let honest = compute();"
1328        );
1329
1330        assert!(first_fake_marker(&diff, &[], NO_IGNORE).is_none());
1331    }
1332
1333    #[test]
1334    fn added_line_with_marker_is_flagged() {
1335        let marker = ["mock", "as", "real"].join("-");
1336        let diff = format!("diff --git a/x b/x\n+ {marker} here");
1337        let error = first_fake_marker(&diff, &[], NO_IGNORE).unwrap();
1338
1339        assert!(matches!(error, GateFailure::FakeMarker { .. }));
1340    }
1341
1342    #[test]
1343    fn configured_fake_marker_overrides_defaults() {
1344        let markers = vec!["pretend-pass".to_owned()];
1345        let error = first_fake_marker("+ pretend-pass", &markers, NO_IGNORE).unwrap();
1346
1347        assert_eq!(
1348            error,
1349            GateFailure::FakeMarker {
1350                marker: "pretend-pass".to_owned(),
1351                line: 1
1352            }
1353        );
1354    }
1355
1356    proptest! {
1357        #[test]
1358        fn claim_roundtrip_preserves_semantic_fields(
1359            what in "[A-Za-z0-9][A-Za-z0-9 _./:-]{0,48}",
1360            verification in "[A-Za-z0-9][A-Za-z0-9 _./:-]{0,48}",
1361            evidence_suffix in "[a-z0-9][a-z0-9_-]{0,24}",
1362        ) {
1363            let evidence = EvidenceRef::parse(&format!("tests:{evidence_suffix}")).unwrap();
1364            let claim = Claim::new(what, verification, vec![evidence]).unwrap();
1365
1366            let parsed = Claim::parse(&claim.to_line()).unwrap();
1367
1368            prop_assert_eq!(parsed, claim);
1369        }
1370    }
1371}