Skip to main content

chant/
spec.rs

1//! Spec parsing, frontmatter handling, and spec lifecycle management.
2//!
3//! # Doc Audit
4//! - audited: 2026-01-25
5//! - docs: concepts/specs.md, reference/schema.md
6//! - ignore: false
7
8// Submodules
9mod frontmatter;
10mod lifecycle;
11mod parse;
12
13// Re-export types from submodules
14pub 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
21// Re-export group/driver functions from spec_group for backward compatibility
22pub 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}