1use std::path::{Path, PathBuf};
12
13use joy_core::items::title_hash_suffix;
14use joy_core::model::item::item_filename;
15use joy_core::store::{read_yaml, write_yaml};
16
17use crate::error::JynError;
18use crate::model::Task;
19
20pub const JYN_DIR: &str = ".jyn";
21pub const ITEMS_DIR: &str = "items";
22pub const ACRONYM: &str = "TODO";
23
24pub fn jyn_dir(root: &Path) -> PathBuf {
25 root.join(JYN_DIR)
26}
27
28pub fn find_workspace_root(start: &Path) -> Option<PathBuf> {
35 let mut dir = Some(start);
36 while let Some(d) = dir {
37 if d.join(JYN_DIR).is_dir() {
38 return Some(d.to_path_buf());
39 }
40 dir = d.parent();
41 }
42 None
43}
44
45pub fn items_dir(root: &Path) -> PathBuf {
46 jyn_dir(root).join(ITEMS_DIR)
47}
48
49pub fn ensure_items_dir(root: &Path) -> Result<(), JynError> {
51 let dir = items_dir(root);
52 std::fs::create_dir_all(&dir)
53 .map_err(|e| JynError::Other(format!("cannot create {}: {}", dir.display(), e)))
54}
55
56pub fn save_task(root: &Path, task: &Task) -> Result<(), JynError> {
58 ensure_items_dir(root)?;
59 let filename = item_filename(&task.item.id, &task.item.title);
60 let path = items_dir(root).join(filename);
61 write_yaml(&path, task)?;
62 Ok(())
63}
64
65pub fn load_tasks(root: &Path) -> Result<Vec<Task>, JynError> {
67 let dir = items_dir(root);
68 if !dir.is_dir() {
69 return Ok(Vec::new());
70 }
71 let mut entries: Vec<_> = std::fs::read_dir(&dir)
72 .map_err(|e| JynError::Other(format!("cannot read {}: {}", dir.display(), e)))?
73 .filter_map(|e| e.ok())
74 .filter(|e| {
75 e.path()
76 .extension()
77 .is_some_and(|ext| ext == "yaml" || ext == "yml")
78 })
79 .collect();
80 entries.sort_by_key(|e| e.file_name());
81
82 let mut tasks = Vec::with_capacity(entries.len());
83 for entry in entries {
84 let task: Task = read_yaml(&entry.path())?;
85 tasks.push(task);
86 }
87 Ok(tasks)
88}
89
90pub fn find_task_file(root: &Path, id: &str) -> Result<PathBuf, JynError> {
94 let dir = items_dir(root);
95 if !dir.is_dir() {
96 return Err(JynError::Other(format!("task not found: {id}")));
97 }
98 let normalized = crate::display::normalize_id_input(id);
99 let prefix = format!("{normalized}-");
100
101 let matches: Vec<PathBuf> = std::fs::read_dir(&dir)
102 .map_err(|e| JynError::Other(format!("cannot read {}: {}", dir.display(), e)))?
103 .filter_map(|e| e.ok())
104 .map(|e| e.path())
105 .filter(|p| {
106 p.file_name()
107 .map(|n| n.to_string_lossy().to_uppercase().starts_with(&prefix))
108 .unwrap_or(false)
109 })
110 .collect();
111
112 match matches.len() {
113 0 => Err(JynError::Other(format!("task not found: {id}"))),
114 1 => Ok(matches.into_iter().next().unwrap()),
115 _ => Err(JynError::Other(format!(
116 "ambiguous ID {id}: {} matches",
117 matches.len()
118 ))),
119 }
120}
121
122pub fn load_task(root: &Path, id: &str) -> Result<Task, JynError> {
124 let path = find_task_file(root, id)?;
125 Ok(read_yaml(&path)?)
126}
127
128pub fn update_task(root: &Path, task: &Task) -> Result<(), JynError> {
131 let old_path = find_task_file(root, &task.item.id)?;
132 save_task(root, task)?;
133 let new_path = items_dir(root).join(item_filename(&task.item.id, &task.item.title));
134 if old_path != new_path {
135 let _ = std::fs::remove_file(&old_path);
136 }
137 Ok(())
138}
139
140pub fn delete_task(root: &Path, id: &str) -> Result<Task, JynError> {
142 let path = find_task_file(root, id)?;
143 let task: Task = read_yaml(&path)?;
144 std::fs::remove_file(&path)
145 .map_err(|e| JynError::Other(format!("cannot remove {}: {}", path.display(), e)))?;
146 Ok(task)
147}
148
149pub fn next_id(root: &Path, title: &str) -> Result<String, JynError> {
151 let suffix = title_hash_suffix(title);
152 let dir = items_dir(root);
153 if !dir.is_dir() {
154 return Ok(format!("{ACRONYM}-0001-{suffix}"));
155 }
156
157 let prefix = format!("{ACRONYM}-");
158 let mut max_num: u16 = 0;
159 for entry in std::fs::read_dir(&dir)
160 .map_err(|e| JynError::Other(format!("cannot read {}: {}", dir.display(), e)))?
161 .filter_map(|e| e.ok())
162 {
163 let name = entry.file_name();
164 let name = name.to_string_lossy();
165 if let Some(rest) = name.strip_prefix(&prefix) {
166 if let Some(hex) = rest.get(..4) {
167 if let Ok(n) = u16::from_str_radix(hex, 16) {
168 max_num = max_num.max(n);
169 }
170 }
171 }
172 }
173
174 let next = max_num.checked_add(1).ok_or_else(|| {
175 JynError::Other(format!("{ACRONYM} ID space exhausted (max {ACRONYM}-FFFF)"))
176 })?;
177 Ok(format!("{ACRONYM}-{next:04X}-{suffix}"))
178}
179
180#[cfg(test)]
181mod tests {
182 use super::*;
183 use tempfile::tempdir;
184
185 fn make_task(id: &str, title: &str) -> Task {
186 Task::new(id.into(), title.into())
187 }
188
189 #[test]
190 fn next_id_first_in_empty_dir() {
191 let dir = tempdir().unwrap();
192 let id = next_id(dir.path(), "Buy milk").unwrap();
193 assert!(id.starts_with("TODO-0001-"), "got: {id}");
194 assert_eq!(id.len(), 12);
195 }
196
197 #[test]
198 fn next_id_increments_after_save() {
199 let dir = tempdir().unwrap();
200 let t1 = make_task("TODO-0001-A3", "First");
201 save_task(dir.path(), &t1).unwrap();
202 let id2 = next_id(dir.path(), "Second").unwrap();
203 assert!(id2.starts_with("TODO-0002-"), "got: {id2}");
204 }
205
206 #[test]
207 fn save_then_load_roundtrip() {
208 let dir = tempdir().unwrap();
209 let id = next_id(dir.path(), "Buy milk").unwrap();
210 let task = make_task(&id, "Buy milk");
211 save_task(dir.path(), &task).unwrap();
212 let loaded = load_tasks(dir.path()).unwrap();
213 assert_eq!(loaded.len(), 1);
214 assert_eq!(loaded[0].item.id, id);
215 assert_eq!(loaded[0].item.title, "Buy milk");
216 }
217
218 #[test]
219 fn load_tasks_empty_returns_empty() {
220 let dir = tempdir().unwrap();
221 let loaded = load_tasks(dir.path()).unwrap();
222 assert!(loaded.is_empty());
223 }
224
225 #[test]
226 fn delete_removes_file() {
227 let dir = tempdir().unwrap();
228 let id = next_id(dir.path(), "Temp").unwrap();
229 let task = make_task(&id, "Temp");
230 save_task(dir.path(), &task).unwrap();
231 let deleted = delete_task(dir.path(), &id).unwrap();
232 assert_eq!(deleted.item.id, id);
233 assert!(load_tasks(dir.path()).unwrap().is_empty());
234 }
235
236 #[test]
237 fn find_task_file_short_form() {
238 let dir = tempdir().unwrap();
239 let id = next_id(dir.path(), "Short form").unwrap();
240 let task = make_task(&id, "Short form");
241 save_task(dir.path(), &task).unwrap();
242 let short = &id[..9]; let path = find_task_file(dir.path(), short).unwrap();
244 assert!(path
245 .file_name()
246 .unwrap()
247 .to_string_lossy()
248 .starts_with(short));
249 }
250
251 #[test]
252 fn find_workspace_root_walks_up() {
253 let dir = tempdir().unwrap();
254 let root = dir.path();
255 std::fs::create_dir_all(root.join(JYN_DIR)).unwrap();
256 let nested = root.join("a").join("b");
257 std::fs::create_dir_all(&nested).unwrap();
258
259 let found = find_workspace_root(&nested).unwrap();
261 assert_eq!(found.canonicalize().unwrap(), root.canonicalize().unwrap());
264 }
265
266 #[test]
267 fn find_workspace_root_none_when_absent() {
268 let dir = tempdir().unwrap();
269 assert!(find_workspace_root(dir.path()).is_none());
271 }
272
273 #[test]
274 fn find_task_file_missing_errors() {
275 let dir = tempdir().unwrap();
276 ensure_items_dir(dir.path()).unwrap();
277 let err = find_task_file(dir.path(), "TODO-9999");
278 assert!(err.is_err());
279 }
280}