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