ito_core/
task_repository.rs1use 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
15pub struct FsTaskRepository<'a, F: FileSystem = StdFs> {
17 ito_path: &'a Path,
18 fs: F,
19}
20
21impl<'a> FsTaskRepository<'a, StdFs> {
22 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 pub fn with_fs(ito_path: &'a Path, fs: F) -> Self {
31 Self { ito_path, fs }
32 }
33
34 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 = crate::change_meta::read_change_meta_from_dir(fs, change_dir);
68 meta.schema
69 .unwrap_or_else(|| default_schema_name().to_string())
70}
71
72impl<F: FileSystem> DomainTaskRepository for FsTaskRepository<'_, F> {
73 fn load_tasks(&self, change_id: &str) -> DomainResult<TasksParseResult> {
74 if tasks_path_checked(self.ito_path, change_id).is_none() {
76 return Ok(TasksParseResult::empty());
77 }
78
79 let schema_name = read_change_schema(self.ito_path, change_id);
80 let mut ctx = ConfigContext::from_process_env();
81 ctx.project_dir = self.ito_path.parent().map(|p| p.to_path_buf());
82
83 let mut tracking_file = "tasks.md".to_string();
84 if let Ok(resolved) = resolve_schema(Some(&schema_name), &ctx)
85 && let Some(apply) = resolved.schema.apply.as_ref()
86 && let Some(tracks) = apply.tracks.as_deref()
87 {
88 tracking_file = tracks.to_string();
89 }
90
91 let Some(path) = tracking_path_checked(self.ito_path, change_id, &tracking_file) else {
92 return Ok(TasksParseResult::empty());
93 };
94 if !self.fs.is_file(&path) {
95 return Ok(TasksParseResult::empty());
96 }
97 let contents = self
98 .fs
99 .read_to_string(&path)
100 .map_err(|source| DomainError::io("reading tasks file", source))?;
101 Ok(parse_tasks_tracking_file(&contents))
102 }
103}
104
105pub type TaskRepository<'a> = FsTaskRepository<'a, StdFs>;
107
108#[cfg(test)]
109mod tests {
110 use std::fs;
111 use std::path::Path;
112
113 use tempfile::TempDir;
114
115 use super::TaskRepository;
116 use ito_domain::tasks::TaskRepository as DomainTaskRepository;
117
118 fn setup_test_change(ito_dir: &Path, change_id: &str, tasks_content: &str) {
119 let change_dir = ito_dir.join("changes").join(change_id);
120 fs::create_dir_all(&change_dir).unwrap();
121 fs::write(change_dir.join("tasks.md"), tasks_content).unwrap();
122 }
123
124 #[test]
125 fn load_tasks_uses_schema_apply_tracks_when_set() {
126 let tmp = TempDir::new().unwrap();
127 let root = tmp.path();
128 let ito_path = root.join(".ito");
129 fs::create_dir_all(&ito_path).unwrap();
130
131 let schema_dir = root
133 .join(".ito")
134 .join("templates")
135 .join("schemas")
136 .join("spec-driven");
137 fs::create_dir_all(&schema_dir).unwrap();
138 fs::write(
139 schema_dir.join("schema.yaml"),
140 "name: spec-driven\nversion: 1\nartifacts: []\napply:\n tracks: todo.md\n",
141 )
142 .unwrap();
143
144 let change_id = "001-03_tracks";
145 let change_dir = ito_path.join("changes").join(change_id);
146 fs::create_dir_all(&change_dir).unwrap();
147 fs::write(change_dir.join(".ito.yaml"), "schema: spec-driven\n").unwrap();
148 fs::write(
149 change_dir.join("todo.md"),
150 "## Tasks\n- [x] one\n- [ ] two\n",
151 )
152 .unwrap();
153
154 let repo = TaskRepository::new(&ito_path);
155 let (completed, total) = repo.get_task_counts(change_id).unwrap();
156
157 assert_eq!(completed, 1);
158 assert_eq!(total, 2);
159 }
160
161 #[test]
162 fn test_get_task_counts_checkbox_format() {
163 let tmp = TempDir::new().unwrap();
164 let ito_path = tmp.path().join(".ito");
165 fs::create_dir_all(&ito_path).unwrap();
166
167 setup_test_change(
168 &ito_path,
169 "001-01_test",
170 r#"# Tasks
171
172- [x] Task 1
173- [x] Task 2
174- [ ] Task 3
175- [ ] Task 4
176"#,
177 );
178
179 let repo = TaskRepository::new(&ito_path);
180 let (completed, total) = repo.get_task_counts("001-01_test").unwrap();
181
182 assert_eq!(completed, 2);
183 assert_eq!(total, 4);
184 }
185
186 #[test]
187 fn test_get_task_counts_enhanced_format() {
188 let tmp = TempDir::new().unwrap();
189 let ito_path = tmp.path().join(".ito");
190 fs::create_dir_all(&ito_path).unwrap();
191
192 setup_test_change(
193 &ito_path,
194 "001-02_enhanced",
195 r#"# Tasks
196
197## Wave 1
198- **Depends On**: None
199
200### Task 1.1: First task
201- **Status**: [x] complete
202- **Updated At**: 2024-01-01
203
204### Task 1.2: Second task
205- **Status**: [ ] pending
206- **Updated At**: 2024-01-01
207
208### Task 1.3: Third task
209- **Status**: [x] complete
210- **Updated At**: 2024-01-01
211"#,
212 );
213
214 let repo = TaskRepository::new(&ito_path);
215 let (completed, total) = repo.get_task_counts("001-02_enhanced").unwrap();
216
217 assert_eq!(completed, 2);
218 assert_eq!(total, 3);
219 }
220
221 #[test]
222 fn test_missing_tasks_file_returns_zero() {
223 let tmp = TempDir::new().unwrap();
224 let ito_path = tmp.path().join(".ito");
225 fs::create_dir_all(&ito_path).unwrap();
226
227 let repo = TaskRepository::new(&ito_path);
228 let (completed, total) = repo.get_task_counts("nonexistent").unwrap();
229
230 assert_eq!(completed, 0);
231 assert_eq!(total, 0);
232 }
233
234 #[test]
235 fn test_has_tasks() {
236 let tmp = TempDir::new().unwrap();
237 let ito_path = tmp.path().join(".ito");
238 fs::create_dir_all(&ito_path).unwrap();
239
240 setup_test_change(&ito_path, "001-01_with-tasks", "# Tasks\n- [ ] Task 1\n");
241 setup_test_change(&ito_path, "001-02_no-tasks", "# Tasks\n\nNo tasks yet.\n");
242
243 let repo = TaskRepository::new(&ito_path);
244
245 assert!(repo.has_tasks("001-01_with-tasks").unwrap());
246 assert!(!repo.has_tasks("001-02_no-tasks").unwrap());
247 assert!(!repo.has_tasks("nonexistent").unwrap());
248 }
249}