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    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    /// Parse a spec from file content.
91    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        // Normalize model name if present
101        if let Some(model) = &frontmatter.model {
102            frontmatter.model = Some(normalize_model_name(model));
103        }
104
105        // Extract title from first heading
106        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    /// Load a spec from a file path.
117    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    /// Load a spec, optionally resolving from its working branch.
130    ///
131    /// If the spec is in_progress and has a branch (frontmatter.branch or chant/{id}),
132    /// attempt to read the spec content from that branch for live progress.
133    pub fn load_with_branch_resolution(spec_path: &Path) -> Result<Self> {
134        let spec = Self::load(spec_path)?;
135
136        // Only resolve for in_progress specs
137        if spec.frontmatter.status != SpecStatus::InProgress {
138            return Ok(spec);
139        }
140
141        // Try to find the working branch
142        let branch_name = spec
143            .frontmatter
144            .branch
145            .clone()
146            .unwrap_or_else(|| format!("chant/{}", spec.id));
147
148        // Check if branch exists
149        if !branch_exists(&branch_name)? {
150            return Ok(spec);
151        }
152
153        // Read spec from branch
154        match read_spec_from_branch(&spec.id, &branch_name) {
155            Ok(branch_spec) => Ok(branch_spec),
156            Err(_) => Ok(spec), // Fall back to main version
157        }
158    }
159
160    /// Save the spec to a file.
161    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    /// Count unchecked checkboxes (`- [ ]`) in the Acceptance Criteria section only.
171    /// Returns the count of unchecked items in that section, skipping code fences.
172    /// Uses the LAST `## Acceptance Criteria` heading outside code fences.
173    pub fn count_unchecked_checkboxes(&self) -> usize {
174        let acceptance_criteria_marker = "## Acceptance Criteria";
175
176        // First pass: find the line number of the LAST AC heading outside code fences
177        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        // Second pass: count checkboxes from the AC section until next ## heading
198        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            // Start counting at the last AC heading we found
215            if line_num == ac_start {
216                in_ac_section = true;
217                continue;
218            }
219
220            // Stop at the next ## heading after our AC section
221            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    /// Count total checkboxes (both checked and unchecked) in the Acceptance Criteria section.
234    /// Used to assess spec complexity.
235    pub fn count_total_checkboxes(&self) -> usize {
236        let acceptance_criteria_marker = "## Acceptance Criteria";
237
238        // First pass: find the line number of the LAST AC heading outside code fences
239        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        // Second pass: count all checkboxes from the AC section until next ## heading
260        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            // Count both unchecked and checked checkboxes
286            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    /// Add derived fields to the spec's frontmatter.
297    /// Updates the frontmatter with the provided derived fields.
298    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            // Track which fields were derived
303            derived_field_names.push(key.clone());
304
305            // Handle specific known derived fields that map to frontmatter
306            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        // Update the derived_fields tracking
327        if !derived_field_names.is_empty() {
328            self.frontmatter.derived_fields = Some(derived_field_names);
329        }
330    }
331
332    /// Auto-check all acceptance criteria checkboxes in the spec body.
333    /// Replaces all `- [ ]` with `- [x]` in the Acceptance Criteria section.
334    /// Returns true if any checkboxes were checked, false otherwise.
335    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        // Find the last AC heading outside code fences
341        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        // Replace unchecked boxes in the AC section
357        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    /// Check if this spec has acceptance criteria.
395    /// Returns true if the spec body contains an "## Acceptance Criteria" section
396    /// with at least one checkbox item.
397    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    /// Check if this spec has unmet dependencies or approval requirements that would block it.
431    pub fn is_blocked(&self, all_specs: &[Spec]) -> bool {
432        // Check the spec's own dependencies
433        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 this is a member spec, also check the driver's dependencies
444        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    /// Check if this spec is ready to execute.
466    pub fn is_ready(&self, all_specs: &[Spec]) -> bool {
467        use crate::spec_group::{all_prior_siblings_completed, is_member_of};
468
469        // Allow both Pending and Failed specs to be considered "ready"
470        // Failed specs can be retried if their dependencies are met
471        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    /// Get the list of dependencies that are blocking this spec.
502    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                    // Only add if not completed
526                    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    /// Check if the spec's frontmatter contains a specific field.
588    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    /// Check if this spec requires approval before work can begin.
624    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    /// Check if this spec has been approved.
635    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    /// Check if this spec has been rejected.
646    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}