chant/operations/
verify.rs1use anyhow::{Context, Result};
6use chrono::Utc;
7
8use crate::config::Config;
9use crate::prompt;
10use crate::spec::Spec;
11use std::path::PathBuf;
12
13#[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#[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#[derive(Debug, Clone)]
51pub struct CriterionResult {
52 pub criterion: String,
53 pub status: CriterionStatus,
54 pub note: Option<String>,
55}
56
57#[derive(Debug, Clone, Default)]
59pub struct VerifyOptions {
60 pub custom_prompt: Option<String>,
62}
63
64pub 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 if trimmed.starts_with("```") {
76 in_code_fence = !in_code_fence;
77 }
78
79 if !in_code_fence && trimmed.starts_with(acceptance_criteria_marker) {
81 in_ac_section = true;
82 continue;
83 }
84
85 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
106pub 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 if trimmed.starts_with("```") {
120 in_code_fence = !in_code_fence;
121 continue;
122 }
123
124 if trimmed.contains("Verification Summary") {
126 in_verification_section = true;
127 continue;
128 }
129
130 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 if trimmed.starts_with("- [") {
145 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 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 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 if !["PASS", "FAIL", "SKIP"].iter().any(|s| status.contains(s)) {
166 continue;
167 }
168
169 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 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 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 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
220pub 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 let _lock =
231 LockGuard::new(&spec.id).context("Failed to acquire lock for verification update")?;
232
233 let now = Utc::now();
235 let timestamp = now.to_rfc3339();
236
237 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 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 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 if overall_status == VerificationStatus::Fail {
274 TransitionBuilder::new(&mut updated_spec).to(crate::spec::SpecStatus::NeedsAttention)?;
275 }
276
277 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
287pub 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 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 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 let message = prompt::assemble(spec, &prompt_path, config)
322 .context("Failed to assemble verification prompt")?;
323
324 let response = invoke_agent_fn(&message, spec, "verify", config)
326 .context("Failed to invoke agent for verification")?;
327
328 let (overall_status, criteria) = parse_verification_response(&response)?;
330
331 update_spec_with_verification_results(spec, overall_status, &criteria)?;
333
334 Ok((overall_status, criteria))
335}