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 write_item_file(&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
150fn write_item_file(path: &Path, item: &Item) -> Result<(), JoyError> {
155 let yaml = serde_yaml_ng::to_string(item).map_err(JoyError::Yaml)?;
156 let bytes = match item.crypt_zone.as_deref() {
157 Some(zone) => {
158 let zone_key =
159 crate::crypt::active_zone_key(zone).ok_or_else(|| JoyError::ZoneAccessDenied {
160 zone: zone.to_string(),
161 })?;
162 crate::crypt::encrypt_blob(zone, &zone_key, yaml.as_bytes())
163 }
164 None => yaml.into_bytes(),
165 };
166 write_atomic(path, &bytes)
167}
168
169#[derive(Debug, Clone)]
177pub struct ItemMeta {
178 pub id: String,
179 pub path: std::path::PathBuf,
180 pub encrypted_zone: Option<String>,
181 pub plaintext_crypt_zone: Option<String>,
184}
185
186impl ItemMeta {
187 pub fn zone(&self) -> Option<&str> {
190 self.encrypted_zone
191 .as_deref()
192 .or(self.plaintext_crypt_zone.as_deref())
193 }
194}
195
196pub fn list_item_metadata(root: &Path) -> Result<Vec<ItemMeta>, JoyError> {
200 let items_dir = store::joy_dir(root).join(store::ITEMS_DIR);
201 if !items_dir.is_dir() {
202 return Ok(Vec::new());
203 }
204 let mut out = Vec::new();
205 for entry in std::fs::read_dir(&items_dir).map_err(|e| JoyError::ReadFile {
206 path: items_dir.clone(),
207 source: e,
208 })? {
209 let Ok(entry) = entry else { continue };
210 let path = entry.path();
211 if !path.is_file() {
212 continue;
213 }
214 let Some(name) = path.file_name().and_then(|s| s.to_str()) else {
215 continue;
216 };
217 let Some(id) = id_from_filename(name) else {
218 continue;
219 };
220 let bytes = std::fs::read(&path).map_err(|e| JoyError::ReadFile {
221 path: path.clone(),
222 source: e,
223 })?;
224 let (encrypted_zone, plaintext_crypt_zone) = if crate::crypt::looks_like_blob(&bytes) {
225 (parse_blob_zone(&bytes), None)
226 } else {
227 (None, parse_plaintext_crypt_zone(&bytes))
228 };
229 out.push(ItemMeta {
230 id,
231 path,
232 encrypted_zone,
233 plaintext_crypt_zone,
234 });
235 }
236 Ok(out)
237}
238
239fn id_from_filename(name: &str) -> Option<String> {
240 let stem = name.strip_suffix(".yaml")?;
245 let parts: Vec<&str> = stem.split('-').collect();
246 if parts.len() >= 2 && parts[1].chars().all(|c| c.is_ascii_hexdigit()) && parts[1].len() == 4 {
247 let id_end = if parts.len() >= 3
249 && parts[2].chars().all(|c| c.is_ascii_hexdigit())
250 && parts[2].len() == 2
251 {
252 3
253 } else {
254 2
255 };
256 Some(parts[..id_end].join("-"))
257 } else {
258 None
259 }
260}
261
262fn parse_blob_zone(bytes: &[u8]) -> Option<String> {
263 if bytes.len() < 10 {
265 return None;
266 }
267 let zone_len = bytes[9] as usize;
268 if bytes.len() < 10 + zone_len {
269 return None;
270 }
271 std::str::from_utf8(&bytes[10..10 + zone_len])
272 .ok()
273 .map(str::to_string)
274}
275
276fn parse_plaintext_crypt_zone(bytes: &[u8]) -> Option<String> {
277 let text = std::str::from_utf8(bytes).ok()?;
278 for line in text.lines() {
279 let trimmed = line.trim_start();
280 if let Some(rest) = trimmed.strip_prefix("crypt_zone:") {
281 let value = rest.trim().trim_matches(|c: char| c == '"' || c == '\'');
282 if value.is_empty() || value == "null" || value == "~" {
283 return None;
284 }
285 return Some(value.to_string());
286 }
287 }
288 None
289}
290
291fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), JoyError> {
293 let parent = path.parent().unwrap_or_else(|| Path::new("."));
294 std::fs::create_dir_all(parent).map_err(|e| JoyError::CreateDir {
295 path: parent.to_path_buf(),
296 source: e,
297 })?;
298 let tmp = parent.join(format!(
299 ".{}.tmp.{}",
300 path.file_name().and_then(|s| s.to_str()).unwrap_or("item"),
301 std::process::id()
302 ));
303 std::fs::write(&tmp, bytes).map_err(|e| JoyError::WriteFile {
304 path: tmp.clone(),
305 source: e,
306 })?;
307 std::fs::rename(&tmp, path).map_err(|e| JoyError::WriteFile {
308 path: path.to_path_buf(),
309 source: e,
310 })?;
311 Ok(())
312}
313
314pub fn next_id(root: &Path, acronym: &str, title: &str) -> Result<String, JoyError> {
321 let prefix = acronym;
322
323 let items_dir = store::joy_dir(root).join(store::ITEMS_DIR);
324 if !items_dir.is_dir() {
325 let suffix = title_hash_suffix(title);
326 return Ok(format!("{prefix}-0001-{suffix}"));
327 }
328
329 let mut max_num: u16 = 0;
330
331 let entries = std::fs::read_dir(&items_dir).map_err(|e| JoyError::ReadFile {
332 path: items_dir.clone(),
333 source: e,
334 })?;
335
336 for entry in entries.filter_map(|e| e.ok()) {
337 let name = entry.file_name();
338 let name = name.to_string_lossy();
339 if let Some(hex_part) = name.strip_prefix(&format!("{prefix}-")) {
340 if let Some(hex_str) = hex_part.get(..4) {
341 if let Ok(num) = u16::from_str_radix(hex_str, 16) {
342 max_num = max_num.max(num);
343 }
344 }
345 }
346 }
347
348 let next = max_num.checked_add(1).ok_or_else(|| {
349 JoyError::Other(format!("{prefix} ID space exhausted (max {prefix}-FFFF)"))
350 })?;
351 let suffix = title_hash_suffix(title);
352 Ok(format!("{prefix}-{next:04X}-{suffix}"))
353}
354
355pub fn title_hash_suffix(title: &str) -> String {
357 use sha2::{Digest, Sha256};
358 let mut hasher = Sha256::new();
359 hasher.update(title.as_bytes());
360 let hash = hasher.finalize();
361 format!("{:02X}", hash[0])
362}
363
364pub fn find_item_file(root: &Path, id: &str) -> Result<std::path::PathBuf, JoyError> {
368 let items_dir = store::joy_dir(root).join(store::ITEMS_DIR);
369
370 let id_upper = id.to_uppercase();
372
373 let entries: Vec<_> = std::fs::read_dir(&items_dir)
374 .map_err(|e| JoyError::ReadFile {
375 path: items_dir.clone(),
376 source: e,
377 })?
378 .filter_map(|e| e.ok())
379 .collect();
380
381 let exact_prefix = format!("{}-", id_upper);
383 for entry in &entries {
384 let name = entry.file_name();
385 let name_upper = name.to_string_lossy().to_uppercase();
386 if name_upper.starts_with(&exact_prefix) {
387 return Ok(entry.path());
388 }
389 }
390
391 let short_prefix = format!("{}-", id_upper);
394 let mut matches: Vec<std::path::PathBuf> = Vec::new();
395 for entry in &entries {
396 let name = entry.file_name();
397 let name_upper = name.to_string_lossy().to_uppercase();
398 if name_upper.starts_with(&short_prefix) {
399 matches.push(entry.path());
400 }
401 }
402
403 match matches.len() {
404 0 => Err(JoyError::ItemNotFound(id.to_string())),
405 1 => Ok(matches.into_iter().next().unwrap()),
406 _ => {
407 let ids: Vec<String> = matches
409 .iter()
410 .filter_map(|p| {
411 let name = p.file_name()?.to_string_lossy().to_string();
412 extract_full_id(&name)
413 })
414 .collect();
415 Err(JoyError::Other(format!("ambiguous ID: {}", ids.join(", "))))
416 }
417 }
418}
419
420fn extract_full_id(filename: &str) -> Option<String> {
424 let name = filename
426 .strip_suffix(".yaml")
427 .or_else(|| filename.strip_suffix(".yml"))?;
428 let parts: Vec<&str> = name.splitn(2, '-').collect();
430 if parts.len() < 2 {
431 return None;
432 }
433 let acronym = parts[0];
434 let rest = parts[1];
435
436 if rest.len() >= 7 && rest.as_bytes()[4] == b'-' {
438 let hex4 = &rest[..4];
440 let maybe_suffix = &rest[5..7];
441 if u16::from_str_radix(hex4, 16).is_ok()
442 && maybe_suffix.len() == 2
443 && u8::from_str_radix(maybe_suffix, 16).is_ok()
444 && (rest.len() == 7 || rest.as_bytes()[7] == b'-')
445 {
446 return Some(format!("{}-{}-{}", acronym, hex4, maybe_suffix).to_uppercase());
447 }
448 }
449
450 let hex4 = &rest[..4.min(rest.len())];
452 if hex4.len() == 4 && u16::from_str_radix(hex4, 16).is_ok() {
453 return Some(format!("{}-{}", acronym, hex4).to_uppercase());
454 }
455
456 None
457}
458
459pub fn load_item(root: &Path, id: &str) -> Result<Item, JoyError> {
466 let path = find_item_file(root, id)?;
467 let target_id: String = store::read_yaml::<Item>(&path)?.id;
468 let items = load_items(root)?;
469 items
470 .into_iter()
471 .find(|i| i.id == target_id)
472 .ok_or(JoyError::ItemNotFound(target_id))
473}
474
475pub fn delete_item(root: &Path, id: &str) -> Result<Item, JoyError> {
477 let path = find_item_file(root, id)?;
478 let item: Item = store::read_yaml(&path)?;
479 let rel = path
480 .strip_prefix(root)
481 .unwrap_or(&path)
482 .to_string_lossy()
483 .to_string();
484 std::fs::remove_file(&path).map_err(|e| JoyError::WriteFile { path, source: e })?;
485 crate::git_ops::auto_git_add(root, &[&rel]);
486 Ok(item)
487}
488
489pub fn remove_references(root: &Path, deleted_id: &str) -> Result<Vec<String>, JoyError> {
491 let items = load_items(root)?;
492 let mut updated = Vec::new();
493 for mut item in items {
494 let mut changed = false;
495 if item.deps.contains(&deleted_id.to_string()) {
496 item.deps.retain(|d| d != deleted_id);
497 changed = true;
498 }
499 if item.parent.as_deref() == Some(deleted_id) {
500 item.parent = None;
501 changed = true;
502 }
503 if changed {
504 item.updated = chrono::Utc::now();
505 update_item(root, &item)?;
506 updated.push(item.id.clone());
507 }
508 }
509 Ok(updated)
510}
511
512pub fn detect_cycle(
515 root: &Path,
516 item_id: &str,
517 new_dep_id: &str,
518) -> Result<Option<Vec<String>>, JoyError> {
519 let items = load_items(root)?;
520 let mut visited = vec![item_id.to_string()];
521 if find_cycle(&items, new_dep_id, &mut visited) {
522 visited.push(new_dep_id.to_string());
523 Ok(Some(visited))
524 } else {
525 Ok(None)
526 }
527}
528
529fn find_cycle(items: &[Item], current: &str, visited: &mut Vec<String>) -> bool {
530 if visited.contains(¤t.to_string()) {
531 return true;
532 }
533 if let Some(item) = items.iter().find(|i| i.id == current) {
534 visited.push(current.to_string());
535 for dep in &item.deps {
536 if find_cycle(items, dep, visited) {
537 return true;
538 }
539 }
540 visited.pop();
541 }
542 false
543}
544
545pub fn update_item(root: &Path, item: &Item) -> Result<(), JoyError> {
547 let old_path = find_item_file(root, &item.id)?;
548 save_item(root, item)?;
550 let new_path = store::joy_dir(root)
552 .join(store::ITEMS_DIR)
553 .join(item_filename(&item.id, &item.title));
554 if old_path != new_path {
555 let _ = std::fs::remove_file(&old_path);
556 let old_rel = old_path
557 .strip_prefix(root)
558 .unwrap_or(&old_path)
559 .to_string_lossy()
560 .to_string();
561 crate::git_ops::auto_git_add(root, &[&old_rel]);
562 }
563 Ok(())
564}
565
566#[cfg(test)]
567mod tests {
568 use super::*;
569 use crate::model::item::{ItemType, Priority};
570 use tempfile::tempdir;
571
572 fn setup_project(dir: &Path) {
573 let joy_dir = dir.join(".joy");
574 std::fs::create_dir_all(joy_dir.join("items")).unwrap();
575 }
576
577 #[test]
578 fn next_id_first_item() {
579 let dir = tempdir().unwrap();
580 setup_project(dir.path());
581 let id = next_id(dir.path(), "JOY", "Test item").unwrap();
582 assert!(id.starts_with("JOY-0001-"), "got: {id}");
583 assert_eq!(id.len(), 11); }
585
586 #[test]
587 fn next_id_increments() {
588 let dir = tempdir().unwrap();
589 setup_project(dir.path());
590
591 let item = Item::new(
592 "JOY-0001".into(),
593 "First".into(),
594 ItemType::Task,
595 Priority::Low,
596 vec![],
597 );
598 save_item(dir.path(), &item).unwrap();
599
600 let id = next_id(dir.path(), "JOY", "Second item").unwrap();
601 assert!(id.starts_with("JOY-0002-"), "got: {id}");
602 }
603
604 #[test]
605 fn next_id_skips_gaps() {
606 let dir = tempdir().unwrap();
607 setup_project(dir.path());
608
609 let item1 = Item::new(
610 "JOY-0001".into(),
611 "First".into(),
612 ItemType::Task,
613 Priority::Low,
614 vec![],
615 );
616 save_item(dir.path(), &item1).unwrap();
617
618 let item3 = Item::new(
619 "JOY-0003".into(),
620 "Third".into(),
621 ItemType::Task,
622 Priority::Low,
623 vec![],
624 );
625 save_item(dir.path(), &item3).unwrap();
626
627 let id = next_id(dir.path(), "JOY", "Fourth item").unwrap();
628 assert!(id.starts_with("JOY-0004-"), "got: {id}");
629 }
630
631 #[test]
632 fn next_id_same_title_same_suffix() {
633 let dir = tempdir().unwrap();
634 setup_project(dir.path());
635 let id1 = next_id(dir.path(), "JOY", "Same title").unwrap();
636 let suffix1 = &id1[9..];
637 let id2_suffix = title_hash_suffix("Same title");
638 assert_eq!(suffix1, id2_suffix);
639 }
640
641 #[test]
642 fn next_id_different_titles_different_suffixes() {
643 let suffix_a = title_hash_suffix("Fix login bug");
644 let suffix_b = title_hash_suffix("Add roadmap feature");
645 assert_ne!(suffix_a, suffix_b);
649 }
650
651 #[test]
652 fn next_id_increments_past_new_format() {
653 let dir = tempdir().unwrap();
654 setup_project(dir.path());
655
656 let item = Item::new(
658 "JOY-0005-A3".into(),
659 "New format".into(),
660 ItemType::Task,
661 Priority::Low,
662 vec![],
663 );
664 save_item(dir.path(), &item).unwrap();
665
666 let id = next_id(dir.path(), "JOY", "Next item").unwrap();
667 assert!(id.starts_with("JOY-0006-"), "got: {id}");
668 }
669
670 #[test]
671 fn load_items_empty() {
672 let dir = tempdir().unwrap();
673 setup_project(dir.path());
674 let items = load_items(dir.path()).unwrap();
675 assert!(items.is_empty());
676 }
677
678 #[test]
679 fn save_and_load_item() {
680 let dir = tempdir().unwrap();
681 setup_project(dir.path());
682
683 let item = Item::new(
684 "JOY-0001".into(),
685 "Test item".into(),
686 ItemType::Story,
687 Priority::High,
688 vec![],
689 );
690 save_item(dir.path(), &item).unwrap();
691
692 let items = load_items(dir.path()).unwrap();
693 assert_eq!(items.len(), 1);
694 assert_eq!(items[0].id, "JOY-0001");
695 assert_eq!(items[0].title, "Test item");
696 }
697
698 #[test]
699 fn load_items_sorted() {
700 let dir = tempdir().unwrap();
701 setup_project(dir.path());
702
703 let item2 = Item::new(
704 "JOY-0002".into(),
705 "Second".into(),
706 ItemType::Task,
707 Priority::Low,
708 vec![],
709 );
710 save_item(dir.path(), &item2).unwrap();
711
712 let item1 = Item::new(
713 "JOY-0001".into(),
714 "First".into(),
715 ItemType::Task,
716 Priority::Low,
717 vec![],
718 );
719 save_item(dir.path(), &item1).unwrap();
720
721 let items = load_items(dir.path()).unwrap();
722 assert_eq!(items[0].id, "JOY-0001");
723 assert_eq!(items[1].id, "JOY-0002");
724 }
725
726 #[test]
727 fn short_form_extracts_prefix_for_suffixed_id() {
728 assert_eq!(short_form("JOY-0042-A3"), Some("JOY-0042"));
729 assert_eq!(short_form("TST-00FF-12"), Some("TST-00FF"));
730 }
731
732 #[test]
733 fn short_form_returns_none_for_legacy_id() {
734 assert_eq!(short_form("JOY-0042"), None);
735 assert_eq!(short_form("JOY-MS-01"), None);
736 }
737
738 #[test]
739 fn short_form_returns_none_for_non_hex_suffix() {
740 assert_eq!(short_form("JOY-0042-XX"), None);
741 assert_eq!(short_form("JOY-0042-AAA"), None);
742 }
743
744 #[test]
745 fn normalize_rewrites_short_form_parent() {
746 let mut parent = Item::new(
747 "JOY-0042-A3".into(),
748 "P".into(),
749 ItemType::Epic,
750 Priority::Medium,
751 vec![],
752 );
753 parent.parent = None;
754 let mut child = Item::new(
755 "JOY-0043-B1".into(),
756 "C".into(),
757 ItemType::Task,
758 Priority::Medium,
759 vec![],
760 );
761 child.parent = Some("JOY-0042".into());
762 let mut items = vec![parent, child];
763 normalize_id_refs(&mut items);
764 assert_eq!(items[1].parent.as_deref(), Some("JOY-0042-A3"));
765 }
766
767 #[test]
768 fn normalize_rewrites_short_form_deps() {
769 let dep = Item::new(
770 "JOY-0042-A3".into(),
771 "D".into(),
772 ItemType::Task,
773 Priority::Medium,
774 vec![],
775 );
776 let mut consumer = Item::new(
777 "JOY-0043-B1".into(),
778 "C".into(),
779 ItemType::Task,
780 Priority::Medium,
781 vec![],
782 );
783 consumer.deps = vec!["JOY-0042".into()];
784 let mut items = vec![dep, consumer];
785 normalize_id_refs(&mut items);
786 assert_eq!(items[1].deps, vec!["JOY-0042-A3".to_string()]);
787 }
788
789 #[test]
790 fn normalize_leaves_full_form_unchanged() {
791 let parent = Item::new(
792 "JOY-0042-A3".into(),
793 "P".into(),
794 ItemType::Epic,
795 Priority::Medium,
796 vec![],
797 );
798 let mut child = Item::new(
799 "JOY-0043-B1".into(),
800 "C".into(),
801 ItemType::Task,
802 Priority::Medium,
803 vec![],
804 );
805 child.parent = Some("JOY-0042-A3".into());
806 let mut items = vec![parent, child];
807 normalize_id_refs(&mut items);
808 assert_eq!(items[1].parent.as_deref(), Some("JOY-0042-A3"));
809 }
810
811 #[test]
812 fn normalize_leaves_unknown_refs_unchanged() {
813 let mut child = Item::new(
814 "JOY-0043-B1".into(),
815 "C".into(),
816 ItemType::Task,
817 Priority::Medium,
818 vec![],
819 );
820 child.parent = Some("JOY-9999".into());
821 child.deps = vec!["JOY-8888".into()];
822 let mut items = vec![child];
823 normalize_id_refs(&mut items);
824 assert_eq!(items[0].parent.as_deref(), Some("JOY-9999"));
825 assert_eq!(items[0].deps, vec!["JOY-8888".to_string()]);
826 }
827
828 #[test]
829 fn normalize_leaves_ambiguous_short_forms_unchanged() {
830 let a = Item::new(
831 "JOY-0042-A3".into(),
832 "A".into(),
833 ItemType::Task,
834 Priority::Medium,
835 vec![],
836 );
837 let b = Item::new(
838 "JOY-0042-B1".into(),
839 "B".into(),
840 ItemType::Task,
841 Priority::Medium,
842 vec![],
843 );
844 let mut child = Item::new(
845 "JOY-0043-CC".into(),
846 "C".into(),
847 ItemType::Task,
848 Priority::Medium,
849 vec![],
850 );
851 child.parent = Some("JOY-0042".into());
852 let mut items = vec![a, b, child];
853 normalize_id_refs(&mut items);
854 assert_eq!(items[2].parent.as_deref(), Some("JOY-0042"));
855 }
856
857 #[test]
858 fn milestone_short_form_extracts_prefix() {
859 assert_eq!(milestone_short_form("JOY-MS-01-A1"), Some("JOY-MS-01"));
860 assert_eq!(milestone_short_form("TST-MS-FF-12"), Some("TST-MS-FF"));
861 }
862
863 #[test]
864 fn milestone_short_form_returns_none_for_legacy_or_item() {
865 assert_eq!(milestone_short_form("JOY-MS-01"), None);
866 assert_eq!(milestone_short_form("JOY-0042-A3"), None);
867 }
868
869 #[test]
870 fn normalize_milestone_rewrites_short_form() {
871 let mut item = Item::new(
872 "JOY-0001-AA".into(),
873 "X".into(),
874 ItemType::Task,
875 Priority::Medium,
876 vec![],
877 );
878 item.milestone = Some("JOY-MS-01".into());
879 let mut items = vec![item];
880 normalize_milestone_refs(&mut items, &["JOY-MS-01-A1".to_string()]);
881 assert_eq!(items[0].milestone.as_deref(), Some("JOY-MS-01-A1"));
882 }
883
884 #[test]
885 fn normalize_milestone_leaves_unknown_unchanged() {
886 let mut item = Item::new(
887 "JOY-0001-AA".into(),
888 "X".into(),
889 ItemType::Task,
890 Priority::Medium,
891 vec![],
892 );
893 item.milestone = Some("JOY-MS-99".into());
894 let mut items = vec![item];
895 normalize_milestone_refs(&mut items, &["JOY-MS-01-A1".to_string()]);
896 assert_eq!(items[0].milestone.as_deref(), Some("JOY-MS-99"));
897 }
898
899 #[test]
900 fn normalize_milestone_leaves_full_form_unchanged() {
901 let mut item = Item::new(
902 "JOY-0001-AA".into(),
903 "X".into(),
904 ItemType::Task,
905 Priority::Medium,
906 vec![],
907 );
908 item.milestone = Some("JOY-MS-01-A1".into());
909 let mut items = vec![item];
910 normalize_milestone_refs(&mut items, &["JOY-MS-01-A1".to_string()]);
911 assert_eq!(items[0].milestone.as_deref(), Some("JOY-MS-01-A1"));
912 }
913
914 #[test]
915 fn normalize_handles_legacy_parent_referenced_by_full_id() {
916 let parent = Item::new(
917 "JOY-0042".into(),
918 "P".into(),
919 ItemType::Epic,
920 Priority::Medium,
921 vec![],
922 );
923 let mut child = Item::new(
924 "JOY-0043-B1".into(),
925 "C".into(),
926 ItemType::Task,
927 Priority::Medium,
928 vec![],
929 );
930 child.parent = Some("JOY-0042".into());
931 let mut items = vec![parent, child];
932 normalize_id_refs(&mut items);
933 assert_eq!(items[1].parent.as_deref(), Some("JOY-0042"));
934 }
935}