1use anyhow::{Context, Result};
4use std::fs;
5use std::path::Path;
6use std::process::Command;
7
8use super::frontmatter::{SpecFrontmatter, SpecStatus};
9use crate::spec::normalize_model_name;
10
11#[derive(Debug, Clone)]
12pub struct Spec {
13 pub id: String,
14 pub frontmatter: SpecFrontmatter,
15 pub title: Option<String>,
16 pub body: String,
17}
18
19pub fn split_frontmatter(content: &str) -> (Option<String>, &str) {
25 let content = content.trim();
26
27 if !content.starts_with("---") {
28 return (None, content);
29 }
30
31 let rest = &content[3..];
32 if let Some(end) = rest.find("---") {
33 let frontmatter = rest[..end].to_string();
34 let body = rest[end + 3..].trim_start();
35 (Some(frontmatter), body)
36 } else {
37 (None, content)
38 }
39}
40
41fn extract_title(body: &str) -> Option<String> {
42 for line in body.lines() {
43 let trimmed = line.trim();
44 if let Some(title) = trimmed.strip_prefix("# ") {
45 return Some(title.to_string());
46 }
47 }
48 None
49}
50
51fn branch_exists(branch: &str) -> Result<bool> {
52 let output = Command::new("git")
53 .args(["rev-parse", "--verify", branch])
54 .output()
55 .context("Failed to check if branch exists")?;
56
57 Ok(output.status.success())
58}
59
60fn read_spec_from_branch(spec_id: &str, branch: &str) -> Result<Spec> {
61 let spec_path = format!(".chant/specs/{}.md", spec_id);
62
63 let output = Command::new("git")
65 .args(["show", &format!("{}:{}", branch, spec_path)])
66 .output()
67 .context(format!("Failed to read spec from branch {}", branch))?;
68
69 if !output.status.success() {
70 let stderr = String::from_utf8_lossy(&output.stderr);
71 anyhow::bail!("git show failed: {}", stderr);
72 }
73
74 let content =
75 String::from_utf8(output.stdout).context("Failed to parse spec content as UTF-8")?;
76
77 Spec::parse(spec_id, &content)
78}
79
80impl Spec {
81 pub fn parse(id: &str, content: &str) -> Result<Self> {
83 let (frontmatter_str, body) = split_frontmatter(content);
84
85 let mut frontmatter: SpecFrontmatter = if let Some(fm) = frontmatter_str {
86 serde_yaml::from_str(&fm).context("Failed to parse spec frontmatter")?
87 } else {
88 SpecFrontmatter::default()
89 };
90
91 if let Some(model) = &frontmatter.model {
93 frontmatter.model = Some(normalize_model_name(model));
94 }
95
96 let title = extract_title(body);
98
99 Ok(Self {
100 id: id.to_string(),
101 frontmatter,
102 title,
103 body: body.to_string(),
104 })
105 }
106
107 pub fn load(path: &Path) -> Result<Self> {
109 let content = fs::read_to_string(path)
110 .with_context(|| format!("Failed to read spec from {}", path.display()))?;
111
112 let id = path
113 .file_stem()
114 .and_then(|s| s.to_str())
115 .ok_or_else(|| anyhow::anyhow!("Invalid spec filename"))?;
116
117 Self::parse(id, &content)
118 }
119
120 pub fn load_with_branch_resolution(spec_path: &Path) -> Result<Self> {
125 let spec = Self::load(spec_path)?;
126
127 if spec.frontmatter.status != SpecStatus::InProgress {
129 return Ok(spec);
130 }
131
132 let branch_name = spec
134 .frontmatter
135 .branch
136 .clone()
137 .unwrap_or_else(|| format!("chant/{}", spec.id));
138
139 if !branch_exists(&branch_name)? {
141 return Ok(spec);
142 }
143
144 match read_spec_from_branch(&spec.id, &branch_name) {
146 Ok(branch_spec) => Ok(branch_spec),
147 Err(_) => Ok(spec), }
149 }
150
151 pub fn save(&self, path: &Path) -> Result<()> {
153 let frontmatter = serde_yaml::to_string(&self.frontmatter)?;
154 let content = format!("---\n{}---\n{}", frontmatter, self.body);
155 fs::write(path, content)?;
156 Ok(())
157 }
158
159 pub fn count_unchecked_checkboxes(&self) -> usize {
163 let acceptance_criteria_marker = "## Acceptance Criteria";
164
165 let mut in_code_fence = false;
167 let mut last_ac_line: Option<usize> = None;
168
169 for (line_num, line) in self.body.lines().enumerate() {
170 let trimmed = line.trim_start();
171
172 if trimmed.starts_with("```") {
173 in_code_fence = !in_code_fence;
174 continue;
175 }
176
177 if !in_code_fence && trimmed.starts_with(acceptance_criteria_marker) {
178 last_ac_line = Some(line_num);
179 }
180 }
181
182 let Some(ac_start) = last_ac_line else {
183 return 0;
184 };
185
186 let mut in_code_fence = false;
188 let mut in_ac_section = false;
189 let mut count = 0;
190
191 for (line_num, line) in self.body.lines().enumerate() {
192 let trimmed = line.trim_start();
193
194 if trimmed.starts_with("```") {
195 in_code_fence = !in_code_fence;
196 continue;
197 }
198
199 if in_code_fence {
200 continue;
201 }
202
203 if line_num == ac_start {
205 in_ac_section = true;
206 continue;
207 }
208
209 if in_ac_section && trimmed.starts_with("## ") {
211 break;
212 }
213
214 if in_ac_section && line.contains("- [ ]") {
215 count += line.matches("- [ ]").count();
216 }
217 }
218
219 count
220 }
221
222 pub fn count_total_checkboxes(&self) -> usize {
225 let acceptance_criteria_marker = "## Acceptance Criteria";
226
227 let mut in_code_fence = false;
229 let mut last_ac_line: Option<usize> = None;
230
231 for (line_num, line) in self.body.lines().enumerate() {
232 let trimmed = line.trim_start();
233
234 if trimmed.starts_with("```") {
235 in_code_fence = !in_code_fence;
236 continue;
237 }
238
239 if !in_code_fence && trimmed.starts_with(acceptance_criteria_marker) {
240 last_ac_line = Some(line_num);
241 }
242 }
243
244 let Some(ac_start) = last_ac_line else {
245 return 0;
246 };
247
248 let mut in_code_fence = false;
250 let mut in_ac_section = false;
251 let mut count = 0;
252
253 for (line_num, line) in self.body.lines().enumerate() {
254 let trimmed = line.trim_start();
255
256 if trimmed.starts_with("```") {
257 in_code_fence = !in_code_fence;
258 continue;
259 }
260
261 if in_code_fence {
262 continue;
263 }
264
265 if line_num == ac_start {
266 in_ac_section = true;
267 continue;
268 }
269
270 if in_ac_section && trimmed.starts_with("## ") {
271 break;
272 }
273
274 if in_ac_section {
276 count += line.matches("- [ ]").count();
277 count += line.matches("- [x]").count();
278 count += line.matches("- [X]").count();
279 }
280 }
281
282 count
283 }
284
285 pub fn add_derived_fields(&mut self, fields: std::collections::HashMap<String, String>) {
288 let mut derived_field_names = Vec::new();
289
290 for (key, value) in fields {
291 derived_field_names.push(key.clone());
293
294 match key.as_str() {
296 "labels" => {
297 let label_vec = value.split(',').map(|s| s.trim().to_string()).collect();
298 self.frontmatter.labels = Some(label_vec);
299 }
300 "context" => {
301 let context_vec = value.split(',').map(|s| s.trim().to_string()).collect();
302 self.frontmatter.context = Some(context_vec);
303 }
304 _ => {
305 if self.frontmatter.context.is_none() {
306 self.frontmatter.context = Some(vec![]);
307 }
308 if let Some(ref mut ctx) = self.frontmatter.context {
309 ctx.push(format!("derived_{}={}", key, value));
310 }
311 }
312 }
313 }
314
315 if !derived_field_names.is_empty() {
317 self.frontmatter.derived_fields = Some(derived_field_names);
318 }
319 }
320
321 pub fn has_acceptance_criteria(&self) -> bool {
325 let acceptance_criteria_marker = "## Acceptance Criteria";
326 let mut in_ac_section = false;
327 let mut in_code_fence = false;
328
329 for line in self.body.lines() {
330 let trimmed = line.trim_start();
331
332 if trimmed.starts_with("```") {
333 in_code_fence = !in_code_fence;
334 }
335
336 if !in_code_fence && trimmed.starts_with(acceptance_criteria_marker) {
337 in_ac_section = true;
338 continue;
339 }
340
341 if in_ac_section && trimmed.starts_with("## ") {
342 break;
343 }
344
345 if in_ac_section
346 && (trimmed.starts_with("- [ ] ")
347 || trimmed.starts_with("- [x] ")
348 || trimmed.starts_with("- [X] "))
349 {
350 return true;
351 }
352 }
353
354 false
355 }
356
357 pub fn is_blocked(&self, all_specs: &[Spec]) -> bool {
359 if let Some(deps) = &self.frontmatter.depends_on {
360 for dep_id in deps {
361 let dep = all_specs.iter().find(|s| s.id == *dep_id);
362 match dep {
363 Some(d) if d.frontmatter.status == SpecStatus::Completed => continue,
364 _ => return true,
365 }
366 }
367 }
368
369 if self.frontmatter.status == SpecStatus::Pending && self.requires_approval() {
370 return true;
371 }
372
373 false
374 }
375
376 pub fn is_ready(&self, all_specs: &[Spec]) -> bool {
378 use crate::spec_group::{all_prior_siblings_completed, is_member_of};
379
380 if self.frontmatter.status != SpecStatus::Pending {
381 return false;
382 }
383
384 if self.is_blocked(all_specs) {
385 return false;
386 }
387
388 if !all_prior_siblings_completed(&self.id, all_specs) {
389 return false;
390 }
391
392 let members: Vec<_> = all_specs
393 .iter()
394 .filter(|s| is_member_of(&s.id, &self.id))
395 .collect();
396
397 if !members.is_empty() && self.has_acceptance_criteria() {
398 for member in members {
399 if member.frontmatter.status != SpecStatus::Completed {
400 return false;
401 }
402 }
403 }
404
405 true
406 }
407
408 pub fn get_blocking_dependencies(
410 &self,
411 all_specs: &[Spec],
412 specs_dir: &Path,
413 ) -> Vec<super::frontmatter::BlockingDependency> {
414 use super::frontmatter::BlockingDependency;
415 use crate::spec_group::{extract_driver_id, extract_member_number};
416
417 let mut blockers = Vec::new();
418
419 if let Some(deps) = &self.frontmatter.depends_on {
420 for dep_id in deps {
421 let spec_path = specs_dir.join(format!("{}.md", dep_id));
422 let dep_spec = if spec_path.exists() {
423 Spec::load(&spec_path).ok()
424 } else {
425 None
426 };
427
428 let dep_spec =
429 dep_spec.or_else(|| all_specs.iter().find(|s| s.id == *dep_id).cloned());
430
431 if let Some(spec) = dep_spec {
432 if spec.frontmatter.status != SpecStatus::Completed {
434 blockers.push(BlockingDependency {
435 spec_id: spec.id.clone(),
436 title: spec.title.clone(),
437 status: spec.frontmatter.status.clone(),
438 completed_at: spec.frontmatter.completed_at.clone(),
439 is_sibling: false,
440 });
441 }
442 } else {
443 blockers.push(BlockingDependency {
444 spec_id: dep_id.clone(),
445 title: None,
446 status: SpecStatus::Pending,
447 completed_at: None,
448 is_sibling: false,
449 });
450 }
451 }
452 }
453
454 if let Some(driver_id) = extract_driver_id(&self.id) {
455 if let Some(member_num) = extract_member_number(&self.id) {
456 for i in 1..member_num {
457 let sibling_id = format!("{}.{}", driver_id, i);
458 let spec_path = specs_dir.join(format!("{}.md", sibling_id));
459 let sibling_spec = if spec_path.exists() {
460 Spec::load(&spec_path).ok()
461 } else {
462 None
463 };
464
465 let sibling_spec = sibling_spec
466 .or_else(|| all_specs.iter().find(|s| s.id == sibling_id).cloned());
467
468 if let Some(spec) = sibling_spec {
469 if spec.frontmatter.status != SpecStatus::Completed {
470 blockers.push(BlockingDependency {
471 spec_id: spec.id.clone(),
472 title: spec.title.clone(),
473 status: spec.frontmatter.status.clone(),
474 completed_at: spec.frontmatter.completed_at.clone(),
475 is_sibling: true,
476 });
477 }
478 } else {
479 blockers.push(BlockingDependency {
480 spec_id: sibling_id,
481 title: None,
482 status: SpecStatus::Pending,
483 completed_at: None,
484 is_sibling: true,
485 });
486 }
487 }
488 }
489 }
490
491 blockers
492 }
493
494 pub fn has_frontmatter_field(&self, field: &str) -> bool {
496 match field {
497 "type" => true,
498 "status" => true,
499 "depends_on" => self.frontmatter.depends_on.is_some(),
500 "labels" => self.frontmatter.labels.is_some(),
501 "target_files" => self.frontmatter.target_files.is_some(),
502 "context" => self.frontmatter.context.is_some(),
503 "prompt" => self.frontmatter.prompt.is_some(),
504 "branch" => self.frontmatter.branch.is_some(),
505 "commits" => self.frontmatter.commits.is_some(),
506 "completed_at" => self.frontmatter.completed_at.is_some(),
507 "model" => self.frontmatter.model.is_some(),
508 "tracks" => self.frontmatter.tracks.is_some(),
509 "informed_by" => self.frontmatter.informed_by.is_some(),
510 "origin" => self.frontmatter.origin.is_some(),
511 "schedule" => self.frontmatter.schedule.is_some(),
512 "source_branch" => self.frontmatter.source_branch.is_some(),
513 "target_branch" => self.frontmatter.target_branch.is_some(),
514 "conflicting_files" => self.frontmatter.conflicting_files.is_some(),
515 "blocked_specs" => self.frontmatter.blocked_specs.is_some(),
516 "original_spec" => self.frontmatter.original_spec.is_some(),
517 "last_verified" => self.frontmatter.last_verified.is_some(),
518 "verification_status" => self.frontmatter.verification_status.is_some(),
519 "verification_failures" => self.frontmatter.verification_failures.is_some(),
520 "replayed_at" => self.frontmatter.replayed_at.is_some(),
521 "replay_count" => self.frontmatter.replay_count.is_some(),
522 "original_completed_at" => self.frontmatter.original_completed_at.is_some(),
523 "approval" => self.frontmatter.approval.is_some(),
524 "members" => self.frontmatter.members.is_some(),
525 "output_schema" => self.frontmatter.output_schema.is_some(),
526 _ => false,
527 }
528 }
529
530 pub fn requires_approval(&self) -> bool {
532 use super::frontmatter::ApprovalStatus;
533
534 if let Some(ref approval) = self.frontmatter.approval {
535 approval.required && approval.status != ApprovalStatus::Approved
536 } else {
537 false
538 }
539 }
540
541 pub fn is_approved(&self) -> bool {
543 use super::frontmatter::ApprovalStatus;
544
545 if let Some(ref approval) = self.frontmatter.approval {
546 approval.status == ApprovalStatus::Approved
547 } else {
548 true
549 }
550 }
551
552 pub fn is_rejected(&self) -> bool {
554 use super::frontmatter::ApprovalStatus;
555
556 if let Some(ref approval) = self.frontmatter.approval {
557 approval.status == ApprovalStatus::Rejected
558 } else {
559 false
560 }
561 }
562}