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