1use 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}