1use std::path::Path;
5
6use crate::error::JoyError;
7use crate::model::item::{item_filename, Item};
8use crate::store;
9
10#[derive(Debug, Clone)]
16pub struct LockedItem {
17 pub id: String,
18 pub zone: String,
19}
20
21pub fn load_items_with_locked(root: &Path) -> Result<(Vec<Item>, Vec<LockedItem>), JoyError> {
27 let mut metas = list_item_metadata(root)?;
28 metas.sort_by(|a, b| a.path.file_name().cmp(&b.path.file_name()));
29
30 let mut items: Vec<Item> = Vec::new();
31 let mut locked: Vec<LockedItem> = Vec::new();
32 for meta in metas {
33 if let Some(zone) = meta.encrypted_zone.as_deref() {
34 if crate::crypt::active_zone_key(zone).is_none() {
35 locked.push(LockedItem {
36 id: meta.id,
37 zone: zone.to_string(),
38 });
39 continue;
40 }
41 }
42 let item: Item = store::read_yaml(&meta.path)?;
43 items.push(item);
44 }
45
46 normalize_id_refs(&mut items);
47 let milestone_ids: Vec<String> = crate::milestones::load_milestones(root)
48 .map(|list| list.into_iter().map(|m| m.id).collect())
49 .unwrap_or_default();
50 normalize_milestone_refs(&mut items, &milestone_ids);
51
52 Ok((items, locked))
53}
54
55pub fn load_items(root: &Path) -> Result<Vec<Item>, JoyError> {
60 let (items, _) = load_items_with_locked(root)?;
61 Ok(items)
62}
63
64fn short_form(full_id: &str) -> Option<&str> {
71 let last_dash = full_id.rfind('-')?;
72 let suffix = &full_id[last_dash + 1..];
73 if suffix.len() != 2 || u8::from_str_radix(suffix, 16).is_err() {
74 return None;
75 }
76 let prefix = &full_id[..last_dash];
77 let prev_dash = prefix.rfind('-')?;
78 let middle = &prefix[prev_dash + 1..];
79 if middle.len() == 4 && u16::from_str_radix(middle, 16).is_ok() {
80 Some(prefix)
81 } else {
82 None
83 }
84}
85
86fn milestone_short_form(full_id: &str) -> Option<&str> {
93 let last_dash = full_id.rfind('-')?;
94 let suffix = &full_id[last_dash + 1..];
95 if suffix.len() != 2 || u8::from_str_radix(suffix, 16).is_err() {
96 return None;
97 }
98 let prefix = &full_id[..last_dash];
99 if prefix.contains("-MS-") {
100 Some(prefix)
101 } else {
102 None
103 }
104}
105
106fn normalize_milestone_refs(items: &mut [Item], milestone_ids: &[String]) {
110 use std::collections::HashMap;
111 let mut map: HashMap<String, Option<String>> = HashMap::new();
112 for ms_id in milestone_ids {
113 if let Some(short) = milestone_short_form(ms_id) {
114 map.entry(short.to_string())
115 .and_modify(|e| *e = None)
116 .or_insert_with(|| Some(ms_id.clone()));
117 }
118 }
119 for item in items.iter_mut() {
120 if let Some(ms) = item.milestone.as_deref() {
121 if let Some(Some(full)) = map.get(ms) {
122 item.milestone = Some(full.clone());
123 }
124 }
125 }
126}
127
128fn normalize_id_refs(items: &mut [Item]) {
132 use std::collections::HashMap;
133 let mut map: HashMap<String, Option<String>> = HashMap::new();
134 for item in items.iter() {
135 if let Some(short) = short_form(&item.id) {
136 map.entry(short.to_string())
137 .and_modify(|e| *e = None)
138 .or_insert_with(|| Some(item.id.clone()));
139 }
140 }
141 for item in items.iter_mut() {
142 if let Some(p) = item.parent.as_deref() {
143 if let Some(Some(full)) = map.get(p) {
144 item.parent = Some(full.clone());
145 }
146 }
147 for dep in &mut item.deps {
148 if let Some(Some(full)) = map.get(dep.as_str()) {
149 *dep = full.clone();
150 }
151 }
152 }
153}
154
155pub fn touch_for_attribute_change(item: &mut Item, by: &str) {
162 let now = chrono::Utc::now();
163 item.updated = now;
164 item.updated_by = Some(by.to_string());
165 item.history
166 .get_or_insert_with(Vec::new)
167 .push(crate::model::item::UpdateEntry {
168 date: now,
169 by: by.to_string(),
170 });
171}
172
173pub fn touch_for_comment_change(item: &mut Item, by: &str) {
180 let now = chrono::Utc::now();
181 item.updated = now;
182 item.updated_by = Some(by.to_string());
183}
184
185pub fn save_item(root: &Path, item: &Item) -> Result<(), JoyError> {
187 let items_dir = store::joy_dir(root).join(store::ITEMS_DIR);
188 let filename = item_filename(&item.id, &item.title);
189 let path = items_dir.join(&filename);
190 write_item_file(&path, item)?;
191 let rel = format!("{}/{}/{}", store::JOY_DIR, store::ITEMS_DIR, filename);
192 crate::git_ops::auto_git_add(root, &[&rel]);
193 Ok(())
194}
195
196fn write_item_file(path: &Path, item: &Item) -> Result<(), JoyError> {
201 let yaml = serde_yaml_ng::to_string(item).map_err(JoyError::Yaml)?;
202 let bytes = match item.crypt_zone.as_deref() {
203 Some(zone) => {
204 let zone_key =
205 crate::crypt::active_zone_key(zone).ok_or_else(|| JoyError::ZoneAccessDenied {
206 zone: zone.to_string(),
207 })?;
208 crate::crypt::encrypt_blob(zone, &zone_key, yaml.as_bytes())
209 }
210 None => yaml.into_bytes(),
211 };
212 write_atomic(path, &bytes)
213}
214
215#[derive(Debug, Clone)]
223pub struct ItemMeta {
224 pub id: String,
225 pub path: std::path::PathBuf,
226 pub encrypted_zone: Option<String>,
227 pub plaintext_crypt_zone: Option<String>,
230}
231
232impl ItemMeta {
233 pub fn zone(&self) -> Option<&str> {
236 self.encrypted_zone
237 .as_deref()
238 .or(self.plaintext_crypt_zone.as_deref())
239 }
240}
241
242pub fn list_item_metadata(root: &Path) -> Result<Vec<ItemMeta>, JoyError> {
246 let items_dir = store::joy_dir(root).join(store::ITEMS_DIR);
247 if !items_dir.is_dir() {
248 return Ok(Vec::new());
249 }
250 let mut out = Vec::new();
251 for entry in std::fs::read_dir(&items_dir).map_err(|e| JoyError::ReadFile {
252 path: items_dir.clone(),
253 source: e,
254 })? {
255 let Ok(entry) = entry else { continue };
256 let path = entry.path();
257 if !path.is_file() {
258 continue;
259 }
260 let Some(name) = path.file_name().and_then(|s| s.to_str()) else {
261 continue;
262 };
263 let Some(id) = id_from_filename(name) else {
264 continue;
265 };
266 let bytes = std::fs::read(&path).map_err(|e| JoyError::ReadFile {
267 path: path.clone(),
268 source: e,
269 })?;
270 let (encrypted_zone, plaintext_crypt_zone) = if crate::crypt::looks_like_blob(&bytes) {
271 (parse_blob_zone(&bytes), None)
272 } else {
273 (None, parse_plaintext_crypt_zone(&bytes))
274 };
275 out.push(ItemMeta {
276 id,
277 path,
278 encrypted_zone,
279 plaintext_crypt_zone,
280 });
281 }
282 Ok(out)
283}
284
285fn id_from_filename(name: &str) -> Option<String> {
286 let stem = name.strip_suffix(".yaml")?;
291 let parts: Vec<&str> = stem.split('-').collect();
292 if parts.len() >= 2 && parts[1].chars().all(|c| c.is_ascii_hexdigit()) && parts[1].len() == 4 {
293 let id_end = if parts.len() >= 3
295 && parts[2].chars().all(|c| c.is_ascii_hexdigit())
296 && parts[2].len() == 2
297 {
298 3
299 } else {
300 2
301 };
302 Some(parts[..id_end].join("-"))
303 } else {
304 None
305 }
306}
307
308fn parse_blob_zone(bytes: &[u8]) -> Option<String> {
309 if bytes.len() < 10 {
311 return None;
312 }
313 let zone_len = bytes[9] as usize;
314 if bytes.len() < 10 + zone_len {
315 return None;
316 }
317 std::str::from_utf8(&bytes[10..10 + zone_len])
318 .ok()
319 .map(str::to_string)
320}
321
322fn parse_plaintext_crypt_zone(bytes: &[u8]) -> Option<String> {
323 let text = std::str::from_utf8(bytes).ok()?;
324 for line in text.lines() {
325 let trimmed = line.trim_start();
326 if let Some(rest) = trimmed.strip_prefix("crypt_zone:") {
327 let value = rest.trim().trim_matches(|c: char| c == '"' || c == '\'');
328 if value.is_empty() || value == "null" || value == "~" {
329 return None;
330 }
331 return Some(value.to_string());
332 }
333 }
334 None
335}
336
337fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), JoyError> {
339 let parent = path.parent().unwrap_or_else(|| Path::new("."));
340 std::fs::create_dir_all(parent).map_err(|e| JoyError::CreateDir {
341 path: parent.to_path_buf(),
342 source: e,
343 })?;
344 let tmp = parent.join(format!(
345 ".{}.tmp.{}",
346 path.file_name().and_then(|s| s.to_str()).unwrap_or("item"),
347 std::process::id()
348 ));
349 std::fs::write(&tmp, bytes).map_err(|e| JoyError::WriteFile {
350 path: tmp.clone(),
351 source: e,
352 })?;
353 std::fs::rename(&tmp, path).map_err(|e| JoyError::WriteFile {
354 path: path.to_path_buf(),
355 source: e,
356 })?;
357 Ok(())
358}
359
360pub fn next_id(root: &Path, acronym: &str, title: &str) -> Result<String, JoyError> {
367 let prefix = acronym;
368
369 let items_dir = store::joy_dir(root).join(store::ITEMS_DIR);
370 if !items_dir.is_dir() {
371 let suffix = title_hash_suffix(title);
372 return Ok(format!("{prefix}-0001-{suffix}"));
373 }
374
375 let mut max_num: u16 = 0;
376
377 let entries = std::fs::read_dir(&items_dir).map_err(|e| JoyError::ReadFile {
378 path: items_dir.clone(),
379 source: e,
380 })?;
381
382 for entry in entries.filter_map(|e| e.ok()) {
383 let name = entry.file_name();
384 let name = name.to_string_lossy();
385 if let Some(hex_part) = name.strip_prefix(&format!("{prefix}-")) {
386 if let Some(hex_str) = hex_part.get(..4) {
387 if let Ok(num) = u16::from_str_radix(hex_str, 16) {
388 max_num = max_num.max(num);
389 }
390 }
391 }
392 }
393
394 let next = max_num.checked_add(1).ok_or_else(|| {
395 JoyError::Other(format!("{prefix} ID space exhausted (max {prefix}-FFFF)"))
396 })?;
397 let suffix = title_hash_suffix(title);
398 Ok(format!("{prefix}-{next:04X}-{suffix}"))
399}
400
401pub fn title_hash_suffix(title: &str) -> String {
403 use sha2::{Digest, Sha256};
404 let mut hasher = Sha256::new();
405 hasher.update(title.as_bytes());
406 let hash = hasher.finalize();
407 format!("{:02X}", hash[0])
408}
409
410pub fn find_item_file(root: &Path, id: &str) -> Result<std::path::PathBuf, JoyError> {
414 let items_dir = store::joy_dir(root).join(store::ITEMS_DIR);
415
416 let id_upper = id.to_uppercase();
418
419 let entries: Vec<_> = std::fs::read_dir(&items_dir)
420 .map_err(|e| JoyError::ReadFile {
421 path: items_dir.clone(),
422 source: e,
423 })?
424 .filter_map(|e| e.ok())
425 .collect();
426
427 let exact_prefix = format!("{}-", id_upper);
429 for entry in &entries {
430 let name = entry.file_name();
431 let name_upper = name.to_string_lossy().to_uppercase();
432 if name_upper.starts_with(&exact_prefix) {
433 return Ok(entry.path());
434 }
435 }
436
437 let short_prefix = format!("{}-", id_upper);
440 let mut matches: Vec<std::path::PathBuf> = Vec::new();
441 for entry in &entries {
442 let name = entry.file_name();
443 let name_upper = name.to_string_lossy().to_uppercase();
444 if name_upper.starts_with(&short_prefix) {
445 matches.push(entry.path());
446 }
447 }
448
449 match matches.len() {
450 0 => Err(JoyError::ItemNotFound(id.to_string())),
451 1 => Ok(matches.into_iter().next().unwrap()),
452 _ => {
453 let ids: Vec<String> = matches
455 .iter()
456 .filter_map(|p| {
457 let name = p.file_name()?.to_string_lossy().to_string();
458 extract_full_id(&name)
459 })
460 .collect();
461 Err(JoyError::Other(format!("ambiguous ID: {}", ids.join(", "))))
462 }
463 }
464}
465
466fn extract_full_id(filename: &str) -> Option<String> {
470 let name = filename
472 .strip_suffix(".yaml")
473 .or_else(|| filename.strip_suffix(".yml"))?;
474 let parts: Vec<&str> = name.splitn(2, '-').collect();
476 if parts.len() < 2 {
477 return None;
478 }
479 let acronym = parts[0];
480 let rest = parts[1];
481
482 if rest.len() >= 7 && rest.as_bytes()[4] == b'-' {
484 let hex4 = &rest[..4];
486 let maybe_suffix = &rest[5..7];
487 if u16::from_str_radix(hex4, 16).is_ok()
488 && maybe_suffix.len() == 2
489 && u8::from_str_radix(maybe_suffix, 16).is_ok()
490 && (rest.len() == 7 || rest.as_bytes()[7] == b'-')
491 {
492 return Some(format!("{}-{}-{}", acronym, hex4, maybe_suffix).to_uppercase());
493 }
494 }
495
496 let hex4 = &rest[..4.min(rest.len())];
498 if hex4.len() == 4 && u16::from_str_radix(hex4, 16).is_ok() {
499 return Some(format!("{}-{}", acronym, hex4).to_uppercase());
500 }
501
502 None
503}
504
505pub fn load_item(root: &Path, id: &str) -> Result<Item, JoyError> {
512 let path = find_item_file(root, id)?;
513 let target_id: String = store::read_yaml::<Item>(&path)?.id;
514 let items = load_items(root)?;
515 items
516 .into_iter()
517 .find(|i| i.id == target_id)
518 .ok_or(JoyError::ItemNotFound(target_id))
519}
520
521pub fn delete_item(root: &Path, id: &str) -> Result<Item, JoyError> {
523 let path = find_item_file(root, id)?;
524 let item: Item = store::read_yaml(&path)?;
525 let rel = path
526 .strip_prefix(root)
527 .unwrap_or(&path)
528 .to_string_lossy()
529 .to_string();
530 std::fs::remove_file(&path).map_err(|e| JoyError::WriteFile { path, source: e })?;
531 crate::git_ops::auto_git_add(root, &[&rel]);
532 Ok(item)
533}
534
535pub fn remove_references(
539 root: &Path,
540 deleted_id: &str,
541 updated_by: &str,
542) -> Result<Vec<String>, JoyError> {
543 let items = load_items(root)?;
544 let mut updated = Vec::new();
545 for mut item in items {
546 let mut changed = false;
547 if item.deps.contains(&deleted_id.to_string()) {
548 item.deps.retain(|d| d != deleted_id);
549 changed = true;
550 }
551 if item.parent.as_deref() == Some(deleted_id) {
552 item.parent = None;
553 changed = true;
554 }
555 if changed {
556 touch_for_attribute_change(&mut item, updated_by);
557 update_item(root, &item)?;
558 updated.push(item.id.clone());
559 }
560 }
561 Ok(updated)
562}
563
564pub fn detect_cycle(
567 root: &Path,
568 item_id: &str,
569 new_dep_id: &str,
570) -> Result<Option<Vec<String>>, JoyError> {
571 let items = load_items(root)?;
572 let mut visited = vec![item_id.to_string()];
573 if find_cycle(&items, new_dep_id, &mut visited) {
574 visited.push(new_dep_id.to_string());
575 Ok(Some(visited))
576 } else {
577 Ok(None)
578 }
579}
580
581fn find_cycle(items: &[Item], current: &str, visited: &mut Vec<String>) -> bool {
582 if visited.contains(¤t.to_string()) {
583 return true;
584 }
585 if let Some(item) = items.iter().find(|i| i.id == current) {
586 visited.push(current.to_string());
587 for dep in &item.deps {
588 if find_cycle(items, dep, visited) {
589 return true;
590 }
591 }
592 visited.pop();
593 }
594 false
595}
596
597pub fn update_item(root: &Path, item: &Item) -> Result<(), JoyError> {
599 let old_path = find_item_file(root, &item.id)?;
600 save_item(root, item)?;
602 let new_path = store::joy_dir(root)
604 .join(store::ITEMS_DIR)
605 .join(item_filename(&item.id, &item.title));
606 if old_path != new_path {
607 let _ = std::fs::remove_file(&old_path);
608 let old_rel = old_path
609 .strip_prefix(root)
610 .unwrap_or(&old_path)
611 .to_string_lossy()
612 .to_string();
613 crate::git_ops::auto_git_add(root, &[&old_rel]);
614 }
615 Ok(())
616}
617
618#[cfg(test)]
619mod tests {
620 use super::*;
621 use crate::model::item::{ItemType, Priority};
622 use tempfile::tempdir;
623
624 fn setup_project(dir: &Path) {
625 let joy_dir = dir.join(".joy");
626 std::fs::create_dir_all(joy_dir.join("items")).unwrap();
627 }
628
629 #[test]
630 fn next_id_first_item() {
631 let dir = tempdir().unwrap();
632 setup_project(dir.path());
633 let id = next_id(dir.path(), "JOY", "Test item").unwrap();
634 assert!(id.starts_with("JOY-0001-"), "got: {id}");
635 assert_eq!(id.len(), 11); }
637
638 #[test]
639 fn next_id_increments() {
640 let dir = tempdir().unwrap();
641 setup_project(dir.path());
642
643 let item = Item::new(
644 "JOY-0001".into(),
645 "First".into(),
646 ItemType::Task,
647 Priority::Low,
648 vec![],
649 );
650 save_item(dir.path(), &item).unwrap();
651
652 let id = next_id(dir.path(), "JOY", "Second item").unwrap();
653 assert!(id.starts_with("JOY-0002-"), "got: {id}");
654 }
655
656 #[test]
657 fn next_id_skips_gaps() {
658 let dir = tempdir().unwrap();
659 setup_project(dir.path());
660
661 let item1 = Item::new(
662 "JOY-0001".into(),
663 "First".into(),
664 ItemType::Task,
665 Priority::Low,
666 vec![],
667 );
668 save_item(dir.path(), &item1).unwrap();
669
670 let item3 = Item::new(
671 "JOY-0003".into(),
672 "Third".into(),
673 ItemType::Task,
674 Priority::Low,
675 vec![],
676 );
677 save_item(dir.path(), &item3).unwrap();
678
679 let id = next_id(dir.path(), "JOY", "Fourth item").unwrap();
680 assert!(id.starts_with("JOY-0004-"), "got: {id}");
681 }
682
683 #[test]
684 fn next_id_same_title_same_suffix() {
685 let dir = tempdir().unwrap();
686 setup_project(dir.path());
687 let id1 = next_id(dir.path(), "JOY", "Same title").unwrap();
688 let suffix1 = &id1[9..];
689 let id2_suffix = title_hash_suffix("Same title");
690 assert_eq!(suffix1, id2_suffix);
691 }
692
693 #[test]
694 fn next_id_different_titles_different_suffixes() {
695 let suffix_a = title_hash_suffix("Fix login bug");
696 let suffix_b = title_hash_suffix("Add roadmap feature");
697 assert_ne!(suffix_a, suffix_b);
701 }
702
703 #[test]
704 fn next_id_increments_past_new_format() {
705 let dir = tempdir().unwrap();
706 setup_project(dir.path());
707
708 let item = Item::new(
710 "JOY-0005-A3".into(),
711 "New format".into(),
712 ItemType::Task,
713 Priority::Low,
714 vec![],
715 );
716 save_item(dir.path(), &item).unwrap();
717
718 let id = next_id(dir.path(), "JOY", "Next item").unwrap();
719 assert!(id.starts_with("JOY-0006-"), "got: {id}");
720 }
721
722 #[test]
723 fn load_items_empty() {
724 let dir = tempdir().unwrap();
725 setup_project(dir.path());
726 let items = load_items(dir.path()).unwrap();
727 assert!(items.is_empty());
728 }
729
730 #[test]
731 fn save_and_load_item() {
732 let dir = tempdir().unwrap();
733 setup_project(dir.path());
734
735 let item = Item::new(
736 "JOY-0001".into(),
737 "Test item".into(),
738 ItemType::Story,
739 Priority::High,
740 vec![],
741 );
742 save_item(dir.path(), &item).unwrap();
743
744 let items = load_items(dir.path()).unwrap();
745 assert_eq!(items.len(), 1);
746 assert_eq!(items[0].id, "JOY-0001");
747 assert_eq!(items[0].title, "Test item");
748 }
749
750 #[test]
751 fn load_items_sorted() {
752 let dir = tempdir().unwrap();
753 setup_project(dir.path());
754
755 let item2 = Item::new(
756 "JOY-0002".into(),
757 "Second".into(),
758 ItemType::Task,
759 Priority::Low,
760 vec![],
761 );
762 save_item(dir.path(), &item2).unwrap();
763
764 let item1 = Item::new(
765 "JOY-0001".into(),
766 "First".into(),
767 ItemType::Task,
768 Priority::Low,
769 vec![],
770 );
771 save_item(dir.path(), &item1).unwrap();
772
773 let items = load_items(dir.path()).unwrap();
774 assert_eq!(items[0].id, "JOY-0001");
775 assert_eq!(items[1].id, "JOY-0002");
776 }
777
778 #[test]
779 fn short_form_extracts_prefix_for_suffixed_id() {
780 assert_eq!(short_form("JOY-0042-A3"), Some("JOY-0042"));
781 assert_eq!(short_form("TST-00FF-12"), Some("TST-00FF"));
782 }
783
784 #[test]
785 fn short_form_returns_none_for_legacy_id() {
786 assert_eq!(short_form("JOY-0042"), None);
787 assert_eq!(short_form("JOY-MS-01"), None);
788 }
789
790 #[test]
791 fn short_form_returns_none_for_non_hex_suffix() {
792 assert_eq!(short_form("JOY-0042-XX"), None);
793 assert_eq!(short_form("JOY-0042-AAA"), None);
794 }
795
796 #[test]
797 fn normalize_rewrites_short_form_parent() {
798 let mut parent = Item::new(
799 "JOY-0042-A3".into(),
800 "P".into(),
801 ItemType::Epic,
802 Priority::Medium,
803 vec![],
804 );
805 parent.parent = None;
806 let mut child = Item::new(
807 "JOY-0043-B1".into(),
808 "C".into(),
809 ItemType::Task,
810 Priority::Medium,
811 vec![],
812 );
813 child.parent = Some("JOY-0042".into());
814 let mut items = vec![parent, child];
815 normalize_id_refs(&mut items);
816 assert_eq!(items[1].parent.as_deref(), Some("JOY-0042-A3"));
817 }
818
819 #[test]
820 fn normalize_rewrites_short_form_deps() {
821 let dep = Item::new(
822 "JOY-0042-A3".into(),
823 "D".into(),
824 ItemType::Task,
825 Priority::Medium,
826 vec![],
827 );
828 let mut consumer = Item::new(
829 "JOY-0043-B1".into(),
830 "C".into(),
831 ItemType::Task,
832 Priority::Medium,
833 vec![],
834 );
835 consumer.deps = vec!["JOY-0042".into()];
836 let mut items = vec![dep, consumer];
837 normalize_id_refs(&mut items);
838 assert_eq!(items[1].deps, vec!["JOY-0042-A3".to_string()]);
839 }
840
841 #[test]
842 fn normalize_leaves_full_form_unchanged() {
843 let parent = Item::new(
844 "JOY-0042-A3".into(),
845 "P".into(),
846 ItemType::Epic,
847 Priority::Medium,
848 vec![],
849 );
850 let mut child = Item::new(
851 "JOY-0043-B1".into(),
852 "C".into(),
853 ItemType::Task,
854 Priority::Medium,
855 vec![],
856 );
857 child.parent = Some("JOY-0042-A3".into());
858 let mut items = vec![parent, child];
859 normalize_id_refs(&mut items);
860 assert_eq!(items[1].parent.as_deref(), Some("JOY-0042-A3"));
861 }
862
863 #[test]
864 fn normalize_leaves_unknown_refs_unchanged() {
865 let mut child = Item::new(
866 "JOY-0043-B1".into(),
867 "C".into(),
868 ItemType::Task,
869 Priority::Medium,
870 vec![],
871 );
872 child.parent = Some("JOY-9999".into());
873 child.deps = vec!["JOY-8888".into()];
874 let mut items = vec![child];
875 normalize_id_refs(&mut items);
876 assert_eq!(items[0].parent.as_deref(), Some("JOY-9999"));
877 assert_eq!(items[0].deps, vec!["JOY-8888".to_string()]);
878 }
879
880 #[test]
881 fn normalize_leaves_ambiguous_short_forms_unchanged() {
882 let a = Item::new(
883 "JOY-0042-A3".into(),
884 "A".into(),
885 ItemType::Task,
886 Priority::Medium,
887 vec![],
888 );
889 let b = Item::new(
890 "JOY-0042-B1".into(),
891 "B".into(),
892 ItemType::Task,
893 Priority::Medium,
894 vec![],
895 );
896 let mut child = Item::new(
897 "JOY-0043-CC".into(),
898 "C".into(),
899 ItemType::Task,
900 Priority::Medium,
901 vec![],
902 );
903 child.parent = Some("JOY-0042".into());
904 let mut items = vec![a, b, child];
905 normalize_id_refs(&mut items);
906 assert_eq!(items[2].parent.as_deref(), Some("JOY-0042"));
907 }
908
909 #[test]
910 fn milestone_short_form_extracts_prefix() {
911 assert_eq!(milestone_short_form("JOY-MS-01-A1"), Some("JOY-MS-01"));
912 assert_eq!(milestone_short_form("TST-MS-FF-12"), Some("TST-MS-FF"));
913 }
914
915 #[test]
916 fn milestone_short_form_returns_none_for_legacy_or_item() {
917 assert_eq!(milestone_short_form("JOY-MS-01"), None);
918 assert_eq!(milestone_short_form("JOY-0042-A3"), None);
919 }
920
921 #[test]
922 fn normalize_milestone_rewrites_short_form() {
923 let mut item = Item::new(
924 "JOY-0001-AA".into(),
925 "X".into(),
926 ItemType::Task,
927 Priority::Medium,
928 vec![],
929 );
930 item.milestone = Some("JOY-MS-01".into());
931 let mut items = vec![item];
932 normalize_milestone_refs(&mut items, &["JOY-MS-01-A1".to_string()]);
933 assert_eq!(items[0].milestone.as_deref(), Some("JOY-MS-01-A1"));
934 }
935
936 #[test]
937 fn normalize_milestone_leaves_unknown_unchanged() {
938 let mut item = Item::new(
939 "JOY-0001-AA".into(),
940 "X".into(),
941 ItemType::Task,
942 Priority::Medium,
943 vec![],
944 );
945 item.milestone = Some("JOY-MS-99".into());
946 let mut items = vec![item];
947 normalize_milestone_refs(&mut items, &["JOY-MS-01-A1".to_string()]);
948 assert_eq!(items[0].milestone.as_deref(), Some("JOY-MS-99"));
949 }
950
951 #[test]
952 fn normalize_milestone_leaves_full_form_unchanged() {
953 let mut item = Item::new(
954 "JOY-0001-AA".into(),
955 "X".into(),
956 ItemType::Task,
957 Priority::Medium,
958 vec![],
959 );
960 item.milestone = Some("JOY-MS-01-A1".into());
961 let mut items = vec![item];
962 normalize_milestone_refs(&mut items, &["JOY-MS-01-A1".to_string()]);
963 assert_eq!(items[0].milestone.as_deref(), Some("JOY-MS-01-A1"));
964 }
965
966 #[test]
967 fn normalize_handles_legacy_parent_referenced_by_full_id() {
968 let parent = Item::new(
969 "JOY-0042".into(),
970 "P".into(),
971 ItemType::Epic,
972 Priority::Medium,
973 vec![],
974 );
975 let mut child = Item::new(
976 "JOY-0043-B1".into(),
977 "C".into(),
978 ItemType::Task,
979 Priority::Medium,
980 vec![],
981 );
982 child.parent = Some("JOY-0042".into());
983 let mut items = vec![parent, child];
984 normalize_id_refs(&mut items);
985 assert_eq!(items[1].parent.as_deref(), Some("JOY-0042"));
986 }
987}