Skip to main content

joy_core/
items.rs

1// Copyright (c) 2026 Joydev GmbH (joydev.com)
2// SPDX-License-Identifier: MIT
3
4use std::path::Path;
5
6use crate::error::JoyError;
7use crate::model::item::{item_filename, Item};
8use crate::store;
9
10/// Load all items from the .joy/items/ directory.
11pub 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
48/// Return the short form of a full item ID, or None if the ID is not
49/// in the new ACRONYM-XXXX-YY shape (legacy four-hex-digit IDs and
50/// non-item IDs like ACRONYM-MS-NN return None).
51/// "JOY-0042-A3" -> Some("JOY-0042")
52/// "JOY-0042"    -> None
53/// "JOY-MS-01"   -> None
54fn 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
70/// Return the short form of a full milestone ID, or None if the ID
71/// is not in the new ACRONYM-MS-NN-YY shape (legacy ACRONYM-MS-NN
72/// IDs return None).
73/// "JOY-MS-01-A1" -> Some("JOY-MS-01")
74/// "JOY-MS-01"    -> None
75/// "JOY-0042-A3"  -> None
76fn 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
90/// Rewrite short-form milestone references in `milestone` to their
91/// full form, using the supplied known milestone IDs. Ambiguous short
92/// forms are left untouched.
93fn 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
112/// Rewrite short-form item ID references in `parent` and `deps` to
113/// their full form, in place. Ambiguous short forms (multiple items
114/// share the same prefix) are left untouched.
115fn 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
139/// Save an item to .joy/items/{ID}-{slug}.yaml.
140pub 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
150/// Generate the next item ID by scanning existing files.
151/// Returns "ACRONYM-0001" for the first item, increments the highest found.
152/// All items share one number space regardless of type.
153///
154/// Legacy format (existing items): ACRONYM-XXXX (4 hex digits)
155/// New format (ADR-027): ACRONYM-XXXX-YY (4 hex digits + 2 hex title hash)
156pub 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
191/// Generate 2 hex digits from the title for collision-safe IDs (ADR-027).
192pub 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
200/// Find the file path for an item by its ID.
201/// Accepts both full IDs (JOY-0042-A3) and short-form (JOY-0042).
202/// Short-form returns an error if ambiguous (multiple matches).
203pub 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    // Normalize: uppercase the ID for matching
207    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    // First try exact match (full ID)
218    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    // Then try short-form match (prefix without suffix)
228    // JOY-0042 matches JOY-0042-A3-some-title.yaml
229    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            // Extract full IDs from filenames for the error message
244            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
256/// Extract the full item ID from a filename.
257/// "JOY-0042-A3-fix-login.yaml" -> "JOY-0042-A3"
258/// "JOY-0042-fix-login.yaml" -> "JOY-0042" (legacy)
259fn extract_full_id(filename: &str) -> Option<String> {
260    // Strip .yaml extension
261    let name = filename
262        .strip_suffix(".yaml")
263        .or_else(|| filename.strip_suffix(".yml"))?;
264    // Find acronym-XXXX pattern
265    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    // Check if it's new format: XXXX-YY-slug or legacy: XXXX-slug
273    if rest.len() >= 7 && rest.as_bytes()[4] == b'-' {
274        // Could be XXXX-YY-slug (new) or XXXX-slug with short slug
275        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    // Legacy format: XXXX-slug
287    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
295/// Load a single item by ID.
296///
297/// Goes through `load_items` so that short-form ID references in
298/// `parent` and `deps` are normalized to full form before the caller
299/// sees them. This guarantees that any subsequent `update_item` call
300/// persists the normalized form.
301pub 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
311/// Delete an item by ID. Returns the deleted item.
312pub 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
325/// Remove references to a deleted item from other items' deps and parent fields.
326pub 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
348/// Check if adding a dependency would create a cycle.
349/// Returns the cycle path if one exists.
350pub 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(&current.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
381/// Update an item in place (overwrites its file).
382pub fn update_item(root: &Path, item: &Item) -> Result<(), JoyError> {
383    let old_path = find_item_file(root, &item.id)?;
384    // Write new file first to avoid data loss if write fails
385    save_item(root, item)?;
386    // Remove old file if the filename changed (title may have changed)
387    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); // JOY-0001-XX
420    }
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        // Not guaranteed different, but astronomically unlikely to be equal
482        // for these specific strings. If this test fails, the hash function
483        // has a collision on these inputs (1:256 chance).
484        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        // Save an item with new format ID
493        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}