Skip to main content

chant/spec/
parse.rs

1//! Spec parsing functions.
2
3use 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
19/// Split content into frontmatter and body.
20///
21/// If the content starts with `---`, extracts the YAML frontmatter between
22/// the first and second `---` delimiters, and returns the body after.
23/// Otherwise returns None for frontmatter and the entire content as body.
24pub 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    // Read spec content from branch
64    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    /// Set the spec status using validated transitions.
82    /// This method validates the transition through the state machine.
83    ///
84    /// INTERNAL USE ONLY: Use transition helpers in state_machine.rs from cmd/ modules.
85    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    /// Parse a spec from file content.
93    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        // Normalize model name if present
103        if let Some(model) = &frontmatter.model {
104            frontmatter.model = Some(normalize_model_name(model));
105        }
106
107        // Extract title from first heading
108        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    /// Load a spec from a file path.
119    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    /// Load a spec, optionally resolving from its working branch.
132    ///
133    /// If the spec is in_progress and has a branch (frontmatter.branch or chant/{id}),
134    /// attempt to read the spec content from that branch for live progress.
135    pub fn load_with_branch_resolution(spec_path: &Path) -> Result<Self> {
136        let spec = Self::load(spec_path)?;
137
138        // Only resolve for in_progress specs
139        if spec.frontmatter.status != SpecStatus::InProgress {
140            return Ok(spec);
141        }
142
143        // Try to find the working branch
144        let branch_name = spec
145            .frontmatter
146            .branch
147            .clone()
148            .unwrap_or_else(|| format!("chant/{}", spec.id));
149
150        // Check if branch exists
151        if !branch_exists(&branch_name)? {
152            return Ok(spec);
153        }
154
155        // Read spec from branch
156        match read_spec_from_branch(&spec.id, &branch_name) {
157            Ok(branch_spec) => Ok(branch_spec),
158            Err(_) => Ok(spec), // Fall back to main version
159        }
160    }
161
162    /// Save the spec to a file.
163    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    /// Count unchecked checkboxes (`- [ ]`) in the Acceptance Criteria section only.
173    /// Returns the count of unchecked items in that section, skipping code fences.
174    /// Uses the LAST `## Acceptance Criteria` heading outside code fences.
175    pub fn count_unchecked_checkboxes(&self) -> usize {
176        let acceptance_criteria_marker = "## Acceptance Criteria";
177
178        // First pass: find the line number of the LAST AC heading outside code fences
179        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        // Second pass: count checkboxes from the AC section until next ## heading
200        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            // Start counting at the last AC heading we found
217            if line_num == ac_start {
218                in_ac_section = true;
219                continue;
220            }
221
222            // Stop at the next ## heading after our AC section
223            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    /// Count total checkboxes (both checked and unchecked) in the Acceptance Criteria section.
236    /// Used to assess spec complexity.
237    pub fn count_total_checkboxes(&self) -> usize {
238        let acceptance_criteria_marker = "## Acceptance Criteria";
239
240        // First pass: find the line number of the LAST AC heading outside code fences
241        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        // Second pass: count all checkboxes from the AC section until next ## heading
262        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            // Count both unchecked and checked checkboxes
288            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    /// Add derived fields to the spec's frontmatter.
299    /// Updates the frontmatter with the provided derived fields.
300    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            // Track which fields were derived
305            derived_field_names.push(key.clone());
306
307            // Handle specific known derived fields that map to frontmatter
308            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        // Update the derived_fields tracking
329        if !derived_field_names.is_empty() {
330            self.frontmatter.derived_fields = Some(derived_field_names);
331        }
332    }
333
334    /// Auto-check all acceptance criteria checkboxes in the spec body.
335    /// Replaces all `- [ ]` with `- [x]` in the Acceptance Criteria section.
336    /// Returns true if any checkboxes were checked, false otherwise.
337    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        // Find the last AC heading outside code fences
343        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        // Replace unchecked boxes in the AC section
359        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    /// Check if this spec has acceptance criteria.
397    /// Returns true if the spec body contains an "## Acceptance Criteria" section
398    /// with at least one checkbox item.
399    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    /// Check if this spec has unmet dependencies or approval requirements that would block it.
433    pub fn is_blocked(&self, all_specs: &[Spec]) -> bool {
434        // Check the spec's own dependencies
435        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 this is a member spec, also check the driver's dependencies
447        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    /// Check if this spec is ready to execute.
470    pub fn is_ready(&self, all_specs: &[Spec]) -> bool {
471        use crate::spec_group::{all_prior_siblings_completed, is_member_of};
472
473        // Allow both Pending and Failed specs to be considered "ready"
474        // Failed specs can be retried if their dependencies are met
475        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    /// Get the list of dependencies that are blocking this spec.
506    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                    // Only add if not completed
530                    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    /// Check if the spec's frontmatter contains a specific field.
592    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    /// Check if this spec requires approval before work can begin.
628    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    /// Check if this spec has been approved.
639    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    /// Check if this spec has been rejected.
650    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}