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 normalize_id_refs(&mut items);
39
40 let milestone_ids: Vec<String> = crate::milestones::load_milestones(root)
41 .map(|list| list.into_iter().map(|m| m.id).collect())
42 .unwrap_or_default();
43 normalize_milestone_refs(&mut items, &milestone_ids);
44
45 Ok(items)
46}
47
48fn short_form(full_id: &str) -> Option<&str> {
55 let last_dash = full_id.rfind('-')?;
56 let suffix = &full_id[last_dash + 1..];
57 if suffix.len() != 2 || u8::from_str_radix(suffix, 16).is_err() {
58 return None;
59 }
60 let prefix = &full_id[..last_dash];
61 let prev_dash = prefix.rfind('-')?;
62 let middle = &prefix[prev_dash + 1..];
63 if middle.len() == 4 && u16::from_str_radix(middle, 16).is_ok() {
64 Some(prefix)
65 } else {
66 None
67 }
68}
69
70fn milestone_short_form(full_id: &str) -> Option<&str> {
77 let last_dash = full_id.rfind('-')?;
78 let suffix = &full_id[last_dash + 1..];
79 if suffix.len() != 2 || u8::from_str_radix(suffix, 16).is_err() {
80 return None;
81 }
82 let prefix = &full_id[..last_dash];
83 if prefix.contains("-MS-") {
84 Some(prefix)
85 } else {
86 None
87 }
88}
89
90fn normalize_milestone_refs(items: &mut [Item], milestone_ids: &[String]) {
94 use std::collections::HashMap;
95 let mut map: HashMap<String, Option<String>> = HashMap::new();
96 for ms_id in milestone_ids {
97 if let Some(short) = milestone_short_form(ms_id) {
98 map.entry(short.to_string())
99 .and_modify(|e| *e = None)
100 .or_insert_with(|| Some(ms_id.clone()));
101 }
102 }
103 for item in items.iter_mut() {
104 if let Some(ms) = item.milestone.as_deref() {
105 if let Some(Some(full)) = map.get(ms) {
106 item.milestone = Some(full.clone());
107 }
108 }
109 }
110}
111
112fn normalize_id_refs(items: &mut [Item]) {
116 use std::collections::HashMap;
117 let mut map: HashMap<String, Option<String>> = HashMap::new();
118 for item in items.iter() {
119 if let Some(short) = short_form(&item.id) {
120 map.entry(short.to_string())
121 .and_modify(|e| *e = None)
122 .or_insert_with(|| Some(item.id.clone()));
123 }
124 }
125 for item in items.iter_mut() {
126 if let Some(p) = item.parent.as_deref() {
127 if let Some(Some(full)) = map.get(p) {
128 item.parent = Some(full.clone());
129 }
130 }
131 for dep in &mut item.deps {
132 if let Some(Some(full)) = map.get(dep.as_str()) {
133 *dep = full.clone();
134 }
135 }
136 }
137}
138
139pub fn save_item(root: &Path, item: &Item) -> Result<(), JoyError> {
141 let items_dir = store::joy_dir(root).join(store::ITEMS_DIR);
142 let filename = item_filename(&item.id, &item.title);
143 let path = items_dir.join(&filename);
144 store::write_yaml(&path, item)?;
145 let rel = format!("{}/{}/{}", store::JOY_DIR, store::ITEMS_DIR, filename);
146 crate::git_ops::auto_git_add(root, &[&rel]);
147 Ok(())
148}
149
150pub fn next_id(root: &Path, acronym: &str, title: &str) -> Result<String, JoyError> {
157 let prefix = acronym;
158
159 let items_dir = store::joy_dir(root).join(store::ITEMS_DIR);
160 if !items_dir.is_dir() {
161 let suffix = title_hash_suffix(title);
162 return Ok(format!("{prefix}-0001-{suffix}"));
163 }
164
165 let mut max_num: u16 = 0;
166
167 let entries = std::fs::read_dir(&items_dir).map_err(|e| JoyError::ReadFile {
168 path: items_dir.clone(),
169 source: e,
170 })?;
171
172 for entry in entries.filter_map(|e| e.ok()) {
173 let name = entry.file_name();
174 let name = name.to_string_lossy();
175 if let Some(hex_part) = name.strip_prefix(&format!("{prefix}-")) {
176 if let Some(hex_str) = hex_part.get(..4) {
177 if let Ok(num) = u16::from_str_radix(hex_str, 16) {
178 max_num = max_num.max(num);
179 }
180 }
181 }
182 }
183
184 let next = max_num.checked_add(1).ok_or_else(|| {
185 JoyError::Other(format!("{prefix} ID space exhausted (max {prefix}-FFFF)"))
186 })?;
187 let suffix = title_hash_suffix(title);
188 Ok(format!("{prefix}-{next:04X}-{suffix}"))
189}
190
191pub fn title_hash_suffix(title: &str) -> String {
193 use sha2::{Digest, Sha256};
194 let mut hasher = Sha256::new();
195 hasher.update(title.as_bytes());
196 let hash = hasher.finalize();
197 format!("{:02X}", hash[0])
198}
199
200pub fn find_item_file(root: &Path, id: &str) -> Result<std::path::PathBuf, JoyError> {
204 let items_dir = store::joy_dir(root).join(store::ITEMS_DIR);
205
206 let id_upper = id.to_uppercase();
208
209 let entries: Vec<_> = std::fs::read_dir(&items_dir)
210 .map_err(|e| JoyError::ReadFile {
211 path: items_dir.clone(),
212 source: e,
213 })?
214 .filter_map(|e| e.ok())
215 .collect();
216
217 let exact_prefix = format!("{}-", id_upper);
219 for entry in &entries {
220 let name = entry.file_name();
221 let name_upper = name.to_string_lossy().to_uppercase();
222 if name_upper.starts_with(&exact_prefix) {
223 return Ok(entry.path());
224 }
225 }
226
227 let short_prefix = format!("{}-", id_upper);
230 let mut matches: Vec<std::path::PathBuf> = Vec::new();
231 for entry in &entries {
232 let name = entry.file_name();
233 let name_upper = name.to_string_lossy().to_uppercase();
234 if name_upper.starts_with(&short_prefix) {
235 matches.push(entry.path());
236 }
237 }
238
239 match matches.len() {
240 0 => Err(JoyError::ItemNotFound(id.to_string())),
241 1 => Ok(matches.into_iter().next().unwrap()),
242 _ => {
243 let ids: Vec<String> = matches
245 .iter()
246 .filter_map(|p| {
247 let name = p.file_name()?.to_string_lossy().to_string();
248 extract_full_id(&name)
249 })
250 .collect();
251 Err(JoyError::Other(format!("ambiguous ID: {}", ids.join(", "))))
252 }
253 }
254}
255
256fn extract_full_id(filename: &str) -> Option<String> {
260 let name = filename
262 .strip_suffix(".yaml")
263 .or_else(|| filename.strip_suffix(".yml"))?;
264 let parts: Vec<&str> = name.splitn(2, '-').collect();
266 if parts.len() < 2 {
267 return None;
268 }
269 let acronym = parts[0];
270 let rest = parts[1];
271
272 if rest.len() >= 7 && rest.as_bytes()[4] == b'-' {
274 let hex4 = &rest[..4];
276 let maybe_suffix = &rest[5..7];
277 if u16::from_str_radix(hex4, 16).is_ok()
278 && maybe_suffix.len() == 2
279 && u8::from_str_radix(maybe_suffix, 16).is_ok()
280 && (rest.len() == 7 || rest.as_bytes()[7] == b'-')
281 {
282 return Some(format!("{}-{}-{}", acronym, hex4, maybe_suffix).to_uppercase());
283 }
284 }
285
286 let hex4 = &rest[..4.min(rest.len())];
288 if hex4.len() == 4 && u16::from_str_radix(hex4, 16).is_ok() {
289 return Some(format!("{}-{}", acronym, hex4).to_uppercase());
290 }
291
292 None
293}
294
295pub fn load_item(root: &Path, id: &str) -> Result<Item, JoyError> {
302 let path = find_item_file(root, id)?;
303 let target_id: String = store::read_yaml::<Item>(&path)?.id;
304 let items = load_items(root)?;
305 items
306 .into_iter()
307 .find(|i| i.id == target_id)
308 .ok_or(JoyError::ItemNotFound(target_id))
309}
310
311pub fn delete_item(root: &Path, id: &str) -> Result<Item, JoyError> {
313 let path = find_item_file(root, id)?;
314 let item: Item = store::read_yaml(&path)?;
315 let rel = path
316 .strip_prefix(root)
317 .unwrap_or(&path)
318 .to_string_lossy()
319 .to_string();
320 std::fs::remove_file(&path).map_err(|e| JoyError::WriteFile { path, source: e })?;
321 crate::git_ops::auto_git_add(root, &[&rel]);
322 Ok(item)
323}
324
325pub fn remove_references(root: &Path, deleted_id: &str) -> Result<Vec<String>, JoyError> {
327 let items = load_items(root)?;
328 let mut updated = Vec::new();
329 for mut item in items {
330 let mut changed = false;
331 if item.deps.contains(&deleted_id.to_string()) {
332 item.deps.retain(|d| d != deleted_id);
333 changed = true;
334 }
335 if item.parent.as_deref() == Some(deleted_id) {
336 item.parent = None;
337 changed = true;
338 }
339 if changed {
340 item.updated = chrono::Utc::now();
341 update_item(root, &item)?;
342 updated.push(item.id.clone());
343 }
344 }
345 Ok(updated)
346}
347
348pub fn detect_cycle(
351 root: &Path,
352 item_id: &str,
353 new_dep_id: &str,
354) -> Result<Option<Vec<String>>, JoyError> {
355 let items = load_items(root)?;
356 let mut visited = vec![item_id.to_string()];
357 if find_cycle(&items, new_dep_id, &mut visited) {
358 visited.push(new_dep_id.to_string());
359 Ok(Some(visited))
360 } else {
361 Ok(None)
362 }
363}
364
365fn find_cycle(items: &[Item], current: &str, visited: &mut Vec<String>) -> bool {
366 if visited.contains(¤t.to_string()) {
367 return true;
368 }
369 if let Some(item) = items.iter().find(|i| i.id == current) {
370 visited.push(current.to_string());
371 for dep in &item.deps {
372 if find_cycle(items, dep, visited) {
373 return true;
374 }
375 }
376 visited.pop();
377 }
378 false
379}
380
381pub fn update_item(root: &Path, item: &Item) -> Result<(), JoyError> {
383 let old_path = find_item_file(root, &item.id)?;
384 save_item(root, item)?;
386 let new_path = store::joy_dir(root)
388 .join(store::ITEMS_DIR)
389 .join(item_filename(&item.id, &item.title));
390 if old_path != new_path {
391 let _ = std::fs::remove_file(&old_path);
392 let old_rel = old_path
393 .strip_prefix(root)
394 .unwrap_or(&old_path)
395 .to_string_lossy()
396 .to_string();
397 crate::git_ops::auto_git_add(root, &[&old_rel]);
398 }
399 Ok(())
400}
401
402#[cfg(test)]
403mod tests {
404 use super::*;
405 use crate::model::item::{ItemType, Priority};
406 use tempfile::tempdir;
407
408 fn setup_project(dir: &Path) {
409 let joy_dir = dir.join(".joy");
410 std::fs::create_dir_all(joy_dir.join("items")).unwrap();
411 }
412
413 #[test]
414 fn next_id_first_item() {
415 let dir = tempdir().unwrap();
416 setup_project(dir.path());
417 let id = next_id(dir.path(), "JOY", "Test item").unwrap();
418 assert!(id.starts_with("JOY-0001-"), "got: {id}");
419 assert_eq!(id.len(), 11); }
421
422 #[test]
423 fn next_id_increments() {
424 let dir = tempdir().unwrap();
425 setup_project(dir.path());
426
427 let item = Item::new(
428 "JOY-0001".into(),
429 "First".into(),
430 ItemType::Task,
431 Priority::Low,
432 vec![],
433 );
434 save_item(dir.path(), &item).unwrap();
435
436 let id = next_id(dir.path(), "JOY", "Second item").unwrap();
437 assert!(id.starts_with("JOY-0002-"), "got: {id}");
438 }
439
440 #[test]
441 fn next_id_skips_gaps() {
442 let dir = tempdir().unwrap();
443 setup_project(dir.path());
444
445 let item1 = Item::new(
446 "JOY-0001".into(),
447 "First".into(),
448 ItemType::Task,
449 Priority::Low,
450 vec![],
451 );
452 save_item(dir.path(), &item1).unwrap();
453
454 let item3 = Item::new(
455 "JOY-0003".into(),
456 "Third".into(),
457 ItemType::Task,
458 Priority::Low,
459 vec![],
460 );
461 save_item(dir.path(), &item3).unwrap();
462
463 let id = next_id(dir.path(), "JOY", "Fourth item").unwrap();
464 assert!(id.starts_with("JOY-0004-"), "got: {id}");
465 }
466
467 #[test]
468 fn next_id_same_title_same_suffix() {
469 let dir = tempdir().unwrap();
470 setup_project(dir.path());
471 let id1 = next_id(dir.path(), "JOY", "Same title").unwrap();
472 let suffix1 = &id1[9..];
473 let id2_suffix = title_hash_suffix("Same title");
474 assert_eq!(suffix1, id2_suffix);
475 }
476
477 #[test]
478 fn next_id_different_titles_different_suffixes() {
479 let suffix_a = title_hash_suffix("Fix login bug");
480 let suffix_b = title_hash_suffix("Add roadmap feature");
481 assert_ne!(suffix_a, suffix_b);
485 }
486
487 #[test]
488 fn next_id_increments_past_new_format() {
489 let dir = tempdir().unwrap();
490 setup_project(dir.path());
491
492 let item = Item::new(
494 "JOY-0005-A3".into(),
495 "New format".into(),
496 ItemType::Task,
497 Priority::Low,
498 vec![],
499 );
500 save_item(dir.path(), &item).unwrap();
501
502 let id = next_id(dir.path(), "JOY", "Next item").unwrap();
503 assert!(id.starts_with("JOY-0006-"), "got: {id}");
504 }
505
506 #[test]
507 fn load_items_empty() {
508 let dir = tempdir().unwrap();
509 setup_project(dir.path());
510 let items = load_items(dir.path()).unwrap();
511 assert!(items.is_empty());
512 }
513
514 #[test]
515 fn save_and_load_item() {
516 let dir = tempdir().unwrap();
517 setup_project(dir.path());
518
519 let item = Item::new(
520 "JOY-0001".into(),
521 "Test item".into(),
522 ItemType::Story,
523 Priority::High,
524 vec![],
525 );
526 save_item(dir.path(), &item).unwrap();
527
528 let items = load_items(dir.path()).unwrap();
529 assert_eq!(items.len(), 1);
530 assert_eq!(items[0].id, "JOY-0001");
531 assert_eq!(items[0].title, "Test item");
532 }
533
534 #[test]
535 fn load_items_sorted() {
536 let dir = tempdir().unwrap();
537 setup_project(dir.path());
538
539 let item2 = Item::new(
540 "JOY-0002".into(),
541 "Second".into(),
542 ItemType::Task,
543 Priority::Low,
544 vec![],
545 );
546 save_item(dir.path(), &item2).unwrap();
547
548 let item1 = Item::new(
549 "JOY-0001".into(),
550 "First".into(),
551 ItemType::Task,
552 Priority::Low,
553 vec![],
554 );
555 save_item(dir.path(), &item1).unwrap();
556
557 let items = load_items(dir.path()).unwrap();
558 assert_eq!(items[0].id, "JOY-0001");
559 assert_eq!(items[1].id, "JOY-0002");
560 }
561
562 #[test]
563 fn short_form_extracts_prefix_for_suffixed_id() {
564 assert_eq!(short_form("JOY-0042-A3"), Some("JOY-0042"));
565 assert_eq!(short_form("TST-00FF-12"), Some("TST-00FF"));
566 }
567
568 #[test]
569 fn short_form_returns_none_for_legacy_id() {
570 assert_eq!(short_form("JOY-0042"), None);
571 assert_eq!(short_form("JOY-MS-01"), None);
572 }
573
574 #[test]
575 fn short_form_returns_none_for_non_hex_suffix() {
576 assert_eq!(short_form("JOY-0042-XX"), None);
577 assert_eq!(short_form("JOY-0042-AAA"), None);
578 }
579
580 #[test]
581 fn normalize_rewrites_short_form_parent() {
582 let mut parent = Item::new(
583 "JOY-0042-A3".into(),
584 "P".into(),
585 ItemType::Epic,
586 Priority::Medium,
587 vec![],
588 );
589 parent.parent = None;
590 let mut child = Item::new(
591 "JOY-0043-B1".into(),
592 "C".into(),
593 ItemType::Task,
594 Priority::Medium,
595 vec![],
596 );
597 child.parent = Some("JOY-0042".into());
598 let mut items = vec![parent, child];
599 normalize_id_refs(&mut items);
600 assert_eq!(items[1].parent.as_deref(), Some("JOY-0042-A3"));
601 }
602
603 #[test]
604 fn normalize_rewrites_short_form_deps() {
605 let dep = Item::new(
606 "JOY-0042-A3".into(),
607 "D".into(),
608 ItemType::Task,
609 Priority::Medium,
610 vec![],
611 );
612 let mut consumer = Item::new(
613 "JOY-0043-B1".into(),
614 "C".into(),
615 ItemType::Task,
616 Priority::Medium,
617 vec![],
618 );
619 consumer.deps = vec!["JOY-0042".into()];
620 let mut items = vec![dep, consumer];
621 normalize_id_refs(&mut items);
622 assert_eq!(items[1].deps, vec!["JOY-0042-A3".to_string()]);
623 }
624
625 #[test]
626 fn normalize_leaves_full_form_unchanged() {
627 let parent = Item::new(
628 "JOY-0042-A3".into(),
629 "P".into(),
630 ItemType::Epic,
631 Priority::Medium,
632 vec![],
633 );
634 let mut child = Item::new(
635 "JOY-0043-B1".into(),
636 "C".into(),
637 ItemType::Task,
638 Priority::Medium,
639 vec![],
640 );
641 child.parent = Some("JOY-0042-A3".into());
642 let mut items = vec![parent, child];
643 normalize_id_refs(&mut items);
644 assert_eq!(items[1].parent.as_deref(), Some("JOY-0042-A3"));
645 }
646
647 #[test]
648 fn normalize_leaves_unknown_refs_unchanged() {
649 let mut child = Item::new(
650 "JOY-0043-B1".into(),
651 "C".into(),
652 ItemType::Task,
653 Priority::Medium,
654 vec![],
655 );
656 child.parent = Some("JOY-9999".into());
657 child.deps = vec!["JOY-8888".into()];
658 let mut items = vec![child];
659 normalize_id_refs(&mut items);
660 assert_eq!(items[0].parent.as_deref(), Some("JOY-9999"));
661 assert_eq!(items[0].deps, vec!["JOY-8888".to_string()]);
662 }
663
664 #[test]
665 fn normalize_leaves_ambiguous_short_forms_unchanged() {
666 let a = Item::new(
667 "JOY-0042-A3".into(),
668 "A".into(),
669 ItemType::Task,
670 Priority::Medium,
671 vec![],
672 );
673 let b = Item::new(
674 "JOY-0042-B1".into(),
675 "B".into(),
676 ItemType::Task,
677 Priority::Medium,
678 vec![],
679 );
680 let mut child = Item::new(
681 "JOY-0043-CC".into(),
682 "C".into(),
683 ItemType::Task,
684 Priority::Medium,
685 vec![],
686 );
687 child.parent = Some("JOY-0042".into());
688 let mut items = vec![a, b, child];
689 normalize_id_refs(&mut items);
690 assert_eq!(items[2].parent.as_deref(), Some("JOY-0042"));
691 }
692
693 #[test]
694 fn milestone_short_form_extracts_prefix() {
695 assert_eq!(milestone_short_form("JOY-MS-01-A1"), Some("JOY-MS-01"));
696 assert_eq!(milestone_short_form("TST-MS-FF-12"), Some("TST-MS-FF"));
697 }
698
699 #[test]
700 fn milestone_short_form_returns_none_for_legacy_or_item() {
701 assert_eq!(milestone_short_form("JOY-MS-01"), None);
702 assert_eq!(milestone_short_form("JOY-0042-A3"), None);
703 }
704
705 #[test]
706 fn normalize_milestone_rewrites_short_form() {
707 let mut item = Item::new(
708 "JOY-0001-AA".into(),
709 "X".into(),
710 ItemType::Task,
711 Priority::Medium,
712 vec![],
713 );
714 item.milestone = Some("JOY-MS-01".into());
715 let mut items = vec![item];
716 normalize_milestone_refs(&mut items, &["JOY-MS-01-A1".to_string()]);
717 assert_eq!(items[0].milestone.as_deref(), Some("JOY-MS-01-A1"));
718 }
719
720 #[test]
721 fn normalize_milestone_leaves_unknown_unchanged() {
722 let mut item = Item::new(
723 "JOY-0001-AA".into(),
724 "X".into(),
725 ItemType::Task,
726 Priority::Medium,
727 vec![],
728 );
729 item.milestone = Some("JOY-MS-99".into());
730 let mut items = vec![item];
731 normalize_milestone_refs(&mut items, &["JOY-MS-01-A1".to_string()]);
732 assert_eq!(items[0].milestone.as_deref(), Some("JOY-MS-99"));
733 }
734
735 #[test]
736 fn normalize_milestone_leaves_full_form_unchanged() {
737 let mut item = Item::new(
738 "JOY-0001-AA".into(),
739 "X".into(),
740 ItemType::Task,
741 Priority::Medium,
742 vec![],
743 );
744 item.milestone = Some("JOY-MS-01-A1".into());
745 let mut items = vec![item];
746 normalize_milestone_refs(&mut items, &["JOY-MS-01-A1".to_string()]);
747 assert_eq!(items[0].milestone.as_deref(), Some("JOY-MS-01-A1"));
748 }
749
750 #[test]
751 fn normalize_handles_legacy_parent_referenced_by_full_id() {
752 let parent = Item::new(
753 "JOY-0042".into(),
754 "P".into(),
755 ItemType::Epic,
756 Priority::Medium,
757 vec![],
758 );
759 let mut child = Item::new(
760 "JOY-0043-B1".into(),
761 "C".into(),
762 ItemType::Task,
763 Priority::Medium,
764 vec![],
765 );
766 child.parent = Some("JOY-0042".into());
767 let mut items = vec![parent, child];
768 normalize_id_refs(&mut items);
769 assert_eq!(items[1].parent.as_deref(), Some("JOY-0042"));
770 }
771}