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/// Lightweight placeholder for an encrypted item the caller cannot
11/// decrypt. ID is read from the filename, zone from the JOYCRYPT magic
12/// header; nothing is decrypted. Used by `joy ls` to render a `[Crypted
13/// in zone <name>]` row instead of failing the whole listing. See
14/// JOY-0174-D3.
15#[derive(Debug, Clone)]
16pub struct LockedItem {
17    pub id: String,
18    pub zone: String,
19}
20
21/// Load all items from `.joy/items/`, separating decryptable ones from
22/// encrypted blobs the caller has no zone-key for. Plaintext items and
23/// items whose zone key is currently active are returned as `Item`;
24/// items in zones without an active key are returned as
25/// [`LockedItem`] placeholders. See JOY-0174-D3.
26pub 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
55/// Load all items from `.joy/items/`. Inaccessible-encrypted items are
56/// silently skipped (the caller treats them as not present). For
57/// surfacing locked-item placeholders, use
58/// [`load_items_with_locked`].
59pub fn load_items(root: &Path) -> Result<Vec<Item>, JoyError> {
60    let (items, _) = load_items_with_locked(root)?;
61    Ok(items)
62}
63
64/// Return the short form of a full item ID, or None if the ID is not
65/// in the new ACRONYM-XXXX-YY shape (legacy four-hex-digit IDs and
66/// non-item IDs like ACRONYM-MS-NN return None).
67/// "JOY-0042-A3" -> Some("JOY-0042")
68/// "JOY-0042"    -> None
69/// "JOY-MS-01"   -> None
70fn 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
86/// Return the short form of a full milestone ID, or None if the ID
87/// is not in the new ACRONYM-MS-NN-YY shape (legacy ACRONYM-MS-NN
88/// IDs return None).
89/// "JOY-MS-01-A1" -> Some("JOY-MS-01")
90/// "JOY-MS-01"    -> None
91/// "JOY-0042-A3"  -> None
92fn 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
106/// Rewrite short-form milestone references in `milestone` to their
107/// full form, using the supplied known milestone IDs. Ambiguous short
108/// forms are left untouched.
109fn 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
128/// Rewrite short-form item ID references in `parent` and `deps` to
129/// their full form, in place. Ambiguous short forms (multiple items
130/// share the same prefix) are left untouched.
131fn 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
155/// Save an item to .joy/items/{ID}-{slug}.yaml.
156pub 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
166/// Write an item file, encrypting in place when `crypt_zone` is set.
167/// Reads the active session's zone keys (set by joy-cli after
168/// passphrase verification); without an active key for the zone the
169/// write fails with `ZoneAccessDenied`. ADR-040.
170fn 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/// Lightweight item metadata available without authentication.
186/// Walks `.joy/items/`, peeks each file: if it is a JOYCRYPT blob,
187/// reads the zone name from the header without decrypting; if it is
188/// plaintext YAML, parses just enough to extract the id and
189/// crypt_zone fields. Used by `joy crypt status` / `joy crypt ls` /
190/// `joy auth` to count and locate Crypt content without prompting
191/// the user for a passphrase.
192#[derive(Debug, Clone)]
193pub struct ItemMeta {
194    pub id: String,
195    pub path: std::path::PathBuf,
196    pub encrypted_zone: Option<String>,
197    /// crypt_zone field as parsed from the plaintext YAML; only
198    /// populated when the file is plaintext.
199    pub plaintext_crypt_zone: Option<String>,
200}
201
202impl ItemMeta {
203    /// The zone this item belongs to, regardless of whether it is
204    /// currently encrypted on disk.
205    pub fn zone(&self) -> Option<&str> {
206        self.encrypted_zone
207            .as_deref()
208            .or(self.plaintext_crypt_zone.as_deref())
209    }
210}
211
212/// Walk `.joy/items/` and return one `ItemMeta` per item file.
213/// Never prompts, never decrypts. Use `load_items` when you need
214/// full Item objects.
215pub 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    // Item filenames look like `<ID>-<title-slug>.yaml`. The ID is
257    // either ACRONYM-XXXX or ACRONYM-XXXX-YY (per ADR-027). Strip
258    // the `.yaml` suffix and split on the last segment that doesn't
259    // match the ID shape.
260    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        // ACRONYM-XXXX[-YY]-...
264        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    // Layout: 8-byte magic + 1 version + 1 zone-len + zone bytes + ...
280    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
307/// Atomic write: temp file in the same directory, fsync, rename.
308fn 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
330/// Generate the next item ID by scanning existing files.
331/// Returns "ACRONYM-0001" for the first item, increments the highest found.
332/// All items share one number space regardless of type.
333///
334/// Legacy format (existing items): ACRONYM-XXXX (4 hex digits)
335/// New format (ADR-027): ACRONYM-XXXX-YY (4 hex digits + 2 hex title hash)
336pub 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
371/// Generate 2 hex digits from the title for collision-safe IDs (ADR-027).
372pub 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
380/// Find the file path for an item by its ID.
381/// Accepts both full IDs (JOY-0042-A3) and short-form (JOY-0042).
382/// Short-form returns an error if ambiguous (multiple matches).
383pub 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    // Normalize: uppercase the ID for matching
387    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    // First try exact match (full ID)
398    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    // Then try short-form match (prefix without suffix)
408    // JOY-0042 matches JOY-0042-A3-some-title.yaml
409    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            // Extract full IDs from filenames for the error message
424            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
436/// Extract the full item ID from a filename.
437/// "JOY-0042-A3-fix-login.yaml" -> "JOY-0042-A3"
438/// "JOY-0042-fix-login.yaml" -> "JOY-0042" (legacy)
439fn extract_full_id(filename: &str) -> Option<String> {
440    // Strip .yaml extension
441    let name = filename
442        .strip_suffix(".yaml")
443        .or_else(|| filename.strip_suffix(".yml"))?;
444    // Find acronym-XXXX pattern
445    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    // Check if it's new format: XXXX-YY-slug or legacy: XXXX-slug
453    if rest.len() >= 7 && rest.as_bytes()[4] == b'-' {
454        // Could be XXXX-YY-slug (new) or XXXX-slug with short slug
455        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    // Legacy format: XXXX-slug
467    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
475/// Load a single item by ID.
476///
477/// Goes through `load_items` so that short-form ID references in
478/// `parent` and `deps` are normalized to full form before the caller
479/// sees them. This guarantees that any subsequent `update_item` call
480/// persists the normalized form.
481pub 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
491/// Delete an item by ID. Returns the deleted item.
492pub 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
505/// Remove references to a deleted item from other items' deps and parent fields.
506pub 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
528/// Check if adding a dependency would create a cycle.
529/// Returns the cycle path if one exists.
530pub 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(&current.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
561/// Update an item in place (overwrites its file).
562pub fn update_item(root: &Path, item: &Item) -> Result<(), JoyError> {
563    let old_path = find_item_file(root, &item.id)?;
564    // Write new file first to avoid data loss if write fails
565    save_item(root, item)?;
566    // Remove old file if the filename changed (title may have changed)
567    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); // JOY-0001-XX
600    }
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        // Not guaranteed different, but astronomically unlikely to be equal
662        // for these specific strings. If this test fails, the hash function
663        // has a collision on these inputs (1:256 chance).
664        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        // Save an item with new format ID
673        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}