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 let dep = all_specs.iter().find(|s| s.id == *dep_id);
510 match dep {
511 Some(d) if d.frontmatter.status == SpecStatus::Completed => continue,
512 Some(d) if d.frontmatter.status == SpecStatus::Cancelled => continue,
513 _ => return true,
514 }
515 }
516 }
517 }
518 }
519
520 if self.frontmatter.status == SpecStatus::Pending && self.requires_approval() {
521 return true;
522 }
523
524 false
525 }
526
527 pub fn is_ready(&self, all_specs: &[Spec]) -> bool {
529 use crate::spec_group::{all_prior_siblings_completed, is_member_of};
530
531 if self.frontmatter.status != SpecStatus::Pending
534 && self.frontmatter.status != SpecStatus::Failed
535 {
536 return false;
537 }
538
539 if self.is_blocked(all_specs) {
540 return false;
541 }
542
543 if !all_prior_siblings_completed(&self.id, all_specs) {
544 return false;
545 }
546
547 let members: Vec<_> = all_specs
548 .iter()
549 .filter(|s| is_member_of(&s.id, &self.id))
550 .collect();
551
552 if !members.is_empty() && self.has_acceptance_criteria() {
553 for member in members {
554 if member.frontmatter.status != SpecStatus::Completed {
555 return false;
556 }
557 }
558 }
559
560 true
561 }
562
563 pub fn get_blocking_dependencies(
565 &self,
566 all_specs: &[Spec],
567 _specs_dir: &Path,
568 ) -> Vec<super::frontmatter::BlockingDependency> {
569 use super::frontmatter::BlockingDependency;
570 use crate::spec_group::{extract_driver_id, extract_member_number};
571 use std::collections::HashMap;
572
573 let mut blockers = Vec::new();
574
575 let spec_map: HashMap<&str, &Spec> = all_specs.iter().map(|s| (s.id.as_str(), s)).collect();
577
578 if let Some(deps) = &self.frontmatter.depends_on {
579 for dep_id in deps {
580 if let Some(&spec) = spec_map.get(dep_id.as_str()) {
581 if spec.frontmatter.status != SpecStatus::Completed {
583 blockers.push(BlockingDependency {
584 spec_id: spec.id.clone(),
585 title: spec.title.clone(),
586 status: spec.frontmatter.status.clone(),
587 completed_at: spec.frontmatter.completed_at.clone(),
588 is_sibling: false,
589 });
590 }
591 } else {
592 blockers.push(BlockingDependency {
593 spec_id: dep_id.clone(),
594 title: None,
595 status: SpecStatus::Pending,
596 completed_at: None,
597 is_sibling: false,
598 });
599 }
600 }
601 }
602
603 if let Some(driver_id) = extract_driver_id(&self.id) {
604 if let Some(member_num) = extract_member_number(&self.id) {
605 for i in 1..member_num {
606 let sibling_id = format!("{}.{}", driver_id, i);
607 if let Some(&spec) = spec_map.get(sibling_id.as_str()) {
608 if spec.frontmatter.status != SpecStatus::Completed {
609 blockers.push(BlockingDependency {
610 spec_id: spec.id.clone(),
611 title: spec.title.clone(),
612 status: spec.frontmatter.status.clone(),
613 completed_at: spec.frontmatter.completed_at.clone(),
614 is_sibling: true,
615 });
616 }
617 } else {
618 blockers.push(BlockingDependency {
619 spec_id: sibling_id,
620 title: None,
621 status: SpecStatus::Pending,
622 completed_at: None,
623 is_sibling: true,
624 });
625 }
626 }
627 }
628 }
629
630 blockers
631 }
632
633 pub fn has_frontmatter_field(&self, field: &str) -> bool {
635 match field {
636 "type" => true,
637 "status" => true,
638 "depends_on" => self.frontmatter.depends_on.is_some(),
639 "labels" => self.frontmatter.labels.is_some(),
640 "target_files" => self.frontmatter.target_files.is_some(),
641 "context" => self.frontmatter.context.is_some(),
642 "prompt" => self.frontmatter.prompt.is_some(),
643 "branch" => self.frontmatter.branch.is_some(),
644 "commits" => self.frontmatter.commits.is_some(),
645 "completed_at" => self.frontmatter.completed_at.is_some(),
646 "model" => self.frontmatter.model.is_some(),
647 "tracks" => self.frontmatter.tracks.is_some(),
648 "informed_by" => self.frontmatter.informed_by.is_some(),
649 "origin" => self.frontmatter.origin.is_some(),
650 "schedule" => self.frontmatter.schedule.is_some(),
651 "source_branch" => self.frontmatter.source_branch.is_some(),
652 "target_branch" => self.frontmatter.target_branch.is_some(),
653 "conflicting_files" => self.frontmatter.conflicting_files.is_some(),
654 "blocked_specs" => self.frontmatter.blocked_specs.is_some(),
655 "original_spec" => self.frontmatter.original_spec.is_some(),
656 "last_verified" => self.frontmatter.last_verified.is_some(),
657 "verification_status" => self.frontmatter.verification_status.is_some(),
658 "verification_failures" => self.frontmatter.verification_failures.is_some(),
659 "replayed_at" => self.frontmatter.replayed_at.is_some(),
660 "replay_count" => self.frontmatter.replay_count.is_some(),
661 "original_completed_at" => self.frontmatter.original_completed_at.is_some(),
662 "approval" => self.frontmatter.approval.is_some(),
663 "members" => self.frontmatter.members.is_some(),
664 "output_schema" => self.frontmatter.output_schema.is_some(),
665 _ => false,
666 }
667 }
668
669 pub fn requires_approval(&self) -> bool {
671 use super::frontmatter::ApprovalStatus;
672
673 if let Some(ref approval) = self.frontmatter.approval {
674 approval.required && approval.status != ApprovalStatus::Approved
675 } else {
676 false
677 }
678 }
679
680 pub fn is_approved(&self) -> bool {
682 use super::frontmatter::ApprovalStatus;
683
684 if let Some(ref approval) = self.frontmatter.approval {
685 approval.status == ApprovalStatus::Approved
686 } else {
687 true
688 }
689 }
690
691 pub fn is_rejected(&self) -> bool {
693 use super::frontmatter::ApprovalStatus;
694
695 if let Some(ref approval) = self.frontmatter.approval {
696 approval.status == ApprovalStatus::Rejected
697 } else {
698 false
699 }
700 }
701}