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