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