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