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