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(crate) fn set_status(
86 &mut self,
87 new_status: SpecStatus,
88 ) -> Result<(), super::state_machine::TransitionError> {
89 super::state_machine::TransitionBuilder::new(self).to(new_status)
90 }
91
92 pub fn parse(id: &str, content: &str) -> Result<Self> {
94 let (frontmatter_str, body) = split_frontmatter(content);
95
96 let mut frontmatter: SpecFrontmatter = if let Some(fm) = frontmatter_str {
97 serde_yaml::from_str(&fm).context("Failed to parse spec frontmatter")?
98 } else {
99 SpecFrontmatter::default()
100 };
101
102 if let Some(model) = &frontmatter.model {
104 frontmatter.model = Some(normalize_model_name(model));
105 }
106
107 let title = extract_title(body);
109
110 Ok(Self {
111 id: id.to_string(),
112 frontmatter,
113 title,
114 body: body.to_string(),
115 })
116 }
117
118 pub fn load(path: &Path) -> Result<Self> {
120 let content = fs::read_to_string(path)
121 .with_context(|| format!("Failed to read spec from {}", path.display()))?;
122
123 let id = path
124 .file_stem()
125 .and_then(|s| s.to_str())
126 .ok_or_else(|| anyhow::anyhow!("Invalid spec filename"))?;
127
128 Self::parse(id, &content)
129 }
130
131 pub fn load_frontmatter_only(path: &Path) -> Result<Self> {
135 use std::io::{BufRead, BufReader};
136
137 let file = fs::File::open(path)
138 .with_context(|| format!("Failed to read spec from {}", path.display()))?;
139 let reader = BufReader::new(file);
140
141 let id = path
142 .file_stem()
143 .and_then(|s| s.to_str())
144 .ok_or_else(|| anyhow::anyhow!("Invalid spec filename"))?;
145
146 let mut frontmatter_str = String::new();
147 let mut in_frontmatter = false;
148 let mut delimiter_count = 0;
149
150 for line in reader.lines() {
152 let line = line?;
153
154 if line.trim() == "---" {
155 delimiter_count += 1;
156 if delimiter_count == 2 {
157 break;
158 }
159 in_frontmatter = true;
160 continue;
161 }
162
163 if in_frontmatter {
164 frontmatter_str.push_str(&line);
165 frontmatter_str.push('\n');
166 }
167 }
168
169 let mut frontmatter: SpecFrontmatter = if delimiter_count == 2 {
170 serde_yaml::from_str(&frontmatter_str).context("Failed to parse spec frontmatter")?
171 } else {
172 SpecFrontmatter::default()
173 };
174
175 if let Some(model) = &frontmatter.model {
177 frontmatter.model = Some(normalize_model_name(model));
178 }
179
180 Ok(Self {
182 id: id.to_string(),
183 frontmatter,
184 title: None,
185 body: String::new(),
186 })
187 }
188
189 pub fn load_with_branch_resolution(spec_path: &Path) -> Result<Self> {
194 let spec = Self::load(spec_path)?;
195
196 if spec.frontmatter.status != SpecStatus::InProgress {
198 return Ok(spec);
199 }
200
201 let branch_name = spec
203 .frontmatter
204 .branch
205 .clone()
206 .unwrap_or_else(|| format!("chant/{}", spec.id));
207
208 if !branch_exists(&branch_name)? {
210 return Ok(spec);
211 }
212
213 match read_spec_from_branch(&spec.id, &branch_name) {
215 Ok(branch_spec) => Ok(branch_spec),
216 Err(_) => Ok(spec), }
218 }
219
220 pub fn save(&self, path: &Path) -> Result<()> {
222 let frontmatter = serde_yaml::to_string(&self.frontmatter)?;
223 let content = format!("---\n{}---\n{}", frontmatter, self.body);
224 let tmp_path = path.with_extension("md.tmp");
225 fs::write(&tmp_path, &content)?;
226 fs::rename(&tmp_path, path)?;
227 Ok(())
228 }
229
230 pub fn count_unchecked_checkboxes(&self) -> usize {
234 let acceptance_criteria_marker = "## Acceptance Criteria";
235
236 let mut in_code_fence = false;
238 let mut last_ac_line: Option<usize> = None;
239
240 for (line_num, line) in self.body.lines().enumerate() {
241 let trimmed = line.trim_start();
242
243 if trimmed.starts_with("```") {
244 in_code_fence = !in_code_fence;
245 continue;
246 }
247
248 if !in_code_fence && trimmed.starts_with(acceptance_criteria_marker) {
249 last_ac_line = Some(line_num);
250 }
251 }
252
253 let Some(ac_start) = last_ac_line else {
254 return 0;
255 };
256
257 let mut in_code_fence = false;
259 let mut in_ac_section = false;
260 let mut count = 0;
261
262 for (line_num, line) in self.body.lines().enumerate() {
263 let trimmed = line.trim_start();
264
265 if trimmed.starts_with("```") {
266 in_code_fence = !in_code_fence;
267 continue;
268 }
269
270 if in_code_fence {
271 continue;
272 }
273
274 if line_num == ac_start {
276 in_ac_section = true;
277 continue;
278 }
279
280 if in_ac_section && trimmed.starts_with("## ") {
282 break;
283 }
284
285 if in_ac_section && line.contains("- [ ]") {
286 count += line.matches("- [ ]").count();
287 }
288 }
289
290 count
291 }
292
293 pub fn count_total_checkboxes(&self) -> usize {
296 let acceptance_criteria_marker = "## Acceptance Criteria";
297
298 let mut in_code_fence = false;
300 let mut last_ac_line: Option<usize> = None;
301
302 for (line_num, line) in self.body.lines().enumerate() {
303 let trimmed = line.trim_start();
304
305 if trimmed.starts_with("```") {
306 in_code_fence = !in_code_fence;
307 continue;
308 }
309
310 if !in_code_fence && trimmed.starts_with(acceptance_criteria_marker) {
311 last_ac_line = Some(line_num);
312 }
313 }
314
315 let Some(ac_start) = last_ac_line else {
316 return 0;
317 };
318
319 let mut in_code_fence = false;
321 let mut in_ac_section = false;
322 let mut count = 0;
323
324 for (line_num, line) in self.body.lines().enumerate() {
325 let trimmed = line.trim_start();
326
327 if trimmed.starts_with("```") {
328 in_code_fence = !in_code_fence;
329 continue;
330 }
331
332 if in_code_fence {
333 continue;
334 }
335
336 if line_num == ac_start {
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 {
347 count += line.matches("- [ ]").count();
348 count += line.matches("- [x]").count();
349 count += line.matches("- [X]").count();
350 }
351 }
352
353 count
354 }
355
356 pub fn add_derived_fields(&mut self, fields: std::collections::HashMap<String, String>) {
359 let mut derived_field_names = Vec::new();
360
361 for (key, value) in fields {
362 derived_field_names.push(key.clone());
364
365 match key.as_str() {
367 "labels" => {
368 let label_vec = value.split(',').map(|s| s.trim().to_string()).collect();
369 self.frontmatter.labels = Some(label_vec);
370 }
371 "context" => {
372 let context_vec = value.split(',').map(|s| s.trim().to_string()).collect();
373 self.frontmatter.context = Some(context_vec);
374 }
375 _ => {
376 if self.frontmatter.context.is_none() {
377 self.frontmatter.context = Some(vec![]);
378 }
379 if let Some(ref mut ctx) = self.frontmatter.context {
380 ctx.push(format!("derived_{}={}", key, value));
381 }
382 }
383 }
384 }
385
386 if !derived_field_names.is_empty() {
388 self.frontmatter.derived_fields = Some(derived_field_names);
389 }
390 }
391
392 pub fn auto_check_acceptance_criteria(&mut self) -> bool {
396 let acceptance_criteria_marker = "## Acceptance Criteria";
397 let mut in_code_fence = false;
398 let mut last_ac_line: Option<usize> = None;
399
400 for (line_num, line) in self.body.lines().enumerate() {
402 let trimmed = line.trim_start();
403 if trimmed.starts_with("```") {
404 in_code_fence = !in_code_fence;
405 continue;
406 }
407 if !in_code_fence && trimmed.starts_with(acceptance_criteria_marker) {
408 last_ac_line = Some(line_num);
409 }
410 }
411
412 let Some(ac_start) = last_ac_line else {
413 return false;
414 };
415
416 let mut in_code_fence = false;
418 let mut in_ac_section = false;
419 let mut modified = false;
420 let mut new_body = String::new();
421
422 for (line_num, line) in self.body.lines().enumerate() {
423 let trimmed = line.trim_start();
424
425 if trimmed.starts_with("```") {
426 in_code_fence = !in_code_fence;
427 }
428
429 if line_num == ac_start {
430 in_ac_section = true;
431 }
432
433 if in_ac_section && !in_code_fence && trimmed.starts_with("## ") && line_num != ac_start
434 {
435 in_ac_section = false;
436 }
437
438 if in_ac_section && !in_code_fence && line.contains("- [ ]") {
439 new_body.push_str(&line.replace("- [ ]", "- [x]"));
440 modified = true;
441 } else {
442 new_body.push_str(line);
443 }
444 new_body.push('\n');
445 }
446
447 if modified {
448 self.body = new_body.trim_end().to_string();
449 }
450
451 modified
452 }
453
454 pub fn has_acceptance_criteria(&self) -> bool {
458 let acceptance_criteria_marker = "## Acceptance Criteria";
459 let mut in_ac_section = false;
460 let mut in_code_fence = false;
461
462 for line in self.body.lines() {
463 let trimmed = line.trim_start();
464
465 if trimmed.starts_with("```") {
466 in_code_fence = !in_code_fence;
467 }
468
469 if !in_code_fence && trimmed.starts_with(acceptance_criteria_marker) {
470 in_ac_section = true;
471 continue;
472 }
473
474 if in_ac_section && trimmed.starts_with("## ") {
475 break;
476 }
477
478 if in_ac_section
479 && (trimmed.starts_with("- [ ] ")
480 || trimmed.starts_with("- [x] ")
481 || trimmed.starts_with("- [X] "))
482 {
483 return true;
484 }
485 }
486
487 false
488 }
489
490 pub fn is_blocked(&self, all_specs: &[Spec]) -> bool {
492 if let Some(deps) = &self.frontmatter.depends_on {
494 for dep_id in deps {
495 let dep = all_specs.iter().find(|s| s.id == *dep_id);
496 match dep {
497 Some(d) if d.frontmatter.status == SpecStatus::Completed => continue,
498 Some(d) if d.frontmatter.status == SpecStatus::Cancelled => continue,
499 _ => return true,
500 }
501 }
502 }
503
504 if let Some(driver_id) = crate::spec_group::extract_driver_id(&self.id) {
506 if let Some(driver_spec) = all_specs.iter().find(|s| s.id == driver_id) {
507 if let Some(driver_deps) = &driver_spec.frontmatter.depends_on {
508 for dep_id in driver_deps {
509 if crate::spec_group::is_member_of(dep_id, &driver_id) {
511 continue;
512 }
513
514 let dep = all_specs.iter().find(|s| s.id == *dep_id);
515 match dep {
516 Some(d) if d.frontmatter.status == SpecStatus::Completed => continue,
517 Some(d) if d.frontmatter.status == SpecStatus::Cancelled => continue,
518 _ => return true,
519 }
520 }
521 }
522 }
523 }
524
525 if self.frontmatter.status == SpecStatus::Pending && self.requires_approval() {
526 return true;
527 }
528
529 false
530 }
531
532 pub fn is_ready(&self, all_specs: &[Spec]) -> bool {
534 use crate::spec_group::{all_prior_siblings_completed, is_member_of};
535
536 if self.frontmatter.status != SpecStatus::Pending
539 && self.frontmatter.status != SpecStatus::Failed
540 {
541 return false;
542 }
543
544 if self.is_blocked(all_specs) {
545 return false;
546 }
547
548 if !all_prior_siblings_completed(&self.id, all_specs) {
549 return false;
550 }
551
552 let members: Vec<_> = all_specs
553 .iter()
554 .filter(|s| is_member_of(&s.id, &self.id))
555 .collect();
556
557 if !members.is_empty() && self.has_acceptance_criteria() {
558 for member in members {
559 if member.frontmatter.status != SpecStatus::Completed {
560 return false;
561 }
562 }
563 }
564
565 true
566 }
567
568 pub fn get_blocking_dependencies(
570 &self,
571 all_specs: &[Spec],
572 _specs_dir: &Path,
573 ) -> Vec<super::frontmatter::BlockingDependency> {
574 use super::frontmatter::BlockingDependency;
575 use crate::spec_group::{extract_driver_id, extract_member_number};
576 use std::collections::HashMap;
577
578 let mut blockers = Vec::new();
579
580 let spec_map: HashMap<&str, &Spec> = all_specs.iter().map(|s| (s.id.as_str(), s)).collect();
582
583 if let Some(deps) = &self.frontmatter.depends_on {
584 for dep_id in deps {
585 if let Some(&spec) = spec_map.get(dep_id.as_str()) {
586 if spec.frontmatter.status != SpecStatus::Completed {
588 blockers.push(BlockingDependency {
589 spec_id: spec.id.clone(),
590 title: spec.title.clone(),
591 status: spec.frontmatter.status.clone(),
592 completed_at: spec.frontmatter.completed_at.clone(),
593 is_sibling: false,
594 });
595 }
596 } else {
597 blockers.push(BlockingDependency {
598 spec_id: dep_id.clone(),
599 title: None,
600 status: SpecStatus::Pending,
601 completed_at: None,
602 is_sibling: false,
603 });
604 }
605 }
606 }
607
608 if let Some(driver_id) = extract_driver_id(&self.id) {
609 if let Some(member_num) = extract_member_number(&self.id) {
610 for i in 1..member_num {
611 let sibling_id = format!("{}.{}", driver_id, i);
612 if let Some(&spec) = spec_map.get(sibling_id.as_str()) {
613 if spec.frontmatter.status != SpecStatus::Completed {
614 blockers.push(BlockingDependency {
615 spec_id: spec.id.clone(),
616 title: spec.title.clone(),
617 status: spec.frontmatter.status.clone(),
618 completed_at: spec.frontmatter.completed_at.clone(),
619 is_sibling: true,
620 });
621 }
622 } else {
623 blockers.push(BlockingDependency {
624 spec_id: sibling_id,
625 title: None,
626 status: SpecStatus::Pending,
627 completed_at: None,
628 is_sibling: true,
629 });
630 }
631 }
632 }
633 }
634
635 blockers
636 }
637
638 pub fn has_frontmatter_field(&self, field: &str) -> bool {
640 match field {
641 "type" => true,
642 "status" => true,
643 "depends_on" => self.frontmatter.depends_on.is_some(),
644 "labels" => self.frontmatter.labels.is_some(),
645 "target_files" => self.frontmatter.target_files.is_some(),
646 "context" => self.frontmatter.context.is_some(),
647 "prompt" => self.frontmatter.prompt.is_some(),
648 "branch" => self.frontmatter.branch.is_some(),
649 "commits" => self.frontmatter.commits.is_some(),
650 "completed_at" => self.frontmatter.completed_at.is_some(),
651 "model" => self.frontmatter.model.is_some(),
652 "tracks" => self.frontmatter.tracks.is_some(),
653 "informed_by" => self.frontmatter.informed_by.is_some(),
654 "origin" => self.frontmatter.origin.is_some(),
655 "schedule" => self.frontmatter.schedule.is_some(),
656 "source_branch" => self.frontmatter.source_branch.is_some(),
657 "target_branch" => self.frontmatter.target_branch.is_some(),
658 "conflicting_files" => self.frontmatter.conflicting_files.is_some(),
659 "blocked_specs" => self.frontmatter.blocked_specs.is_some(),
660 "original_spec" => self.frontmatter.original_spec.is_some(),
661 "last_verified" => self.frontmatter.last_verified.is_some(),
662 "verification_status" => self.frontmatter.verification_status.is_some(),
663 "verification_failures" => self.frontmatter.verification_failures.is_some(),
664 "replayed_at" => self.frontmatter.replayed_at.is_some(),
665 "replay_count" => self.frontmatter.replay_count.is_some(),
666 "original_completed_at" => self.frontmatter.original_completed_at.is_some(),
667 "approval" => self.frontmatter.approval.is_some(),
668 "members" => self.frontmatter.members.is_some(),
669 "output_schema" => self.frontmatter.output_schema.is_some(),
670 _ => false,
671 }
672 }
673
674 pub fn requires_approval(&self) -> bool {
676 use super::frontmatter::ApprovalStatus;
677
678 if let Some(ref approval) = self.frontmatter.approval {
679 approval.required && approval.status != ApprovalStatus::Approved
680 } else {
681 false
682 }
683 }
684
685 pub fn is_approved(&self) -> bool {
687 use super::frontmatter::ApprovalStatus;
688
689 if let Some(ref approval) = self.frontmatter.approval {
690 approval.status == ApprovalStatus::Approved
691 } else {
692 true
693 }
694 }
695
696 pub fn is_rejected(&self) -> bool {
698 use super::frontmatter::ApprovalStatus;
699
700 if let Some(ref approval) = self.frontmatter.approval {
701 approval.status == ApprovalStatus::Rejected
702 } else {
703 false
704 }
705 }
706}
707
708#[cfg(test)]
709mod tests {
710 use super::*;
711 use crate::spec::SpecType;
712
713 #[test]
714 fn test_is_blocked_circular_dependency_with_driver_members() {
715 let driver = Spec {
719 id: "2026-02-13-001-abc".to_string(),
720 frontmatter: SpecFrontmatter {
721 status: SpecStatus::Pending,
722 r#type: SpecType::Group,
723 depends_on: Some(vec!["2026-02-13-001-abc.1".to_string()]),
724 ..Default::default()
725 },
726 title: Some("Driver spec".to_string()),
727 body: "# Driver spec\n\nBody.".to_string(),
728 };
729
730 let member = Spec {
732 id: "2026-02-13-001-abc.1".to_string(),
733 frontmatter: SpecFrontmatter {
734 status: SpecStatus::Pending,
735 depends_on: None,
736 ..Default::default()
737 },
738 title: Some("Member spec".to_string()),
739 body: "# Member spec\n\nBody.".to_string(),
740 };
741
742 let all_specs = vec![driver.clone(), member.clone()];
743
744 assert!(
746 !member.is_blocked(&all_specs),
747 "Member spec should not be blocked by driver's self-reference"
748 );
749 }
750
751 #[test]
752 fn test_is_blocked_external_driver_dependency() {
753 let external = Spec {
757 id: "2026-02-13-000-xyz".to_string(),
758 frontmatter: SpecFrontmatter {
759 status: SpecStatus::Pending,
760 ..Default::default()
761 },
762 title: Some("External spec".to_string()),
763 body: "# External spec\n\nBody.".to_string(),
764 };
765
766 let driver = Spec {
768 id: "2026-02-13-001-abc".to_string(),
769 frontmatter: SpecFrontmatter {
770 status: SpecStatus::Pending,
771 r#type: SpecType::Group,
772 depends_on: Some(vec![
773 "2026-02-13-000-xyz".to_string(), "2026-02-13-001-abc.1".to_string(), ]),
776 ..Default::default()
777 },
778 title: Some("Driver spec".to_string()),
779 body: "# Driver spec\n\nBody.".to_string(),
780 };
781
782 let member = Spec {
784 id: "2026-02-13-001-abc.1".to_string(),
785 frontmatter: SpecFrontmatter {
786 status: SpecStatus::Pending,
787 depends_on: None,
788 ..Default::default()
789 },
790 title: Some("Member spec".to_string()),
791 body: "# Member spec\n\nBody.".to_string(),
792 };
793
794 let all_specs = vec![external.clone(), driver.clone(), member.clone()];
795
796 assert!(
798 member.is_blocked(&all_specs),
799 "Member spec should be blocked by driver's external dependency"
800 );
801
802 let external_completed = Spec {
804 id: "2026-02-13-000-xyz".to_string(),
805 frontmatter: SpecFrontmatter {
806 status: SpecStatus::Completed,
807 ..Default::default()
808 },
809 title: Some("External spec".to_string()),
810 body: "# External spec\n\nBody.".to_string(),
811 };
812
813 let all_specs_updated = vec![external_completed, driver.clone(), member.clone()];
814
815 assert!(
817 !member.is_blocked(&all_specs_updated),
818 "Member spec should not be blocked after external dependency is completed"
819 );
820 }
821
822 #[test]
823 fn test_is_blocked_multiple_siblings() {
824 let driver = Spec {
827 id: "2026-02-13-002-def".to_string(),
828 frontmatter: SpecFrontmatter {
829 status: SpecStatus::Pending,
830 r#type: SpecType::Group,
831 depends_on: Some(vec![
832 "2026-02-13-002-def.1".to_string(),
833 "2026-02-13-002-def.2".to_string(),
834 "2026-02-13-002-def.3".to_string(),
835 ]),
836 ..Default::default()
837 },
838 title: Some("Driver spec".to_string()),
839 body: "# Driver spec\n\nBody.".to_string(),
840 };
841
842 let member1 = Spec {
843 id: "2026-02-13-002-def.1".to_string(),
844 frontmatter: SpecFrontmatter {
845 status: SpecStatus::Pending,
846 depends_on: None,
847 ..Default::default()
848 },
849 title: Some("Member 1".to_string()),
850 body: "# Member 1\n\nBody.".to_string(),
851 };
852
853 let member2 = Spec {
854 id: "2026-02-13-002-def.2".to_string(),
855 frontmatter: SpecFrontmatter {
856 status: SpecStatus::Pending,
857 depends_on: None,
858 ..Default::default()
859 },
860 title: Some("Member 2".to_string()),
861 body: "# Member 2\n\nBody.".to_string(),
862 };
863
864 let member3 = Spec {
865 id: "2026-02-13-002-def.3".to_string(),
866 frontmatter: SpecFrontmatter {
867 status: SpecStatus::Pending,
868 depends_on: None,
869 ..Default::default()
870 },
871 title: Some("Member 3".to_string()),
872 body: "# Member 3\n\nBody.".to_string(),
873 };
874
875 let all_specs = vec![
876 driver.clone(),
877 member1.clone(),
878 member2.clone(),
879 member3.clone(),
880 ];
881
882 assert!(
884 !member1.is_blocked(&all_specs),
885 "Member 1 should not be blocked by driver's dependency on siblings"
886 );
887 assert!(
888 !member2.is_blocked(&all_specs),
889 "Member 2 should not be blocked by driver's dependency on siblings"
890 );
891 assert!(
892 !member3.is_blocked(&all_specs),
893 "Member 3 should not be blocked by driver's dependency on siblings"
894 );
895 }
896}