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