Skip to main content

kanbrain_core/
store.rs

1//! The data folder: one `NNNN-slug.md` file per task, `tags.md`, and exported reports in `reports/`.
2
3use crate::model::{Task, slugify};
4use crate::projects;
5use crate::tags::TagColors;
6use anyhow::{Context, Result, bail};
7use std::cell::RefCell;
8use std::fs;
9use std::io::ErrorKind;
10use std::path::{Path, PathBuf};
11
12/// Tag color assignments; lives next to the task files but isn't a task.
13const TAGS_FILE: &str = "tags.md";
14
15/// A project's own data folder, found by walking up from the current directory like `.git`.
16pub const PROJECT_DIR: &str = ".brain";
17
18/// Settings file of older versions (`- track: on|off`); time is always tracked now, so it's only
19/// kept out of the task files.
20const CONFIG_FILE: &str = "config.md";
21
22/// The highest id the folder has given an entry, so ids are never handed out twice: a link to a deleted
23/// entry, left in a file edited by hand or in a copy that was offline, would otherwise point at the
24/// unrelated entry that took its number. Not markdown, so it's never read as an entry.
25const LAST_ID_FILE: &str = "last-id";
26
27/// Subfolder for markdown report exports; never read back.
28const REPORTS_DIR: &str = "reports";
29
30/// The data folder where tasks are stored.
31#[derive(Debug)]
32pub struct Store {
33    /// Absolute or relative path of the folder; created on open.
34    dir: PathBuf,
35    /// While recording (see [`Store::record`]), each file written or deleted as it was before.
36    journal: RefCell<Option<Vec<Change>>>,
37}
38
39/// A file a change wrote or deleted: what it held before and after, `None` where it didn't exist.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct Change {
42    /// The file.
43    pub path: PathBuf,
44    /// Its contents before the change.
45    pub before: Option<String>,
46    /// Its contents after the change, filled in by [`Store::recorded`].
47    pub after: Option<String>,
48}
49
50/// Why changes can't be undone.
51#[derive(Debug, PartialEq, Eq)]
52pub enum UndoError {
53    /// Another program, or a later change, wrote this file since: undoing would lose that.
54    ChangedSince(String),
55}
56
57impl std::fmt::Display for UndoError {
58    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
59        match self {
60            Self::ChangedSince(name) => write!(f, "can't undo: {name} changed since"),
61        }
62    }
63}
64
65impl std::error::Error for UndoError {}
66
67/// Puts the files `changes` wrote back as they were before, last change first. Nothing is written
68/// unless every file still holds what the changes left in it.
69///
70/// # Errors
71///
72/// [`UndoError::ChangedSince`] when a file was changed since, or the error of a write that failed.
73pub fn undo(changes: &[Change]) -> Result<()> {
74    for change in changes {
75        if fs::read_to_string(&change.path).ok() != change.after {
76            let name = change.path.file_name().unwrap_or_default().to_string_lossy().into_owned();
77            return Err(UndoError::ChangedSince(name).into());
78        }
79    }
80    for change in changes.iter().rev() {
81        match &change.before {
82            Some(text) => write_file(&change.path, text)?,
83            None => fs::remove_file(&change.path).with_context(|| format!("removing {}", change.path.display()))?,
84        }
85    }
86    Ok(())
87}
88
89impl Store {
90    /// The nearest `.brain/` in the current directory or above it, so each project's repository
91    /// keeps its own; else the central brain (`$KANBRAIN_DIR`, else the platform data dir, e.g.
92    /// `~/.local/share/kanbrain`).
93    pub fn open() -> Result<Self> {
94        let cwd = std::env::current_dir().context("reading the current directory")?;
95        match find_project(&cwd) {
96            Some(dir) => Self::at(dir),
97            None => Self::at(projects::central_dir()?),
98        }
99    }
100
101    /// The brain of the project registered as `name` (`central` for the central one), from anywhere;
102    /// without a name, the one [`Store::open`] finds.
103    pub fn open_named(name: Option<&str>) -> Result<Self> {
104        match name {
105            None => Self::open(),
106            Some(projects::CENTRAL) => Self::at(projects::central_dir()?),
107            Some(label) => Self::at(projects::find(&projects::central_dir()?, label)?.dir),
108        }
109    }
110
111    /// The project's name: the one on its card (`project.md`), else the folder holding `.brain/`;
112    /// `None` for the central brain.
113    pub fn project(&self) -> Option<String> {
114        if let Some(projects::RepoCard { project: name, .. }) = projects::read_repo_card(&self.dir) {
115            return Some(name);
116        }
117        if self.dir.file_name()? != PROJECT_DIR {
118            return None;
119        }
120        let root = self.dir.parent()?;
121        // `.brain` given relatively has an empty parent: the current directory.
122        let root = if root.as_os_str().is_empty() { std::env::current_dir().ok()? } else { root.to_path_buf() };
123        Some(root.file_name()?.to_string_lossy().into_owned())
124    }
125
126    /// A store in `dir`, created if missing.
127    pub fn at(dir: impl Into<PathBuf>) -> Result<Self> {
128        let dir = dir.into();
129        fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
130        Ok(Self { dir, journal: RefCell::new(None) })
131    }
132
133    /// Takes this brain's write lock, waiting while another program holds it, until the returned guard is
134    /// dropped. Each `kb` that changes entries reads every file, changes some and writes them back: an agent
135    /// running several at once (one per subagent) would otherwise have the last one write over the others'
136    /// changes, or two `add`s take the same id. The lock is released when the process ends, however it ends.
137    ///
138    /// The lock file lives in the user's cache folder, named after the brain's folder, so `.brain/` (often
139    /// committed) gets no extra file. Taking it again on a thread that holds it gets a guard that does nothing:
140    /// the app holds every brain's while it acts, and managing projects takes some of them itself.
141    pub fn lock(&self) -> Result<BrainLock> {
142        let path = lock_path(&self.dir)?;
143        if HELD.with(|held| held.borrow().contains(&path)) {
144            return Ok(BrainLock { file: None, path });
145        }
146        if let Some(parent) = path.parent() {
147            fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?;
148        }
149        let file = fs::OpenOptions::new()
150            .create(true)
151            .truncate(false)
152            .write(true)
153            .open(&path)
154            .with_context(|| format!("opening {}", path.display()))?;
155        fs4::FileExt::lock(&file).with_context(|| format!("locking {}", path.display()))?;
156        HELD.with(|held| held.borrow_mut().push(path.clone()));
157        Ok(BrainLock { file: Some(file), path })
158    }
159
160    /// Starts recording the files written or deleted from now on, so they can be put back (see [`undo`]).
161    pub fn record(&self) {
162        *self.journal.borrow_mut() = Some(Vec::new());
163    }
164
165    /// Stops recording, and returns what was recorded, with what each file holds now.
166    pub fn recorded(&self) -> Vec<Change> {
167        let mut changes = self.journal.borrow_mut().take().unwrap_or_default();
168        for change in &mut changes {
169            change.after = fs::read_to_string(&change.path).ok();
170        }
171        changes.retain(|c| c.before != c.after);
172        changes
173    }
174
175    /// Keeps `path` as it is now, the first time it's about to change while recording.
176    fn remember(&self, path: &Path) {
177        if let Some(journal) = self.journal.borrow_mut().as_mut()
178            && !journal.iter().any(|c| c.path == path)
179        {
180            journal.push(Change { path: path.to_path_buf(), before: fs::read_to_string(path).ok(), after: None });
181        }
182    }
183
184    /// The data folder.
185    pub fn dir(&self) -> &Path {
186        &self.dir
187    }
188
189    /// Where report exports are written.
190    pub fn reports_dir(&self) -> PathBuf {
191        self.dir.join(REPORTS_DIR)
192    }
193
194    /// Returns the parsed tasks plus a warning for every file that could not be read.
195    pub fn load(&self) -> Result<(Vec<Task>, Vec<String>)> {
196        // Every read goes through here: a brain a newer kanbrain wrote is refused before anything misreads it.
197        crate::migrate::check(&self.dir)?;
198        let mut tasks = Vec::new();
199        let mut warnings = Vec::new();
200        let entries = fs::read_dir(&self.dir).with_context(|| format!("reading {}", self.dir.display()))?;
201        for entry in entries {
202            let path = entry?.path();
203            if !is_task_file(&path) {
204                continue;
205            }
206            let name = path.file_name().unwrap_or_default().to_string_lossy().into_owned();
207            let parsed =
208                fs::read_to_string(&path).map_err(|e| e.to_string()).and_then(|text| Task::from_markdown(&text));
209            match parsed {
210                Ok(mut task) => {
211                    task.path = Some(path);
212                    tasks.push(task);
213                }
214                Err(e) => warnings.push(format!("{name}: {e}")),
215            }
216        }
217        Ok((tasks, warnings))
218    }
219
220    /// Writes atomically and renames the file when the title (slug) changed.
221    pub fn save(&self, task: &mut Task) -> Result<()> {
222        let name = format!("{:04}-{}.md", task.id, slugify(&task.title));
223        let path = self.dir.join(&name);
224        self.remember(&path);
225        if let Some(old) = &task.path {
226            self.remember(old);
227        }
228        write_file(&path, &task.to_markdown())?;
229        // A new title means a new file name: remove the file saved under the old one.
230        if let Some(old) = task.path.replace(path)
231            && Some(&old) != task.path.as_ref()
232        {
233            // The old name only matters if it still exists; a failure here leaves a stale copy, not lost data.
234            let _ = fs::remove_file(old);
235        }
236        Ok(())
237    }
238
239    /// Removes the task's file. Refuses anything outside the data folder.
240    pub fn delete(&self, task: &Task) -> Result<()> {
241        let Some(path) = &task.path else { return Ok(()) };
242        if !path.starts_with(&self.dir) {
243            bail!("refusing to delete {}: not in {}", path.display(), self.dir.display());
244        }
245        self.remember(path);
246        fs::remove_file(path).with_context(|| format!("deleting {}", path.display()))
247    }
248
249    /// The highest id given so far, as `last-id` keeps it; 0 when it's missing (a folder from an older
250    /// version) or unreadable, and then the entries' own ids are all there is to go by.
251    pub fn last_id(&self) -> u32 {
252        fs::read_to_string(self.dir.join(LAST_ID_FILE)).ok().and_then(|text| text.trim().parse().ok()).unwrap_or(0)
253    }
254
255    /// Writes `last-id`.
256    pub fn save_last_id(&self, id: u32) -> Result<()> {
257        let path = self.dir.join(LAST_ID_FILE);
258        write_file(&path, &format!("{id}\n"))
259    }
260
261    /// Reads `tags.md`; no file means no colors assigned yet.
262    pub fn load_tag_colors(&self) -> Result<TagColors> {
263        let path = self.dir.join(TAGS_FILE);
264        match fs::read_to_string(&path) {
265            Ok(text) => Ok(TagColors::parse(&text)),
266            Err(e) if e.kind() == ErrorKind::NotFound => Ok(TagColors::default()),
267            Err(e) => Err(e).with_context(|| format!("reading {}", path.display())),
268        }
269    }
270
271    /// Writes `tags.md`.
272    pub fn save_tag_colors(&self, colors: &TagColors) -> Result<()> {
273        let path = self.dir.join(TAGS_FILE);
274        self.remember(&path);
275        write_file(&path, &colors.to_markdown())
276    }
277}
278
279thread_local! {
280    /// The lock files this thread holds. `flock` on a second handle of the same file waits for the first, so a
281    /// thread taking a lock it holds would wait for itself forever.
282    static HELD: RefCell<Vec<PathBuf>> = const { RefCell::new(Vec::new()) };
283}
284
285/// A brain's write lock, held until dropped (see [`Store::lock`]).
286#[derive(Debug)]
287pub struct BrainLock {
288    /// The locked file, whose handle holds the lock; `None` for a lock the thread already held.
289    file: Option<fs::File>,
290    /// The lock file.
291    path: PathBuf,
292}
293
294impl Drop for BrainLock {
295    fn drop(&mut self) {
296        // The handle closes after this, releasing the lock.
297        if self.file.is_some() {
298            HELD.with(|held| held.borrow_mut().retain(|p| *p != self.path));
299        }
300    }
301}
302
303/// Where the lock of the brain in `dir` is: `<cache>/kanbrain/locks/<key>.lock` (see [`cache_dir_of`]).
304fn lock_path(dir: &Path) -> Result<PathBuf> {
305    Ok(cache_dir_of(dir, "locks")?.with_extension("lock"))
306}
307
308/// `<cache>/kanbrain/<kind>/<key>`, where the user's cache keeps something about the brain in `dir` (its lock, its
309/// backups) out of `.brain/`, which is often committed. The key is FNV-1a of the folder's canonical path rather
310/// than std's hasher, whose output may change between Rust versions: two `kb` builds must agree on it.
311pub(crate) fn cache_dir_of(dir: &Path, kind: &str) -> Result<PathBuf> {
312    let dir = dir.canonicalize().with_context(|| format!("reading {}", dir.display()))?;
313    let hash = dir
314        .as_os_str()
315        .as_encoded_bytes()
316        .iter()
317        .fold(0xcbf2_9ce4_8422_2325_u64, |hash, &byte| (hash ^ u64::from(byte)).wrapping_mul(0x0100_0000_01b3));
318    let cache = dirs::cache_dir().unwrap_or_else(std::env::temp_dir);
319    Ok(cache.join("kanbrain").join(kind).join(format!("{hash:016x}")))
320}
321
322/// Writes `text` to `path`, creating its folder if missing. It goes to a hidden temporary file beside it
323/// first, then takes the file's place in one rename: a crash or a full disk can never leave a file half
324/// written, be it an entry, `tags.md` or a repository's `CLAUDE.md`. The watcher ignores hidden files.
325pub(crate) fn write_file(path: &Path, text: &str) -> Result<()> {
326    let dir = path.parent().filter(|d| !d.as_os_str().is_empty()).unwrap_or(Path::new("."));
327    fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?;
328    let name = path.file_name().with_context(|| format!("{} isn't a file", path.display()))?;
329    let tmp = dir.join(format!(".{}.tmp", name.to_string_lossy()));
330    fs::write(&tmp, text).with_context(|| format!("writing {}", tmp.display()))?;
331    fs::rename(&tmp, path).with_context(|| format!("writing {}", path.display()))
332}
333
334/// The nearest `.brain/` folder in `start` or one of its parents.
335pub fn find_project(start: &Path) -> Option<PathBuf> {
336    start.ancestors().map(|dir| dir.join(PROJECT_DIR)).find(|dir| dir.is_dir())
337}
338
339/// Task files are the `.md` files in the folder, except the tag colors, the project's card, the
340/// central brain's list of projects and the old settings.
341fn is_task_file(path: &Path) -> bool {
342    let other = [TAGS_FILE, CONFIG_FILE, projects::PROJECT_FILE, projects::REGISTRY_FILE];
343    path.extension().is_some_and(|e| e == "md") && path.file_name().is_some_and(|n| !other.iter().any(|o| n == *o))
344}
345
346#[cfg(test)]
347#[path = "../tests/store.rs"]
348mod tests;