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 auto_check_acceptance_criteria(&mut self) -> bool {
325 let acceptance_criteria_marker = "## Acceptance Criteria";
326 let mut in_code_fence = false;
327 let mut last_ac_line: Option<usize> = None;
328
329 for (line_num, line) in self.body.lines().enumerate() {
331 let trimmed = line.trim_start();
332 if trimmed.starts_with("```") {
333 in_code_fence = !in_code_fence;
334 continue;
335 }
336 if !in_code_fence && trimmed.starts_with(acceptance_criteria_marker) {
337 last_ac_line = Some(line_num);
338 }
339 }
340
341 let Some(ac_start) = last_ac_line else {
342 return false;
343 };
344
345 let mut in_code_fence = false;
347 let mut in_ac_section = false;
348 let mut modified = false;
349 let mut new_body = String::new();
350
351 for (line_num, line) in self.body.lines().enumerate() {
352 let trimmed = line.trim_start();
353
354 if trimmed.starts_with("```") {
355 in_code_fence = !in_code_fence;
356 }
357
358 if line_num == ac_start {
359 in_ac_section = true;
360 }
361
362 if in_ac_section && !in_code_fence && trimmed.starts_with("## ") && line_num != ac_start
363 {
364 in_ac_section = false;
365 }
366
367 if in_ac_section && !in_code_fence && line.contains("- [ ]") {
368 new_body.push_str(&line.replace("- [ ]", "- [x]"));
369 modified = true;
370 } else {
371 new_body.push_str(line);
372 }
373 new_body.push('\n');
374 }
375
376 if modified {
377 self.body = new_body.trim_end().to_string();
378 }
379
380 modified
381 }
382
383 pub fn has_acceptance_criteria(&self) -> bool {
387 let acceptance_criteria_marker = "## Acceptance Criteria";
388 let mut in_ac_section = false;
389 let mut in_code_fence = false;
390
391 for line in self.body.lines() {
392 let trimmed = line.trim_start();
393
394 if trimmed.starts_with("```") {
395 in_code_fence = !in_code_fence;
396 }
397
398 if !in_code_fence && trimmed.starts_with(acceptance_criteria_marker) {
399 in_ac_section = true;
400 continue;
401 }
402
403 if in_ac_section && trimmed.starts_with("## ") {
404 break;
405 }
406
407 if in_ac_section
408 && (trimmed.starts_with("- [ ] ")
409 || trimmed.starts_with("- [x] ")
410 || trimmed.starts_with("- [X] "))
411 {
412 return true;
413 }
414 }
415
416 false
417 }
418
419 pub fn is_blocked(&self, all_specs: &[Spec]) -> bool {
421 if let Some(deps) = &self.frontmatter.depends_on {
422 for dep_id in deps {
423 let dep = all_specs.iter().find(|s| s.id == *dep_id);
424 match dep {
425 Some(d) if d.frontmatter.status == SpecStatus::Completed => continue,
426 _ => return true,
427 }
428 }
429 }
430
431 if self.frontmatter.status == SpecStatus::Pending && self.requires_approval() {
432 return true;
433 }
434
435 false
436 }
437
438 pub fn is_ready(&self, all_specs: &[Spec]) -> bool {
440 use crate::spec_group::{all_prior_siblings_completed, is_member_of};
441
442 if self.frontmatter.status != SpecStatus::Pending {
443 return false;
444 }
445
446 if self.is_blocked(all_specs) {
447 return false;
448 }
449
450 if !all_prior_siblings_completed(&self.id, all_specs) {
451 return false;
452 }
453
454 let members: Vec<_> = all_specs
455 .iter()
456 .filter(|s| is_member_of(&s.id, &self.id))
457 .collect();
458
459 if !members.is_empty() && self.has_acceptance_criteria() {
460 for member in members {
461 if member.frontmatter.status != SpecStatus::Completed {
462 return false;
463 }
464 }
465 }
466
467 true
468 }
469
470 pub fn get_blocking_dependencies(
472 &self,
473 all_specs: &[Spec],
474 specs_dir: &Path,
475 ) -> Vec<super::frontmatter::BlockingDependency> {
476 use super::frontmatter::BlockingDependency;
477 use crate::spec_group::{extract_driver_id, extract_member_number};
478
479 let mut blockers = Vec::new();
480
481 if let Some(deps) = &self.frontmatter.depends_on {
482 for dep_id in deps {
483 let spec_path = specs_dir.join(format!("{}.md", dep_id));
484 let dep_spec = if spec_path.exists() {
485 Spec::load(&spec_path).ok()
486 } else {
487 None
488 };
489
490 let dep_spec =
491 dep_spec.or_else(|| all_specs.iter().find(|s| s.id == *dep_id).cloned());
492
493 if let Some(spec) = dep_spec {
494 if spec.frontmatter.status != SpecStatus::Completed {
496 blockers.push(BlockingDependency {
497 spec_id: spec.id.clone(),
498 title: spec.title.clone(),
499 status: spec.frontmatter.status.clone(),
500 completed_at: spec.frontmatter.completed_at.clone(),
501 is_sibling: false,
502 });
503 }
504 } else {
505 blockers.push(BlockingDependency {
506 spec_id: dep_id.clone(),
507 title: None,
508 status: SpecStatus::Pending,
509 completed_at: None,
510 is_sibling: false,
511 });
512 }
513 }
514 }
515
516 if let Some(driver_id) = extract_driver_id(&self.id) {
517 if let Some(member_num) = extract_member_number(&self.id) {
518 for i in 1..member_num {
519 let sibling_id = format!("{}.{}", driver_id, i);
520 let spec_path = specs_dir.join(format!("{}.md", sibling_id));
521 let sibling_spec = if spec_path.exists() {
522 Spec::load(&spec_path).ok()
523 } else {
524 None
525 };
526
527 let sibling_spec = sibling_spec
528 .or_else(|| all_specs.iter().find(|s| s.id == sibling_id).cloned());
529
530 if let Some(spec) = sibling_spec {
531 if spec.frontmatter.status != SpecStatus::Completed {
532 blockers.push(BlockingDependency {
533 spec_id: spec.id.clone(),
534 title: spec.title.clone(),
535 status: spec.frontmatter.status.clone(),
536 completed_at: spec.frontmatter.completed_at.clone(),
537 is_sibling: true,
538 });
539 }
540 } else {
541 blockers.push(BlockingDependency {
542 spec_id: sibling_id,
543 title: None,
544 status: SpecStatus::Pending,
545 completed_at: None,
546 is_sibling: true,
547 });
548 }
549 }
550 }
551 }
552
553 blockers
554 }
555
556 pub fn has_frontmatter_field(&self, field: &str) -> bool {
558 match field {
559 "type" => true,
560 "status" => true,
561 "depends_on" => self.frontmatter.depends_on.is_some(),
562 "labels" => self.frontmatter.labels.is_some(),
563 "target_files" => self.frontmatter.target_files.is_some(),
564 "context" => self.frontmatter.context.is_some(),
565 "prompt" => self.frontmatter.prompt.is_some(),
566 "branch" => self.frontmatter.branch.is_some(),
567 "commits" => self.frontmatter.commits.is_some(),
568 "completed_at" => self.frontmatter.completed_at.is_some(),
569 "model" => self.frontmatter.model.is_some(),
570 "tracks" => self.frontmatter.tracks.is_some(),
571 "informed_by" => self.frontmatter.informed_by.is_some(),
572 "origin" => self.frontmatter.origin.is_some(),
573 "schedule" => self.frontmatter.schedule.is_some(),
574 "source_branch" => self.frontmatter.source_branch.is_some(),
575 "target_branch" => self.frontmatter.target_branch.is_some(),
576 "conflicting_files" => self.frontmatter.conflicting_files.is_some(),
577 "blocked_specs" => self.frontmatter.blocked_specs.is_some(),
578 "original_spec" => self.frontmatter.original_spec.is_some(),
579 "last_verified" => self.frontmatter.last_verified.is_some(),
580 "verification_status" => self.frontmatter.verification_status.is_some(),
581 "verification_failures" => self.frontmatter.verification_failures.is_some(),
582 "replayed_at" => self.frontmatter.replayed_at.is_some(),
583 "replay_count" => self.frontmatter.replay_count.is_some(),
584 "original_completed_at" => self.frontmatter.original_completed_at.is_some(),
585 "approval" => self.frontmatter.approval.is_some(),
586 "members" => self.frontmatter.members.is_some(),
587 "output_schema" => self.frontmatter.output_schema.is_some(),
588 _ => false,
589 }
590 }
591
592 pub fn requires_approval(&self) -> bool {
594 use super::frontmatter::ApprovalStatus;
595
596 if let Some(ref approval) = self.frontmatter.approval {
597 approval.required && approval.status != ApprovalStatus::Approved
598 } else {
599 false
600 }
601 }
602
603 pub fn is_approved(&self) -> bool {
605 use super::frontmatter::ApprovalStatus;
606
607 if let Some(ref approval) = self.frontmatter.approval {
608 approval.status == ApprovalStatus::Approved
609 } else {
610 true
611 }
612 }
613
614 pub fn is_rejected(&self) -> bool {
616 use super::frontmatter::ApprovalStatus;
617
618 if let Some(ref approval) = self.frontmatter.approval {
619 approval.status == ApprovalStatus::Rejected
620 } else {
621 false
622 }
623 }
624}