Skip to main content

autom8/
spec.rs

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    /// Returns true if spec has incomplete stories
90    pub fn is_incomplete(&self) -> bool {
91        !self.all_complete()
92    }
93
94    /// Returns (completed, total) story counts
95    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    // ===========================================
134    // Validation tests
135    // ===========================================
136
137    #[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    // ===========================================
200    // Load and save round-trip tests
201    // ===========================================
202
203    #[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        // Valid JSON but empty project name
251        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    // ===========================================
265    // next_incomplete_story tests
266    // ===========================================
267
268    #[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"); // Lower priority number = higher priority
276    }
277
278    #[test]
279    fn test_next_incomplete_story_skips_completed() {
280        let spec = make_spec(vec![
281            make_story("US-001", 1, true), // completed, lowest priority number
282            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        // With same priority, min_by_key returns first found
313        assert_eq!(next.id, "US-001");
314    }
315
316    // ===========================================
317    // Completion calculation tests
318    // ===========================================
319
320    #[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    // ===========================================
390    // mark_story_complete tests
391    // ===========================================
392
393    #[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"); // doesn't exist
408        assert!(!spec.user_stories[0].passes); // unchanged
409    }
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); // still true
416    }
417
418    // ===========================================
419    // Default branch name test
420    // ===========================================
421
422    #[test]
423    fn test_default_branch_name_used_when_missing() {
424        let mut file = NamedTempFile::new().unwrap();
425        // JSON without branchName field
426        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}