1use std::path::Path;
5
6use crate::error::JoyError;
7use crate::model::item::{item_filename, Item};
8use crate::store;
9
10pub fn load_items(root: &Path) -> Result<Vec<Item>, JoyError> {
12 let items_dir = store::joy_dir(root).join(store::ITEMS_DIR);
13 if !items_dir.is_dir() {
14 return Ok(Vec::new());
15 }
16
17 let mut items = Vec::new();
18 let mut entries: Vec<_> = std::fs::read_dir(&items_dir)
19 .map_err(|e| JoyError::ReadFile {
20 path: items_dir.clone(),
21 source: e,
22 })?
23 .filter_map(|e| e.ok())
24 .filter(|e| {
25 e.path()
26 .extension()
27 .is_some_and(|ext| ext == "yaml" || ext == "yml")
28 })
29 .collect();
30
31 entries.sort_by_key(|e| e.file_name());
32
33 for entry in entries {
34 let item: Item = store::read_yaml(&entry.path())?;
35 items.push(item);
36 }
37
38 Ok(items)
39}
40
41pub fn save_item(root: &Path, item: &Item) -> Result<(), JoyError> {
43 let items_dir = store::joy_dir(root).join(store::ITEMS_DIR);
44 let filename = item_filename(&item.id, &item.title);
45 let path = items_dir.join(&filename);
46 store::write_yaml(&path, item)?;
47 let rel = format!("{}/{}/{}", store::JOY_DIR, store::ITEMS_DIR, filename);
48 crate::git_ops::auto_git_add(root, &[&rel]);
49 Ok(())
50}
51
52pub fn next_id(root: &Path, acronym: &str, title: &str) -> Result<String, JoyError> {
59 let prefix = acronym;
60
61 let items_dir = store::joy_dir(root).join(store::ITEMS_DIR);
62 if !items_dir.is_dir() {
63 let suffix = title_hash_suffix(title);
64 return Ok(format!("{prefix}-0001-{suffix}"));
65 }
66
67 let mut max_num: u16 = 0;
68
69 let entries = std::fs::read_dir(&items_dir).map_err(|e| JoyError::ReadFile {
70 path: items_dir.clone(),
71 source: e,
72 })?;
73
74 for entry in entries.filter_map(|e| e.ok()) {
75 let name = entry.file_name();
76 let name = name.to_string_lossy();
77 if let Some(hex_part) = name.strip_prefix(&format!("{prefix}-")) {
78 if let Some(hex_str) = hex_part.get(..4) {
79 if let Ok(num) = u16::from_str_radix(hex_str, 16) {
80 max_num = max_num.max(num);
81 }
82 }
83 }
84 }
85
86 let next = max_num.checked_add(1).ok_or_else(|| {
87 JoyError::Other(format!("{prefix} ID space exhausted (max {prefix}-FFFF)"))
88 })?;
89 let suffix = title_hash_suffix(title);
90 Ok(format!("{prefix}-{next:04X}-{suffix}"))
91}
92
93pub fn title_hash_suffix(title: &str) -> String {
95 use sha2::{Digest, Sha256};
96 let mut hasher = Sha256::new();
97 hasher.update(title.as_bytes());
98 let hash = hasher.finalize();
99 format!("{:02X}", hash[0])
100}
101
102pub fn find_item_file(root: &Path, id: &str) -> Result<std::path::PathBuf, JoyError> {
106 let items_dir = store::joy_dir(root).join(store::ITEMS_DIR);
107
108 let id_upper = id.to_uppercase();
110
111 let entries: Vec<_> = std::fs::read_dir(&items_dir)
112 .map_err(|e| JoyError::ReadFile {
113 path: items_dir.clone(),
114 source: e,
115 })?
116 .filter_map(|e| e.ok())
117 .collect();
118
119 let exact_prefix = format!("{}-", id_upper);
121 for entry in &entries {
122 let name = entry.file_name();
123 let name_upper = name.to_string_lossy().to_uppercase();
124 if name_upper.starts_with(&exact_prefix) {
125 return Ok(entry.path());
126 }
127 }
128
129 let short_prefix = format!("{}-", id_upper);
132 let mut matches: Vec<std::path::PathBuf> = Vec::new();
133 for entry in &entries {
134 let name = entry.file_name();
135 let name_upper = name.to_string_lossy().to_uppercase();
136 if name_upper.starts_with(&short_prefix) {
137 matches.push(entry.path());
138 }
139 }
140
141 match matches.len() {
142 0 => Err(JoyError::ItemNotFound(id.to_string())),
143 1 => Ok(matches.into_iter().next().unwrap()),
144 _ => {
145 let ids: Vec<String> = matches
147 .iter()
148 .filter_map(|p| {
149 let name = p.file_name()?.to_string_lossy().to_string();
150 extract_full_id(&name)
151 })
152 .collect();
153 Err(JoyError::Other(format!("ambiguous ID: {}", ids.join(", "))))
154 }
155 }
156}
157
158fn extract_full_id(filename: &str) -> Option<String> {
162 let name = filename
164 .strip_suffix(".yaml")
165 .or_else(|| filename.strip_suffix(".yml"))?;
166 let parts: Vec<&str> = name.splitn(2, '-').collect();
168 if parts.len() < 2 {
169 return None;
170 }
171 let acronym = parts[0];
172 let rest = parts[1];
173
174 if rest.len() >= 7 && rest.as_bytes()[4] == b'-' {
176 let hex4 = &rest[..4];
178 let maybe_suffix = &rest[5..7];
179 if u16::from_str_radix(hex4, 16).is_ok()
180 && maybe_suffix.len() == 2
181 && u8::from_str_radix(maybe_suffix, 16).is_ok()
182 && (rest.len() == 7 || rest.as_bytes()[7] == b'-')
183 {
184 return Some(format!("{}-{}-{}", acronym, hex4, maybe_suffix).to_uppercase());
185 }
186 }
187
188 let hex4 = &rest[..4.min(rest.len())];
190 if hex4.len() == 4 && u16::from_str_radix(hex4, 16).is_ok() {
191 return Some(format!("{}-{}", acronym, hex4).to_uppercase());
192 }
193
194 None
195}
196
197pub fn load_item(root: &Path, id: &str) -> Result<Item, JoyError> {
199 let path = find_item_file(root, id)?;
200 store::read_yaml(&path)
201}
202
203pub fn delete_item(root: &Path, id: &str) -> Result<Item, JoyError> {
205 let path = find_item_file(root, id)?;
206 let item: Item = store::read_yaml(&path)?;
207 let rel = path
208 .strip_prefix(root)
209 .unwrap_or(&path)
210 .to_string_lossy()
211 .to_string();
212 std::fs::remove_file(&path).map_err(|e| JoyError::WriteFile { path, source: e })?;
213 crate::git_ops::auto_git_add(root, &[&rel]);
214 Ok(item)
215}
216
217pub fn remove_references(root: &Path, deleted_id: &str) -> Result<Vec<String>, JoyError> {
219 let items = load_items(root)?;
220 let mut updated = Vec::new();
221 for mut item in items {
222 let mut changed = false;
223 if item.deps.contains(&deleted_id.to_string()) {
224 item.deps.retain(|d| d != deleted_id);
225 changed = true;
226 }
227 if item.parent.as_deref() == Some(deleted_id) {
228 item.parent = None;
229 changed = true;
230 }
231 if changed {
232 item.updated = chrono::Utc::now();
233 update_item(root, &item)?;
234 updated.push(item.id.clone());
235 }
236 }
237 Ok(updated)
238}
239
240pub fn detect_cycle(
243 root: &Path,
244 item_id: &str,
245 new_dep_id: &str,
246) -> Result<Option<Vec<String>>, JoyError> {
247 let items = load_items(root)?;
248 let mut visited = vec![item_id.to_string()];
249 if find_cycle(&items, new_dep_id, &mut visited) {
250 visited.push(new_dep_id.to_string());
251 Ok(Some(visited))
252 } else {
253 Ok(None)
254 }
255}
256
257fn find_cycle(items: &[Item], current: &str, visited: &mut Vec<String>) -> bool {
258 if visited.contains(¤t.to_string()) {
259 return true;
260 }
261 if let Some(item) = items.iter().find(|i| i.id == current) {
262 visited.push(current.to_string());
263 for dep in &item.deps {
264 if find_cycle(items, dep, visited) {
265 return true;
266 }
267 }
268 visited.pop();
269 }
270 false
271}
272
273pub fn update_item(root: &Path, item: &Item) -> Result<(), JoyError> {
275 let old_path = find_item_file(root, &item.id)?;
276 save_item(root, item)?;
278 let new_path = store::joy_dir(root)
280 .join(store::ITEMS_DIR)
281 .join(item_filename(&item.id, &item.title));
282 if old_path != new_path {
283 let _ = std::fs::remove_file(&old_path);
284 let old_rel = old_path
285 .strip_prefix(root)
286 .unwrap_or(&old_path)
287 .to_string_lossy()
288 .to_string();
289 crate::git_ops::auto_git_add(root, &[&old_rel]);
290 }
291 Ok(())
292}
293
294#[cfg(test)]
295mod tests {
296 use super::*;
297 use crate::model::item::{ItemType, Priority};
298 use tempfile::tempdir;
299
300 fn setup_project(dir: &Path) {
301 let joy_dir = dir.join(".joy");
302 std::fs::create_dir_all(joy_dir.join("items")).unwrap();
303 }
304
305 #[test]
306 fn next_id_first_item() {
307 let dir = tempdir().unwrap();
308 setup_project(dir.path());
309 let id = next_id(dir.path(), "JOY", "Test item").unwrap();
310 assert!(id.starts_with("JOY-0001-"), "got: {id}");
311 assert_eq!(id.len(), 11); }
313
314 #[test]
315 fn next_id_increments() {
316 let dir = tempdir().unwrap();
317 setup_project(dir.path());
318
319 let item = Item::new(
320 "JOY-0001".into(),
321 "First".into(),
322 ItemType::Task,
323 Priority::Low,
324 vec![],
325 );
326 save_item(dir.path(), &item).unwrap();
327
328 let id = next_id(dir.path(), "JOY", "Second item").unwrap();
329 assert!(id.starts_with("JOY-0002-"), "got: {id}");
330 }
331
332 #[test]
333 fn next_id_skips_gaps() {
334 let dir = tempdir().unwrap();
335 setup_project(dir.path());
336
337 let item1 = Item::new(
338 "JOY-0001".into(),
339 "First".into(),
340 ItemType::Task,
341 Priority::Low,
342 vec![],
343 );
344 save_item(dir.path(), &item1).unwrap();
345
346 let item3 = Item::new(
347 "JOY-0003".into(),
348 "Third".into(),
349 ItemType::Task,
350 Priority::Low,
351 vec![],
352 );
353 save_item(dir.path(), &item3).unwrap();
354
355 let id = next_id(dir.path(), "JOY", "Fourth item").unwrap();
356 assert!(id.starts_with("JOY-0004-"), "got: {id}");
357 }
358
359 #[test]
360 fn next_id_same_title_same_suffix() {
361 let dir = tempdir().unwrap();
362 setup_project(dir.path());
363 let id1 = next_id(dir.path(), "JOY", "Same title").unwrap();
364 let suffix1 = &id1[9..];
365 let id2_suffix = title_hash_suffix("Same title");
366 assert_eq!(suffix1, id2_suffix);
367 }
368
369 #[test]
370 fn next_id_different_titles_different_suffixes() {
371 let suffix_a = title_hash_suffix("Fix login bug");
372 let suffix_b = title_hash_suffix("Add roadmap feature");
373 assert_ne!(suffix_a, suffix_b);
377 }
378
379 #[test]
380 fn next_id_increments_past_new_format() {
381 let dir = tempdir().unwrap();
382 setup_project(dir.path());
383
384 let item = Item::new(
386 "JOY-0005-A3".into(),
387 "New format".into(),
388 ItemType::Task,
389 Priority::Low,
390 vec![],
391 );
392 save_item(dir.path(), &item).unwrap();
393
394 let id = next_id(dir.path(), "JOY", "Next item").unwrap();
395 assert!(id.starts_with("JOY-0006-"), "got: {id}");
396 }
397
398 #[test]
399 fn load_items_empty() {
400 let dir = tempdir().unwrap();
401 setup_project(dir.path());
402 let items = load_items(dir.path()).unwrap();
403 assert!(items.is_empty());
404 }
405
406 #[test]
407 fn save_and_load_item() {
408 let dir = tempdir().unwrap();
409 setup_project(dir.path());
410
411 let item = Item::new(
412 "JOY-0001".into(),
413 "Test item".into(),
414 ItemType::Story,
415 Priority::High,
416 vec![],
417 );
418 save_item(dir.path(), &item).unwrap();
419
420 let items = load_items(dir.path()).unwrap();
421 assert_eq!(items.len(), 1);
422 assert_eq!(items[0].id, "JOY-0001");
423 assert_eq!(items[0].title, "Test item");
424 }
425
426 #[test]
427 fn load_items_sorted() {
428 let dir = tempdir().unwrap();
429 setup_project(dir.path());
430
431 let item2 = Item::new(
432 "JOY-0002".into(),
433 "Second".into(),
434 ItemType::Task,
435 Priority::Low,
436 vec![],
437 );
438 save_item(dir.path(), &item2).unwrap();
439
440 let item1 = Item::new(
441 "JOY-0001".into(),
442 "First".into(),
443 ItemType::Task,
444 Priority::Low,
445 vec![],
446 );
447 save_item(dir.path(), &item1).unwrap();
448
449 let items = load_items(dir.path()).unwrap();
450 assert_eq!(items[0].id, "JOY-0001");
451 assert_eq!(items[1].id, "JOY-0002");
452 }
453}