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::{
22 transition_to_blocked, transition_to_failed, transition_to_in_progress, transition_to_paused,
23 TransitionBuilder, TransitionError,
24};
25
26pub 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
34pub 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}