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    /// Check if this spec has acceptance criteria.
322    /// Returns true if the spec body contains an "## Acceptance Criteria" section
323    /// with at least one checkbox item.
324    pub fn has_acceptance_criteria(&self) -> bool {
325        let acceptance_criteria_marker = "## Acceptance Criteria";
326        let mut in_ac_section = false;
327        let mut in_code_fence = false;
328
329        for line in self.body.lines() {
330            let trimmed = line.trim_start();
331
332            if trimmed.starts_with("```") {
333                in_code_fence = !in_code_fence;
334            }
335
336            if !in_code_fence && trimmed.starts_with(acceptance_criteria_marker) {
337                in_ac_section = true;
338                continue;
339            }
340
341            if in_ac_section && trimmed.starts_with("## ") {
342                break;
343            }
344
345            if in_ac_section
346                && (trimmed.starts_with("- [ ] ")
347                    || trimmed.starts_with("- [x] ")
348                    || trimmed.starts_with("- [X] "))
349            {
350                return true;
351            }
352        }
353
354        false
355    }
356
357    /// Check if this spec has unmet dependencies or approval requirements that would block it.
358    pub fn is_blocked(&self, all_specs: &[Spec]) -> bool {
359        if let Some(deps) = &self.frontmatter.depends_on {
360            for dep_id in deps {
361                let dep = all_specs.iter().find(|s| s.id == *dep_id);
362                match dep {
363                    Some(d) if d.frontmatter.status == SpecStatus::Completed => continue,
364                    _ => return true,
365                }
366            }
367        }
368
369        if self.frontmatter.status == SpecStatus::Pending && self.requires_approval() {
370            return true;
371        }
372
373        false
374    }
375
376    /// Check if this spec is ready to execute.
377    pub fn is_ready(&self, all_specs: &[Spec]) -> bool {
378        use crate::spec_group::{all_prior_siblings_completed, is_member_of};
379
380        if self.frontmatter.status != SpecStatus::Pending {
381            return false;
382        }
383
384        if self.is_blocked(all_specs) {
385            return false;
386        }
387
388        if !all_prior_siblings_completed(&self.id, all_specs) {
389            return false;
390        }
391
392        let members: Vec<_> = all_specs
393            .iter()
394            .filter(|s| is_member_of(&s.id, &self.id))
395            .collect();
396
397        if !members.is_empty() && self.has_acceptance_criteria() {
398            for member in members {
399                if member.frontmatter.status != SpecStatus::Completed {
400                    return false;
401                }
402            }
403        }
404
405        true
406    }
407
408    /// Get the list of dependencies that are blocking this spec.
409    pub fn get_blocking_dependencies(
410        &self,
411        all_specs: &[Spec],
412        specs_dir: &Path,
413    ) -> Vec<super::frontmatter::BlockingDependency> {
414        use super::frontmatter::BlockingDependency;
415        use crate::spec_group::{extract_driver_id, extract_member_number};
416
417        let mut blockers = Vec::new();
418
419        if let Some(deps) = &self.frontmatter.depends_on {
420            for dep_id in deps {
421                let spec_path = specs_dir.join(format!("{}.md", dep_id));
422                let dep_spec = if spec_path.exists() {
423                    Spec::load(&spec_path).ok()
424                } else {
425                    None
426                };
427
428                let dep_spec =
429                    dep_spec.or_else(|| all_specs.iter().find(|s| s.id == *dep_id).cloned());
430
431                if let Some(spec) = dep_spec {
432                    // Only add if not completed
433                    if spec.frontmatter.status != SpecStatus::Completed {
434                        blockers.push(BlockingDependency {
435                            spec_id: spec.id.clone(),
436                            title: spec.title.clone(),
437                            status: spec.frontmatter.status.clone(),
438                            completed_at: spec.frontmatter.completed_at.clone(),
439                            is_sibling: false,
440                        });
441                    }
442                } else {
443                    blockers.push(BlockingDependency {
444                        spec_id: dep_id.clone(),
445                        title: None,
446                        status: SpecStatus::Pending,
447                        completed_at: None,
448                        is_sibling: false,
449                    });
450                }
451            }
452        }
453
454        if let Some(driver_id) = extract_driver_id(&self.id) {
455            if let Some(member_num) = extract_member_number(&self.id) {
456                for i in 1..member_num {
457                    let sibling_id = format!("{}.{}", driver_id, i);
458                    let spec_path = specs_dir.join(format!("{}.md", sibling_id));
459                    let sibling_spec = if spec_path.exists() {
460                        Spec::load(&spec_path).ok()
461                    } else {
462                        None
463                    };
464
465                    let sibling_spec = sibling_spec
466                        .or_else(|| all_specs.iter().find(|s| s.id == sibling_id).cloned());
467
468                    if let Some(spec) = sibling_spec {
469                        if spec.frontmatter.status != SpecStatus::Completed {
470                            blockers.push(BlockingDependency {
471                                spec_id: spec.id.clone(),
472                                title: spec.title.clone(),
473                                status: spec.frontmatter.status.clone(),
474                                completed_at: spec.frontmatter.completed_at.clone(),
475                                is_sibling: true,
476                            });
477                        }
478                    } else {
479                        blockers.push(BlockingDependency {
480                            spec_id: sibling_id,
481                            title: None,
482                            status: SpecStatus::Pending,
483                            completed_at: None,
484                            is_sibling: true,
485                        });
486                    }
487                }
488            }
489        }
490
491        blockers
492    }
493
494    /// Check if the spec's frontmatter contains a specific field.
495    pub fn has_frontmatter_field(&self, field: &str) -> bool {
496        match field {
497            "type" => true,
498            "status" => true,
499            "depends_on" => self.frontmatter.depends_on.is_some(),
500            "labels" => self.frontmatter.labels.is_some(),
501            "target_files" => self.frontmatter.target_files.is_some(),
502            "context" => self.frontmatter.context.is_some(),
503            "prompt" => self.frontmatter.prompt.is_some(),
504            "branch" => self.frontmatter.branch.is_some(),
505            "commits" => self.frontmatter.commits.is_some(),
506            "completed_at" => self.frontmatter.completed_at.is_some(),
507            "model" => self.frontmatter.model.is_some(),
508            "tracks" => self.frontmatter.tracks.is_some(),
509            "informed_by" => self.frontmatter.informed_by.is_some(),
510            "origin" => self.frontmatter.origin.is_some(),
511            "schedule" => self.frontmatter.schedule.is_some(),
512            "source_branch" => self.frontmatter.source_branch.is_some(),
513            "target_branch" => self.frontmatter.target_branch.is_some(),
514            "conflicting_files" => self.frontmatter.conflicting_files.is_some(),
515            "blocked_specs" => self.frontmatter.blocked_specs.is_some(),
516            "original_spec" => self.frontmatter.original_spec.is_some(),
517            "last_verified" => self.frontmatter.last_verified.is_some(),
518            "verification_status" => self.frontmatter.verification_status.is_some(),
519            "verification_failures" => self.frontmatter.verification_failures.is_some(),
520            "replayed_at" => self.frontmatter.replayed_at.is_some(),
521            "replay_count" => self.frontmatter.replay_count.is_some(),
522            "original_completed_at" => self.frontmatter.original_completed_at.is_some(),
523            "approval" => self.frontmatter.approval.is_some(),
524            "members" => self.frontmatter.members.is_some(),
525            "output_schema" => self.frontmatter.output_schema.is_some(),
526            _ => false,
527        }
528    }
529
530    /// Check if this spec requires approval before work can begin.
531    pub fn requires_approval(&self) -> bool {
532        use super::frontmatter::ApprovalStatus;
533
534        if let Some(ref approval) = self.frontmatter.approval {
535            approval.required && approval.status != ApprovalStatus::Approved
536        } else {
537            false
538        }
539    }
540
541    /// Check if this spec has been approved.
542    pub fn is_approved(&self) -> bool {
543        use super::frontmatter::ApprovalStatus;
544
545        if let Some(ref approval) = self.frontmatter.approval {
546            approval.status == ApprovalStatus::Approved
547        } else {
548            true
549        }
550    }
551
552    /// Check if this spec has been rejected.
553    pub fn is_rejected(&self) -> bool {
554        use super::frontmatter::ApprovalStatus;
555
556        if let Some(ref approval) = self.frontmatter.approval {
557            approval.status == ApprovalStatus::Rejected
558        } else {
559            false
560        }
561    }
562}