Skip to main content

chant/operations/
verify.rs

1//! Spec verification operation.
2//!
3//! Canonical implementation for verifying specs against acceptance criteria.
4
5use anyhow::{Context, Result};
6use chrono::Utc;
7
8use crate::config::Config;
9use crate::prompt;
10use crate::spec::Spec;
11use std::path::PathBuf;
12
13/// Verification status for a spec
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum VerificationStatus {
16    Pass,
17    Fail,
18    Mixed,
19}
20
21impl std::fmt::Display for VerificationStatus {
22    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
23        match self {
24            Self::Pass => write!(f, "PASS"),
25            Self::Fail => write!(f, "FAIL"),
26            Self::Mixed => write!(f, "MIXED"),
27        }
28    }
29}
30
31/// Status of an individual criterion
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum CriterionStatus {
34    Pass,
35    Fail,
36    Skip,
37}
38
39impl std::fmt::Display for CriterionStatus {
40    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41        match self {
42            Self::Pass => write!(f, "PASS"),
43            Self::Fail => write!(f, "FAIL"),
44            Self::Skip => write!(f, "SKIP"),
45        }
46    }
47}
48
49/// Result of verifying an individual criterion
50#[derive(Debug, Clone)]
51pub struct CriterionResult {
52    pub criterion: String,
53    pub status: CriterionStatus,
54    pub note: Option<String>,
55}
56
57/// Options for verification
58#[derive(Debug, Clone, Default)]
59pub struct VerifyOptions {
60    /// Custom prompt to use for verification
61    pub custom_prompt: Option<String>,
62}
63
64/// Extract acceptance criteria section from spec body
65pub fn extract_acceptance_criteria(spec: &Spec) -> Option<String> {
66    let acceptance_criteria_marker = "## Acceptance Criteria";
67    let mut in_ac_section = false;
68    let mut ac_content = String::new();
69    let mut in_code_fence = false;
70
71    for line in spec.body.lines() {
72        let trimmed = line.trim_start();
73
74        // Track code fences
75        if trimmed.starts_with("```") {
76            in_code_fence = !in_code_fence;
77        }
78
79        // Look for AC section heading outside code fences
80        if !in_code_fence && trimmed.starts_with(acceptance_criteria_marker) {
81            in_ac_section = true;
82            continue;
83        }
84
85        // Stop at next heading
86        if in_ac_section
87            && trimmed.starts_with("## ")
88            && !trimmed.starts_with(acceptance_criteria_marker)
89        {
90            break;
91        }
92
93        if in_ac_section {
94            ac_content.push_str(line);
95            ac_content.push('\n');
96        }
97    }
98
99    if ac_content.is_empty() {
100        None
101    } else {
102        Some(ac_content)
103    }
104}
105
106/// Parse verification response from the agent
107pub fn parse_verification_response(
108    response: &str,
109) -> Result<(VerificationStatus, Vec<CriterionResult>)> {
110    let mut criteria_results = Vec::new();
111    let mut overall_status = VerificationStatus::Pass;
112    let mut in_verification_section = false;
113    let mut in_code_fence = false;
114
115    for line in response.lines() {
116        let trimmed = line.trim();
117
118        // Track code fence boundaries
119        if trimmed.starts_with("```") {
120            in_code_fence = !in_code_fence;
121            continue;
122        }
123
124        // Look for the Verification Summary section (can be anywhere, including inside code fences)
125        if trimmed.contains("Verification Summary") {
126            in_verification_section = true;
127            continue;
128        }
129
130        // Stop at next section (marked by ## heading), but only if we're not in a code fence
131        if in_verification_section
132            && !in_code_fence
133            && trimmed.starts_with("##")
134            && !trimmed.contains("Verification Summary")
135        {
136            break;
137        }
138
139        if !in_verification_section {
140            continue;
141        }
142
143        // Parse criterion lines: "- [x] Criterion: STATUS — optional note"
144        if trimmed.starts_with("- [") {
145            // Extract the status and criterion
146            if let Some(rest) = trimmed.strip_prefix("- [") {
147                if let Some(criterion_part) = rest.split_once(']') {
148                    let criterion_line = criterion_part.1.trim();
149
150                    // Parse criterion and status
151                    if let Some(colon_pos) = criterion_line.find(':') {
152                        let criterion_text = criterion_line[..colon_pos].trim().to_string();
153                        let status_part = criterion_line[colon_pos + 1..].trim();
154
155                        // Extract status and optional note
156                        let (status, note) = if let Some(dash_idx) = status_part.find(" — ") {
157                            let status_text = status_part[..dash_idx].trim().to_uppercase();
158                            let note_text = status_part[dash_idx + " — ".len()..].trim();
159                            (status_text, Some(note_text.to_string()))
160                        } else {
161                            (status_part.to_uppercase(), None)
162                        };
163
164                        // Validate status
165                        if !["PASS", "FAIL", "SKIP"].iter().any(|s| status.contains(s)) {
166                            continue;
167                        }
168
169                        // Determine criterion status
170                        let criterion_status = if status.contains("PASS") {
171                            CriterionStatus::Pass
172                        } else if status.contains("FAIL") {
173                            CriterionStatus::Fail
174                        } else {
175                            CriterionStatus::Skip
176                        };
177
178                        // Update overall status based on individual results
179                        if criterion_status == CriterionStatus::Fail {
180                            overall_status = VerificationStatus::Fail;
181                        } else if criterion_status == CriterionStatus::Skip
182                            && overall_status == VerificationStatus::Pass
183                        {
184                            overall_status = VerificationStatus::Mixed;
185                        }
186
187                        criteria_results.push(CriterionResult {
188                            criterion: criterion_text,
189                            status: criterion_status,
190                            note,
191                        });
192                    }
193                }
194            }
195        }
196
197        // Also look for "Overall status: X" line
198        if trimmed.starts_with("Overall status:") {
199            if let Some(status_text) = trimmed.split(':').nth(1) {
200                let status_upper = status_text.trim().to_uppercase();
201                overall_status = if status_upper.contains("FAIL") {
202                    VerificationStatus::Fail
203                } else if status_upper.contains("PASS") {
204                    VerificationStatus::Pass
205                } else {
206                    VerificationStatus::Mixed
207                };
208            }
209        }
210    }
211
212    // If we didn't find any criteria, it's an error
213    if criteria_results.is_empty() {
214        anyhow::bail!("Could not parse verification response from agent. Expected format with 'Verification Summary' section.");
215    }
216
217    Ok((overall_status, criteria_results))
218}
219
220/// Update spec frontmatter with verification results
221pub fn update_spec_with_verification_results(
222    spec: &Spec,
223    overall_status: VerificationStatus,
224    criteria: &[CriterionResult],
225) -> Result<()> {
226    use crate::lock::LockGuard;
227    use crate::spec::TransitionBuilder;
228
229    // Acquire spec-level lock to prevent concurrent writes
230    let _lock =
231        LockGuard::new(&spec.id).context("Failed to acquire lock for verification update")?;
232
233    // Get current UTC timestamp in ISO 8601 format
234    let now = Utc::now();
235    let timestamp = now.to_rfc3339();
236
237    // Determine verification status enum from frontmatter
238    use crate::spec::VerificationStatus as FrontmatterVerificationStatus;
239    let verification_status = match overall_status {
240        VerificationStatus::Pass => FrontmatterVerificationStatus::Passed,
241        VerificationStatus::Fail => FrontmatterVerificationStatus::Failed,
242        VerificationStatus::Mixed => FrontmatterVerificationStatus::Partial,
243    };
244
245    // Extract failure reasons from FAIL criteria
246    let verification_failures: Option<Vec<String>> = {
247        let failures: Vec<String> = criteria
248            .iter()
249            .filter(|c| c.status == CriterionStatus::Fail)
250            .map(|c| {
251                if let Some(note) = &c.note {
252                    format!("{} — {}", c.criterion, note)
253                } else {
254                    c.criterion.clone()
255                }
256            })
257            .collect();
258
259        if failures.is_empty() {
260            None
261        } else {
262            Some(failures)
263        }
264    };
265
266    // Create updated spec with new frontmatter
267    let mut updated_spec = spec.clone();
268    updated_spec.frontmatter.last_verified = Some(timestamp);
269    updated_spec.frontmatter.verification_status = Some(verification_status);
270    updated_spec.frontmatter.verification_failures = verification_failures;
271
272    // Update status to needs_attention if verification failed
273    if overall_status == VerificationStatus::Fail {
274        TransitionBuilder::new(&mut updated_spec).to(crate::spec::SpecStatus::NeedsAttention)?;
275    }
276
277    // Save the updated spec to disk
278    let spec_path = PathBuf::from(format!(".chant/specs/{}.md", spec.id));
279    updated_spec.save(&spec_path).context(format!(
280        "Failed to write updated spec to {}",
281        spec_path.display()
282    ))?;
283
284    Ok(())
285}
286
287/// Verify a spec by invoking the agent.
288///
289/// This is the canonical verification logic:
290/// - Checks for acceptance criteria
291/// - Assembles verification prompt
292/// - Invokes the agent
293/// - Parses verification response
294/// - Updates spec frontmatter
295///
296/// Returns (overall_status, criteria_results).
297pub fn verify_spec(
298    spec: &Spec,
299    config: &Config,
300    options: VerifyOptions,
301    invoke_agent_fn: impl Fn(&str, &Spec, &str, &Config) -> Result<String>,
302) -> Result<(VerificationStatus, Vec<CriterionResult>)> {
303    // Check if spec has acceptance criteria
304    let ac_section = extract_acceptance_criteria(spec);
305    if ac_section.is_none() {
306        anyhow::bail!("No acceptance criteria found in spec");
307    }
308
309    // Determine which prompt to use
310    let prompt_name = options.custom_prompt.as_deref().unwrap_or("verify");
311    let prompt_path = PathBuf::from(format!(".chant/prompts/{}.md", prompt_name));
312
313    if !prompt_path.exists() {
314        anyhow::bail!(
315            "Prompt file not found: {}. Run `chant init` to create default prompts.",
316            prompt_path.display()
317        );
318    }
319
320    // Assemble the prompt with spec context
321    let message = prompt::assemble(spec, &prompt_path, config)
322        .context("Failed to assemble verification prompt")?;
323
324    // Invoke the agent
325    let response = invoke_agent_fn(&message, spec, "verify", config)
326        .context("Failed to invoke agent for verification")?;
327
328    // Parse the response
329    let (overall_status, criteria) = parse_verification_response(&response)?;
330
331    // Update spec frontmatter with verification results
332    update_spec_with_verification_results(spec, overall_status, &criteria)?;
333
334    Ok((overall_status, criteria))
335}