Skip to main content

jyn_core/
storage.rs

1// Copyright (c) 2026 Joydev GmbH (joydev.com)
2// SPDX-License-Identifier: MIT
3
4//! Workspace storage for jyn.
5//!
6//! Jyn reuses joy-core's generic YAML primitives (`store::write_yaml`,
7//! `store::read_yaml`) and ID helpers (`item_filename`, `title_hash_suffix`)
8//! to operate on `.jyn/` without duplicating IO logic or requiring changes
9//! to joy-core. See `docs/dev/Architecture.md`.
10
11use std::path::{Path, PathBuf};
12
13use joy_core::items::title_hash_suffix;
14use joy_core::model::item::item_filename;
15use joy_core::store::{read_yaml, write_yaml};
16
17use crate::error::JynError;
18use crate::model::Task;
19
20pub const JYN_DIR: &str = ".jyn";
21pub const ITEMS_DIR: &str = "items";
22pub const ACRONYM: &str = "TODO";
23
24pub fn jyn_dir(root: &Path) -> PathBuf {
25    root.join(JYN_DIR)
26}
27
28/// Walk up from `start` to the nearest directory that contains a `.jyn/`
29/// subdirectory, and return that directory (the workspace root). Mirrors
30/// how git and joy locate their data dir, so running jyn from a
31/// subdirectory still finds the workspace above it. Returns `None` when
32/// no `.jyn/` exists between `start` and the filesystem root; callers
33/// then fall back to `start` so a first `jyn add` creates `.jyn/` there.
34pub fn find_workspace_root(start: &Path) -> Option<PathBuf> {
35    let mut dir = Some(start);
36    while let Some(d) = dir {
37        if d.join(JYN_DIR).is_dir() {
38            return Some(d.to_path_buf());
39        }
40        dir = d.parent();
41    }
42    None
43}
44
45pub fn items_dir(root: &Path) -> PathBuf {
46    jyn_dir(root).join(ITEMS_DIR)
47}
48
49/// Create `.jyn/items/` if missing. Idempotent.
50pub fn ensure_items_dir(root: &Path) -> Result<(), JynError> {
51    let dir = items_dir(root);
52    std::fs::create_dir_all(&dir)
53        .map_err(|e| JynError::Other(format!("cannot create {}: {}", dir.display(), e)))
54}
55
56/// Write a task to `.jyn/items/{ID}-{slug}.yaml`.
57pub fn save_task(root: &Path, task: &Task) -> Result<(), JynError> {
58    ensure_items_dir(root)?;
59    let filename = item_filename(&task.item.id, &task.item.title);
60    let path = items_dir(root).join(filename);
61    write_yaml(&path, task)?;
62    Ok(())
63}
64
65/// Load all tasks from `.jyn/items/`, sorted by filename.
66pub fn load_tasks(root: &Path) -> Result<Vec<Task>, JynError> {
67    let dir = items_dir(root);
68    if !dir.is_dir() {
69        return Ok(Vec::new());
70    }
71    let mut entries: Vec<_> = std::fs::read_dir(&dir)
72        .map_err(|e| JynError::Other(format!("cannot read {}: {}", dir.display(), e)))?
73        .filter_map(|e| e.ok())
74        .filter(|e| {
75            e.path()
76                .extension()
77                .is_some_and(|ext| ext == "yaml" || ext == "yml")
78        })
79        .collect();
80    entries.sort_by_key(|e| e.file_name());
81
82    let mut tasks = Vec::with_capacity(entries.len());
83    for entry in entries {
84        let task: Task = read_yaml(&entry.path())?;
85        tasks.push(task);
86    }
87    Ok(tasks)
88}
89
90/// Find the file for a task ID. Accepts the display short form (`#A1`,
91/// `A1`), the ADR-027 short form (`TODO-00A1`), or the full form
92/// (`TODO-00A1-EA`). Returns an error if the ID is ambiguous or missing.
93pub fn find_task_file(root: &Path, id: &str) -> Result<PathBuf, JynError> {
94    let dir = items_dir(root);
95    if !dir.is_dir() {
96        return Err(JynError::Other(format!("task not found: {id}")));
97    }
98    let normalized = crate::display::normalize_id_input(id);
99    let prefix = format!("{normalized}-");
100
101    let matches: Vec<PathBuf> = std::fs::read_dir(&dir)
102        .map_err(|e| JynError::Other(format!("cannot read {}: {}", dir.display(), e)))?
103        .filter_map(|e| e.ok())
104        .map(|e| e.path())
105        .filter(|p| {
106            p.file_name()
107                .map(|n| n.to_string_lossy().to_uppercase().starts_with(&prefix))
108                .unwrap_or(false)
109        })
110        .collect();
111
112    match matches.len() {
113        0 => Err(JynError::Other(format!("task not found: {id}"))),
114        1 => Ok(matches.into_iter().next().unwrap()),
115        _ => Err(JynError::Other(format!(
116            "ambiguous ID {id}: {} matches",
117            matches.len()
118        ))),
119    }
120}
121
122/// Load a single task by its full or short ID.
123pub fn load_task(root: &Path, id: &str) -> Result<Task, JynError> {
124    let path = find_task_file(root, id)?;
125    Ok(read_yaml(&path)?)
126}
127
128/// Overwrite a task on disk. If the title changed and produced a new
129/// filename (slug-derived), the old file is removed.
130pub fn update_task(root: &Path, task: &Task) -> Result<(), JynError> {
131    let old_path = find_task_file(root, &task.item.id)?;
132    save_task(root, task)?;
133    let new_path = items_dir(root).join(item_filename(&task.item.id, &task.item.title));
134    if old_path != new_path {
135        let _ = std::fs::remove_file(&old_path);
136    }
137    Ok(())
138}
139
140/// Delete a task by ID. Returns the deleted task.
141pub fn delete_task(root: &Path, id: &str) -> Result<Task, JynError> {
142    let path = find_task_file(root, id)?;
143    let task: Task = read_yaml(&path)?;
144    std::fs::remove_file(&path)
145        .map_err(|e| JynError::Other(format!("cannot remove {}: {}", path.display(), e)))?;
146    Ok(task)
147}
148
149/// Generate the next ID in the form `TODO-XXXX-YY` (ADR-027).
150pub fn next_id(root: &Path, title: &str) -> Result<String, JynError> {
151    let suffix = title_hash_suffix(title);
152    let dir = items_dir(root);
153    if !dir.is_dir() {
154        return Ok(format!("{ACRONYM}-0001-{suffix}"));
155    }
156
157    let prefix = format!("{ACRONYM}-");
158    let mut max_num: u16 = 0;
159    for entry in std::fs::read_dir(&dir)
160        .map_err(|e| JynError::Other(format!("cannot read {}: {}", dir.display(), e)))?
161        .filter_map(|e| e.ok())
162    {
163        let name = entry.file_name();
164        let name = name.to_string_lossy();
165        if let Some(rest) = name.strip_prefix(&prefix) {
166            if let Some(hex) = rest.get(..4) {
167                if let Ok(n) = u16::from_str_radix(hex, 16) {
168                    max_num = max_num.max(n);
169                }
170            }
171        }
172    }
173
174    let next = max_num.checked_add(1).ok_or_else(|| {
175        JynError::Other(format!("{ACRONYM} ID space exhausted (max {ACRONYM}-FFFF)"))
176    })?;
177    Ok(format!("{ACRONYM}-{next:04X}-{suffix}"))
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183    use tempfile::tempdir;
184
185    fn make_task(id: &str, title: &str) -> Task {
186        Task::new(id.into(), title.into())
187    }
188
189    #[test]
190    fn next_id_first_in_empty_dir() {
191        let dir = tempdir().unwrap();
192        let id = next_id(dir.path(), "Buy milk").unwrap();
193        assert!(id.starts_with("TODO-0001-"), "got: {id}");
194        assert_eq!(id.len(), 12);
195    }
196
197    #[test]
198    fn next_id_increments_after_save() {
199        let dir = tempdir().unwrap();
200        let t1 = make_task("TODO-0001-A3", "First");
201        save_task(dir.path(), &t1).unwrap();
202        let id2 = next_id(dir.path(), "Second").unwrap();
203        assert!(id2.starts_with("TODO-0002-"), "got: {id2}");
204    }
205
206    #[test]
207    fn save_then_load_roundtrip() {
208        let dir = tempdir().unwrap();
209        let id = next_id(dir.path(), "Buy milk").unwrap();
210        let task = make_task(&id, "Buy milk");
211        save_task(dir.path(), &task).unwrap();
212        let loaded = load_tasks(dir.path()).unwrap();
213        assert_eq!(loaded.len(), 1);
214        assert_eq!(loaded[0].item.id, id);
215        assert_eq!(loaded[0].item.title, "Buy milk");
216    }
217
218    #[test]
219    fn load_tasks_empty_returns_empty() {
220        let dir = tempdir().unwrap();
221        let loaded = load_tasks(dir.path()).unwrap();
222        assert!(loaded.is_empty());
223    }
224
225    #[test]
226    fn delete_removes_file() {
227        let dir = tempdir().unwrap();
228        let id = next_id(dir.path(), "Temp").unwrap();
229        let task = make_task(&id, "Temp");
230        save_task(dir.path(), &task).unwrap();
231        let deleted = delete_task(dir.path(), &id).unwrap();
232        assert_eq!(deleted.item.id, id);
233        assert!(load_tasks(dir.path()).unwrap().is_empty());
234    }
235
236    #[test]
237    fn find_task_file_short_form() {
238        let dir = tempdir().unwrap();
239        let id = next_id(dir.path(), "Short form").unwrap();
240        let task = make_task(&id, "Short form");
241        save_task(dir.path(), &task).unwrap();
242        let short = &id[..9]; // "TODO-0001"
243        let path = find_task_file(dir.path(), short).unwrap();
244        assert!(path
245            .file_name()
246            .unwrap()
247            .to_string_lossy()
248            .starts_with(short));
249    }
250
251    #[test]
252    fn find_workspace_root_walks_up() {
253        let dir = tempdir().unwrap();
254        let root = dir.path();
255        std::fs::create_dir_all(root.join(JYN_DIR)).unwrap();
256        let nested = root.join("a").join("b");
257        std::fs::create_dir_all(&nested).unwrap();
258
259        // From a nested subdirectory, resolution finds the .jyn/ above.
260        let found = find_workspace_root(&nested).unwrap();
261        // tempfile may hand back a symlinked path (e.g. /var -> /private/var
262        // on macOS), so compare canonical forms rather than the raw paths.
263        assert_eq!(found.canonicalize().unwrap(), root.canonicalize().unwrap());
264    }
265
266    #[test]
267    fn find_workspace_root_none_when_absent() {
268        let dir = tempdir().unwrap();
269        // No .jyn/ anywhere under this fresh temp dir.
270        assert!(find_workspace_root(dir.path()).is_none());
271    }
272
273    #[test]
274    fn find_task_file_missing_errors() {
275        let dir = tempdir().unwrap();
276        ensure_items_dir(dir.path()).unwrap();
277        let err = find_task_file(dir.path(), "TODO-9999");
278        assert!(err.is_err());
279    }
280}