Skip to main content

jot_core/
storage.rs

1// Copyright (c) 2026 Joydev GmbH (joydev.com)
2// SPDX-License-Identifier: MIT
3
4//! Workspace storage for jot.
5//!
6//! Jot 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 `.jot/` 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::JotError;
18use crate::model::Task;
19
20pub const JOT_DIR: &str = ".jot";
21pub const ITEMS_DIR: &str = "items";
22pub const ACRONYM: &str = "TODO";
23
24pub fn jot_dir(root: &Path) -> PathBuf {
25    root.join(JOT_DIR)
26}
27
28pub fn items_dir(root: &Path) -> PathBuf {
29    jot_dir(root).join(ITEMS_DIR)
30}
31
32/// Create `.jot/items/` if missing. Idempotent.
33pub fn ensure_items_dir(root: &Path) -> Result<(), JotError> {
34    let dir = items_dir(root);
35    std::fs::create_dir_all(&dir)
36        .map_err(|e| JotError::Other(format!("cannot create {}: {}", dir.display(), e)))
37}
38
39/// Write a task to `.jot/items/{ID}-{slug}.yaml`.
40pub fn save_task(root: &Path, task: &Task) -> Result<(), JotError> {
41    ensure_items_dir(root)?;
42    let filename = item_filename(&task.item.id, &task.item.title);
43    let path = items_dir(root).join(filename);
44    write_yaml(&path, task)?;
45    Ok(())
46}
47
48/// Load all tasks from `.jot/items/`, sorted by filename.
49pub fn load_tasks(root: &Path) -> Result<Vec<Task>, JotError> {
50    let dir = items_dir(root);
51    if !dir.is_dir() {
52        return Ok(Vec::new());
53    }
54    let mut entries: Vec<_> = std::fs::read_dir(&dir)
55        .map_err(|e| JotError::Other(format!("cannot read {}: {}", dir.display(), e)))?
56        .filter_map(|e| e.ok())
57        .filter(|e| {
58            e.path()
59                .extension()
60                .is_some_and(|ext| ext == "yaml" || ext == "yml")
61        })
62        .collect();
63    entries.sort_by_key(|e| e.file_name());
64
65    let mut tasks = Vec::with_capacity(entries.len());
66    for entry in entries {
67        let task: Task = read_yaml(&entry.path())?;
68        tasks.push(task);
69    }
70    Ok(tasks)
71}
72
73/// Find the file for a task ID. Accepts the display short form (`#A1`,
74/// `A1`), the ADR-027 short form (`TODO-00A1`), or the full form
75/// (`TODO-00A1-EA`). Returns an error if the ID is ambiguous or missing.
76pub fn find_task_file(root: &Path, id: &str) -> Result<PathBuf, JotError> {
77    let dir = items_dir(root);
78    if !dir.is_dir() {
79        return Err(JotError::Other(format!("task not found: {id}")));
80    }
81    let normalized = crate::display::normalize_id_input(id);
82    let prefix = format!("{normalized}-");
83
84    let matches: Vec<PathBuf> = std::fs::read_dir(&dir)
85        .map_err(|e| JotError::Other(format!("cannot read {}: {}", dir.display(), e)))?
86        .filter_map(|e| e.ok())
87        .map(|e| e.path())
88        .filter(|p| {
89            p.file_name()
90                .map(|n| n.to_string_lossy().to_uppercase().starts_with(&prefix))
91                .unwrap_or(false)
92        })
93        .collect();
94
95    match matches.len() {
96        0 => Err(JotError::Other(format!("task not found: {id}"))),
97        1 => Ok(matches.into_iter().next().unwrap()),
98        _ => Err(JotError::Other(format!(
99            "ambiguous ID {id}: {} matches",
100            matches.len()
101        ))),
102    }
103}
104
105/// Load a single task by its full or short ID.
106pub fn load_task(root: &Path, id: &str) -> Result<Task, JotError> {
107    let path = find_task_file(root, id)?;
108    Ok(read_yaml(&path)?)
109}
110
111/// Overwrite a task on disk. If the title changed and produced a new
112/// filename (slug-derived), the old file is removed.
113pub fn update_task(root: &Path, task: &Task) -> Result<(), JotError> {
114    let old_path = find_task_file(root, &task.item.id)?;
115    save_task(root, task)?;
116    let new_path = items_dir(root).join(item_filename(&task.item.id, &task.item.title));
117    if old_path != new_path {
118        let _ = std::fs::remove_file(&old_path);
119    }
120    Ok(())
121}
122
123/// Delete a task by ID. Returns the deleted task.
124pub fn delete_task(root: &Path, id: &str) -> Result<Task, JotError> {
125    let path = find_task_file(root, id)?;
126    let task: Task = read_yaml(&path)?;
127    std::fs::remove_file(&path)
128        .map_err(|e| JotError::Other(format!("cannot remove {}: {}", path.display(), e)))?;
129    Ok(task)
130}
131
132/// Generate the next ID in the form `TODO-XXXX-YY` (ADR-027).
133pub fn next_id(root: &Path, title: &str) -> Result<String, JotError> {
134    let suffix = title_hash_suffix(title);
135    let dir = items_dir(root);
136    if !dir.is_dir() {
137        return Ok(format!("{ACRONYM}-0001-{suffix}"));
138    }
139
140    let prefix = format!("{ACRONYM}-");
141    let mut max_num: u16 = 0;
142    for entry in std::fs::read_dir(&dir)
143        .map_err(|e| JotError::Other(format!("cannot read {}: {}", dir.display(), e)))?
144        .filter_map(|e| e.ok())
145    {
146        let name = entry.file_name();
147        let name = name.to_string_lossy();
148        if let Some(rest) = name.strip_prefix(&prefix) {
149            if let Some(hex) = rest.get(..4) {
150                if let Ok(n) = u16::from_str_radix(hex, 16) {
151                    max_num = max_num.max(n);
152                }
153            }
154        }
155    }
156
157    let next = max_num.checked_add(1).ok_or_else(|| {
158        JotError::Other(format!("{ACRONYM} ID space exhausted (max {ACRONYM}-FFFF)"))
159    })?;
160    Ok(format!("{ACRONYM}-{next:04X}-{suffix}"))
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166    use tempfile::tempdir;
167
168    fn make_task(id: &str, title: &str) -> Task {
169        Task::new(id.into(), title.into())
170    }
171
172    #[test]
173    fn next_id_first_in_empty_dir() {
174        let dir = tempdir().unwrap();
175        let id = next_id(dir.path(), "Buy milk").unwrap();
176        assert!(id.starts_with("TODO-0001-"), "got: {id}");
177        assert_eq!(id.len(), 12);
178    }
179
180    #[test]
181    fn next_id_increments_after_save() {
182        let dir = tempdir().unwrap();
183        let t1 = make_task("TODO-0001-A3", "First");
184        save_task(dir.path(), &t1).unwrap();
185        let id2 = next_id(dir.path(), "Second").unwrap();
186        assert!(id2.starts_with("TODO-0002-"), "got: {id2}");
187    }
188
189    #[test]
190    fn save_then_load_roundtrip() {
191        let dir = tempdir().unwrap();
192        let id = next_id(dir.path(), "Buy milk").unwrap();
193        let task = make_task(&id, "Buy milk");
194        save_task(dir.path(), &task).unwrap();
195        let loaded = load_tasks(dir.path()).unwrap();
196        assert_eq!(loaded.len(), 1);
197        assert_eq!(loaded[0].item.id, id);
198        assert_eq!(loaded[0].item.title, "Buy milk");
199    }
200
201    #[test]
202    fn load_tasks_empty_returns_empty() {
203        let dir = tempdir().unwrap();
204        let loaded = load_tasks(dir.path()).unwrap();
205        assert!(loaded.is_empty());
206    }
207
208    #[test]
209    fn delete_removes_file() {
210        let dir = tempdir().unwrap();
211        let id = next_id(dir.path(), "Temp").unwrap();
212        let task = make_task(&id, "Temp");
213        save_task(dir.path(), &task).unwrap();
214        let deleted = delete_task(dir.path(), &id).unwrap();
215        assert_eq!(deleted.item.id, id);
216        assert!(load_tasks(dir.path()).unwrap().is_empty());
217    }
218
219    #[test]
220    fn find_task_file_short_form() {
221        let dir = tempdir().unwrap();
222        let id = next_id(dir.path(), "Short form").unwrap();
223        let task = make_task(&id, "Short form");
224        save_task(dir.path(), &task).unwrap();
225        let short = &id[..9]; // "TODO-0001"
226        let path = find_task_file(dir.path(), short).unwrap();
227        assert!(path
228            .file_name()
229            .unwrap()
230            .to_string_lossy()
231            .starts_with(short));
232    }
233
234    #[test]
235    fn find_task_file_missing_errors() {
236        let dir = tempdir().unwrap();
237        ensure_items_dir(dir.path()).unwrap();
238        let err = find_task_file(dir.path(), "TODO-9999");
239        assert!(err.is_err());
240    }
241}