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 {
433 for dep_id in deps {
434 let dep = all_specs.iter().find(|s| s.id == *dep_id);
435 match dep {
436 Some(d) if d.frontmatter.status == SpecStatus::Completed => continue,
437 _ => return true,
438 }
439 }
440 }
441
442 if self.frontmatter.status == SpecStatus::Pending && self.requires_approval() {
443 return true;
444 }
445
446 false
447 }
448
449 pub fn is_ready(&self, all_specs: &[Spec]) -> bool {
451 use crate::spec_group::{all_prior_siblings_completed, is_member_of};
452
453 if self.frontmatter.status != SpecStatus::Pending {
454 return false;
455 }
456
457 if self.is_blocked(all_specs) {
458 return false;
459 }
460
461 if !all_prior_siblings_completed(&self.id, all_specs) {
462 return false;
463 }
464
465 let members: Vec<_> = all_specs
466 .iter()
467 .filter(|s| is_member_of(&s.id, &self.id))
468 .collect();
469
470 if !members.is_empty() && self.has_acceptance_criteria() {
471 for member in members {
472 if member.frontmatter.status != SpecStatus::Completed {
473 return false;
474 }
475 }
476 }
477
478 true
479 }
480
481 pub fn get_blocking_dependencies(
483 &self,
484 all_specs: &[Spec],
485 specs_dir: &Path,
486 ) -> Vec<super::frontmatter::BlockingDependency> {
487 use super::frontmatter::BlockingDependency;
488 use crate::spec_group::{extract_driver_id, extract_member_number};
489
490 let mut blockers = Vec::new();
491
492 if let Some(deps) = &self.frontmatter.depends_on {
493 for dep_id in deps {
494 let spec_path = specs_dir.join(format!("{}.md", dep_id));
495 let dep_spec = if spec_path.exists() {
496 Spec::load(&spec_path).ok()
497 } else {
498 None
499 };
500
501 let dep_spec =
502 dep_spec.or_else(|| all_specs.iter().find(|s| s.id == *dep_id).cloned());
503
504 if let Some(spec) = dep_spec {
505 if spec.frontmatter.status != SpecStatus::Completed {
507 blockers.push(BlockingDependency {
508 spec_id: spec.id.clone(),
509 title: spec.title.clone(),
510 status: spec.frontmatter.status.clone(),
511 completed_at: spec.frontmatter.completed_at.clone(),
512 is_sibling: false,
513 });
514 }
515 } else {
516 blockers.push(BlockingDependency {
517 spec_id: dep_id.clone(),
518 title: None,
519 status: SpecStatus::Pending,
520 completed_at: None,
521 is_sibling: false,
522 });
523 }
524 }
525 }
526
527 if let Some(driver_id) = extract_driver_id(&self.id) {
528 if let Some(member_num) = extract_member_number(&self.id) {
529 for i in 1..member_num {
530 let sibling_id = format!("{}.{}", driver_id, i);
531 let spec_path = specs_dir.join(format!("{}.md", sibling_id));
532 let sibling_spec = if spec_path.exists() {
533 Spec::load(&spec_path).ok()
534 } else {
535 None
536 };
537
538 let sibling_spec = sibling_spec
539 .or_else(|| all_specs.iter().find(|s| s.id == sibling_id).cloned());
540
541 if let Some(spec) = sibling_spec {
542 if spec.frontmatter.status != SpecStatus::Completed {
543 blockers.push(BlockingDependency {
544 spec_id: spec.id.clone(),
545 title: spec.title.clone(),
546 status: spec.frontmatter.status.clone(),
547 completed_at: spec.frontmatter.completed_at.clone(),
548 is_sibling: true,
549 });
550 }
551 } else {
552 blockers.push(BlockingDependency {
553 spec_id: sibling_id,
554 title: None,
555 status: SpecStatus::Pending,
556 completed_at: None,
557 is_sibling: true,
558 });
559 }
560 }
561 }
562 }
563
564 blockers
565 }
566
567 pub fn has_frontmatter_field(&self, field: &str) -> bool {
569 match field {
570 "type" => true,
571 "status" => true,
572 "depends_on" => self.frontmatter.depends_on.is_some(),
573 "labels" => self.frontmatter.labels.is_some(),
574 "target_files" => self.frontmatter.target_files.is_some(),
575 "context" => self.frontmatter.context.is_some(),
576 "prompt" => self.frontmatter.prompt.is_some(),
577 "branch" => self.frontmatter.branch.is_some(),
578 "commits" => self.frontmatter.commits.is_some(),
579 "completed_at" => self.frontmatter.completed_at.is_some(),
580 "model" => self.frontmatter.model.is_some(),
581 "tracks" => self.frontmatter.tracks.is_some(),
582 "informed_by" => self.frontmatter.informed_by.is_some(),
583 "origin" => self.frontmatter.origin.is_some(),
584 "schedule" => self.frontmatter.schedule.is_some(),
585 "source_branch" => self.frontmatter.source_branch.is_some(),
586 "target_branch" => self.frontmatter.target_branch.is_some(),
587 "conflicting_files" => self.frontmatter.conflicting_files.is_some(),
588 "blocked_specs" => self.frontmatter.blocked_specs.is_some(),
589 "original_spec" => self.frontmatter.original_spec.is_some(),
590 "last_verified" => self.frontmatter.last_verified.is_some(),
591 "verification_status" => self.frontmatter.verification_status.is_some(),
592 "verification_failures" => self.frontmatter.verification_failures.is_some(),
593 "replayed_at" => self.frontmatter.replayed_at.is_some(),
594 "replay_count" => self.frontmatter.replay_count.is_some(),
595 "original_completed_at" => self.frontmatter.original_completed_at.is_some(),
596 "approval" => self.frontmatter.approval.is_some(),
597 "members" => self.frontmatter.members.is_some(),
598 "output_schema" => self.frontmatter.output_schema.is_some(),
599 _ => false,
600 }
601 }
602
603 pub fn requires_approval(&self) -> bool {
605 use super::frontmatter::ApprovalStatus;
606
607 if let Some(ref approval) = self.frontmatter.approval {
608 approval.required && approval.status != ApprovalStatus::Approved
609 } else {
610 false
611 }
612 }
613
614 pub fn is_approved(&self) -> bool {
616 use super::frontmatter::ApprovalStatus;
617
618 if let Some(ref approval) = self.frontmatter.approval {
619 approval.status == ApprovalStatus::Approved
620 } else {
621 true
622 }
623 }
624
625 pub fn is_rejected(&self) -> bool {
627 use super::frontmatter::ApprovalStatus;
628
629 if let Some(ref approval) = self.frontmatter.approval {
630 approval.status == ApprovalStatus::Rejected
631 } else {
632 false
633 }
634 }
635}