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    /// Parse a spec from file content.
82    pub fn parse(id: &str, content: &str) -> Result<Self> {
83        let (frontmatter_str, body) = split_frontmatter(content);
84
85        let mut frontmatter: SpecFrontmatter = if let Some(fm) = frontmatter_str {
86            serde_yaml::from_str(&fm).context("Failed to parse spec frontmatter")?
87        } else {
88            SpecFrontmatter::default()
89        };
90
91        // Normalize model name if present
92        if let Some(model) = &frontmatter.model {
93            frontmatter.model = Some(normalize_model_name(model));
94        }
95
96        // Extract title from first heading
97        let title = extract_title(body);
98
99        Ok(Self {
100            id: id.to_string(),
101            frontmatter,
102            title,
103            body: body.to_string(),
104        })
105    }
106
107    /// Load a spec from a file path.
108    pub fn load(path: &Path) -> Result<Self> {
109        let content = fs::read_to_string(path)
110            .with_context(|| format!("Failed to read spec from {}", path.display()))?;
111
112        let id = path
113            .file_stem()
114            .and_then(|s| s.to_str())
115            .ok_or_else(|| anyhow::anyhow!("Invalid spec filename"))?;
116
117        Self::parse(id, &content)
118    }
119
120    /// Load a spec, optionally resolving from its working branch.
121    ///
122    /// If the spec is in_progress and has a branch (frontmatter.branch or chant/{id}),
123    /// attempt to read the spec content from that branch for live progress.
124    pub fn load_with_branch_resolution(spec_path: &Path) -> Result<Self> {
125        let spec = Self::load(spec_path)?;
126
127        // Only resolve for in_progress specs
128        if spec.frontmatter.status != SpecStatus::InProgress {
129            return Ok(spec);
130        }
131
132        // Try to find the working branch
133        let branch_name = spec
134            .frontmatter
135            .branch
136            .clone()
137            .unwrap_or_else(|| format!("chant/{}", spec.id));
138
139        // Check if branch exists
140        if !branch_exists(&branch_name)? {
141            return Ok(spec);
142        }
143
144        // Read spec from branch
145        match read_spec_from_branch(&spec.id, &branch_name) {
146            Ok(branch_spec) => Ok(branch_spec),
147            Err(_) => Ok(spec), // Fall back to main version
148        }
149    }
150
151    /// Save the spec to a file.
152    pub fn save(&self, path: &Path) -> Result<()> {
153        let frontmatter = serde_yaml::to_string(&self.frontmatter)?;
154        let content = format!("---\n{}---\n{}", frontmatter, self.body);
155        fs::write(path, content)?;
156        Ok(())
157    }
158
159    /// Count unchecked checkboxes (`- [ ]`) in the Acceptance Criteria section only.
160    /// Returns the count of unchecked items in that section, skipping code fences.
161    /// Uses the LAST `## Acceptance Criteria` heading outside code fences.
162    pub fn count_unchecked_checkboxes(&self) -> usize {
163        let acceptance_criteria_marker = "## Acceptance Criteria";
164
165        // First pass: find the line number of the LAST AC heading outside code fences
166        let mut in_code_fence = false;
167        let mut last_ac_line: Option<usize> = None;
168
169        for (line_num, line) in self.body.lines().enumerate() {
170            let trimmed = line.trim_start();
171
172            if trimmed.starts_with("```") {
173                in_code_fence = !in_code_fence;
174                continue;
175            }
176
177            if !in_code_fence && trimmed.starts_with(acceptance_criteria_marker) {
178                last_ac_line = Some(line_num);
179            }
180        }
181
182        let Some(ac_start) = last_ac_line else {
183            return 0;
184        };
185
186        // Second pass: count checkboxes from the AC section until next ## heading
187        let mut in_code_fence = false;
188        let mut in_ac_section = false;
189        let mut count = 0;
190
191        for (line_num, line) in self.body.lines().enumerate() {
192            let trimmed = line.trim_start();
193
194            if trimmed.starts_with("```") {
195                in_code_fence = !in_code_fence;
196                continue;
197            }
198
199            if in_code_fence {
200                continue;
201            }
202
203            // Start counting at the last AC heading we found
204            if line_num == ac_start {
205                in_ac_section = true;
206                continue;
207            }
208
209            // Stop at the next ## heading after our AC section
210            if in_ac_section && trimmed.starts_with("## ") {
211                break;
212            }
213
214            if in_ac_section && line.contains("- [ ]") {
215                count += line.matches("- [ ]").count();
216            }
217        }
218
219        count
220    }
221
222    /// Count total checkboxes (both checked and unchecked) in the Acceptance Criteria section.
223    /// Used to assess spec complexity.
224    pub fn count_total_checkboxes(&self) -> usize {
225        let acceptance_criteria_marker = "## Acceptance Criteria";
226
227        // First pass: find the line number of the LAST AC heading outside code fences
228        let mut in_code_fence = false;
229        let mut last_ac_line: Option<usize> = None;
230
231        for (line_num, line) in self.body.lines().enumerate() {
232            let trimmed = line.trim_start();
233
234            if trimmed.starts_with("```") {
235                in_code_fence = !in_code_fence;
236                continue;
237            }
238
239            if !in_code_fence && trimmed.starts_with(acceptance_criteria_marker) {
240                last_ac_line = Some(line_num);
241            }
242        }
243
244        let Some(ac_start) = last_ac_line else {
245            return 0;
246        };
247
248        // Second pass: count all checkboxes from the AC section until next ## heading
249        let mut in_code_fence = false;
250        let mut in_ac_section = false;
251        let mut count = 0;
252
253        for (line_num, line) in self.body.lines().enumerate() {
254            let trimmed = line.trim_start();
255
256            if trimmed.starts_with("```") {
257                in_code_fence = !in_code_fence;
258                continue;
259            }
260
261            if in_code_fence {
262                continue;
263            }
264
265            if line_num == ac_start {
266                in_ac_section = true;
267                continue;
268            }
269
270            if in_ac_section && trimmed.starts_with("## ") {
271                break;
272            }
273
274            // Count both unchecked and checked checkboxes
275            if in_ac_section {
276                count += line.matches("- [ ]").count();
277                count += line.matches("- [x]").count();
278                count += line.matches("- [X]").count();
279            }
280        }
281
282        count
283    }
284
285    /// Add derived fields to the spec's frontmatter.
286    /// Updates the frontmatter with the provided derived fields.
287    pub fn add_derived_fields(&mut self, fields: std::collections::HashMap<String, String>) {
288        let mut derived_field_names = Vec::new();
289
290        for (key, value) in fields {
291            // Track which fields were derived
292            derived_field_names.push(key.clone());
293
294            // Handle specific known derived fields that map to frontmatter
295            match key.as_str() {
296                "labels" => {
297                    let label_vec = value.split(',').map(|s| s.trim().to_string()).collect();
298                    self.frontmatter.labels = Some(label_vec);
299                }
300                "context" => {
301                    let context_vec = value.split(',').map(|s| s.trim().to_string()).collect();
302                    self.frontmatter.context = Some(context_vec);
303                }
304                _ => {
305                    if self.frontmatter.context.is_none() {
306                        self.frontmatter.context = Some(vec![]);
307                    }
308                    if let Some(ref mut ctx) = self.frontmatter.context {
309                        ctx.push(format!("derived_{}={}", key, value));
310                    }
311                }
312            }
313        }
314
315        // Update the derived_fields tracking
316        if !derived_field_names.is_empty() {
317            self.frontmatter.derived_fields = Some(derived_field_names);
318        }
319    }
320
321    /// Auto-check all acceptance criteria checkboxes in the spec body.
322    /// Replaces all `- [ ]` with `- [x]` in the Acceptance Criteria section.
323    /// Returns true if any checkboxes were checked, false otherwise.
324    pub fn auto_check_acceptance_criteria(&mut self) -> bool {
325        let acceptance_criteria_marker = "## Acceptance Criteria";
326        let mut in_code_fence = false;
327        let mut last_ac_line: Option<usize> = None;
328
329        // Find the last AC heading outside code fences
330        for (line_num, line) in self.body.lines().enumerate() {
331            let trimmed = line.trim_start();
332            if trimmed.starts_with("```") {
333                in_code_fence = !in_code_fence;
334                continue;
335            }
336            if !in_code_fence && trimmed.starts_with(acceptance_criteria_marker) {
337                last_ac_line = Some(line_num);
338            }
339        }
340
341        let Some(ac_start) = last_ac_line else {
342            return false;
343        };
344
345        // Replace unchecked boxes in the AC section
346        let mut in_code_fence = false;
347        let mut in_ac_section = false;
348        let mut modified = false;
349        let mut new_body = String::new();
350
351        for (line_num, line) in self.body.lines().enumerate() {
352            let trimmed = line.trim_start();
353
354            if trimmed.starts_with("```") {
355                in_code_fence = !in_code_fence;
356            }
357
358            if line_num == ac_start {
359                in_ac_section = true;
360            }
361
362            if in_ac_section && !in_code_fence && trimmed.starts_with("## ") && line_num != ac_start
363            {
364                in_ac_section = false;
365            }
366
367            if in_ac_section && !in_code_fence && line.contains("- [ ]") {
368                new_body.push_str(&line.replace("- [ ]", "- [x]"));
369                modified = true;
370            } else {
371                new_body.push_str(line);
372            }
373            new_body.push('\n');
374        }
375
376        if modified {
377            self.body = new_body.trim_end().to_string();
378        }
379
380        modified
381    }
382
383    /// Check if this spec has acceptance criteria.
384    /// Returns true if the spec body contains an "## Acceptance Criteria" section
385    /// with at least one checkbox item.
386    pub fn has_acceptance_criteria(&self) -> bool {
387        let acceptance_criteria_marker = "## Acceptance Criteria";
388        let mut in_ac_section = false;
389        let mut in_code_fence = false;
390
391        for line in self.body.lines() {
392            let trimmed = line.trim_start();
393
394            if trimmed.starts_with("```") {
395                in_code_fence = !in_code_fence;
396            }
397
398            if !in_code_fence && trimmed.starts_with(acceptance_criteria_marker) {
399                in_ac_section = true;
400                continue;
401            }
402
403            if in_ac_section && trimmed.starts_with("## ") {
404                break;
405            }
406
407            if in_ac_section
408                && (trimmed.starts_with("- [ ] ")
409                    || trimmed.starts_with("- [x] ")
410                    || trimmed.starts_with("- [X] "))
411            {
412                return true;
413            }
414        }
415
416        false
417    }
418
419    /// Check if this spec has unmet dependencies or approval requirements that would block it.
420    pub fn is_blocked(&self, all_specs: &[Spec]) -> bool {
421        if let Some(deps) = &self.frontmatter.depends_on {
422            for dep_id in deps {
423                let dep = all_specs.iter().find(|s| s.id == *dep_id);
424                match dep {
425                    Some(d) if d.frontmatter.status == SpecStatus::Completed => continue,
426                    _ => return true,
427                }
428            }
429        }
430
431        if self.frontmatter.status == SpecStatus::Pending && self.requires_approval() {
432            return true;
433        }
434
435        false
436    }
437
438    /// Check if this spec is ready to execute.
439    pub fn is_ready(&self, all_specs: &[Spec]) -> bool {
440        use crate::spec_group::{all_prior_siblings_completed, is_member_of};
441
442        if self.frontmatter.status != SpecStatus::Pending {
443            return false;
444        }
445
446        if self.is_blocked(all_specs) {
447            return false;
448        }
449
450        if !all_prior_siblings_completed(&self.id, all_specs) {
451            return false;
452        }
453
454        let members: Vec<_> = all_specs
455            .iter()
456            .filter(|s| is_member_of(&s.id, &self.id))
457            .collect();
458
459        if !members.is_empty() && self.has_acceptance_criteria() {
460            for member in members {
461                if member.frontmatter.status != SpecStatus::Completed {
462                    return false;
463                }
464            }
465        }
466
467        true
468    }
469
470    /// Get the list of dependencies that are blocking this spec.
471    pub fn get_blocking_dependencies(
472        &self,
473        all_specs: &[Spec],
474        specs_dir: &Path,
475    ) -> Vec<super::frontmatter::BlockingDependency> {
476        use super::frontmatter::BlockingDependency;
477        use crate::spec_group::{extract_driver_id, extract_member_number};
478
479        let mut blockers = Vec::new();
480
481        if let Some(deps) = &self.frontmatter.depends_on {
482            for dep_id in deps {
483                let spec_path = specs_dir.join(format!("{}.md", dep_id));
484                let dep_spec = if spec_path.exists() {
485                    Spec::load(&spec_path).ok()
486                } else {
487                    None
488                };
489
490                let dep_spec =
491                    dep_spec.or_else(|| all_specs.iter().find(|s| s.id == *dep_id).cloned());
492
493                if let Some(spec) = dep_spec {
494                    // Only add if not completed
495                    if spec.frontmatter.status != SpecStatus::Completed {
496                        blockers.push(BlockingDependency {
497                            spec_id: spec.id.clone(),
498                            title: spec.title.clone(),
499                            status: spec.frontmatter.status.clone(),
500                            completed_at: spec.frontmatter.completed_at.clone(),
501                            is_sibling: false,
502                        });
503                    }
504                } else {
505                    blockers.push(BlockingDependency {
506                        spec_id: dep_id.clone(),
507                        title: None,
508                        status: SpecStatus::Pending,
509                        completed_at: None,
510                        is_sibling: false,
511                    });
512                }
513            }
514        }
515
516        if let Some(driver_id) = extract_driver_id(&self.id) {
517            if let Some(member_num) = extract_member_number(&self.id) {
518                for i in 1..member_num {
519                    let sibling_id = format!("{}.{}", driver_id, i);
520                    let spec_path = specs_dir.join(format!("{}.md", sibling_id));
521                    let sibling_spec = if spec_path.exists() {
522                        Spec::load(&spec_path).ok()
523                    } else {
524                        None
525                    };
526
527                    let sibling_spec = sibling_spec
528                        .or_else(|| all_specs.iter().find(|s| s.id == sibling_id).cloned());
529
530                    if let Some(spec) = sibling_spec {
531                        if spec.frontmatter.status != SpecStatus::Completed {
532                            blockers.push(BlockingDependency {
533                                spec_id: spec.id.clone(),
534                                title: spec.title.clone(),
535                                status: spec.frontmatter.status.clone(),
536                                completed_at: spec.frontmatter.completed_at.clone(),
537                                is_sibling: true,
538                            });
539                        }
540                    } else {
541                        blockers.push(BlockingDependency {
542                            spec_id: sibling_id,
543                            title: None,
544                            status: SpecStatus::Pending,
545                            completed_at: None,
546                            is_sibling: true,
547                        });
548                    }
549                }
550            }
551        }
552
553        blockers
554    }
555
556    /// Check if the spec's frontmatter contains a specific field.
557    pub fn has_frontmatter_field(&self, field: &str) -> bool {
558        match field {
559            "type" => true,
560            "status" => true,
561            "depends_on" => self.frontmatter.depends_on.is_some(),
562            "labels" => self.frontmatter.labels.is_some(),
563            "target_files" => self.frontmatter.target_files.is_some(),
564            "context" => self.frontmatter.context.is_some(),
565            "prompt" => self.frontmatter.prompt.is_some(),
566            "branch" => self.frontmatter.branch.is_some(),
567            "commits" => self.frontmatter.commits.is_some(),
568            "completed_at" => self.frontmatter.completed_at.is_some(),
569            "model" => self.frontmatter.model.is_some(),
570            "tracks" => self.frontmatter.tracks.is_some(),
571            "informed_by" => self.frontmatter.informed_by.is_some(),
572            "origin" => self.frontmatter.origin.is_some(),
573            "schedule" => self.frontmatter.schedule.is_some(),
574            "source_branch" => self.frontmatter.source_branch.is_some(),
575            "target_branch" => self.frontmatter.target_branch.is_some(),
576            "conflicting_files" => self.frontmatter.conflicting_files.is_some(),
577            "blocked_specs" => self.frontmatter.blocked_specs.is_some(),
578            "original_spec" => self.frontmatter.original_spec.is_some(),
579            "last_verified" => self.frontmatter.last_verified.is_some(),
580            "verification_status" => self.frontmatter.verification_status.is_some(),
581            "verification_failures" => self.frontmatter.verification_failures.is_some(),
582            "replayed_at" => self.frontmatter.replayed_at.is_some(),
583            "replay_count" => self.frontmatter.replay_count.is_some(),
584            "original_completed_at" => self.frontmatter.original_completed_at.is_some(),
585            "approval" => self.frontmatter.approval.is_some(),
586            "members" => self.frontmatter.members.is_some(),
587            "output_schema" => self.frontmatter.output_schema.is_some(),
588            _ => false,
589        }
590    }
591
592    /// Check if this spec requires approval before work can begin.
593    pub fn requires_approval(&self) -> bool {
594        use super::frontmatter::ApprovalStatus;
595
596        if let Some(ref approval) = self.frontmatter.approval {
597            approval.required && approval.status != ApprovalStatus::Approved
598        } else {
599            false
600        }
601    }
602
603    /// Check if this spec has been approved.
604    pub fn is_approved(&self) -> bool {
605        use super::frontmatter::ApprovalStatus;
606
607        if let Some(ref approval) = self.frontmatter.approval {
608            approval.status == ApprovalStatus::Approved
609        } else {
610            true
611        }
612    }
613
614    /// Check if this spec has been rejected.
615    pub fn is_rejected(&self) -> bool {
616        use super::frontmatter::ApprovalStatus;
617
618        if let Some(ref approval) = self.frontmatter.approval {
619            approval.status == ApprovalStatus::Rejected
620        } else {
621            false
622        }
623    }
624}