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