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)]
33pub struct CriterionResult {
34 pub criterion: String,
35 pub status: String, pub note: Option<String>,
37}
38
39#[derive(Debug, Clone, Default)]
41pub struct VerifyOptions {
42 pub custom_prompt: Option<String>,
44}
45
46pub 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 if trimmed.starts_with("```") {
58 in_code_fence = !in_code_fence;
59 }
60
61 if !in_code_fence && trimmed.starts_with(acceptance_criteria_marker) {
63 in_ac_section = true;
64 continue;
65 }
66
67 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
88pub 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 if trimmed.starts_with("```") {
102 in_code_fence = !in_code_fence;
103 continue;
104 }
105
106 if trimmed.contains("Verification Summary") {
108 in_verification_section = true;
109 continue;
110 }
111
112 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 if trimmed.starts_with("- [") {
127 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 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 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 if !["PASS", "FAIL", "SKIP"].iter().any(|s| status.contains(s)) {
148 continue;
149 }
150
151 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 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 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
199pub 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 let now = Utc::now();
209 let timestamp = now.to_rfc3339();
210
211 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 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 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 if overall_status == VerificationStatus::Fail {
247 TransitionBuilder::new(&mut updated_spec).to(crate::spec::SpecStatus::NeedsAttention)?;
248 }
249
250 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
260pub 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 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 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 let message = prompt::assemble(spec, &prompt_path, config)
295 .context("Failed to assemble verification prompt")?;
296
297 let response = invoke_agent_fn(&message, spec, "verify", config)
299 .context("Failed to invoke agent for verification")?;
300
301 let (overall_status, criteria) = parse_verification_response(&response)?;
303
304 update_spec_with_verification_results(spec, overall_status, &criteria)?;
306
307 Ok((overall_status, criteria))
308}