Skip to main content

chant/
spec.rs

1//! Spec parsing, frontmatter handling, and spec lifecycle management.
2//!
3//! # Doc Audit
4//! - audited: 2026-01-25
5//! - docs: concepts/specs.md, reference/schema.md
6//! - ignore: false
7
8// Submodules
9mod frontmatter;
10mod lifecycle;
11mod parse;
12mod state_machine;
13
14// Re-export types from submodules
15pub use frontmatter::{
16    Approval, ApprovalStatus, BlockingDependency, SpecFrontmatter, SpecStatus, SpecType,
17    VerificationStatus,
18};
19pub use lifecycle::{
20    apply_blocked_status_with_repos, is_completed, is_failed, load_all_specs,
21    load_all_specs_with_options, resolve_spec,
22};
23pub use parse::{split_frontmatter, Spec};
24pub use state_machine::{
25    transition_to_blocked, transition_to_failed, transition_to_in_progress, transition_to_paused,
26    TransitionBuilder, TransitionError,
27};
28
29// Re-export group/driver functions from spec_group for backward compatibility
30pub use crate::spec_group::{
31    all_members_completed, all_prior_siblings_completed, auto_complete_driver_if_ready,
32    extract_driver_id, extract_member_number, get_incomplete_members, get_members, is_member_of,
33    mark_driver_failed_on_member_failure, mark_driver_in_progress,
34    mark_driver_in_progress_conditional,
35};
36
37/// Normalize model names from full Claude model IDs to short names.
38/// Examples: "claude-sonnet-4-20250514" -> "sonnet", "claude-opus-4-5" -> "opus"
39///
40/// This is used to ensure consistent model names across the system, whether they come
41/// from environment variables, config files, or are parsed from spec frontmatter.
42pub fn normalize_model_name(model: &str) -> String {
43    let lower = model.to_lowercase();
44    if lower.contains("opus") {
45        "opus".to_string()
46    } else if lower.contains("sonnet") {
47        "sonnet".to_string()
48    } else if lower.contains("haiku") {
49        "haiku".to_string()
50    } else {
51        model.to_string()
52    }
53}
54
55#[cfg(test)]
56mod tests {
57    use super::*;
58
59    fn assert_verification_fields(
60        spec: &Spec,
61        last_verified: Option<&str>,
62        verification_status: Option<&str>,
63        verification_failures: Option<Vec<&str>>,
64    ) {
65        use std::str::FromStr;
66        assert_eq!(
67            spec.frontmatter.last_verified,
68            last_verified.map(String::from)
69        );
70        assert_eq!(
71            spec.frontmatter.verification_status,
72            verification_status.and_then(|s| VerificationStatus::from_str(s).ok())
73        );
74        assert_eq!(
75            spec.frontmatter.verification_failures,
76            verification_failures.map(|v| v.iter().map(|s| s.to_string()).collect())
77        );
78    }
79
80    fn assert_replay_fields(
81        spec: &Spec,
82        replayed_at: Option<&str>,
83        replay_count: Option<u32>,
84        original_completed_at: Option<&str>,
85    ) {
86        assert_eq!(spec.frontmatter.replayed_at, replayed_at.map(String::from));
87        assert_eq!(spec.frontmatter.replay_count, replay_count);
88        assert_eq!(
89            spec.frontmatter.original_completed_at,
90            original_completed_at.map(String::from)
91        );
92    }
93
94    #[test]
95    fn test_normalize_model_name_opus() {
96        assert_eq!(normalize_model_name("claude-opus-4-5"), "opus");
97        assert_eq!(normalize_model_name("claude-opus-4-20250514"), "opus");
98        assert_eq!(normalize_model_name("CLAUDE-OPUS-4"), "opus");
99        assert_eq!(normalize_model_name("opus"), "opus");
100    }
101
102    #[test]
103    fn test_normalize_model_name_sonnet() {
104        assert_eq!(normalize_model_name("claude-sonnet-4-20250514"), "sonnet");
105        assert_eq!(normalize_model_name("claude-sonnet-4-5"), "sonnet");
106        assert_eq!(normalize_model_name("CLAUDE-SONNET-3"), "sonnet");
107        assert_eq!(normalize_model_name("sonnet"), "sonnet");
108    }
109
110    #[test]
111    fn test_normalize_model_name_haiku() {
112        assert_eq!(normalize_model_name("claude-haiku-4-5"), "haiku");
113        assert_eq!(normalize_model_name("claude-haiku-3-20240307"), "haiku");
114        assert_eq!(normalize_model_name("CLAUDE-HAIKU-4"), "haiku");
115        assert_eq!(normalize_model_name("haiku"), "haiku");
116    }
117
118    #[test]
119    fn test_normalize_model_name_passthrough() {
120        assert_eq!(
121            normalize_model_name("gpt-4"),
122            "gpt-4",
123            "Non-Claude models should pass through unchanged"
124        );
125        assert_eq!(
126            normalize_model_name("llama-3"),
127            "llama-3",
128            "Non-Claude models should pass through unchanged"
129        );
130    }
131
132    #[test]
133    fn test_parse_spec() {
134        let content = r#"---
135type: code
136status: pending
137---
138
139# Fix the bug
140
141Description here.
142"#;
143        let spec = Spec::parse("2026-01-22-001-x7m", content).unwrap();
144        assert_eq!(spec.id, "2026-01-22-001-x7m");
145        assert_eq!(spec.frontmatter.status, SpecStatus::Pending);
146        assert_eq!(spec.title, Some("Fix the bug".to_string()));
147    }
148
149    #[test]
150    fn test_spec_is_ready() {
151        let spec = Spec::parse(
152            "001",
153            r#"---
154status: pending
155---
156# Test
157"#,
158        )
159        .unwrap();
160        assert!(spec.is_ready(&[]));
161
162        let spec2 = Spec::parse(
163            "002",
164            r#"---
165status: in_progress
166---
167# Test
168"#,
169        )
170        .unwrap();
171        assert!(!spec2.is_ready(&[]));
172    }
173
174    #[test]
175    fn test_spec_has_acceptance_criteria() {
176        let spec_with_ac = Spec::parse(
177            "001",
178            r#"---
179status: pending
180---
181# Test
182
183## Acceptance Criteria
184
185- [ ] Thing 1
186- [ ] Thing 2
187"#,
188        )
189        .unwrap();
190        assert!(spec_with_ac.has_acceptance_criteria());
191
192        let spec_without_ac = Spec::parse(
193            "002",
194            r#"---
195status: pending
196---
197# Test
198
199Description
200"#,
201        )
202        .unwrap();
203        assert!(!spec_without_ac.has_acceptance_criteria());
204    }
205
206    #[test]
207    fn test_count_checkboxes() {
208        let spec = Spec::parse(
209            "001",
210            r#"---
211status: pending
212---
213# Test
214
215## Acceptance Criteria
216
217- [ ] Thing 1
218- [x] Thing 2
219- [ ] Thing 3
220"#,
221        )
222        .unwrap();
223        assert_eq!(spec.count_unchecked_checkboxes(), 2);
224        assert_eq!(spec.count_total_checkboxes(), 3);
225    }
226
227    #[test]
228    fn test_split_frontmatter() {
229        let content = r#"---
230type: code
231status: pending
232---
233
234# Title
235
236Body"#;
237        let (fm, body) = split_frontmatter(content);
238        assert!(fm.is_some());
239        assert!(body.contains("# Title"));
240    }
241
242    #[test]
243    fn test_split_frontmatter_no_frontmatter() {
244        let content = "# Title\n\nBody";
245        let (fm, body) = split_frontmatter(content);
246        assert!(fm.is_none());
247        assert_eq!(body, content);
248    }
249
250    #[test]
251    fn test_approval_required() {
252        let spec = Spec::parse(
253            "001",
254            r#"---
255status: pending
256approval:
257  required: true
258  status: pending
259---
260# Test
261"#,
262        )
263        .unwrap();
264        assert!(spec.requires_approval());
265        assert!(!spec.is_approved());
266        assert!(!spec.is_rejected());
267    }
268
269    #[test]
270    fn test_approval_granted() {
271        let spec = Spec::parse(
272            "001",
273            r#"---
274status: pending
275approval:
276  required: true
277  status: approved
278  by: "user@example.com"
279  at: "2026-01-25T12:00:00Z"
280---
281# Test
282"#,
283        )
284        .unwrap();
285        assert!(!spec.requires_approval());
286        assert!(spec.is_approved());
287        assert!(!spec.is_rejected());
288    }
289
290    #[test]
291    fn test_approval_rejected() {
292        let spec = Spec::parse(
293            "001",
294            r#"---
295status: pending
296approval:
297  required: true
298  status: rejected
299  by: "user@example.com"
300  at: "2026-01-25T12:00:00Z"
301---
302# Test
303"#,
304        )
305        .unwrap();
306        assert!(spec.requires_approval());
307        assert!(!spec.is_approved());
308        assert!(spec.is_rejected());
309    }
310
311    #[test]
312    fn test_verification_fields() {
313        let spec = Spec::parse(
314            "001",
315            r#"---
316status: completed
317last_verified: "2026-01-25T12:00:00Z"
318verification_status: "passed"
319---
320# Test
321"#,
322        )
323        .unwrap();
324        assert_verification_fields(&spec, Some("2026-01-25T12:00:00Z"), Some("passed"), None);
325    }
326
327    #[test]
328    fn test_replay_fields() {
329        let spec = Spec::parse(
330            "001",
331            r#"---
332status: pending
333replayed_at: "2026-01-25T14:00:00Z"
334replay_count: 2
335original_completed_at: "2026-01-24T12:00:00Z"
336---
337# Test
338"#,
339        )
340        .unwrap();
341        assert_replay_fields(
342            &spec,
343            Some("2026-01-25T14:00:00Z"),
344            Some(2),
345            Some("2026-01-24T12:00:00Z"),
346        );
347    }
348
349    #[test]
350    fn test_has_frontmatter_field() {
351        let spec = Spec::parse(
352            "001",
353            r#"---
354status: pending
355model: "sonnet"
356labels: ["bug", "urgent"]
357---
358# Test
359"#,
360        )
361        .unwrap();
362        assert!(spec.has_frontmatter_field("status"));
363        assert!(spec.has_frontmatter_field("model"));
364        assert!(spec.has_frontmatter_field("labels"));
365        assert!(!spec.has_frontmatter_field("context"));
366    }
367}