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_config::ConfigContext;
7use ito_domain::errors::{DomainError, DomainResult};
8use ito_domain::tasks::{
9    TaskRepository as DomainTaskRepository, TasksParseResult, parse_tasks_tracking_file,
10    tasks_path_checked, tracking_path_checked,
11};
12
13use crate::templates::{read_change_schema, resolve_schema};
14
15/// Filesystem-backed implementation of the domain `TaskRepository` port.
16pub struct FsTaskRepository<'a, F: FileSystem = StdFs> {
17    ito_path: &'a Path,
18    fs: F,
19}
20
21impl<'a> FsTaskRepository<'a, StdFs> {
22    /// Create a filesystem-backed task repository using the standard filesystem.
23    pub fn new(ito_path: &'a Path) -> Self {
24        Self::with_fs(ito_path, StdFs)
25    }
26}
27
28impl<'a, F: FileSystem> FsTaskRepository<'a, F> {
29    /// Create a filesystem-backed task repository with a custom filesystem.
30    pub fn with_fs(ito_path: &'a Path, fs: F) -> Self {
31        Self { ito_path, fs }
32    }
33}
34
35impl<F: FileSystem> DomainTaskRepository for FsTaskRepository<'_, F> {
36    fn load_tasks(&self, change_id: &str) -> DomainResult<TasksParseResult> {
37        // `read_change_schema` uses `change_id` as a path segment; reject traversal.
38        if tasks_path_checked(self.ito_path, change_id).is_none() {
39            return Ok(TasksParseResult::empty());
40        }
41
42        let schema_name = read_change_schema(self.ito_path, change_id);
43        let mut ctx = ConfigContext::from_process_env();
44        ctx.project_dir = self.ito_path.parent().map(|p| p.to_path_buf());
45
46        let mut tracking_file = "tasks.md".to_string();
47        if let Ok(resolved) = resolve_schema(Some(&schema_name), &ctx)
48            && let Some(apply) = resolved.schema.apply.as_ref()
49            && let Some(tracks) = apply.tracks.as_deref()
50        {
51            tracking_file = tracks.to_string();
52        }
53
54        let Some(path) = tracking_path_checked(self.ito_path, change_id, &tracking_file) else {
55            return Ok(TasksParseResult::empty());
56        };
57        if !self.fs.is_file(&path) {
58            return Ok(TasksParseResult::empty());
59        }
60        let contents = self
61            .fs
62            .read_to_string(&path)
63            .map_err(|source| DomainError::io("reading tasks file", source))?;
64        Ok(parse_tasks_tracking_file(&contents))
65    }
66}
67
68/// Backward-compatible alias for the default filesystem-backed task repository.
69pub type TaskRepository<'a> = FsTaskRepository<'a, StdFs>;
70
71#[cfg(test)]
72mod tests {
73    use std::fs;
74    use std::path::Path;
75
76    use tempfile::TempDir;
77
78    use super::TaskRepository;
79    use ito_domain::tasks::TaskRepository as DomainTaskRepository;
80
81    fn setup_test_change(ito_dir: &Path, change_id: &str, tasks_content: &str) {
82        let change_dir = ito_dir.join("changes").join(change_id);
83        fs::create_dir_all(&change_dir).unwrap();
84        fs::write(change_dir.join("tasks.md"), tasks_content).unwrap();
85    }
86
87    #[test]
88    fn load_tasks_uses_schema_apply_tracks_when_set() {
89        let tmp = TempDir::new().unwrap();
90        let root = tmp.path();
91        let ito_path = root.join(".ito");
92        fs::create_dir_all(&ito_path).unwrap();
93
94        // Override the project schema to point tracking at todo.md.
95        let schema_dir = root
96            .join(".ito")
97            .join("templates")
98            .join("schemas")
99            .join("spec-driven");
100        fs::create_dir_all(&schema_dir).unwrap();
101        fs::write(
102            schema_dir.join("schema.yaml"),
103            "name: spec-driven\nversion: 1\nartifacts: []\napply:\n  tracks: todo.md\n",
104        )
105        .unwrap();
106
107        let change_id = "001-03_tracks";
108        let change_dir = ito_path.join("changes").join(change_id);
109        fs::create_dir_all(&change_dir).unwrap();
110        fs::write(change_dir.join(".ito.yaml"), "schema: spec-driven\n").unwrap();
111        fs::write(
112            change_dir.join("todo.md"),
113            "## Tasks\n- [x] one\n- [ ] two\n",
114        )
115        .unwrap();
116
117        let repo = TaskRepository::new(&ito_path);
118        let (completed, total) = repo.get_task_counts(change_id).unwrap();
119
120        assert_eq!(completed, 1);
121        assert_eq!(total, 2);
122    }
123
124    #[test]
125    fn test_get_task_counts_checkbox_format() {
126        let tmp = TempDir::new().unwrap();
127        let ito_path = tmp.path().join(".ito");
128        fs::create_dir_all(&ito_path).unwrap();
129
130        setup_test_change(
131            &ito_path,
132            "001-01_test",
133            r#"# Tasks
134
135- [x] Task 1
136- [x] Task 2
137- [ ] Task 3
138- [ ] Task 4
139"#,
140        );
141
142        let repo = TaskRepository::new(&ito_path);
143        let (completed, total) = repo.get_task_counts("001-01_test").unwrap();
144
145        assert_eq!(completed, 2);
146        assert_eq!(total, 4);
147    }
148
149    #[test]
150    fn test_get_task_counts_enhanced_format() {
151        let tmp = TempDir::new().unwrap();
152        let ito_path = tmp.path().join(".ito");
153        fs::create_dir_all(&ito_path).unwrap();
154
155        setup_test_change(
156            &ito_path,
157            "001-02_enhanced",
158            r#"# Tasks
159
160## Wave 1
161- **Depends On**: None
162
163### Task 1.1: First task
164- **Status**: [x] complete
165- **Updated At**: 2024-01-01
166
167### Task 1.2: Second task
168- **Status**: [ ] pending
169- **Updated At**: 2024-01-01
170
171### Task 1.3: Third task
172- **Status**: [x] complete
173- **Updated At**: 2024-01-01
174"#,
175        );
176
177        let repo = TaskRepository::new(&ito_path);
178        let (completed, total) = repo.get_task_counts("001-02_enhanced").unwrap();
179
180        assert_eq!(completed, 2);
181        assert_eq!(total, 3);
182    }
183
184    #[test]
185    fn test_missing_tasks_file_returns_zero() {
186        let tmp = TempDir::new().unwrap();
187        let ito_path = tmp.path().join(".ito");
188        fs::create_dir_all(&ito_path).unwrap();
189
190        let repo = TaskRepository::new(&ito_path);
191        let (completed, total) = repo.get_task_counts("nonexistent").unwrap();
192
193        assert_eq!(completed, 0);
194        assert_eq!(total, 0);
195    }
196
197    #[test]
198    fn test_has_tasks() {
199        let tmp = TempDir::new().unwrap();
200        let ito_path = tmp.path().join(".ito");
201        fs::create_dir_all(&ito_path).unwrap();
202
203        setup_test_change(&ito_path, "001-01_with-tasks", "# Tasks\n- [ ] Task 1\n");
204        setup_test_change(&ito_path, "001-02_no-tasks", "# Tasks\n\nNo tasks yet.\n");
205
206        let repo = TaskRepository::new(&ito_path);
207
208        assert!(repo.has_tasks("001-01_with-tasks").unwrap());
209        assert!(!repo.has_tasks("001-02_no-tasks").unwrap());
210        assert!(!repo.has_tasks("nonexistent").unwrap());
211    }
212}