Skip to main content

ito_core/
task_repository.rs

1//! Filesystem-backed task repository implementation.
2
3use std::path::Path;
4
5use ito_common::fs::{FileSystem, StdFs};
6use ito_domain::errors::{DomainError, DomainResult};
7use ito_domain::tasks::{
8    TaskRepository as DomainTaskRepository, TasksParseResult, parse_tasks_tracking_file, tasks_path,
9};
10
11/// Filesystem-backed implementation of the domain `TaskRepository` port.
12pub struct FsTaskRepository<'a, F: FileSystem = StdFs> {
13    ito_path: &'a Path,
14    fs: F,
15}
16
17impl<'a> FsTaskRepository<'a, StdFs> {
18    /// Create a filesystem-backed task repository using the standard filesystem.
19    pub fn new(ito_path: &'a Path) -> Self {
20        Self::with_fs(ito_path, StdFs)
21    }
22}
23
24impl<'a, F: FileSystem> FsTaskRepository<'a, F> {
25    /// Create a filesystem-backed task repository with a custom filesystem.
26    pub fn with_fs(ito_path: &'a Path, fs: F) -> Self {
27        Self { ito_path, fs }
28    }
29}
30
31impl<F: FileSystem> DomainTaskRepository for FsTaskRepository<'_, F> {
32    fn load_tasks(&self, change_id: &str) -> DomainResult<TasksParseResult> {
33        let path = tasks_path(self.ito_path, change_id);
34        if !self.fs.is_file(&path) {
35            return Ok(TasksParseResult::empty());
36        }
37        let contents = self
38            .fs
39            .read_to_string(&path)
40            .map_err(|source| DomainError::io("reading tasks file", source))?;
41        Ok(parse_tasks_tracking_file(&contents))
42    }
43}
44
45/// Backward-compatible alias for the default filesystem-backed task repository.
46pub type TaskRepository<'a> = FsTaskRepository<'a, StdFs>;
47
48#[cfg(test)]
49mod tests {
50    use std::fs;
51    use std::path::Path;
52
53    use tempfile::TempDir;
54
55    use super::TaskRepository;
56    use ito_domain::tasks::TaskRepository as DomainTaskRepository;
57
58    fn setup_test_change(ito_dir: &Path, change_id: &str, tasks_content: &str) {
59        let change_dir = ito_dir.join("changes").join(change_id);
60        fs::create_dir_all(&change_dir).unwrap();
61        fs::write(change_dir.join("tasks.md"), tasks_content).unwrap();
62    }
63
64    #[test]
65    fn test_get_task_counts_checkbox_format() {
66        let tmp = TempDir::new().unwrap();
67        let ito_path = tmp.path().join(".ito");
68        fs::create_dir_all(&ito_path).unwrap();
69
70        setup_test_change(
71            &ito_path,
72            "001-01_test",
73            r#"# Tasks
74
75- [x] Task 1
76- [x] Task 2
77- [ ] Task 3
78- [ ] Task 4
79"#,
80        );
81
82        let repo = TaskRepository::new(&ito_path);
83        let (completed, total) = repo.get_task_counts("001-01_test").unwrap();
84
85        assert_eq!(completed, 2);
86        assert_eq!(total, 4);
87    }
88
89    #[test]
90    fn test_get_task_counts_enhanced_format() {
91        let tmp = TempDir::new().unwrap();
92        let ito_path = tmp.path().join(".ito");
93        fs::create_dir_all(&ito_path).unwrap();
94
95        setup_test_change(
96            &ito_path,
97            "001-02_enhanced",
98            r#"# Tasks
99
100## Wave 1
101- **Depends On**: None
102
103### Task 1.1: First task
104- **Status**: [x] complete
105- **Updated At**: 2024-01-01
106
107### Task 1.2: Second task
108- **Status**: [ ] pending
109- **Updated At**: 2024-01-01
110
111### Task 1.3: Third task
112- **Status**: [x] complete
113- **Updated At**: 2024-01-01
114"#,
115        );
116
117        let repo = TaskRepository::new(&ito_path);
118        let (completed, total) = repo.get_task_counts("001-02_enhanced").unwrap();
119
120        assert_eq!(completed, 2);
121        assert_eq!(total, 3);
122    }
123
124    #[test]
125    fn test_missing_tasks_file_returns_zero() {
126        let tmp = TempDir::new().unwrap();
127        let ito_path = tmp.path().join(".ito");
128        fs::create_dir_all(&ito_path).unwrap();
129
130        let repo = TaskRepository::new(&ito_path);
131        let (completed, total) = repo.get_task_counts("nonexistent").unwrap();
132
133        assert_eq!(completed, 0);
134        assert_eq!(total, 0);
135    }
136
137    #[test]
138    fn test_has_tasks() {
139        let tmp = TempDir::new().unwrap();
140        let ito_path = tmp.path().join(".ito");
141        fs::create_dir_all(&ito_path).unwrap();
142
143        setup_test_change(&ito_path, "001-01_with-tasks", "# Tasks\n- [ ] Task 1\n");
144        setup_test_change(&ito_path, "001-02_no-tasks", "# Tasks\n\nNo tasks yet.\n");
145
146        let repo = TaskRepository::new(&ito_path);
147
148        assert!(repo.has_tasks("001-01_with-tasks").unwrap());
149        assert!(!repo.has_tasks("001-02_no-tasks").unwrap());
150        assert!(!repo.has_tasks("nonexistent").unwrap());
151    }
152}