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    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
150/// Write an item file, encrypting in place when `crypt_zone` is set.
151/// Reads the active session's zone keys (set by joy-cli after
152/// passphrase verification); without an active key for the zone the
153/// write fails with `ZoneAccessDenied`. ADR-040.
154fn 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/// Lightweight item metadata available without authentication.
170/// Walks `.joy/items/`, peeks each file: if it is a JOYCRYPT blob,
171/// reads the zone name from the header without decrypting; if it is
172/// plaintext YAML, parses just enough to extract the id and
173/// crypt_zone fields. Used by `joy crypt status` / `joy crypt ls` /
174/// `joy auth` to count and locate Crypt content without prompting
175/// the user for a passphrase.
176#[derive(Debug, Clone)]
177pub struct ItemMeta {
178    pub id: String,
179    pub path: std::path::PathBuf,
180    pub encrypted_zone: Option<String>,
181    /// crypt_zone field as parsed from the plaintext YAML; only
182    /// populated when the file is plaintext.
183    pub plaintext_crypt_zone: Option<String>,
184}
185
186impl ItemMeta {
187    /// The zone this item belongs to, regardless of whether it is
188    /// currently encrypted on disk.
189    pub fn zone(&self) -> Option<&str> {
190        self.encrypted_zone
191            .as_deref()
192            .or(self.plaintext_crypt_zone.as_deref())
193    }
194}
195
196/// Walk `.joy/items/` and return one `ItemMeta` per item file.
197/// Never prompts, never decrypts. Use `load_items` when you need
198/// full Item objects.
199pub 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    // Item filenames look like `<ID>-<title-slug>.yaml`. The ID is
241    // either ACRONYM-XXXX or ACRONYM-XXXX-YY (per ADR-027). Strip
242    // the `.yaml` suffix and split on the last segment that doesn't
243    // match the ID shape.
244    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        // ACRONYM-XXXX[-YY]-...
248        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    // Layout: 8-byte magic + 1 version + 1 zone-len + zone bytes + ...
264    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
291/// Atomic write: temp file in the same directory, fsync, rename.
292fn 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
314/// Generate the next item ID by scanning existing files.
315/// Returns "ACRONYM-0001" for the first item, increments the highest found.
316/// All items share one number space regardless of type.
317///
318/// Legacy format (existing items): ACRONYM-XXXX (4 hex digits)
319/// New format (ADR-027): ACRONYM-XXXX-YY (4 hex digits + 2 hex title hash)
320pub 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
355/// Generate 2 hex digits from the title for collision-safe IDs (ADR-027).
356pub 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
364/// Find the file path for an item by its ID.
365/// Accepts both full IDs (JOY-0042-A3) and short-form (JOY-0042).
366/// Short-form returns an error if ambiguous (multiple matches).
367pub 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    // Normalize: uppercase the ID for matching
371    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    // First try exact match (full ID)
382    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    // Then try short-form match (prefix without suffix)
392    // JOY-0042 matches JOY-0042-A3-some-title.yaml
393    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            // Extract full IDs from filenames for the error message
408            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
420/// Extract the full item ID from a filename.
421/// "JOY-0042-A3-fix-login.yaml" -> "JOY-0042-A3"
422/// "JOY-0042-fix-login.yaml" -> "JOY-0042" (legacy)
423fn extract_full_id(filename: &str) -> Option<String> {
424    // Strip .yaml extension
425    let name = filename
426        .strip_suffix(".yaml")
427        .or_else(|| filename.strip_suffix(".yml"))?;
428    // Find acronym-XXXX pattern
429    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    // Check if it's new format: XXXX-YY-slug or legacy: XXXX-slug
437    if rest.len() >= 7 && rest.as_bytes()[4] == b'-' {
438        // Could be XXXX-YY-slug (new) or XXXX-slug with short slug
439        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    // Legacy format: XXXX-slug
451    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
459/// Load a single item by ID.
460///
461/// Goes through `load_items` so that short-form ID references in
462/// `parent` and `deps` are normalized to full form before the caller
463/// sees them. This guarantees that any subsequent `update_item` call
464/// persists the normalized form.
465pub 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
475/// Delete an item by ID. Returns the deleted item.
476pub 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
489/// Remove references to a deleted item from other items' deps and parent fields.
490pub 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
512/// Check if adding a dependency would create a cycle.
513/// Returns the cycle path if one exists.
514pub 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(&current.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
545/// Update an item in place (overwrites its file).
546pub fn update_item(root: &Path, item: &Item) -> Result<(), JoyError> {
547    let old_path = find_item_file(root, &item.id)?;
548    // Write new file first to avoid data loss if write fails
549    save_item(root, item)?;
550    // Remove old file if the filename changed (title may have changed)
551    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); // JOY-0001-XX
584    }
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        // Not guaranteed different, but astronomically unlikely to be equal
646        // for these specific strings. If this test fails, the hash function
647        // has a collision on these inputs (1:256 chance).
648        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        // Save an item with new format ID
657        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}