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};
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
18/// Normalize model names from full Claude model IDs to short names.
19/// Examples: "claude-sonnet-4-20250514" -> "sonnet", "claude-opus-4-5" -> "opus"
20fn 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
33/// Split content into frontmatter and body.
34///
35/// If the content starts with `---`, extracts the YAML frontmatter between
36/// the first and second `---` delimiters, and returns the body after.
37/// Otherwise returns None for frontmatter and the entire content as body.
38pub 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    // Read spec content from branch
78    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    /// Parse a spec from file content.
96    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        // Normalize model name if present
106        if let Some(model) = &frontmatter.model {
107            frontmatter.model = Some(normalize_model_name(model));
108        }
109
110        // Extract title from first heading
111        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    /// Load a spec from a file path.
122    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    /// Load a spec, optionally resolving from its working branch.
135    ///
136    /// If the spec is in_progress and has a branch (frontmatter.branch or chant/{id}),
137    /// attempt to read the spec content from that branch for live progress.
138    pub fn load_with_branch_resolution(spec_path: &Path) -> Result<Self> {
139        let spec = Self::load(spec_path)?;
140
141        // Only resolve for in_progress specs
142        if spec.frontmatter.status != SpecStatus::InProgress {
143            return Ok(spec);
144        }
145
146        // Try to find the working branch
147        let branch_name = spec
148            .frontmatter
149            .branch
150            .clone()
151            .unwrap_or_else(|| format!("chant/{}", spec.id));
152
153        // Check if branch exists
154        if !branch_exists(&branch_name)? {
155            return Ok(spec);
156        }
157
158        // Read spec from branch
159        match read_spec_from_branch(&spec.id, &branch_name) {
160            Ok(branch_spec) => Ok(branch_spec),
161            Err(_) => Ok(spec), // Fall back to main version
162        }
163    }
164
165    /// Save the spec to a file.
166    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    /// Count unchecked checkboxes (`- [ ]`) in the Acceptance Criteria section only.
174    /// Returns the count of unchecked items in that section, skipping code fences.
175    /// Uses the LAST `## Acceptance Criteria` heading outside code fences.
176    pub fn count_unchecked_checkboxes(&self) -> usize {
177        let acceptance_criteria_marker = "## Acceptance Criteria";
178
179        // First pass: find the line number of the LAST AC heading outside code fences
180        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        // Second pass: count checkboxes from the AC section until next ## heading
201        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            // Start counting at the last AC heading we found
218            if line_num == ac_start {
219                in_ac_section = true;
220                continue;
221            }
222
223            // Stop at the next ## heading after our AC section
224            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    /// Count total checkboxes (both checked and unchecked) in the Acceptance Criteria section.
237    /// Used to assess spec complexity.
238    pub fn count_total_checkboxes(&self) -> usize {
239        let acceptance_criteria_marker = "## Acceptance Criteria";
240
241        // First pass: find the line number of the LAST AC heading outside code fences
242        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        // Second pass: count all checkboxes from the AC section until next ## heading
263        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            // Count both unchecked and checked checkboxes
289            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    /// Add derived fields to the spec's frontmatter.
300    /// Updates the frontmatter with the provided derived fields.
301    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            // Track which fields were derived
306            derived_field_names.push(key.clone());
307
308            // Handle specific known derived fields that map to frontmatter
309            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        // Update the derived_fields tracking
330        if !derived_field_names.is_empty() {
331            self.frontmatter.derived_fields = Some(derived_field_names);
332        }
333    }
334
335    /// Check if this spec has acceptance criteria.
336    /// Returns true if the spec body contains an "## Acceptance Criteria" section
337    /// with at least one checkbox item.
338    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    /// Check if this spec has unmet dependencies or approval requirements that would block it.
372    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    /// Check if this spec is ready to execute.
391    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    /// Get the list of dependencies that are blocking this spec.
423    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                    // Only add if not completed
447                    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    /// Check if the spec's frontmatter contains a specific field.
509    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    /// Check if this spec requires approval before work can begin.
545    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    /// Check if this spec has been approved.
556    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    /// Check if this spec has been rejected.
567    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}