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, is_safe_tracking_filename,
10    parse_tasks_tracking_file, tasks_path_checked, tracking_path_checked,
11};
12
13use crate::templates::{default_schema_name, 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    /// Load tasks from an explicit change directory.
35    pub(crate) fn load_tasks_from_dir(&self, change_dir: &Path) -> DomainResult<TasksParseResult> {
36        let schema_name = read_schema_from_dir(&self.fs, change_dir);
37        let mut ctx = ConfigContext::from_process_env();
38        ctx.project_dir = self.ito_path.parent().map(|p| p.to_path_buf());
39
40        let mut tracking_file = "tasks.md".to_string();
41        if let Ok(resolved) = resolve_schema(Some(&schema_name), &ctx)
42            && let Some(apply) = resolved.schema.apply.as_ref()
43            && let Some(tracks) = apply.tracks.as_deref()
44            && is_safe_tracking_filename(tracks)
45        {
46            tracking_file = tracks.to_string();
47        }
48
49        if !is_safe_tracking_filename(&tracking_file) {
50            return Ok(TasksParseResult::empty());
51        }
52
53        let path = change_dir.join(&tracking_file);
54        if !self.fs.is_file(&path) {
55            return Ok(TasksParseResult::empty());
56        }
57
58        let contents = self
59            .fs
60            .read_to_string(&path)
61            .map_err(|source| DomainError::io("reading tasks file", source))?;
62        Ok(parse_tasks_tracking_file(&contents))
63    }
64}
65
66fn read_schema_from_dir<F: FileSystem>(fs: &F, change_dir: &Path) -> String {
67    let meta = change_dir.join(".ito.yaml");
68    if fs.is_file(&meta)
69        && let Ok(contents) = fs.read_to_string(&meta)
70    {
71        for line in contents.lines() {
72            let l = line.trim();
73            if let Some(rest) = l.strip_prefix("schema:") {
74                let v = rest.trim();
75                if !v.is_empty() {
76                    return v.to_string();
77                }
78            }
79        }
80    }
81
82    default_schema_name().to_string()
83}
84
85impl<F: FileSystem> DomainTaskRepository for FsTaskRepository<'_, F> {
86    fn load_tasks(&self, change_id: &str) -> DomainResult<TasksParseResult> {
87        // `read_change_schema` uses `change_id` as a path segment; reject traversal.
88        if tasks_path_checked(self.ito_path, change_id).is_none() {
89            return Ok(TasksParseResult::empty());
90        }
91
92        let schema_name = read_change_schema(self.ito_path, change_id);
93        let mut ctx = ConfigContext::from_process_env();
94        ctx.project_dir = self.ito_path.parent().map(|p| p.to_path_buf());
95
96        let mut tracking_file = "tasks.md".to_string();
97        if let Ok(resolved) = resolve_schema(Some(&schema_name), &ctx)
98            && let Some(apply) = resolved.schema.apply.as_ref()
99            && let Some(tracks) = apply.tracks.as_deref()
100        {
101            tracking_file = tracks.to_string();
102        }
103
104        let Some(path) = tracking_path_checked(self.ito_path, change_id, &tracking_file) else {
105            return Ok(TasksParseResult::empty());
106        };
107        if !self.fs.is_file(&path) {
108            return Ok(TasksParseResult::empty());
109        }
110        let contents = self
111            .fs
112            .read_to_string(&path)
113            .map_err(|source| DomainError::io("reading tasks file", source))?;
114        Ok(parse_tasks_tracking_file(&contents))
115    }
116}
117
118/// Backward-compatible alias for the default filesystem-backed task repository.
119pub type TaskRepository<'a> = FsTaskRepository<'a, StdFs>;
120
121#[cfg(test)]
122mod tests {
123    use std::fs;
124    use std::path::Path;
125
126    use tempfile::TempDir;
127
128    use super::TaskRepository;
129    use ito_domain::tasks::TaskRepository as DomainTaskRepository;
130
131    fn setup_test_change(ito_dir: &Path, change_id: &str, tasks_content: &str) {
132        let change_dir = ito_dir.join("changes").join(change_id);
133        fs::create_dir_all(&change_dir).unwrap();
134        fs::write(change_dir.join("tasks.md"), tasks_content).unwrap();
135    }
136
137    #[test]
138    fn load_tasks_uses_schema_apply_tracks_when_set() {
139        let tmp = TempDir::new().unwrap();
140        let root = tmp.path();
141        let ito_path = root.join(".ito");
142        fs::create_dir_all(&ito_path).unwrap();
143
144        // Override the project schema to point tracking at todo.md.
145        let schema_dir = root
146            .join(".ito")
147            .join("templates")
148            .join("schemas")
149            .join("spec-driven");
150        fs::create_dir_all(&schema_dir).unwrap();
151        fs::write(
152            schema_dir.join("schema.yaml"),
153            "name: spec-driven\nversion: 1\nartifacts: []\napply:\n  tracks: todo.md\n",
154        )
155        .unwrap();
156
157        let change_id = "001-03_tracks";
158        let change_dir = ito_path.join("changes").join(change_id);
159        fs::create_dir_all(&change_dir).unwrap();
160        fs::write(change_dir.join(".ito.yaml"), "schema: spec-driven\n").unwrap();
161        fs::write(
162            change_dir.join("todo.md"),
163            "## Tasks\n- [x] one\n- [ ] two\n",
164        )
165        .unwrap();
166
167        let repo = TaskRepository::new(&ito_path);
168        let (completed, total) = repo.get_task_counts(change_id).unwrap();
169
170        assert_eq!(completed, 1);
171        assert_eq!(total, 2);
172    }
173
174    #[test]
175    fn test_get_task_counts_checkbox_format() {
176        let tmp = TempDir::new().unwrap();
177        let ito_path = tmp.path().join(".ito");
178        fs::create_dir_all(&ito_path).unwrap();
179
180        setup_test_change(
181            &ito_path,
182            "001-01_test",
183            r#"# Tasks
184
185- [x] Task 1
186- [x] Task 2
187- [ ] Task 3
188- [ ] Task 4
189"#,
190        );
191
192        let repo = TaskRepository::new(&ito_path);
193        let (completed, total) = repo.get_task_counts("001-01_test").unwrap();
194
195        assert_eq!(completed, 2);
196        assert_eq!(total, 4);
197    }
198
199    #[test]
200    fn test_get_task_counts_enhanced_format() {
201        let tmp = TempDir::new().unwrap();
202        let ito_path = tmp.path().join(".ito");
203        fs::create_dir_all(&ito_path).unwrap();
204
205        setup_test_change(
206            &ito_path,
207            "001-02_enhanced",
208            r#"# Tasks
209
210## Wave 1
211- **Depends On**: None
212
213### Task 1.1: First task
214- **Status**: [x] complete
215- **Updated At**: 2024-01-01
216
217### Task 1.2: Second task
218- **Status**: [ ] pending
219- **Updated At**: 2024-01-01
220
221### Task 1.3: Third task
222- **Status**: [x] complete
223- **Updated At**: 2024-01-01
224"#,
225        );
226
227        let repo = TaskRepository::new(&ito_path);
228        let (completed, total) = repo.get_task_counts("001-02_enhanced").unwrap();
229
230        assert_eq!(completed, 2);
231        assert_eq!(total, 3);
232    }
233
234    #[test]
235    fn test_missing_tasks_file_returns_zero() {
236        let tmp = TempDir::new().unwrap();
237        let ito_path = tmp.path().join(".ito");
238        fs::create_dir_all(&ito_path).unwrap();
239
240        let repo = TaskRepository::new(&ito_path);
241        let (completed, total) = repo.get_task_counts("nonexistent").unwrap();
242
243        assert_eq!(completed, 0);
244        assert_eq!(total, 0);
245    }
246
247    #[test]
248    fn test_has_tasks() {
249        let tmp = TempDir::new().unwrap();
250        let ito_path = tmp.path().join(".ito");
251        fs::create_dir_all(&ito_path).unwrap();
252
253        setup_test_change(&ito_path, "001-01_with-tasks", "# Tasks\n- [ ] Task 1\n");
254        setup_test_change(&ito_path, "001-02_no-tasks", "# Tasks\n\nNo tasks yet.\n");
255
256        let repo = TaskRepository::new(&ito_path);
257
258        assert!(repo.has_tasks("001-01_with-tasks").unwrap());
259        assert!(!repo.has_tasks("001-02_no-tasks").unwrap());
260        assert!(!repo.has_tasks("nonexistent").unwrap());
261    }
262}