1use anyhow::{Context, Result};
4use std::fs;
5use std::path::Path;
6use std::process::Command;
7
8use super::frontmatter::{SpecFrontmatter, SpecStatus};
9
10#[derive(Debug, Clone)]
11pub struct Spec {
12 pub id: String,
13 pub frontmatter: SpecFrontmatter,
14 pub title: Option<String>,
15 pub body: String,
16}
17
18fn normalize_model_name(model: &str) -> String {
21 let lower = model.to_lowercase();
22 if lower.contains("opus") {
23 "opus".to_string()
24 } else if lower.contains("sonnet") {
25 "sonnet".to_string()
26 } else if lower.contains("haiku") {
27 "haiku".to_string()
28 } else {
29 model.to_string()
30 }
31}
32
33pub fn split_frontmatter(content: &str) -> (Option<String>, &str) {
39 let content = content.trim();
40
41 if !content.starts_with("---") {
42 return (None, content);
43 }
44
45 let rest = &content[3..];
46 if let Some(end) = rest.find("---") {
47 let frontmatter = rest[..end].to_string();
48 let body = rest[end + 3..].trim_start();
49 (Some(frontmatter), body)
50 } else {
51 (None, content)
52 }
53}
54
55fn extract_title(body: &str) -> Option<String> {
56 for line in body.lines() {
57 let trimmed = line.trim();
58 if let Some(title) = trimmed.strip_prefix("# ") {
59 return Some(title.to_string());
60 }
61 }
62 None
63}
64
65fn branch_exists(branch: &str) -> Result<bool> {
66 let output = Command::new("git")
67 .args(["rev-parse", "--verify", branch])
68 .output()
69 .context("Failed to check if branch exists")?;
70
71 Ok(output.status.success())
72}
73
74fn read_spec_from_branch(spec_id: &str, branch: &str) -> Result<Spec> {
75 let spec_path = format!(".chant/specs/{}.md", spec_id);
76
77 let output = Command::new("git")
79 .args(["show", &format!("{}:{}", branch, spec_path)])
80 .output()
81 .context(format!("Failed to read spec from branch {}", branch))?;
82
83 if !output.status.success() {
84 let stderr = String::from_utf8_lossy(&output.stderr);
85 anyhow::bail!("git show failed: {}", stderr);
86 }
87
88 let content =
89 String::from_utf8(output.stdout).context("Failed to parse spec content as UTF-8")?;
90
91 Spec::parse(spec_id, &content)
92}
93
94impl Spec {
95 pub fn parse(id: &str, content: &str) -> Result<Self> {
97 let (frontmatter_str, body) = split_frontmatter(content);
98
99 let mut frontmatter: SpecFrontmatter = if let Some(fm) = frontmatter_str {
100 serde_yaml::from_str(&fm).context("Failed to parse spec frontmatter")?
101 } else {
102 SpecFrontmatter::default()
103 };
104
105 if let Some(model) = &frontmatter.model {
107 frontmatter.model = Some(normalize_model_name(model));
108 }
109
110 let title = extract_title(body);
112
113 Ok(Self {
114 id: id.to_string(),
115 frontmatter,
116 title,
117 body: body.to_string(),
118 })
119 }
120
121 pub fn load(path: &Path) -> Result<Self> {
123 let content = fs::read_to_string(path)
124 .with_context(|| format!("Failed to read spec from {}", path.display()))?;
125
126 let id = path
127 .file_stem()
128 .and_then(|s| s.to_str())
129 .ok_or_else(|| anyhow::anyhow!("Invalid spec filename"))?;
130
131 Self::parse(id, &content)
132 }
133
134 pub fn load_with_branch_resolution(spec_path: &Path) -> Result<Self> {
139 let spec = Self::load(spec_path)?;
140
141 if spec.frontmatter.status != SpecStatus::InProgress {
143 return Ok(spec);
144 }
145
146 let branch_name = spec
148 .frontmatter
149 .branch
150 .clone()
151 .unwrap_or_else(|| format!("chant/{}", spec.id));
152
153 if !branch_exists(&branch_name)? {
155 return Ok(spec);
156 }
157
158 match read_spec_from_branch(&spec.id, &branch_name) {
160 Ok(branch_spec) => Ok(branch_spec),
161 Err(_) => Ok(spec), }
163 }
164
165 pub fn save(&self, path: &Path) -> Result<()> {
167 let frontmatter = serde_yaml::to_string(&self.frontmatter)?;
168 let content = format!("---\n{}---\n{}", frontmatter, self.body);
169 fs::write(path, content)?;
170 Ok(())
171 }
172
173 pub fn count_unchecked_checkboxes(&self) -> usize {
177 let acceptance_criteria_marker = "## Acceptance Criteria";
178
179 let mut in_code_fence = false;
181 let mut last_ac_line: Option<usize> = None;
182
183 for (line_num, line) in self.body.lines().enumerate() {
184 let trimmed = line.trim_start();
185
186 if trimmed.starts_with("```") {
187 in_code_fence = !in_code_fence;
188 continue;
189 }
190
191 if !in_code_fence && trimmed.starts_with(acceptance_criteria_marker) {
192 last_ac_line = Some(line_num);
193 }
194 }
195
196 let Some(ac_start) = last_ac_line else {
197 return 0;
198 };
199
200 let mut in_code_fence = false;
202 let mut in_ac_section = false;
203 let mut count = 0;
204
205 for (line_num, line) in self.body.lines().enumerate() {
206 let trimmed = line.trim_start();
207
208 if trimmed.starts_with("```") {
209 in_code_fence = !in_code_fence;
210 continue;
211 }
212
213 if in_code_fence {
214 continue;
215 }
216
217 if line_num == ac_start {
219 in_ac_section = true;
220 continue;
221 }
222
223 if in_ac_section && trimmed.starts_with("## ") {
225 break;
226 }
227
228 if in_ac_section && line.contains("- [ ]") {
229 count += line.matches("- [ ]").count();
230 }
231 }
232
233 count
234 }
235
236 pub fn count_total_checkboxes(&self) -> usize {
239 let acceptance_criteria_marker = "## Acceptance Criteria";
240
241 let mut in_code_fence = false;
243 let mut last_ac_line: Option<usize> = None;
244
245 for (line_num, line) in self.body.lines().enumerate() {
246 let trimmed = line.trim_start();
247
248 if trimmed.starts_with("```") {
249 in_code_fence = !in_code_fence;
250 continue;
251 }
252
253 if !in_code_fence && trimmed.starts_with(acceptance_criteria_marker) {
254 last_ac_line = Some(line_num);
255 }
256 }
257
258 let Some(ac_start) = last_ac_line else {
259 return 0;
260 };
261
262 let mut in_code_fence = false;
264 let mut in_ac_section = false;
265 let mut count = 0;
266
267 for (line_num, line) in self.body.lines().enumerate() {
268 let trimmed = line.trim_start();
269
270 if trimmed.starts_with("```") {
271 in_code_fence = !in_code_fence;
272 continue;
273 }
274
275 if in_code_fence {
276 continue;
277 }
278
279 if line_num == ac_start {
280 in_ac_section = true;
281 continue;
282 }
283
284 if in_ac_section && trimmed.starts_with("## ") {
285 break;
286 }
287
288 if in_ac_section {
290 count += line.matches("- [ ]").count();
291 count += line.matches("- [x]").count();
292 count += line.matches("- [X]").count();
293 }
294 }
295
296 count
297 }
298
299 pub fn add_derived_fields(&mut self, fields: std::collections::HashMap<String, String>) {
302 let mut derived_field_names = Vec::new();
303
304 for (key, value) in fields {
305 derived_field_names.push(key.clone());
307
308 match key.as_str() {
310 "labels" => {
311 let label_vec = value.split(',').map(|s| s.trim().to_string()).collect();
312 self.frontmatter.labels = Some(label_vec);
313 }
314 "context" => {
315 let context_vec = value.split(',').map(|s| s.trim().to_string()).collect();
316 self.frontmatter.context = Some(context_vec);
317 }
318 _ => {
319 if self.frontmatter.context.is_none() {
320 self.frontmatter.context = Some(vec![]);
321 }
322 if let Some(ref mut ctx) = self.frontmatter.context {
323 ctx.push(format!("derived_{}={}", key, value));
324 }
325 }
326 }
327 }
328
329 if !derived_field_names.is_empty() {
331 self.frontmatter.derived_fields = Some(derived_field_names);
332 }
333 }
334
335 pub fn has_acceptance_criteria(&self) -> bool {
339 let acceptance_criteria_marker = "## Acceptance Criteria";
340 let mut in_ac_section = false;
341 let mut in_code_fence = false;
342
343 for line in self.body.lines() {
344 let trimmed = line.trim_start();
345
346 if trimmed.starts_with("```") {
347 in_code_fence = !in_code_fence;
348 }
349
350 if !in_code_fence && trimmed.starts_with(acceptance_criteria_marker) {
351 in_ac_section = true;
352 continue;
353 }
354
355 if in_ac_section && trimmed.starts_with("## ") {
356 break;
357 }
358
359 if in_ac_section
360 && (trimmed.starts_with("- [ ] ")
361 || trimmed.starts_with("- [x] ")
362 || trimmed.starts_with("- [X] "))
363 {
364 return true;
365 }
366 }
367
368 false
369 }
370
371 pub fn is_blocked(&self, all_specs: &[Spec]) -> bool {
373 if let Some(deps) = &self.frontmatter.depends_on {
374 for dep_id in deps {
375 let dep = all_specs.iter().find(|s| s.id == *dep_id);
376 match dep {
377 Some(d) if d.frontmatter.status == SpecStatus::Completed => continue,
378 _ => return true,
379 }
380 }
381 }
382
383 if self.frontmatter.status == SpecStatus::Pending && self.requires_approval() {
384 return true;
385 }
386
387 false
388 }
389
390 pub fn is_ready(&self, all_specs: &[Spec]) -> bool {
392 use crate::spec_group::{all_prior_siblings_completed, is_member_of};
393
394 if self.frontmatter.status != SpecStatus::Pending {
395 return false;
396 }
397
398 if self.is_blocked(all_specs) {
399 return false;
400 }
401
402 if !all_prior_siblings_completed(&self.id, all_specs) {
403 return false;
404 }
405
406 let members: Vec<_> = all_specs
407 .iter()
408 .filter(|s| is_member_of(&s.id, &self.id))
409 .collect();
410
411 if !members.is_empty() && self.has_acceptance_criteria() {
412 for member in members {
413 if member.frontmatter.status != SpecStatus::Completed {
414 return false;
415 }
416 }
417 }
418
419 true
420 }
421
422 pub fn get_blocking_dependencies(
424 &self,
425 all_specs: &[Spec],
426 specs_dir: &Path,
427 ) -> Vec<super::frontmatter::BlockingDependency> {
428 use super::frontmatter::BlockingDependency;
429 use crate::spec_group::{extract_driver_id, extract_member_number};
430
431 let mut blockers = Vec::new();
432
433 if let Some(deps) = &self.frontmatter.depends_on {
434 for dep_id in deps {
435 let spec_path = specs_dir.join(format!("{}.md", dep_id));
436 let dep_spec = if spec_path.exists() {
437 Spec::load(&spec_path).ok()
438 } else {
439 None
440 };
441
442 let dep_spec =
443 dep_spec.or_else(|| all_specs.iter().find(|s| s.id == *dep_id).cloned());
444
445 if let Some(spec) = dep_spec {
446 if spec.frontmatter.status != SpecStatus::Completed {
448 blockers.push(BlockingDependency {
449 spec_id: spec.id.clone(),
450 title: spec.title.clone(),
451 status: spec.frontmatter.status.clone(),
452 completed_at: spec.frontmatter.completed_at.clone(),
453 is_sibling: false,
454 });
455 }
456 } else {
457 blockers.push(BlockingDependency {
458 spec_id: dep_id.clone(),
459 title: None,
460 status: SpecStatus::Pending,
461 completed_at: None,
462 is_sibling: false,
463 });
464 }
465 }
466 }
467
468 if let Some(driver_id) = extract_driver_id(&self.id) {
469 if let Some(member_num) = extract_member_number(&self.id) {
470 for i in 1..member_num {
471 let sibling_id = format!("{}.{}", driver_id, i);
472 let spec_path = specs_dir.join(format!("{}.md", sibling_id));
473 let sibling_spec = if spec_path.exists() {
474 Spec::load(&spec_path).ok()
475 } else {
476 None
477 };
478
479 let sibling_spec = sibling_spec
480 .or_else(|| all_specs.iter().find(|s| s.id == sibling_id).cloned());
481
482 if let Some(spec) = sibling_spec {
483 if spec.frontmatter.status != SpecStatus::Completed {
484 blockers.push(BlockingDependency {
485 spec_id: spec.id.clone(),
486 title: spec.title.clone(),
487 status: spec.frontmatter.status.clone(),
488 completed_at: spec.frontmatter.completed_at.clone(),
489 is_sibling: true,
490 });
491 }
492 } else {
493 blockers.push(BlockingDependency {
494 spec_id: sibling_id,
495 title: None,
496 status: SpecStatus::Pending,
497 completed_at: None,
498 is_sibling: true,
499 });
500 }
501 }
502 }
503 }
504
505 blockers
506 }
507
508 pub fn has_frontmatter_field(&self, field: &str) -> bool {
510 match field {
511 "type" => true,
512 "status" => true,
513 "depends_on" => self.frontmatter.depends_on.is_some(),
514 "labels" => self.frontmatter.labels.is_some(),
515 "target_files" => self.frontmatter.target_files.is_some(),
516 "context" => self.frontmatter.context.is_some(),
517 "prompt" => self.frontmatter.prompt.is_some(),
518 "branch" => self.frontmatter.branch.is_some(),
519 "commits" => self.frontmatter.commits.is_some(),
520 "completed_at" => self.frontmatter.completed_at.is_some(),
521 "model" => self.frontmatter.model.is_some(),
522 "tracks" => self.frontmatter.tracks.is_some(),
523 "informed_by" => self.frontmatter.informed_by.is_some(),
524 "origin" => self.frontmatter.origin.is_some(),
525 "schedule" => self.frontmatter.schedule.is_some(),
526 "source_branch" => self.frontmatter.source_branch.is_some(),
527 "target_branch" => self.frontmatter.target_branch.is_some(),
528 "conflicting_files" => self.frontmatter.conflicting_files.is_some(),
529 "blocked_specs" => self.frontmatter.blocked_specs.is_some(),
530 "original_spec" => self.frontmatter.original_spec.is_some(),
531 "last_verified" => self.frontmatter.last_verified.is_some(),
532 "verification_status" => self.frontmatter.verification_status.is_some(),
533 "verification_failures" => self.frontmatter.verification_failures.is_some(),
534 "replayed_at" => self.frontmatter.replayed_at.is_some(),
535 "replay_count" => self.frontmatter.replay_count.is_some(),
536 "original_completed_at" => self.frontmatter.original_completed_at.is_some(),
537 "approval" => self.frontmatter.approval.is_some(),
538 "members" => self.frontmatter.members.is_some(),
539 "output_schema" => self.frontmatter.output_schema.is_some(),
540 _ => false,
541 }
542 }
543
544 pub fn requires_approval(&self) -> bool {
546 use super::frontmatter::ApprovalStatus;
547
548 if let Some(ref approval) = self.frontmatter.approval {
549 approval.required && approval.status != ApprovalStatus::Approved
550 } else {
551 false
552 }
553 }
554
555 pub fn is_approved(&self) -> bool {
557 use super::frontmatter::ApprovalStatus;
558
559 if let Some(ref approval) = self.frontmatter.approval {
560 approval.status == ApprovalStatus::Approved
561 } else {
562 true
563 }
564 }
565
566 pub fn is_rejected(&self) -> bool {
568 use super::frontmatter::ApprovalStatus;
569
570 if let Some(ref approval) = self.frontmatter.approval {
571 approval.status == ApprovalStatus::Rejected
572 } else {
573 false
574 }
575 }
576}