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,
26};
27
28#[cfg(test)]
29mod tests {
30 use super::*;
31
32 fn assert_verification_fields(
33 spec: &Spec,
34 last_verified: Option<&str>,
35 verification_status: Option<&str>,
36 verification_failures: Option<Vec<&str>>,
37 ) {
38 assert_eq!(
39 spec.frontmatter.last_verified,
40 last_verified.map(String::from)
41 );
42 assert_eq!(
43 spec.frontmatter.verification_status,
44 verification_status.map(String::from)
45 );
46 assert_eq!(
47 spec.frontmatter.verification_failures,
48 verification_failures.map(|v| v.iter().map(|s| s.to_string()).collect())
49 );
50 }
51
52 fn assert_replay_fields(
53 spec: &Spec,
54 replayed_at: Option<&str>,
55 replay_count: Option<u32>,
56 original_completed_at: Option<&str>,
57 ) {
58 assert_eq!(spec.frontmatter.replayed_at, replayed_at.map(String::from));
59 assert_eq!(spec.frontmatter.replay_count, replay_count);
60 assert_eq!(
61 spec.frontmatter.original_completed_at,
62 original_completed_at.map(String::from)
63 );
64 }
65
66 #[test]
67 fn test_parse_spec() {
68 let content = r#"---
69type: code
70status: pending
71---
72
73# Fix the bug
74
75Description here.
76"#;
77 let spec = Spec::parse("2026-01-22-001-x7m", content).unwrap();
78 assert_eq!(spec.id, "2026-01-22-001-x7m");
79 assert_eq!(spec.frontmatter.status, SpecStatus::Pending);
80 assert_eq!(spec.title, Some("Fix the bug".to_string()));
81 }
82
83 #[test]
84 fn test_spec_is_ready() {
85 let spec = Spec::parse(
86 "001",
87 r#"---
88status: pending
89---
90# Test
91"#,
92 )
93 .unwrap();
94 assert!(spec.is_ready(&[]));
95
96 let spec2 = Spec::parse(
97 "002",
98 r#"---
99status: in_progress
100---
101# Test
102"#,
103 )
104 .unwrap();
105 assert!(!spec2.is_ready(&[]));
106 }
107
108 #[test]
109 fn test_spec_has_acceptance_criteria() {
110 let spec_with_ac = Spec::parse(
111 "001",
112 r#"---
113status: pending
114---
115# Test
116
117## Acceptance Criteria
118
119- [ ] Thing 1
120- [ ] Thing 2
121"#,
122 )
123 .unwrap();
124 assert!(spec_with_ac.has_acceptance_criteria());
125
126 let spec_without_ac = Spec::parse(
127 "002",
128 r#"---
129status: pending
130---
131# Test
132
133Description
134"#,
135 )
136 .unwrap();
137 assert!(!spec_without_ac.has_acceptance_criteria());
138 }
139
140 #[test]
141 fn test_count_checkboxes() {
142 let spec = Spec::parse(
143 "001",
144 r#"---
145status: pending
146---
147# Test
148
149## Acceptance Criteria
150
151- [ ] Thing 1
152- [x] Thing 2
153- [ ] Thing 3
154"#,
155 )
156 .unwrap();
157 assert_eq!(spec.count_unchecked_checkboxes(), 2);
158 assert_eq!(spec.count_total_checkboxes(), 3);
159 }
160
161 #[test]
162 fn test_split_frontmatter() {
163 let content = r#"---
164type: code
165status: pending
166---
167
168# Title
169
170Body"#;
171 let (fm, body) = split_frontmatter(content);
172 assert!(fm.is_some());
173 assert!(body.contains("# Title"));
174 }
175
176 #[test]
177 fn test_split_frontmatter_no_frontmatter() {
178 let content = "# Title\n\nBody";
179 let (fm, body) = split_frontmatter(content);
180 assert!(fm.is_none());
181 assert_eq!(body, content);
182 }
183
184 #[test]
185 fn test_approval_required() {
186 let spec = Spec::parse(
187 "001",
188 r#"---
189status: pending
190approval:
191 required: true
192 status: pending
193---
194# Test
195"#,
196 )
197 .unwrap();
198 assert!(spec.requires_approval());
199 assert!(!spec.is_approved());
200 assert!(!spec.is_rejected());
201 }
202
203 #[test]
204 fn test_approval_granted() {
205 let spec = Spec::parse(
206 "001",
207 r#"---
208status: pending
209approval:
210 required: true
211 status: approved
212 by: "user@example.com"
213 at: "2026-01-25T12:00:00Z"
214---
215# Test
216"#,
217 )
218 .unwrap();
219 assert!(!spec.requires_approval());
220 assert!(spec.is_approved());
221 assert!(!spec.is_rejected());
222 }
223
224 #[test]
225 fn test_approval_rejected() {
226 let spec = Spec::parse(
227 "001",
228 r#"---
229status: pending
230approval:
231 required: true
232 status: rejected
233 by: "user@example.com"
234 at: "2026-01-25T12:00:00Z"
235---
236# Test
237"#,
238 )
239 .unwrap();
240 assert!(spec.requires_approval());
241 assert!(!spec.is_approved());
242 assert!(spec.is_rejected());
243 }
244
245 #[test]
246 fn test_verification_fields() {
247 let spec = Spec::parse(
248 "001",
249 r#"---
250status: completed
251last_verified: "2026-01-25T12:00:00Z"
252verification_status: "passed"
253---
254# Test
255"#,
256 )
257 .unwrap();
258 assert_verification_fields(&spec, Some("2026-01-25T12:00:00Z"), Some("passed"), None);
259 }
260
261 #[test]
262 fn test_replay_fields() {
263 let spec = Spec::parse(
264 "001",
265 r#"---
266status: pending
267replayed_at: "2026-01-25T14:00:00Z"
268replay_count: 2
269original_completed_at: "2026-01-24T12:00:00Z"
270---
271# Test
272"#,
273 )
274 .unwrap();
275 assert_replay_fields(
276 &spec,
277 Some("2026-01-25T14:00:00Z"),
278 Some(2),
279 Some("2026-01-24T12:00:00Z"),
280 );
281 }
282
283 #[test]
284 fn test_has_frontmatter_field() {
285 let spec = Spec::parse(
286 "001",
287 r#"---
288status: pending
289model: "sonnet"
290labels: ["bug", "urgent"]
291---
292# Test
293"#,
294 )
295 .unwrap();
296 assert!(spec.has_frontmatter_field("status"));
297 assert!(spec.has_frontmatter_field("model"));
298 assert!(spec.has_frontmatter_field("labels"));
299 assert!(!spec.has_frontmatter_field("context"));
300 }
301}