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::spec::TransitionBuilder;
206
207    // Get current UTC timestamp in ISO 8601 format
208    let now = Utc::now();
209    let timestamp = now.to_rfc3339();
210
211    // Determine verification status string
212    let verification_status = match overall_status {
213        VerificationStatus::Pass => "passed".to_string(),
214        VerificationStatus::Fail => "failed".to_string(),
215        VerificationStatus::Mixed => "partial".to_string(),
216    };
217
218    // Extract failure reasons from FAIL criteria
219    let verification_failures: Option<Vec<String>> = {
220        let failures: Vec<String> = criteria
221            .iter()
222            .filter(|c| c.status == "FAIL")
223            .map(|c| {
224                if let Some(note) = &c.note {
225                    format!("{} — {}", c.criterion, note)
226                } else {
227                    c.criterion.clone()
228                }
229            })
230            .collect();
231
232        if failures.is_empty() {
233            None
234        } else {
235            Some(failures)
236        }
237    };
238
239    // Create updated spec with new frontmatter
240    let mut updated_spec = spec.clone();
241    updated_spec.frontmatter.last_verified = Some(timestamp);
242    updated_spec.frontmatter.verification_status = Some(verification_status);
243    updated_spec.frontmatter.verification_failures = verification_failures;
244
245    // Update status to needs_attention if verification failed
246    if overall_status == VerificationStatus::Fail {
247        TransitionBuilder::new(&mut updated_spec).to(crate::spec::SpecStatus::NeedsAttention)?;
248    }
249
250    // Save the updated spec to disk
251    let spec_path = PathBuf::from(format!(".chant/specs/{}.md", spec.id));
252    updated_spec.save(&spec_path).context(format!(
253        "Failed to write updated spec to {}",
254        spec_path.display()
255    ))?;
256
257    Ok(())
258}
259
260/// Verify a spec by invoking the agent.
261///
262/// This is the canonical verification logic:
263/// - Checks for acceptance criteria
264/// - Assembles verification prompt
265/// - Invokes the agent
266/// - Parses verification response
267/// - Updates spec frontmatter
268///
269/// Returns (overall_status, criteria_results).
270pub fn verify_spec(
271    spec: &Spec,
272    config: &Config,
273    options: VerifyOptions,
274    invoke_agent_fn: impl Fn(&str, &Spec, &str, &Config) -> Result<String>,
275) -> Result<(VerificationStatus, Vec<CriterionResult>)> {
276    // Check if spec has acceptance criteria
277    let ac_section = extract_acceptance_criteria(spec);
278    if ac_section.is_none() {
279        anyhow::bail!("No acceptance criteria found in spec");
280    }
281
282    // Determine which prompt to use
283    let prompt_name = options.custom_prompt.as_deref().unwrap_or("verify");
284    let prompt_path = PathBuf::from(format!(".chant/prompts/{}.md", prompt_name));
285
286    if !prompt_path.exists() {
287        anyhow::bail!(
288            "Prompt file not found: {}. Run `chant init` to create default prompts.",
289            prompt_path.display()
290        );
291    }
292
293    // Assemble the prompt with spec context
294    let message = prompt::assemble(spec, &prompt_path, config)
295        .context("Failed to assemble verification prompt")?;
296
297    // Invoke the agent
298    let response = invoke_agent_fn(&message, spec, "verify", config)
299        .context("Failed to invoke agent for verification")?;
300
301    // Parse the response
302    let (overall_status, criteria) = parse_verification_response(&response)?;
303
304    // Update spec frontmatter with verification results
305    update_spec_with_verification_results(spec, overall_status, &criteria)?;
306
307    Ok((overall_status, criteria))
308}