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    Ok(items)
39}
40
41/// Save an item to .joy/items/{ID}-{slug}.yaml.
42pub fn save_item(root: &Path, item: &Item) -> Result<(), JoyError> {
43    let items_dir = store::joy_dir(root).join(store::ITEMS_DIR);
44    let filename = item_filename(&item.id, &item.title);
45    let path = items_dir.join(&filename);
46    store::write_yaml(&path, item)?;
47    let rel = format!("{}/{}/{}", store::JOY_DIR, store::ITEMS_DIR, filename);
48    crate::git_ops::auto_git_add(root, &[&rel]);
49    Ok(())
50}
51
52/// Generate the next item ID by scanning existing files.
53/// Returns "ACRONYM-0001" for the first item, increments the highest found.
54/// All items share one number space regardless of type.
55///
56/// Legacy format (existing items): ACRONYM-XXXX (4 hex digits)
57/// New format (ADR-027): ACRONYM-XXXX-YY (4 hex digits + 2 hex title hash)
58pub fn next_id(root: &Path, acronym: &str, title: &str) -> Result<String, JoyError> {
59    let prefix = acronym;
60
61    let items_dir = store::joy_dir(root).join(store::ITEMS_DIR);
62    if !items_dir.is_dir() {
63        let suffix = title_hash_suffix(title);
64        return Ok(format!("{prefix}-0001-{suffix}"));
65    }
66
67    let mut max_num: u16 = 0;
68
69    let entries = std::fs::read_dir(&items_dir).map_err(|e| JoyError::ReadFile {
70        path: items_dir.clone(),
71        source: e,
72    })?;
73
74    for entry in entries.filter_map(|e| e.ok()) {
75        let name = entry.file_name();
76        let name = name.to_string_lossy();
77        if let Some(hex_part) = name.strip_prefix(&format!("{prefix}-")) {
78            if let Some(hex_str) = hex_part.get(..4) {
79                if let Ok(num) = u16::from_str_radix(hex_str, 16) {
80                    max_num = max_num.max(num);
81                }
82            }
83        }
84    }
85
86    let next = max_num.checked_add(1).ok_or_else(|| {
87        JoyError::Other(format!("{prefix} ID space exhausted (max {prefix}-FFFF)"))
88    })?;
89    let suffix = title_hash_suffix(title);
90    Ok(format!("{prefix}-{next:04X}-{suffix}"))
91}
92
93/// Generate 2 hex digits from the title for collision-safe IDs (ADR-027).
94pub fn title_hash_suffix(title: &str) -> String {
95    use sha2::{Digest, Sha256};
96    let mut hasher = Sha256::new();
97    hasher.update(title.as_bytes());
98    let hash = hasher.finalize();
99    format!("{:02X}", hash[0])
100}
101
102/// Find the file path for an item by its ID.
103/// Accepts both full IDs (JOY-0042-A3) and short-form (JOY-0042).
104/// Short-form returns an error if ambiguous (multiple matches).
105pub fn find_item_file(root: &Path, id: &str) -> Result<std::path::PathBuf, JoyError> {
106    let items_dir = store::joy_dir(root).join(store::ITEMS_DIR);
107
108    // Normalize: uppercase the ID for matching
109    let id_upper = id.to_uppercase();
110
111    let entries: Vec<_> = std::fs::read_dir(&items_dir)
112        .map_err(|e| JoyError::ReadFile {
113            path: items_dir.clone(),
114            source: e,
115        })?
116        .filter_map(|e| e.ok())
117        .collect();
118
119    // First try exact match (full ID)
120    let exact_prefix = format!("{}-", id_upper);
121    for entry in &entries {
122        let name = entry.file_name();
123        let name_upper = name.to_string_lossy().to_uppercase();
124        if name_upper.starts_with(&exact_prefix) {
125            return Ok(entry.path());
126        }
127    }
128
129    // Then try short-form match (prefix without suffix)
130    // JOY-0042 matches JOY-0042-A3-some-title.yaml
131    let short_prefix = format!("{}-", id_upper);
132    let mut matches: Vec<std::path::PathBuf> = Vec::new();
133    for entry in &entries {
134        let name = entry.file_name();
135        let name_upper = name.to_string_lossy().to_uppercase();
136        if name_upper.starts_with(&short_prefix) {
137            matches.push(entry.path());
138        }
139    }
140
141    match matches.len() {
142        0 => Err(JoyError::ItemNotFound(id.to_string())),
143        1 => Ok(matches.into_iter().next().unwrap()),
144        _ => {
145            // Extract full IDs from filenames for the error message
146            let ids: Vec<String> = matches
147                .iter()
148                .filter_map(|p| {
149                    let name = p.file_name()?.to_string_lossy().to_string();
150                    extract_full_id(&name)
151                })
152                .collect();
153            Err(JoyError::Other(format!("ambiguous ID: {}", ids.join(", "))))
154        }
155    }
156}
157
158/// Extract the full item ID from a filename.
159/// "JOY-0042-A3-fix-login.yaml" -> "JOY-0042-A3"
160/// "JOY-0042-fix-login.yaml" -> "JOY-0042" (legacy)
161fn extract_full_id(filename: &str) -> Option<String> {
162    // Strip .yaml extension
163    let name = filename
164        .strip_suffix(".yaml")
165        .or_else(|| filename.strip_suffix(".yml"))?;
166    // Find acronym-XXXX pattern
167    let parts: Vec<&str> = name.splitn(2, '-').collect();
168    if parts.len() < 2 {
169        return None;
170    }
171    let acronym = parts[0];
172    let rest = parts[1];
173
174    // Check if it's new format: XXXX-YY-slug or legacy: XXXX-slug
175    if rest.len() >= 7 && rest.as_bytes()[4] == b'-' {
176        // Could be XXXX-YY-slug (new) or XXXX-slug with short slug
177        let hex4 = &rest[..4];
178        let maybe_suffix = &rest[5..7];
179        if u16::from_str_radix(hex4, 16).is_ok()
180            && maybe_suffix.len() == 2
181            && u8::from_str_radix(maybe_suffix, 16).is_ok()
182            && (rest.len() == 7 || rest.as_bytes()[7] == b'-')
183        {
184            return Some(format!("{}-{}-{}", acronym, hex4, maybe_suffix).to_uppercase());
185        }
186    }
187
188    // Legacy format: XXXX-slug
189    let hex4 = &rest[..4.min(rest.len())];
190    if hex4.len() == 4 && u16::from_str_radix(hex4, 16).is_ok() {
191        return Some(format!("{}-{}", acronym, hex4).to_uppercase());
192    }
193
194    None
195}
196
197/// Load a single item by ID.
198pub fn load_item(root: &Path, id: &str) -> Result<Item, JoyError> {
199    let path = find_item_file(root, id)?;
200    store::read_yaml(&path)
201}
202
203/// Delete an item by ID. Returns the deleted item.
204pub fn delete_item(root: &Path, id: &str) -> Result<Item, JoyError> {
205    let path = find_item_file(root, id)?;
206    let item: Item = store::read_yaml(&path)?;
207    let rel = path
208        .strip_prefix(root)
209        .unwrap_or(&path)
210        .to_string_lossy()
211        .to_string();
212    std::fs::remove_file(&path).map_err(|e| JoyError::WriteFile { path, source: e })?;
213    crate::git_ops::auto_git_add(root, &[&rel]);
214    Ok(item)
215}
216
217/// Remove references to a deleted item from other items' deps and parent fields.
218pub fn remove_references(root: &Path, deleted_id: &str) -> Result<Vec<String>, JoyError> {
219    let items = load_items(root)?;
220    let mut updated = Vec::new();
221    for mut item in items {
222        let mut changed = false;
223        if item.deps.contains(&deleted_id.to_string()) {
224            item.deps.retain(|d| d != deleted_id);
225            changed = true;
226        }
227        if item.parent.as_deref() == Some(deleted_id) {
228            item.parent = None;
229            changed = true;
230        }
231        if changed {
232            item.updated = chrono::Utc::now();
233            update_item(root, &item)?;
234            updated.push(item.id.clone());
235        }
236    }
237    Ok(updated)
238}
239
240/// Check if adding a dependency would create a cycle.
241/// Returns the cycle path if one exists.
242pub fn detect_cycle(
243    root: &Path,
244    item_id: &str,
245    new_dep_id: &str,
246) -> Result<Option<Vec<String>>, JoyError> {
247    let items = load_items(root)?;
248    let mut visited = vec![item_id.to_string()];
249    if find_cycle(&items, new_dep_id, &mut visited) {
250        visited.push(new_dep_id.to_string());
251        Ok(Some(visited))
252    } else {
253        Ok(None)
254    }
255}
256
257fn find_cycle(items: &[Item], current: &str, visited: &mut Vec<String>) -> bool {
258    if visited.contains(&current.to_string()) {
259        return true;
260    }
261    if let Some(item) = items.iter().find(|i| i.id == current) {
262        visited.push(current.to_string());
263        for dep in &item.deps {
264            if find_cycle(items, dep, visited) {
265                return true;
266            }
267        }
268        visited.pop();
269    }
270    false
271}
272
273/// Update an item in place (overwrites its file).
274pub fn update_item(root: &Path, item: &Item) -> Result<(), JoyError> {
275    let old_path = find_item_file(root, &item.id)?;
276    // Write new file first to avoid data loss if write fails
277    save_item(root, item)?;
278    // Remove old file if the filename changed (title may have changed)
279    let new_path = store::joy_dir(root)
280        .join(store::ITEMS_DIR)
281        .join(item_filename(&item.id, &item.title));
282    if old_path != new_path {
283        let _ = std::fs::remove_file(&old_path);
284        let old_rel = old_path
285            .strip_prefix(root)
286            .unwrap_or(&old_path)
287            .to_string_lossy()
288            .to_string();
289        crate::git_ops::auto_git_add(root, &[&old_rel]);
290    }
291    Ok(())
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297    use crate::model::item::{ItemType, Priority};
298    use tempfile::tempdir;
299
300    fn setup_project(dir: &Path) {
301        let joy_dir = dir.join(".joy");
302        std::fs::create_dir_all(joy_dir.join("items")).unwrap();
303    }
304
305    #[test]
306    fn next_id_first_item() {
307        let dir = tempdir().unwrap();
308        setup_project(dir.path());
309        let id = next_id(dir.path(), "JOY", "Test item").unwrap();
310        assert!(id.starts_with("JOY-0001-"), "got: {id}");
311        assert_eq!(id.len(), 11); // JOY-0001-XX
312    }
313
314    #[test]
315    fn next_id_increments() {
316        let dir = tempdir().unwrap();
317        setup_project(dir.path());
318
319        let item = Item::new(
320            "JOY-0001".into(),
321            "First".into(),
322            ItemType::Task,
323            Priority::Low,
324            vec![],
325        );
326        save_item(dir.path(), &item).unwrap();
327
328        let id = next_id(dir.path(), "JOY", "Second item").unwrap();
329        assert!(id.starts_with("JOY-0002-"), "got: {id}");
330    }
331
332    #[test]
333    fn next_id_skips_gaps() {
334        let dir = tempdir().unwrap();
335        setup_project(dir.path());
336
337        let item1 = Item::new(
338            "JOY-0001".into(),
339            "First".into(),
340            ItemType::Task,
341            Priority::Low,
342            vec![],
343        );
344        save_item(dir.path(), &item1).unwrap();
345
346        let item3 = Item::new(
347            "JOY-0003".into(),
348            "Third".into(),
349            ItemType::Task,
350            Priority::Low,
351            vec![],
352        );
353        save_item(dir.path(), &item3).unwrap();
354
355        let id = next_id(dir.path(), "JOY", "Fourth item").unwrap();
356        assert!(id.starts_with("JOY-0004-"), "got: {id}");
357    }
358
359    #[test]
360    fn next_id_same_title_same_suffix() {
361        let dir = tempdir().unwrap();
362        setup_project(dir.path());
363        let id1 = next_id(dir.path(), "JOY", "Same title").unwrap();
364        let suffix1 = &id1[9..];
365        let id2_suffix = title_hash_suffix("Same title");
366        assert_eq!(suffix1, id2_suffix);
367    }
368
369    #[test]
370    fn next_id_different_titles_different_suffixes() {
371        let suffix_a = title_hash_suffix("Fix login bug");
372        let suffix_b = title_hash_suffix("Add roadmap feature");
373        // Not guaranteed different, but astronomically unlikely to be equal
374        // for these specific strings. If this test fails, the hash function
375        // has a collision on these inputs (1:256 chance).
376        assert_ne!(suffix_a, suffix_b);
377    }
378
379    #[test]
380    fn next_id_increments_past_new_format() {
381        let dir = tempdir().unwrap();
382        setup_project(dir.path());
383
384        // Save an item with new format ID
385        let item = Item::new(
386            "JOY-0005-A3".into(),
387            "New format".into(),
388            ItemType::Task,
389            Priority::Low,
390            vec![],
391        );
392        save_item(dir.path(), &item).unwrap();
393
394        let id = next_id(dir.path(), "JOY", "Next item").unwrap();
395        assert!(id.starts_with("JOY-0006-"), "got: {id}");
396    }
397
398    #[test]
399    fn load_items_empty() {
400        let dir = tempdir().unwrap();
401        setup_project(dir.path());
402        let items = load_items(dir.path()).unwrap();
403        assert!(items.is_empty());
404    }
405
406    #[test]
407    fn save_and_load_item() {
408        let dir = tempdir().unwrap();
409        setup_project(dir.path());
410
411        let item = Item::new(
412            "JOY-0001".into(),
413            "Test item".into(),
414            ItemType::Story,
415            Priority::High,
416            vec![],
417        );
418        save_item(dir.path(), &item).unwrap();
419
420        let items = load_items(dir.path()).unwrap();
421        assert_eq!(items.len(), 1);
422        assert_eq!(items[0].id, "JOY-0001");
423        assert_eq!(items[0].title, "Test item");
424    }
425
426    #[test]
427    fn load_items_sorted() {
428        let dir = tempdir().unwrap();
429        setup_project(dir.path());
430
431        let item2 = Item::new(
432            "JOY-0002".into(),
433            "Second".into(),
434            ItemType::Task,
435            Priority::Low,
436            vec![],
437        );
438        save_item(dir.path(), &item2).unwrap();
439
440        let item1 = Item::new(
441            "JOY-0001".into(),
442            "First".into(),
443            ItemType::Task,
444            Priority::Low,
445            vec![],
446        );
447        save_item(dir.path(), &item1).unwrap();
448
449        let items = load_items(dir.path()).unwrap();
450        assert_eq!(items[0].id, "JOY-0001");
451        assert_eq!(items[1].id, "JOY-0002");
452    }
453}