1mod frontmatter;
10mod lifecycle;
11mod parse;
12mod state_machine;
13
14pub 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
29pub 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
37pub 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}