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, parse_tasks_tracking_file,
10 tasks_path_checked, tracking_path_checked,
11};
12
13use crate::templates::{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
35impl<F: FileSystem> DomainTaskRepository for FsTaskRepository<'_, F> {
36 fn load_tasks(&self, change_id: &str) -> DomainResult<TasksParseResult> {
37 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
68pub 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 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}