1mod frontmatter;
10mod lifecycle;
11mod parse;
12
13pub 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
21pub 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
28pub 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}