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