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/// Record an attribute-level mutation on an item. Sets `updated` /
156/// `updated_by` for sort recency AND appends an entry to `history` for
157/// the audit footer. Use this whenever you mutate an item attribute
158/// (status, priority, deps, assignee, edit, ...). Do NOT use it for
159/// comment add / edit / rm; those use `touch_for_comment_change`
160/// instead, because per-comment audit lives on the comment itself.
161pub fn touch_for_attribute_change(item: &mut Item, by: &str) {
162    let now = chrono::Utc::now();
163    item.updated = now;
164    item.updated_by = Some(by.to_string());
165    item.history
166        .get_or_insert_with(Vec::new)
167        .push(crate::model::item::UpdateEntry {
168            date: now,
169            by: by.to_string(),
170        });
171}
172
173/// Bump an item's `updated` / `updated_by` for sort recency without
174/// appending to its attribute history. Use this for comment add / edit
175/// / rm: the item is touched but no attribute changed, so the audit
176/// trail of comment activity lives on the comment itself (its `edits`
177/// list, plus the item's `comments` Vec membership) rather than in the
178/// item's attribute history.
179pub fn touch_for_comment_change(item: &mut Item, by: &str) {
180    let now = chrono::Utc::now();
181    item.updated = now;
182    item.updated_by = Some(by.to_string());
183}
184
185/// Save an item to .joy/items/{ID}-{slug}.yaml.
186pub fn save_item(root: &Path, item: &Item) -> Result<(), JoyError> {
187    let items_dir = store::joy_dir(root).join(store::ITEMS_DIR);
188    let filename = item_filename(&item.id, &item.title);
189    let path = items_dir.join(&filename);
190    write_item_file(&path, item)?;
191    let rel = format!("{}/{}/{}", store::JOY_DIR, store::ITEMS_DIR, filename);
192    crate::git_ops::auto_git_add(root, &[&rel]);
193    Ok(())
194}
195
196/// Write an item file, encrypting in place when `crypt_zone` is set.
197/// Reads the active session's zone keys (set by joy-cli after
198/// passphrase verification); without an active key for the zone the
199/// write fails with `ZoneAccessDenied`. ADR-040.
200fn write_item_file(path: &Path, item: &Item) -> Result<(), JoyError> {
201    let yaml = serde_yaml_ng::to_string(item).map_err(JoyError::Yaml)?;
202    let bytes = match item.crypt_zone.as_deref() {
203        Some(zone) => {
204            let zone_key =
205                crate::crypt::active_zone_key(zone).ok_or_else(|| JoyError::ZoneAccessDenied {
206                    zone: zone.to_string(),
207                })?;
208            crate::crypt::encrypt_blob(zone, &zone_key, yaml.as_bytes())
209        }
210        None => yaml.into_bytes(),
211    };
212    write_atomic(path, &bytes)
213}
214
215/// Lightweight item metadata available without authentication.
216/// Walks `.joy/items/`, peeks each file: if it is a JOYCRYPT blob,
217/// reads the zone name from the header without decrypting; if it is
218/// plaintext YAML, parses just enough to extract the id and
219/// crypt_zone fields. Used by `joy crypt status` / `joy crypt ls` /
220/// `joy auth` to count and locate Crypt content without prompting
221/// the user for a passphrase.
222#[derive(Debug, Clone)]
223pub struct ItemMeta {
224    pub id: String,
225    pub path: std::path::PathBuf,
226    pub encrypted_zone: Option<String>,
227    /// crypt_zone field as parsed from the plaintext YAML; only
228    /// populated when the file is plaintext.
229    pub plaintext_crypt_zone: Option<String>,
230}
231
232impl ItemMeta {
233    /// The zone this item belongs to, regardless of whether it is
234    /// currently encrypted on disk.
235    pub fn zone(&self) -> Option<&str> {
236        self.encrypted_zone
237            .as_deref()
238            .or(self.plaintext_crypt_zone.as_deref())
239    }
240}
241
242/// Walk `.joy/items/` and return one `ItemMeta` per item file.
243/// Never prompts, never decrypts. Use `load_items` when you need
244/// full Item objects.
245pub fn list_item_metadata(root: &Path) -> Result<Vec<ItemMeta>, JoyError> {
246    let items_dir = store::joy_dir(root).join(store::ITEMS_DIR);
247    if !items_dir.is_dir() {
248        return Ok(Vec::new());
249    }
250    let mut out = Vec::new();
251    for entry in std::fs::read_dir(&items_dir).map_err(|e| JoyError::ReadFile {
252        path: items_dir.clone(),
253        source: e,
254    })? {
255        let Ok(entry) = entry else { continue };
256        let path = entry.path();
257        if !path.is_file() {
258            continue;
259        }
260        let Some(name) = path.file_name().and_then(|s| s.to_str()) else {
261            continue;
262        };
263        let Some(id) = id_from_filename(name) else {
264            continue;
265        };
266        let bytes = std::fs::read(&path).map_err(|e| JoyError::ReadFile {
267            path: path.clone(),
268            source: e,
269        })?;
270        let (encrypted_zone, plaintext_crypt_zone) = if crate::crypt::looks_like_blob(&bytes) {
271            (parse_blob_zone(&bytes), None)
272        } else {
273            (None, parse_plaintext_crypt_zone(&bytes))
274        };
275        out.push(ItemMeta {
276            id,
277            path,
278            encrypted_zone,
279            plaintext_crypt_zone,
280        });
281    }
282    Ok(out)
283}
284
285fn id_from_filename(name: &str) -> Option<String> {
286    // Item filenames look like `<ID>-<title-slug>.yaml`. The ID is
287    // either ACRONYM-XXXX or ACRONYM-XXXX-YY (per ADR-027). Strip
288    // the `.yaml` suffix and split on the last segment that doesn't
289    // match the ID shape.
290    let stem = name.strip_suffix(".yaml")?;
291    let parts: Vec<&str> = stem.split('-').collect();
292    if parts.len() >= 2 && parts[1].chars().all(|c| c.is_ascii_hexdigit()) && parts[1].len() == 4 {
293        // ACRONYM-XXXX[-YY]-...
294        let id_end = if parts.len() >= 3
295            && parts[2].chars().all(|c| c.is_ascii_hexdigit())
296            && parts[2].len() == 2
297        {
298            3
299        } else {
300            2
301        };
302        Some(parts[..id_end].join("-"))
303    } else {
304        None
305    }
306}
307
308fn parse_blob_zone(bytes: &[u8]) -> Option<String> {
309    // Layout: 8-byte magic + 1 version + 1 zone-len + zone bytes + ...
310    if bytes.len() < 10 {
311        return None;
312    }
313    let zone_len = bytes[9] as usize;
314    if bytes.len() < 10 + zone_len {
315        return None;
316    }
317    std::str::from_utf8(&bytes[10..10 + zone_len])
318        .ok()
319        .map(str::to_string)
320}
321
322fn parse_plaintext_crypt_zone(bytes: &[u8]) -> Option<String> {
323    let text = std::str::from_utf8(bytes).ok()?;
324    for line in text.lines() {
325        let trimmed = line.trim_start();
326        if let Some(rest) = trimmed.strip_prefix("crypt_zone:") {
327            let value = rest.trim().trim_matches(|c: char| c == '"' || c == '\'');
328            if value.is_empty() || value == "null" || value == "~" {
329                return None;
330            }
331            return Some(value.to_string());
332        }
333    }
334    None
335}
336
337/// Atomic write: temp file in the same directory, fsync, rename.
338fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), JoyError> {
339    let parent = path.parent().unwrap_or_else(|| Path::new("."));
340    std::fs::create_dir_all(parent).map_err(|e| JoyError::CreateDir {
341        path: parent.to_path_buf(),
342        source: e,
343    })?;
344    let tmp = parent.join(format!(
345        ".{}.tmp.{}",
346        path.file_name().and_then(|s| s.to_str()).unwrap_or("item"),
347        std::process::id()
348    ));
349    std::fs::write(&tmp, bytes).map_err(|e| JoyError::WriteFile {
350        path: tmp.clone(),
351        source: e,
352    })?;
353    std::fs::rename(&tmp, path).map_err(|e| JoyError::WriteFile {
354        path: path.to_path_buf(),
355        source: e,
356    })?;
357    Ok(())
358}
359
360/// Generate the next item ID by scanning existing files.
361/// Returns "ACRONYM-0001" for the first item, increments the highest found.
362/// All items share one number space regardless of type.
363///
364/// Legacy format (existing items): ACRONYM-XXXX (4 hex digits)
365/// New format (ADR-027): ACRONYM-XXXX-YY (4 hex digits + 2 hex title hash)
366pub fn next_id(root: &Path, acronym: &str, title: &str) -> Result<String, JoyError> {
367    let prefix = acronym;
368
369    let items_dir = store::joy_dir(root).join(store::ITEMS_DIR);
370    if !items_dir.is_dir() {
371        let suffix = title_hash_suffix(title);
372        return Ok(format!("{prefix}-0001-{suffix}"));
373    }
374
375    let mut max_num: u16 = 0;
376
377    let entries = std::fs::read_dir(&items_dir).map_err(|e| JoyError::ReadFile {
378        path: items_dir.clone(),
379        source: e,
380    })?;
381
382    for entry in entries.filter_map(|e| e.ok()) {
383        let name = entry.file_name();
384        let name = name.to_string_lossy();
385        if let Some(hex_part) = name.strip_prefix(&format!("{prefix}-")) {
386            if let Some(hex_str) = hex_part.get(..4) {
387                if let Ok(num) = u16::from_str_radix(hex_str, 16) {
388                    max_num = max_num.max(num);
389                }
390            }
391        }
392    }
393
394    let next = max_num.checked_add(1).ok_or_else(|| {
395        JoyError::Other(format!("{prefix} ID space exhausted (max {prefix}-FFFF)"))
396    })?;
397    let suffix = title_hash_suffix(title);
398    Ok(format!("{prefix}-{next:04X}-{suffix}"))
399}
400
401/// Generate 2 hex digits from the title for collision-safe IDs (ADR-027).
402pub fn title_hash_suffix(title: &str) -> String {
403    use sha2::{Digest, Sha256};
404    let mut hasher = Sha256::new();
405    hasher.update(title.as_bytes());
406    let hash = hasher.finalize();
407    format!("{:02X}", hash[0])
408}
409
410/// Find the file path for an item by its ID.
411/// Accepts both full IDs (JOY-0042-A3) and short-form (JOY-0042).
412/// Short-form returns an error if ambiguous (multiple matches).
413pub fn find_item_file(root: &Path, id: &str) -> Result<std::path::PathBuf, JoyError> {
414    let items_dir = store::joy_dir(root).join(store::ITEMS_DIR);
415
416    // Normalize: uppercase the ID for matching
417    let id_upper = id.to_uppercase();
418
419    let entries: Vec<_> = std::fs::read_dir(&items_dir)
420        .map_err(|e| JoyError::ReadFile {
421            path: items_dir.clone(),
422            source: e,
423        })?
424        .filter_map(|e| e.ok())
425        .collect();
426
427    // First try exact match (full ID)
428    let exact_prefix = format!("{}-", id_upper);
429    for entry in &entries {
430        let name = entry.file_name();
431        let name_upper = name.to_string_lossy().to_uppercase();
432        if name_upper.starts_with(&exact_prefix) {
433            return Ok(entry.path());
434        }
435    }
436
437    // Then try short-form match (prefix without suffix)
438    // JOY-0042 matches JOY-0042-A3-some-title.yaml
439    let short_prefix = format!("{}-", id_upper);
440    let mut matches: Vec<std::path::PathBuf> = Vec::new();
441    for entry in &entries {
442        let name = entry.file_name();
443        let name_upper = name.to_string_lossy().to_uppercase();
444        if name_upper.starts_with(&short_prefix) {
445            matches.push(entry.path());
446        }
447    }
448
449    match matches.len() {
450        0 => Err(JoyError::ItemNotFound(id.to_string())),
451        1 => Ok(matches.into_iter().next().unwrap()),
452        _ => {
453            // Extract full IDs from filenames for the error message
454            let ids: Vec<String> = matches
455                .iter()
456                .filter_map(|p| {
457                    let name = p.file_name()?.to_string_lossy().to_string();
458                    extract_full_id(&name)
459                })
460                .collect();
461            Err(JoyError::Other(format!("ambiguous ID: {}", ids.join(", "))))
462        }
463    }
464}
465
466/// Extract the full item ID from a filename.
467/// "JOY-0042-A3-fix-login.yaml" -> "JOY-0042-A3"
468/// "JOY-0042-fix-login.yaml" -> "JOY-0042" (legacy)
469fn extract_full_id(filename: &str) -> Option<String> {
470    // Strip .yaml extension
471    let name = filename
472        .strip_suffix(".yaml")
473        .or_else(|| filename.strip_suffix(".yml"))?;
474    // Find acronym-XXXX pattern
475    let parts: Vec<&str> = name.splitn(2, '-').collect();
476    if parts.len() < 2 {
477        return None;
478    }
479    let acronym = parts[0];
480    let rest = parts[1];
481
482    // Check if it's new format: XXXX-YY-slug or legacy: XXXX-slug
483    if rest.len() >= 7 && rest.as_bytes()[4] == b'-' {
484        // Could be XXXX-YY-slug (new) or XXXX-slug with short slug
485        let hex4 = &rest[..4];
486        let maybe_suffix = &rest[5..7];
487        if u16::from_str_radix(hex4, 16).is_ok()
488            && maybe_suffix.len() == 2
489            && u8::from_str_radix(maybe_suffix, 16).is_ok()
490            && (rest.len() == 7 || rest.as_bytes()[7] == b'-')
491        {
492            return Some(format!("{}-{}-{}", acronym, hex4, maybe_suffix).to_uppercase());
493        }
494    }
495
496    // Legacy format: XXXX-slug
497    let hex4 = &rest[..4.min(rest.len())];
498    if hex4.len() == 4 && u16::from_str_radix(hex4, 16).is_ok() {
499        return Some(format!("{}-{}", acronym, hex4).to_uppercase());
500    }
501
502    None
503}
504
505/// Load a single item by ID.
506///
507/// Goes through `load_items` so that short-form ID references in
508/// `parent` and `deps` are normalized to full form before the caller
509/// sees them. This guarantees that any subsequent `update_item` call
510/// persists the normalized form.
511pub fn load_item(root: &Path, id: &str) -> Result<Item, JoyError> {
512    let path = find_item_file(root, id)?;
513    let target_id: String = store::read_yaml::<Item>(&path)?.id;
514    let items = load_items(root)?;
515    items
516        .into_iter()
517        .find(|i| i.id == target_id)
518        .ok_or(JoyError::ItemNotFound(target_id))
519}
520
521/// Delete an item by ID. Returns the deleted item.
522pub fn delete_item(root: &Path, id: &str) -> Result<Item, JoyError> {
523    let path = find_item_file(root, id)?;
524    let item: Item = store::read_yaml(&path)?;
525    let rel = path
526        .strip_prefix(root)
527        .unwrap_or(&path)
528        .to_string_lossy()
529        .to_string();
530    std::fs::remove_file(&path).map_err(|e| JoyError::WriteFile { path, source: e })?;
531    crate::git_ops::auto_git_add(root, &[&rel]);
532    Ok(item)
533}
534
535/// Remove references to a deleted item from other items' deps and parent fields.
536/// `updated_by` is recorded on each touched item so the audit trail names
537/// the actor who triggered the dereference.
538pub fn remove_references(
539    root: &Path,
540    deleted_id: &str,
541    updated_by: &str,
542) -> Result<Vec<String>, JoyError> {
543    let items = load_items(root)?;
544    let mut updated = Vec::new();
545    for mut item in items {
546        let mut changed = false;
547        if item.deps.contains(&deleted_id.to_string()) {
548            item.deps.retain(|d| d != deleted_id);
549            changed = true;
550        }
551        if item.parent.as_deref() == Some(deleted_id) {
552            item.parent = None;
553            changed = true;
554        }
555        if changed {
556            touch_for_attribute_change(&mut item, updated_by);
557            update_item(root, &item)?;
558            updated.push(item.id.clone());
559        }
560    }
561    Ok(updated)
562}
563
564/// Check if adding a dependency would create a cycle.
565/// Returns the cycle path if one exists.
566pub fn detect_cycle(
567    root: &Path,
568    item_id: &str,
569    new_dep_id: &str,
570) -> Result<Option<Vec<String>>, JoyError> {
571    let items = load_items(root)?;
572    let mut visited = vec![item_id.to_string()];
573    if find_cycle(&items, new_dep_id, &mut visited) {
574        visited.push(new_dep_id.to_string());
575        Ok(Some(visited))
576    } else {
577        Ok(None)
578    }
579}
580
581fn find_cycle(items: &[Item], current: &str, visited: &mut Vec<String>) -> bool {
582    if visited.contains(&current.to_string()) {
583        return true;
584    }
585    if let Some(item) = items.iter().find(|i| i.id == current) {
586        visited.push(current.to_string());
587        for dep in &item.deps {
588            if find_cycle(items, dep, visited) {
589                return true;
590            }
591        }
592        visited.pop();
593    }
594    false
595}
596
597/// Update an item in place (overwrites its file).
598pub fn update_item(root: &Path, item: &Item) -> Result<(), JoyError> {
599    let old_path = find_item_file(root, &item.id)?;
600    // Write new file first to avoid data loss if write fails
601    save_item(root, item)?;
602    // Remove old file if the filename changed (title may have changed)
603    let new_path = store::joy_dir(root)
604        .join(store::ITEMS_DIR)
605        .join(item_filename(&item.id, &item.title));
606    if old_path != new_path {
607        let _ = std::fs::remove_file(&old_path);
608        let old_rel = old_path
609            .strip_prefix(root)
610            .unwrap_or(&old_path)
611            .to_string_lossy()
612            .to_string();
613        crate::git_ops::auto_git_add(root, &[&old_rel]);
614    }
615    Ok(())
616}
617
618#[cfg(test)]
619mod tests {
620    use super::*;
621    use crate::model::item::{ItemType, Priority};
622    use tempfile::tempdir;
623
624    fn setup_project(dir: &Path) {
625        let joy_dir = dir.join(".joy");
626        std::fs::create_dir_all(joy_dir.join("items")).unwrap();
627    }
628
629    #[test]
630    fn next_id_first_item() {
631        let dir = tempdir().unwrap();
632        setup_project(dir.path());
633        let id = next_id(dir.path(), "JOY", "Test item").unwrap();
634        assert!(id.starts_with("JOY-0001-"), "got: {id}");
635        assert_eq!(id.len(), 11); // JOY-0001-XX
636    }
637
638    #[test]
639    fn next_id_increments() {
640        let dir = tempdir().unwrap();
641        setup_project(dir.path());
642
643        let item = Item::new(
644            "JOY-0001".into(),
645            "First".into(),
646            ItemType::Task,
647            Priority::Low,
648            vec![],
649        );
650        save_item(dir.path(), &item).unwrap();
651
652        let id = next_id(dir.path(), "JOY", "Second item").unwrap();
653        assert!(id.starts_with("JOY-0002-"), "got: {id}");
654    }
655
656    #[test]
657    fn next_id_skips_gaps() {
658        let dir = tempdir().unwrap();
659        setup_project(dir.path());
660
661        let item1 = Item::new(
662            "JOY-0001".into(),
663            "First".into(),
664            ItemType::Task,
665            Priority::Low,
666            vec![],
667        );
668        save_item(dir.path(), &item1).unwrap();
669
670        let item3 = Item::new(
671            "JOY-0003".into(),
672            "Third".into(),
673            ItemType::Task,
674            Priority::Low,
675            vec![],
676        );
677        save_item(dir.path(), &item3).unwrap();
678
679        let id = next_id(dir.path(), "JOY", "Fourth item").unwrap();
680        assert!(id.starts_with("JOY-0004-"), "got: {id}");
681    }
682
683    #[test]
684    fn next_id_same_title_same_suffix() {
685        let dir = tempdir().unwrap();
686        setup_project(dir.path());
687        let id1 = next_id(dir.path(), "JOY", "Same title").unwrap();
688        let suffix1 = &id1[9..];
689        let id2_suffix = title_hash_suffix("Same title");
690        assert_eq!(suffix1, id2_suffix);
691    }
692
693    #[test]
694    fn next_id_different_titles_different_suffixes() {
695        let suffix_a = title_hash_suffix("Fix login bug");
696        let suffix_b = title_hash_suffix("Add roadmap feature");
697        // Not guaranteed different, but astronomically unlikely to be equal
698        // for these specific strings. If this test fails, the hash function
699        // has a collision on these inputs (1:256 chance).
700        assert_ne!(suffix_a, suffix_b);
701    }
702
703    #[test]
704    fn next_id_increments_past_new_format() {
705        let dir = tempdir().unwrap();
706        setup_project(dir.path());
707
708        // Save an item with new format ID
709        let item = Item::new(
710            "JOY-0005-A3".into(),
711            "New format".into(),
712            ItemType::Task,
713            Priority::Low,
714            vec![],
715        );
716        save_item(dir.path(), &item).unwrap();
717
718        let id = next_id(dir.path(), "JOY", "Next item").unwrap();
719        assert!(id.starts_with("JOY-0006-"), "got: {id}");
720    }
721
722    #[test]
723    fn load_items_empty() {
724        let dir = tempdir().unwrap();
725        setup_project(dir.path());
726        let items = load_items(dir.path()).unwrap();
727        assert!(items.is_empty());
728    }
729
730    #[test]
731    fn save_and_load_item() {
732        let dir = tempdir().unwrap();
733        setup_project(dir.path());
734
735        let item = Item::new(
736            "JOY-0001".into(),
737            "Test item".into(),
738            ItemType::Story,
739            Priority::High,
740            vec![],
741        );
742        save_item(dir.path(), &item).unwrap();
743
744        let items = load_items(dir.path()).unwrap();
745        assert_eq!(items.len(), 1);
746        assert_eq!(items[0].id, "JOY-0001");
747        assert_eq!(items[0].title, "Test item");
748    }
749
750    #[test]
751    fn load_items_sorted() {
752        let dir = tempdir().unwrap();
753        setup_project(dir.path());
754
755        let item2 = Item::new(
756            "JOY-0002".into(),
757            "Second".into(),
758            ItemType::Task,
759            Priority::Low,
760            vec![],
761        );
762        save_item(dir.path(), &item2).unwrap();
763
764        let item1 = Item::new(
765            "JOY-0001".into(),
766            "First".into(),
767            ItemType::Task,
768            Priority::Low,
769            vec![],
770        );
771        save_item(dir.path(), &item1).unwrap();
772
773        let items = load_items(dir.path()).unwrap();
774        assert_eq!(items[0].id, "JOY-0001");
775        assert_eq!(items[1].id, "JOY-0002");
776    }
777
778    #[test]
779    fn short_form_extracts_prefix_for_suffixed_id() {
780        assert_eq!(short_form("JOY-0042-A3"), Some("JOY-0042"));
781        assert_eq!(short_form("TST-00FF-12"), Some("TST-00FF"));
782    }
783
784    #[test]
785    fn short_form_returns_none_for_legacy_id() {
786        assert_eq!(short_form("JOY-0042"), None);
787        assert_eq!(short_form("JOY-MS-01"), None);
788    }
789
790    #[test]
791    fn short_form_returns_none_for_non_hex_suffix() {
792        assert_eq!(short_form("JOY-0042-XX"), None);
793        assert_eq!(short_form("JOY-0042-AAA"), None);
794    }
795
796    #[test]
797    fn normalize_rewrites_short_form_parent() {
798        let mut parent = Item::new(
799            "JOY-0042-A3".into(),
800            "P".into(),
801            ItemType::Epic,
802            Priority::Medium,
803            vec![],
804        );
805        parent.parent = None;
806        let mut child = Item::new(
807            "JOY-0043-B1".into(),
808            "C".into(),
809            ItemType::Task,
810            Priority::Medium,
811            vec![],
812        );
813        child.parent = Some("JOY-0042".into());
814        let mut items = vec![parent, child];
815        normalize_id_refs(&mut items);
816        assert_eq!(items[1].parent.as_deref(), Some("JOY-0042-A3"));
817    }
818
819    #[test]
820    fn normalize_rewrites_short_form_deps() {
821        let dep = Item::new(
822            "JOY-0042-A3".into(),
823            "D".into(),
824            ItemType::Task,
825            Priority::Medium,
826            vec![],
827        );
828        let mut consumer = Item::new(
829            "JOY-0043-B1".into(),
830            "C".into(),
831            ItemType::Task,
832            Priority::Medium,
833            vec![],
834        );
835        consumer.deps = vec!["JOY-0042".into()];
836        let mut items = vec![dep, consumer];
837        normalize_id_refs(&mut items);
838        assert_eq!(items[1].deps, vec!["JOY-0042-A3".to_string()]);
839    }
840
841    #[test]
842    fn normalize_leaves_full_form_unchanged() {
843        let parent = Item::new(
844            "JOY-0042-A3".into(),
845            "P".into(),
846            ItemType::Epic,
847            Priority::Medium,
848            vec![],
849        );
850        let mut child = Item::new(
851            "JOY-0043-B1".into(),
852            "C".into(),
853            ItemType::Task,
854            Priority::Medium,
855            vec![],
856        );
857        child.parent = Some("JOY-0042-A3".into());
858        let mut items = vec![parent, child];
859        normalize_id_refs(&mut items);
860        assert_eq!(items[1].parent.as_deref(), Some("JOY-0042-A3"));
861    }
862
863    #[test]
864    fn normalize_leaves_unknown_refs_unchanged() {
865        let mut child = Item::new(
866            "JOY-0043-B1".into(),
867            "C".into(),
868            ItemType::Task,
869            Priority::Medium,
870            vec![],
871        );
872        child.parent = Some("JOY-9999".into());
873        child.deps = vec!["JOY-8888".into()];
874        let mut items = vec![child];
875        normalize_id_refs(&mut items);
876        assert_eq!(items[0].parent.as_deref(), Some("JOY-9999"));
877        assert_eq!(items[0].deps, vec!["JOY-8888".to_string()]);
878    }
879
880    #[test]
881    fn normalize_leaves_ambiguous_short_forms_unchanged() {
882        let a = Item::new(
883            "JOY-0042-A3".into(),
884            "A".into(),
885            ItemType::Task,
886            Priority::Medium,
887            vec![],
888        );
889        let b = Item::new(
890            "JOY-0042-B1".into(),
891            "B".into(),
892            ItemType::Task,
893            Priority::Medium,
894            vec![],
895        );
896        let mut child = Item::new(
897            "JOY-0043-CC".into(),
898            "C".into(),
899            ItemType::Task,
900            Priority::Medium,
901            vec![],
902        );
903        child.parent = Some("JOY-0042".into());
904        let mut items = vec![a, b, child];
905        normalize_id_refs(&mut items);
906        assert_eq!(items[2].parent.as_deref(), Some("JOY-0042"));
907    }
908
909    #[test]
910    fn milestone_short_form_extracts_prefix() {
911        assert_eq!(milestone_short_form("JOY-MS-01-A1"), Some("JOY-MS-01"));
912        assert_eq!(milestone_short_form("TST-MS-FF-12"), Some("TST-MS-FF"));
913    }
914
915    #[test]
916    fn milestone_short_form_returns_none_for_legacy_or_item() {
917        assert_eq!(milestone_short_form("JOY-MS-01"), None);
918        assert_eq!(milestone_short_form("JOY-0042-A3"), None);
919    }
920
921    #[test]
922    fn normalize_milestone_rewrites_short_form() {
923        let mut item = Item::new(
924            "JOY-0001-AA".into(),
925            "X".into(),
926            ItemType::Task,
927            Priority::Medium,
928            vec![],
929        );
930        item.milestone = Some("JOY-MS-01".into());
931        let mut items = vec![item];
932        normalize_milestone_refs(&mut items, &["JOY-MS-01-A1".to_string()]);
933        assert_eq!(items[0].milestone.as_deref(), Some("JOY-MS-01-A1"));
934    }
935
936    #[test]
937    fn normalize_milestone_leaves_unknown_unchanged() {
938        let mut item = Item::new(
939            "JOY-0001-AA".into(),
940            "X".into(),
941            ItemType::Task,
942            Priority::Medium,
943            vec![],
944        );
945        item.milestone = Some("JOY-MS-99".into());
946        let mut items = vec![item];
947        normalize_milestone_refs(&mut items, &["JOY-MS-01-A1".to_string()]);
948        assert_eq!(items[0].milestone.as_deref(), Some("JOY-MS-99"));
949    }
950
951    #[test]
952    fn normalize_milestone_leaves_full_form_unchanged() {
953        let mut item = Item::new(
954            "JOY-0001-AA".into(),
955            "X".into(),
956            ItemType::Task,
957            Priority::Medium,
958            vec![],
959        );
960        item.milestone = Some("JOY-MS-01-A1".into());
961        let mut items = vec![item];
962        normalize_milestone_refs(&mut items, &["JOY-MS-01-A1".to_string()]);
963        assert_eq!(items[0].milestone.as_deref(), Some("JOY-MS-01-A1"));
964    }
965
966    #[test]
967    fn normalize_handles_legacy_parent_referenced_by_full_id() {
968        let parent = Item::new(
969            "JOY-0042".into(),
970            "P".into(),
971            ItemType::Epic,
972            Priority::Medium,
973            vec![],
974        );
975        let mut child = Item::new(
976            "JOY-0043-B1".into(),
977            "C".into(),
978            ItemType::Task,
979            Priority::Medium,
980            vec![],
981        );
982        child.parent = Some("JOY-0042".into());
983        let mut items = vec![parent, child];
984        normalize_id_refs(&mut items);
985        assert_eq!(items[1].parent.as_deref(), Some("JOY-0042"));
986    }
987}