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_with_branch_resolution(spec_path: &Path) -> Result<Self> {
136 let spec = Self::load(spec_path)?;
137
138 if spec.frontmatter.status != SpecStatus::InProgress {
140 return Ok(spec);
141 }
142
143 let branch_name = spec
145 .frontmatter
146 .branch
147 .clone()
148 .unwrap_or_else(|| format!("chant/{}", spec.id));
149
150 if !branch_exists(&branch_name)? {
152 return Ok(spec);
153 }
154
155 match read_spec_from_branch(&spec.id, &branch_name) {
157 Ok(branch_spec) => Ok(branch_spec),
158 Err(_) => Ok(spec), }
160 }
161
162 pub fn save(&self, path: &Path) -> Result<()> {
164 let frontmatter = serde_yaml::to_string(&self.frontmatter)?;
165 let content = format!("---\n{}---\n{}", frontmatter, self.body);
166 let tmp_path = path.with_extension("md.tmp");
167 fs::write(&tmp_path, &content)?;
168 fs::rename(&tmp_path, path)?;
169 Ok(())
170 }
171
172 pub fn count_unchecked_checkboxes(&self) -> usize {
176 let acceptance_criteria_marker = "## Acceptance Criteria";
177
178 let mut in_code_fence = false;
180 let mut last_ac_line: Option<usize> = None;
181
182 for (line_num, line) in self.body.lines().enumerate() {
183 let trimmed = line.trim_start();
184
185 if trimmed.starts_with("```") {
186 in_code_fence = !in_code_fence;
187 continue;
188 }
189
190 if !in_code_fence && trimmed.starts_with(acceptance_criteria_marker) {
191 last_ac_line = Some(line_num);
192 }
193 }
194
195 let Some(ac_start) = last_ac_line else {
196 return 0;
197 };
198
199 let mut in_code_fence = false;
201 let mut in_ac_section = false;
202 let mut count = 0;
203
204 for (line_num, line) in self.body.lines().enumerate() {
205 let trimmed = line.trim_start();
206
207 if trimmed.starts_with("```") {
208 in_code_fence = !in_code_fence;
209 continue;
210 }
211
212 if in_code_fence {
213 continue;
214 }
215
216 if line_num == ac_start {
218 in_ac_section = true;
219 continue;
220 }
221
222 if in_ac_section && trimmed.starts_with("## ") {
224 break;
225 }
226
227 if in_ac_section && line.contains("- [ ]") {
228 count += line.matches("- [ ]").count();
229 }
230 }
231
232 count
233 }
234
235 pub fn count_total_checkboxes(&self) -> usize {
238 let acceptance_criteria_marker = "## Acceptance Criteria";
239
240 let mut in_code_fence = false;
242 let mut last_ac_line: Option<usize> = None;
243
244 for (line_num, line) in self.body.lines().enumerate() {
245 let trimmed = line.trim_start();
246
247 if trimmed.starts_with("```") {
248 in_code_fence = !in_code_fence;
249 continue;
250 }
251
252 if !in_code_fence && trimmed.starts_with(acceptance_criteria_marker) {
253 last_ac_line = Some(line_num);
254 }
255 }
256
257 let Some(ac_start) = last_ac_line else {
258 return 0;
259 };
260
261 let mut in_code_fence = false;
263 let mut in_ac_section = false;
264 let mut count = 0;
265
266 for (line_num, line) in self.body.lines().enumerate() {
267 let trimmed = line.trim_start();
268
269 if trimmed.starts_with("```") {
270 in_code_fence = !in_code_fence;
271 continue;
272 }
273
274 if in_code_fence {
275 continue;
276 }
277
278 if line_num == ac_start {
279 in_ac_section = true;
280 continue;
281 }
282
283 if in_ac_section && trimmed.starts_with("## ") {
284 break;
285 }
286
287 if in_ac_section {
289 count += line.matches("- [ ]").count();
290 count += line.matches("- [x]").count();
291 count += line.matches("- [X]").count();
292 }
293 }
294
295 count
296 }
297
298 pub fn add_derived_fields(&mut self, fields: std::collections::HashMap<String, String>) {
301 let mut derived_field_names = Vec::new();
302
303 for (key, value) in fields {
304 derived_field_names.push(key.clone());
306
307 match key.as_str() {
309 "labels" => {
310 let label_vec = value.split(',').map(|s| s.trim().to_string()).collect();
311 self.frontmatter.labels = Some(label_vec);
312 }
313 "context" => {
314 let context_vec = value.split(',').map(|s| s.trim().to_string()).collect();
315 self.frontmatter.context = Some(context_vec);
316 }
317 _ => {
318 if self.frontmatter.context.is_none() {
319 self.frontmatter.context = Some(vec![]);
320 }
321 if let Some(ref mut ctx) = self.frontmatter.context {
322 ctx.push(format!("derived_{}={}", key, value));
323 }
324 }
325 }
326 }
327
328 if !derived_field_names.is_empty() {
330 self.frontmatter.derived_fields = Some(derived_field_names);
331 }
332 }
333
334 pub fn auto_check_acceptance_criteria(&mut self) -> bool {
338 let acceptance_criteria_marker = "## Acceptance Criteria";
339 let mut in_code_fence = false;
340 let mut last_ac_line: Option<usize> = None;
341
342 for (line_num, line) in self.body.lines().enumerate() {
344 let trimmed = line.trim_start();
345 if trimmed.starts_with("```") {
346 in_code_fence = !in_code_fence;
347 continue;
348 }
349 if !in_code_fence && trimmed.starts_with(acceptance_criteria_marker) {
350 last_ac_line = Some(line_num);
351 }
352 }
353
354 let Some(ac_start) = last_ac_line else {
355 return false;
356 };
357
358 let mut in_code_fence = false;
360 let mut in_ac_section = false;
361 let mut modified = false;
362 let mut new_body = String::new();
363
364 for (line_num, line) in self.body.lines().enumerate() {
365 let trimmed = line.trim_start();
366
367 if trimmed.starts_with("```") {
368 in_code_fence = !in_code_fence;
369 }
370
371 if line_num == ac_start {
372 in_ac_section = true;
373 }
374
375 if in_ac_section && !in_code_fence && trimmed.starts_with("## ") && line_num != ac_start
376 {
377 in_ac_section = false;
378 }
379
380 if in_ac_section && !in_code_fence && line.contains("- [ ]") {
381 new_body.push_str(&line.replace("- [ ]", "- [x]"));
382 modified = true;
383 } else {
384 new_body.push_str(line);
385 }
386 new_body.push('\n');
387 }
388
389 if modified {
390 self.body = new_body.trim_end().to_string();
391 }
392
393 modified
394 }
395
396 pub fn has_acceptance_criteria(&self) -> bool {
400 let acceptance_criteria_marker = "## Acceptance Criteria";
401 let mut in_ac_section = false;
402 let mut in_code_fence = false;
403
404 for line in self.body.lines() {
405 let trimmed = line.trim_start();
406
407 if trimmed.starts_with("```") {
408 in_code_fence = !in_code_fence;
409 }
410
411 if !in_code_fence && trimmed.starts_with(acceptance_criteria_marker) {
412 in_ac_section = true;
413 continue;
414 }
415
416 if in_ac_section && trimmed.starts_with("## ") {
417 break;
418 }
419
420 if in_ac_section
421 && (trimmed.starts_with("- [ ] ")
422 || trimmed.starts_with("- [x] ")
423 || trimmed.starts_with("- [X] "))
424 {
425 return true;
426 }
427 }
428
429 false
430 }
431
432 pub fn is_blocked(&self, all_specs: &[Spec]) -> bool {
434 if let Some(deps) = &self.frontmatter.depends_on {
436 for dep_id in deps {
437 let dep = all_specs.iter().find(|s| s.id == *dep_id);
438 match dep {
439 Some(d) if d.frontmatter.status == SpecStatus::Completed => continue,
440 Some(d) if d.frontmatter.status == SpecStatus::Cancelled => continue,
441 _ => return true,
442 }
443 }
444 }
445
446 if let Some(driver_id) = crate::spec_group::extract_driver_id(&self.id) {
448 if let Some(driver_spec) = all_specs.iter().find(|s| s.id == driver_id) {
449 if let Some(driver_deps) = &driver_spec.frontmatter.depends_on {
450 for dep_id in driver_deps {
451 let dep = all_specs.iter().find(|s| s.id == *dep_id);
452 match dep {
453 Some(d) if d.frontmatter.status == SpecStatus::Completed => continue,
454 Some(d) if d.frontmatter.status == SpecStatus::Cancelled => continue,
455 _ => return true,
456 }
457 }
458 }
459 }
460 }
461
462 if self.frontmatter.status == SpecStatus::Pending && self.requires_approval() {
463 return true;
464 }
465
466 false
467 }
468
469 pub fn is_ready(&self, all_specs: &[Spec]) -> bool {
471 use crate::spec_group::{all_prior_siblings_completed, is_member_of};
472
473 if self.frontmatter.status != SpecStatus::Pending
476 && self.frontmatter.status != SpecStatus::Failed
477 {
478 return false;
479 }
480
481 if self.is_blocked(all_specs) {
482 return false;
483 }
484
485 if !all_prior_siblings_completed(&self.id, all_specs) {
486 return false;
487 }
488
489 let members: Vec<_> = all_specs
490 .iter()
491 .filter(|s| is_member_of(&s.id, &self.id))
492 .collect();
493
494 if !members.is_empty() && self.has_acceptance_criteria() {
495 for member in members {
496 if member.frontmatter.status != SpecStatus::Completed {
497 return false;
498 }
499 }
500 }
501
502 true
503 }
504
505 pub fn get_blocking_dependencies(
507 &self,
508 all_specs: &[Spec],
509 specs_dir: &Path,
510 ) -> Vec<super::frontmatter::BlockingDependency> {
511 use super::frontmatter::BlockingDependency;
512 use crate::spec_group::{extract_driver_id, extract_member_number};
513
514 let mut blockers = Vec::new();
515
516 if let Some(deps) = &self.frontmatter.depends_on {
517 for dep_id in deps {
518 let spec_path = specs_dir.join(format!("{}.md", dep_id));
519 let dep_spec = if spec_path.exists() {
520 Spec::load(&spec_path).ok()
521 } else {
522 None
523 };
524
525 let dep_spec =
526 dep_spec.or_else(|| all_specs.iter().find(|s| s.id == *dep_id).cloned());
527
528 if let Some(spec) = dep_spec {
529 if spec.frontmatter.status != SpecStatus::Completed {
531 blockers.push(BlockingDependency {
532 spec_id: spec.id.clone(),
533 title: spec.title.clone(),
534 status: spec.frontmatter.status.clone(),
535 completed_at: spec.frontmatter.completed_at.clone(),
536 is_sibling: false,
537 });
538 }
539 } else {
540 blockers.push(BlockingDependency {
541 spec_id: dep_id.clone(),
542 title: None,
543 status: SpecStatus::Pending,
544 completed_at: None,
545 is_sibling: false,
546 });
547 }
548 }
549 }
550
551 if let Some(driver_id) = extract_driver_id(&self.id) {
552 if let Some(member_num) = extract_member_number(&self.id) {
553 for i in 1..member_num {
554 let sibling_id = format!("{}.{}", driver_id, i);
555 let spec_path = specs_dir.join(format!("{}.md", sibling_id));
556 let sibling_spec = if spec_path.exists() {
557 Spec::load(&spec_path).ok()
558 } else {
559 None
560 };
561
562 let sibling_spec = sibling_spec
563 .or_else(|| all_specs.iter().find(|s| s.id == sibling_id).cloned());
564
565 if let Some(spec) = sibling_spec {
566 if spec.frontmatter.status != SpecStatus::Completed {
567 blockers.push(BlockingDependency {
568 spec_id: spec.id.clone(),
569 title: spec.title.clone(),
570 status: spec.frontmatter.status.clone(),
571 completed_at: spec.frontmatter.completed_at.clone(),
572 is_sibling: true,
573 });
574 }
575 } else {
576 blockers.push(BlockingDependency {
577 spec_id: sibling_id,
578 title: None,
579 status: SpecStatus::Pending,
580 completed_at: None,
581 is_sibling: true,
582 });
583 }
584 }
585 }
586 }
587
588 blockers
589 }
590
591 pub fn has_frontmatter_field(&self, field: &str) -> bool {
593 match field {
594 "type" => true,
595 "status" => true,
596 "depends_on" => self.frontmatter.depends_on.is_some(),
597 "labels" => self.frontmatter.labels.is_some(),
598 "target_files" => self.frontmatter.target_files.is_some(),
599 "context" => self.frontmatter.context.is_some(),
600 "prompt" => self.frontmatter.prompt.is_some(),
601 "branch" => self.frontmatter.branch.is_some(),
602 "commits" => self.frontmatter.commits.is_some(),
603 "completed_at" => self.frontmatter.completed_at.is_some(),
604 "model" => self.frontmatter.model.is_some(),
605 "tracks" => self.frontmatter.tracks.is_some(),
606 "informed_by" => self.frontmatter.informed_by.is_some(),
607 "origin" => self.frontmatter.origin.is_some(),
608 "schedule" => self.frontmatter.schedule.is_some(),
609 "source_branch" => self.frontmatter.source_branch.is_some(),
610 "target_branch" => self.frontmatter.target_branch.is_some(),
611 "conflicting_files" => self.frontmatter.conflicting_files.is_some(),
612 "blocked_specs" => self.frontmatter.blocked_specs.is_some(),
613 "original_spec" => self.frontmatter.original_spec.is_some(),
614 "last_verified" => self.frontmatter.last_verified.is_some(),
615 "verification_status" => self.frontmatter.verification_status.is_some(),
616 "verification_failures" => self.frontmatter.verification_failures.is_some(),
617 "replayed_at" => self.frontmatter.replayed_at.is_some(),
618 "replay_count" => self.frontmatter.replay_count.is_some(),
619 "original_completed_at" => self.frontmatter.original_completed_at.is_some(),
620 "approval" => self.frontmatter.approval.is_some(),
621 "members" => self.frontmatter.members.is_some(),
622 "output_schema" => self.frontmatter.output_schema.is_some(),
623 _ => false,
624 }
625 }
626
627 pub fn requires_approval(&self) -> bool {
629 use super::frontmatter::ApprovalStatus;
630
631 if let Some(ref approval) = self.frontmatter.approval {
632 approval.required && approval.status != ApprovalStatus::Approved
633 } else {
634 false
635 }
636 }
637
638 pub fn is_approved(&self) -> bool {
640 use super::frontmatter::ApprovalStatus;
641
642 if let Some(ref approval) = self.frontmatter.approval {
643 approval.status == ApprovalStatus::Approved
644 } else {
645 true
646 }
647 }
648
649 pub fn is_rejected(&self) -> bool {
651 use super::frontmatter::ApprovalStatus;
652
653 if let Some(ref approval) = self.frontmatter.approval {
654 approval.status == ApprovalStatus::Rejected
655 } else {
656 false
657 }
658 }
659}