1use crate::error::{Autom8Error, Result};
2use serde::{Deserialize, Serialize};
3use std::fs;
4use std::path::Path;
5
6#[derive(Debug, Clone, Serialize, Deserialize)]
7#[serde(rename_all = "camelCase")]
8pub struct Spec {
9 pub project: String,
10 #[serde(default = "default_branch_name")]
11 pub branch_name: String,
12 pub description: String,
13 pub user_stories: Vec<UserStory>,
14}
15
16fn default_branch_name() -> String {
17 "autom8/feature".to_string()
18}
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
21#[serde(rename_all = "camelCase")]
22pub struct UserStory {
23 pub id: String,
24 pub title: String,
25 pub description: String,
26 pub acceptance_criteria: Vec<String>,
27 pub priority: u32,
28 pub passes: bool,
29 #[serde(default)]
30 pub notes: String,
31}
32
33impl Spec {
34 pub fn load(path: &Path) -> Result<Self> {
35 if !path.exists() {
36 return Err(Autom8Error::SpecNotFound(path.to_path_buf()));
37 }
38
39 let content = fs::read_to_string(path)?;
40 let spec: Spec =
41 serde_json::from_str(&content).map_err(|e| Autom8Error::InvalidSpec(e.to_string()))?;
42
43 spec.validate()?;
44 Ok(spec)
45 }
46
47 pub fn save(&self, path: &Path) -> Result<()> {
48 let content = serde_json::to_string_pretty(self)?;
49 fs::write(path, content)?;
50 Ok(())
51 }
52
53 fn validate(&self) -> Result<()> {
54 if self.project.is_empty() {
55 return Err(Autom8Error::InvalidSpec("project name is required".into()));
56 }
57 if self.user_stories.is_empty() {
58 return Err(Autom8Error::InvalidSpec(
59 "at least one user story is required".into(),
60 ));
61 }
62 for story in &self.user_stories {
63 if story.id.is_empty() {
64 return Err(Autom8Error::InvalidSpec("story id is required".into()));
65 }
66 }
67 Ok(())
68 }
69
70 pub fn next_incomplete_story(&self) -> Option<&UserStory> {
71 self.user_stories
72 .iter()
73 .filter(|s| !s.passes)
74 .min_by_key(|s| s.priority)
75 }
76
77 pub fn completed_count(&self) -> usize {
78 self.user_stories.iter().filter(|s| s.passes).count()
79 }
80
81 pub fn total_count(&self) -> usize {
82 self.user_stories.len()
83 }
84
85 pub fn all_complete(&self) -> bool {
86 self.user_stories.iter().all(|s| s.passes)
87 }
88
89 pub fn is_incomplete(&self) -> bool {
91 !self.all_complete()
92 }
93
94 pub fn progress(&self) -> (usize, usize) {
96 (self.completed_count(), self.total_count())
97 }
98
99 pub fn mark_story_complete(&mut self, story_id: &str) {
100 if let Some(story) = self.user_stories.iter_mut().find(|s| s.id == story_id) {
101 story.passes = true;
102 }
103 }
104}
105
106#[cfg(test)]
107mod tests {
108 use super::*;
109 use std::io::Write;
110 use tempfile::NamedTempFile;
111
112 fn make_story(id: &str, priority: u32, passes: bool) -> UserStory {
113 UserStory {
114 id: id.into(),
115 title: format!("Story {}", id),
116 description: format!("Description for {}", id),
117 acceptance_criteria: vec!["Criteria 1".into()],
118 priority,
119 passes,
120 notes: String::new(),
121 }
122 }
123
124 fn make_spec(stories: Vec<UserStory>) -> Spec {
125 Spec {
126 project: "TestProject".into(),
127 branch_name: "test-branch".into(),
128 description: "Test description".into(),
129 user_stories: stories,
130 }
131 }
132
133 #[test]
138 fn test_validate_empty_project_name_fails() {
139 let spec = Spec {
140 project: "".into(),
141 branch_name: "test".into(),
142 description: "Test".into(),
143 user_stories: vec![make_story("US-001", 1, false)],
144 };
145 let result = spec.validate();
146 assert!(result.is_err());
147 assert!(result
148 .unwrap_err()
149 .to_string()
150 .contains("project name is required"));
151 }
152
153 #[test]
154 fn test_validate_empty_stories_fails() {
155 let spec = Spec {
156 project: "Test".into(),
157 branch_name: "test".into(),
158 description: "Test".into(),
159 user_stories: vec![],
160 };
161 let result = spec.validate();
162 assert!(result.is_err());
163 assert!(result
164 .unwrap_err()
165 .to_string()
166 .contains("at least one user story is required"));
167 }
168
169 #[test]
170 fn test_validate_story_with_empty_id_fails() {
171 let spec = Spec {
172 project: "Test".into(),
173 branch_name: "test".into(),
174 description: "Test".into(),
175 user_stories: vec![UserStory {
176 id: "".into(),
177 title: "Story".into(),
178 description: "Desc".into(),
179 acceptance_criteria: vec![],
180 priority: 1,
181 passes: false,
182 notes: String::new(),
183 }],
184 };
185 let result = spec.validate();
186 assert!(result.is_err());
187 assert!(result
188 .unwrap_err()
189 .to_string()
190 .contains("story id is required"));
191 }
192
193 #[test]
194 fn test_validate_valid_spec_succeeds() {
195 let spec = make_spec(vec![make_story("US-001", 1, false)]);
196 assert!(spec.validate().is_ok());
197 }
198
199 #[test]
204 fn test_load_nonexistent_file_returns_spec_not_found() {
205 let path = Path::new("/nonexistent/path/spec.json");
206 let result = Spec::load(path);
207 assert!(result.is_err());
208 match result.unwrap_err() {
209 Autom8Error::SpecNotFound(_) => {}
210 e => panic!("Expected SpecNotFound, got {:?}", e),
211 }
212 }
213
214 #[test]
215 fn test_load_invalid_json_returns_invalid_spec() {
216 let mut file = NamedTempFile::new().unwrap();
217 writeln!(file, "not valid json {{}}").unwrap();
218 let result = Spec::load(file.path());
219 assert!(result.is_err());
220 match result.unwrap_err() {
221 Autom8Error::InvalidSpec(_) => {}
222 e => panic!("Expected InvalidSpec, got {:?}", e),
223 }
224 }
225
226 #[test]
227 fn test_save_and_load_round_trip() {
228 let spec = make_spec(vec![
229 make_story("US-001", 1, true),
230 make_story("US-002", 2, false),
231 ]);
232 let file = NamedTempFile::new().unwrap();
233
234 spec.save(file.path()).unwrap();
235 let loaded = Spec::load(file.path()).unwrap();
236
237 assert_eq!(loaded.project, spec.project);
238 assert_eq!(loaded.branch_name, spec.branch_name);
239 assert_eq!(loaded.description, spec.description);
240 assert_eq!(loaded.user_stories.len(), 2);
241 assert_eq!(loaded.user_stories[0].id, "US-001");
242 assert!(loaded.user_stories[0].passes);
243 assert_eq!(loaded.user_stories[1].id, "US-002");
244 assert!(!loaded.user_stories[1].passes);
245 }
246
247 #[test]
248 fn test_load_validates_after_parsing() {
249 let mut file = NamedTempFile::new().unwrap();
250 writeln!(
252 file,
253 r#"{{"project": "", "branchName": "test", "description": "Test", "userStories": [{{"id": "US-001", "title": "T", "description": "D", "acceptanceCriteria": [], "priority": 1, "passes": false}}]}}"#
254 )
255 .unwrap();
256 let result = Spec::load(file.path());
257 assert!(result.is_err());
258 assert!(result
259 .unwrap_err()
260 .to_string()
261 .contains("project name is required"));
262 }
263
264 #[test]
269 fn test_next_incomplete_story_returns_lowest_priority_number() {
270 let spec = make_spec(vec![
271 make_story("US-001", 2, false),
272 make_story("US-002", 1, false),
273 ]);
274 let next = spec.next_incomplete_story().unwrap();
275 assert_eq!(next.id, "US-002"); }
277
278 #[test]
279 fn test_next_incomplete_story_skips_completed() {
280 let spec = make_spec(vec![
281 make_story("US-001", 1, true), make_story("US-002", 2, false),
283 make_story("US-003", 3, false),
284 ]);
285 let next = spec.next_incomplete_story().unwrap();
286 assert_eq!(next.id, "US-002");
287 }
288
289 #[test]
290 fn test_next_incomplete_story_returns_none_when_all_complete() {
291 let spec = make_spec(vec![
292 make_story("US-001", 1, true),
293 make_story("US-002", 2, true),
294 ]);
295 assert!(spec.next_incomplete_story().is_none());
296 }
297
298 #[test]
299 fn test_next_incomplete_story_with_single_incomplete() {
300 let spec = make_spec(vec![make_story("US-001", 5, false)]);
301 let next = spec.next_incomplete_story().unwrap();
302 assert_eq!(next.id, "US-001");
303 }
304
305 #[test]
306 fn test_next_incomplete_story_with_same_priority_returns_first_encountered() {
307 let spec = make_spec(vec![
308 make_story("US-001", 1, false),
309 make_story("US-002", 1, false),
310 ]);
311 let next = spec.next_incomplete_story().unwrap();
312 assert_eq!(next.id, "US-001");
314 }
315
316 #[test]
321 fn test_completed_count_with_no_complete() {
322 let spec = make_spec(vec![
323 make_story("US-001", 1, false),
324 make_story("US-002", 2, false),
325 ]);
326 assert_eq!(spec.completed_count(), 0);
327 }
328
329 #[test]
330 fn test_completed_count_with_some_complete() {
331 let spec = make_spec(vec![
332 make_story("US-001", 1, true),
333 make_story("US-002", 2, false),
334 make_story("US-003", 3, true),
335 ]);
336 assert_eq!(spec.completed_count(), 2);
337 }
338
339 #[test]
340 fn test_total_count() {
341 let spec = make_spec(vec![
342 make_story("US-001", 1, false),
343 make_story("US-002", 2, true),
344 make_story("US-003", 3, false),
345 ]);
346 assert_eq!(spec.total_count(), 3);
347 }
348
349 #[test]
350 fn test_all_complete_returns_false_when_incomplete_exists() {
351 let spec = make_spec(vec![
352 make_story("US-001", 1, true),
353 make_story("US-002", 2, false),
354 ]);
355 assert!(!spec.all_complete());
356 }
357
358 #[test]
359 fn test_all_complete_returns_true_when_all_done() {
360 let spec = make_spec(vec![
361 make_story("US-001", 1, true),
362 make_story("US-002", 2, true),
363 ]);
364 assert!(spec.all_complete());
365 }
366
367 #[test]
368 fn test_is_incomplete_inverse_of_all_complete() {
369 let complete_spec = make_spec(vec![make_story("US-001", 1, true)]);
370 let incomplete_spec = make_spec(vec![make_story("US-001", 1, false)]);
371
372 assert!(!complete_spec.is_incomplete());
373 assert!(incomplete_spec.is_incomplete());
374 }
375
376 #[test]
377 fn test_progress_returns_completed_and_total() {
378 let spec = make_spec(vec![
379 make_story("US-001", 1, true),
380 make_story("US-002", 2, true),
381 make_story("US-003", 3, false),
382 make_story("US-004", 4, false),
383 ]);
384 let (completed, total) = spec.progress();
385 assert_eq!(completed, 2);
386 assert_eq!(total, 4);
387 }
388
389 #[test]
394 fn test_mark_story_complete_marks_correct_story() {
395 let mut spec = make_spec(vec![
396 make_story("US-001", 1, false),
397 make_story("US-002", 2, false),
398 ]);
399 spec.mark_story_complete("US-001");
400 assert!(spec.user_stories[0].passes);
401 assert!(!spec.user_stories[1].passes);
402 }
403
404 #[test]
405 fn test_mark_story_complete_nonexistent_id_is_noop() {
406 let mut spec = make_spec(vec![make_story("US-001", 1, false)]);
407 spec.mark_story_complete("US-999"); assert!(!spec.user_stories[0].passes); }
410
411 #[test]
412 fn test_mark_story_complete_already_complete_is_idempotent() {
413 let mut spec = make_spec(vec![make_story("US-001", 1, true)]);
414 spec.mark_story_complete("US-001");
415 assert!(spec.user_stories[0].passes); }
417
418 #[test]
423 fn test_default_branch_name_used_when_missing() {
424 let mut file = NamedTempFile::new().unwrap();
425 writeln!(
427 file,
428 r#"{{"project": "Test", "description": "Test", "userStories": [{{"id": "US-001", "title": "T", "description": "D", "acceptanceCriteria": [], "priority": 1, "passes": false}}]}}"#
429 )
430 .unwrap();
431 let loaded = Spec::load(file.path()).unwrap();
432 assert_eq!(loaded.branch_name, "autom8/feature");
433 }
434}