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::{ParseError, 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, Write};
10use std::path::{Path, PathBuf};
11use std::sync::atomic::{AtomicU32, Ordering};
12use std::time::Duration;
13
14/// Tag color assignments; lives next to the task files but isn't a task.
15pub(crate) const TAGS_FILE: &str = "tags.md";
16
17/// A project's own data folder, found by walking up from the current directory like `.git`.
18pub const PROJECT_DIR: &str = ".brain";
19
20/// The highest id the folder has given an entry, so ids are never handed out twice: a link to a deleted
21/// entry, left in a file edited by hand or in a copy that was offline, would otherwise point at the
22/// unrelated entry that took its number. Not markdown, so it's never read as an entry.
23pub(crate) const LAST_ID_FILE: &str = "last-id";
24
25/// Subfolder for markdown report exports; never read back.
26pub(crate) const REPORTS_DIR: &str = "reports";
27
28/// The data folder where tasks are stored.
29#[derive(Debug)]
30pub struct Store {
31    /// Absolute or relative path of the folder; created on open.
32    dir: PathBuf,
33    /// While recording (see [`Store::record`]), each file written or deleted as it was before.
34    journal: RefCell<Option<Vec<Change>>>,
35}
36
37/// A markdown file [`Store::load_entries`] didn't read as an entry, said as `{file}: {why}`.
38#[derive(Debug, Clone, PartialEq, Eq)]
39#[non_exhaustive]
40pub struct Skipped {
41    /// Its name in the brain's folder.
42    pub file: String,
43    /// Why it isn't an entry.
44    pub why: SkipReason,
45}
46
47/// Why a file isn't an entry.
48#[derive(Debug, Clone, PartialEq, Eq)]
49#[non_exhaustive]
50pub enum SkipReason {
51    /// It has no `- id:` line: other markdown, which a central brain kept in a notes vault holds, rather than an entry.
52    NoId,
53    /// An entry with a line that doesn't read (a session or history line), saying which.
54    Broken(String),
55    /// The file couldn't be read, with the system's error.
56    Unreadable(String),
57}
58
59impl std::fmt::Display for Skipped {
60    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61        write!(f, "{}: {}", self.file, self.why)
62    }
63}
64
65impl std::fmt::Display for SkipReason {
66    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67        match self {
68            SkipReason::NoId => f.write_str(crate::model::MISSING_ID),
69            SkipReason::Broken(why) | SkipReason::Unreadable(why) => f.write_str(why),
70        }
71    }
72}
73
74/// A file a change wrote or deleted: what it held before and after, `None` where it didn't exist.
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub struct Change {
77    /// The file.
78    pub path: PathBuf,
79    /// Its contents before the change.
80    pub before: Option<String>,
81    /// Its contents after the change, filled in by [`Store::recorded`].
82    pub after: Option<String>,
83}
84
85/// Why changes can't be undone.
86#[derive(Debug, PartialEq, Eq)]
87pub enum UndoError {
88    /// Another program, or a later change, wrote this file since: undoing would lose that.
89    ChangedSince(String),
90}
91
92impl std::fmt::Display for UndoError {
93    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
94        match self {
95            Self::ChangedSince(name) => write!(f, "can't undo: {name} changed since"),
96        }
97    }
98}
99
100impl std::error::Error for UndoError {}
101
102/// Puts the files `changes` wrote back as they were before, last change first. Nothing is written
103/// unless every file still holds what the changes left in it.
104///
105/// # Errors
106///
107/// [`UndoError::ChangedSince`] when a file was changed since, or the error of a write that failed.
108pub fn undo(changes: &[Change]) -> Result<()> {
109    for change in changes {
110        if fs::read_to_string(&change.path).ok() != change.after {
111            let name = change.path.file_name().unwrap_or_default().to_string_lossy().into_owned();
112            return Err(UndoError::ChangedSince(name).into());
113        }
114    }
115    for change in changes.iter().rev() {
116        match &change.before {
117            Some(text) => write_file(&change.path, text)?,
118            None => remove_file(&change.path).with_context(|| format!("removing {}", change.path.display()))?,
119        }
120    }
121    Ok(())
122}
123
124impl Store {
125    /// The nearest `.brain/` in the current directory or above it, so each project's repository
126    /// keeps its own; else the central brain (`$KANBRAIN_DIR`, else the platform data dir, e.g.
127    /// `~/.local/share/kanbrain`).
128    pub fn open() -> Result<Self> {
129        let cwd = std::env::current_dir().context("reading the current directory")?;
130        match find_project(&cwd) {
131            Some(dir) => Self::at(dir),
132            None => Self::at(projects::central_dir()?),
133        }
134    }
135
136    /// The brain of the project registered as `name` (`central` for the central one), from anywhere;
137    /// without a name, the one [`Store::open`] finds.
138    pub fn open_named(name: Option<&str>) -> Result<Self> {
139        match name {
140            None => Self::open(),
141            Some(projects::CENTRAL) => Self::at(projects::central_dir()?),
142            Some(label) => {
143                let central = projects::central_dir()?;
144                let repo = match projects::find(&central, label) {
145                    Ok(repo) => repo,
146                    // In any case, as projects' names are, unless a project has it.
147                    Err(_) if label.eq_ignore_ascii_case(projects::CENTRAL) => return Self::at(central),
148                    Err(e) => return Err(e),
149                };
150                if !repo.dir.is_dir() {
151                    bail!("{} is gone: `kb --project {label} deinit` takes it out", repo.dir.display());
152                }
153                Self::at(repo.dir)
154            }
155        }
156    }
157
158    /// The project's name: the one on its card (`project.md`), else the folder holding `.brain/`;
159    /// `None` for the central brain.
160    pub fn project(&self) -> Option<String> {
161        if let Some(projects::RepoCard { project: name, .. }) = projects::read_repo_card(&self.dir) {
162            return Some(name);
163        }
164        if self.dir.file_name()? != PROJECT_DIR {
165            return None;
166        }
167        let root = self.dir.parent()?;
168        // `.brain` given relatively has an empty parent: the current directory.
169        let root = if root.as_os_str().is_empty() { std::env::current_dir().ok()? } else { root.to_path_buf() };
170        Some(root.file_name()?.to_string_lossy().into_owned())
171    }
172
173    /// A store in `dir`, created if missing.
174    pub fn at(dir: impl Into<PathBuf>) -> Result<Self> {
175        let dir = dir.into();
176        if !dir.is_dir() {
177            fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
178            // A brain made now is in this version's format: without the file it would read as format 1 once there's
179            // a 2, and have the migrations it doesn't need run over it.
180            let format = format!("{}\n", crate::migrate::FORMAT);
181            write_file(&dir.join(crate::migrate::FORMAT_FILE), &format)?;
182        }
183        Ok(Self { dir, journal: RefCell::new(None) })
184    }
185
186    /// Takes this brain's write lock, waiting while another program holds it, until the returned guard is
187    /// dropped. Each `kb` that changes entries reads every file, changes some and writes them back: an agent
188    /// running several at once (one per subagent) would otherwise have the last one write over the others'
189    /// changes, or two `add`s take the same id. The lock is released when the process ends, however it ends.
190    ///
191    /// The lock file lives in the user's cache folder, named after the brain's folder, so `.brain/` (often
192    /// committed) gets no extra file. Taking it again on a thread that holds it gets a guard that does nothing:
193    /// the app holds every brain's while it acts, and managing projects takes some of them itself.
194    pub fn lock(&self) -> Result<BrainLock> {
195        let path = lock_path(&self.dir)?;
196        if HELD.with(|held| held.borrow().contains(&path)) {
197            return Ok(BrainLock { file: None, path });
198        }
199        if let Some(parent) = path.parent() {
200            fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?;
201        }
202        let mut file = fs::OpenOptions::new()
203            .create(true)
204            .truncate(false)
205            .write(true)
206            .open(&path)
207            .with_context(|| format!("opening {}", path.display()))?;
208        fs4::FileExt::lock(&file).with_context(|| format!("locking {}", path.display()))?;
209        // Which brain it's for, in one write ending in a newline, so the cache can be tidied once the brain is gone.
210        if file.metadata().is_ok_and(|m| m.len() == 0) {
211            let brain = self.dir.canonicalize().with_context(|| format!("reading {}", self.dir.display()))?;
212            file.write_all(format!("{}\n", brain.display()).as_bytes())
213                .with_context(|| format!("writing {}", path.display()))?;
214        }
215        // Only once nothing can fail: an error drops `file`, releasing the lock, and a path left in `HELD` would
216        // have every later `lock` on this thread hand back a guard that holds nothing.
217        HELD.with(|held| held.borrow_mut().push(path.clone()));
218        forget_gone_temp_brains(&path);
219        forget_cut_writes(&self.dir);
220        Ok(BrainLock { file: Some(file), path })
221    }
222
223    /// Starts recording the files written or deleted from now on, so they can be put back (see [`undo`]).
224    pub fn record(&self) {
225        *self.journal.borrow_mut() = Some(Vec::new());
226    }
227
228    /// Stops recording, and returns what was recorded, with what each file holds now.
229    pub fn recorded(&self) -> Vec<Change> {
230        let mut changes = self.journal.borrow_mut().take().unwrap_or_default();
231        for change in &mut changes {
232            change.after = fs::read_to_string(&change.path).ok();
233        }
234        changes.retain(|c| c.before != c.after);
235        changes
236    }
237
238    /// Keeps `path` as it is now, the first time it's about to change while recording.
239    fn remember(&self, path: &Path) {
240        if let Some(journal) = self.journal.borrow_mut().as_mut()
241            && !journal.iter().any(|c| c.path == path)
242        {
243            journal.push(Change { path: path.to_path_buf(), before: fs::read_to_string(path).ok(), after: None });
244        }
245    }
246
247    /// The data folder.
248    pub fn dir(&self) -> &Path {
249        &self.dir
250    }
251
252    /// Where report exports are written.
253    pub fn reports_dir(&self) -> PathBuf {
254        self.dir.join(REPORTS_DIR)
255    }
256
257    /// The ids of the entries' files, by their names (`0005-fix-login.md` is 5), read or not: an entry whose file
258    /// doesn't parse is still there, and links to it must stay.
259    pub(crate) fn file_ids(&self) -> std::collections::HashSet<u32> {
260        let Ok(entries) = fs::read_dir(&self.dir) else { return std::collections::HashSet::new() };
261        entries
262            .filter_map(|entry| entry.ok()?.path().file_name()?.to_str().map(str::to_string))
263            .filter(|name| is_task_file(Path::new(name)))
264            .filter_map(|name| name.split('-').next()?.parse().ok())
265            .collect()
266    }
267
268    /// Returns the parsed tasks plus a warning for every file that could not be read.
269    #[deprecated(since = "0.6.3", note = "use `Store::load_entries`, which says why each file was skipped")]
270    pub fn load(&self) -> Result<(Vec<Task>, Vec<String>)> {
271        let (tasks, skipped) = self.load_entries()?;
272        Ok((tasks, skipped.iter().map(ToString::to_string).collect()))
273    }
274
275    /// Returns the parsed tasks, and every markdown file that didn't read as one, with why.
276    pub fn load_entries(&self) -> Result<(Vec<Task>, Vec<Skipped>)> {
277        // Every read goes through here: a brain a newer kanbrain wrote is refused before anything misreads it.
278        crate::migrate::check(&self.dir)?;
279        let mut tasks = Vec::new();
280        let mut skipped = Vec::new();
281        let entries = fs::read_dir(&self.dir).with_context(|| format!("reading {}", self.dir.display()))?;
282        for entry in entries {
283            let path = entry?.path();
284            if !is_task_file(&path) {
285                continue;
286            }
287            let file = path.file_name().unwrap_or_default().to_string_lossy().into_owned();
288            let parsed = read_text(&path).map_err(|e| SkipReason::Unreadable(e.to_string())).and_then(|text| {
289                Task::parse(&text).map_err(|e| match e {
290                    ParseError::MissingId => SkipReason::NoId,
291                    ParseError::Line(why) => SkipReason::Broken(why),
292                })
293            });
294            match parsed {
295                Ok(mut task) => {
296                    task.path = Some(path);
297                    tasks.push(task);
298                }
299                Err(why) => skipped.push(Skipped { file, why }),
300            }
301        }
302        Ok((tasks, skipped))
303    }
304
305    /// Writes atomically and renames the file when the title (slug) changed.
306    pub fn save(&self, task: &mut Task) -> Result<()> {
307        let path = self.dir.join(file_name(task));
308        // A name that differs only in case (renamed by hand or by a sync tool) is kept: on a case-insensitive file
309        // system it's the same file, and removing the "old" one after writing the new would delete the only copy.
310        let path = match &task.path {
311            Some(old) if old.parent() == Some(self.dir.as_path()) && same_ignoring_case(old, &path) => old.clone(),
312            _ => path,
313        };
314        // A file there that isn't this entry's is one that didn't parse (a sync conflict's, a hand edit's): writing
315        // over it would lose what it holds.
316        if task.path.as_ref() != Some(&path) && path.exists() {
317            bail!("{} is there and couldn't be read as #{}: fix or move it, then try again", path.display(), task.id);
318        }
319        self.remember(&path);
320        if let Some(old) = &task.path {
321            self.remember(old);
322        }
323        write_file(&path, &task.to_markdown())?;
324        // A new title means a new file name: remove the file saved under the old one.
325        if let Some(old) = task.path.replace(path)
326            && Some(&old) != task.path.as_ref()
327        {
328            // The old name only matters if it still exists; a failure here leaves a stale copy, not lost data.
329            let _ = remove_file(&old);
330        }
331        Ok(())
332    }
333
334    /// Removes the task's file. Refuses anything outside the data folder.
335    pub fn delete(&self, task: &Task) -> Result<()> {
336        let Some(path) = &task.path else { return Ok(()) };
337        if !path.starts_with(&self.dir) {
338            bail!("refusing to delete {}: not in {}", path.display(), self.dir.display());
339        }
340        self.remember(path);
341        remove_file(path).with_context(|| format!("deleting {}", path.display()))
342    }
343
344    /// The highest id given so far, as `last-id` keeps it; 0 when it's missing (a folder from an older
345    /// version) or unreadable, and then the entries' own ids are all there is to go by.
346    pub fn last_id(&self) -> u32 {
347        read_text(&self.dir.join(LAST_ID_FILE)).ok().and_then(|text| text.trim().parse().ok()).unwrap_or(0)
348    }
349
350    /// Writes `last-id`.
351    pub fn save_last_id(&self, id: u32) -> Result<()> {
352        let path = self.dir.join(LAST_ID_FILE);
353        write_file(&path, &format!("{id}\n"))
354    }
355
356    /// Reads `tags.md`; no file means no colors assigned yet.
357    pub fn load_tag_colors(&self) -> Result<TagColors> {
358        let path = self.dir.join(TAGS_FILE);
359        match read_text(&path) {
360            Ok(text) => Ok(TagColors::parse(&text)),
361            Err(e) if e.kind() == ErrorKind::NotFound => Ok(TagColors::default()),
362            Err(e) => Err(e).with_context(|| format!("reading {}", path.display())),
363        }
364    }
365
366    /// Writes `tags.md`.
367    pub fn save_tag_colors(&self, colors: &TagColors) -> Result<()> {
368        let path = self.dir.join(TAGS_FILE);
369        self.remember(&path);
370        write_file(&path, &colors.to_markdown())
371    }
372}
373
374thread_local! {
375    /// The lock files this thread holds. `flock` on a second handle of the same file waits for the first, so a
376    /// thread taking a lock it holds would wait for itself forever.
377    static HELD: RefCell<Vec<PathBuf>> = const { RefCell::new(Vec::new()) };
378}
379
380/// A brain's write lock, held until dropped (see [`Store::lock`]).
381#[derive(Debug)]
382pub struct BrainLock {
383    /// The locked file, whose handle holds the lock; `None` for a lock the thread already held.
384    file: Option<fs::File>,
385    /// The lock file.
386    path: PathBuf,
387}
388
389impl Drop for BrainLock {
390    fn drop(&mut self) {
391        // The handle closes after this, releasing the lock.
392        if self.file.is_some() {
393            HELD.with(|held| held.borrow_mut().retain(|p| *p != self.path));
394        }
395    }
396}
397
398/// Where the lock of the brain in `dir` is: `<cache>/kanbrain/locks/<key>.lock` (see [`cache_dir_of`]).
399fn lock_path(dir: &Path) -> Result<PathBuf> {
400    Ok(cache_dir_of(dir, "locks")?.with_extension("lock"))
401}
402
403/// `<cache>/kanbrain/<kind>/<key>`, where the user's cache keeps something about the brain in `dir` (its lock, its
404/// backups) out of `.brain/`, which is often committed. The key is [`fnv1a`] of the folder's canonical path, which
405/// two `kb` builds must agree on. A brain in
406/// the system's temporary folder (a test's, a try-out) keeps it in `<tmp>/kanbrain-cache/<kind>/<key>` instead, so
407/// a throwaway brain leaves nothing in the user's cache, and what it leaves there goes once it's gone (see
408/// [`forget_gone_temp_brains`]); under `/tmp`, in `/tmp/kanbrain-cache`.
409pub(crate) fn cache_dir_of(dir: &Path, kind: &str) -> Result<PathBuf> {
410    let dir = dir.canonicalize().with_context(|| format!("reading {}", dir.display()))?;
411    let hash = fnv1a(dir.as_os_str().as_encoded_bytes());
412    // `/tmp` first, whatever `TMPDIR` says: two processes on one brain must agree on its lock, and some set
413    // `TMPDIR` to a folder inside `/tmp` (Claude Code does) while others don't.
414    let tmp = std::env::temp_dir();
415    let roots = [cfg!(unix).then(|| PathBuf::from("/tmp")), Some(tmp.clone())];
416    let cache = roots
417        .into_iter()
418        .flatten()
419        .filter_map(|root| root.canonicalize().ok())
420        .find(|root| dir.starts_with(root))
421        .map_or_else(|| dirs::cache_dir().unwrap_or(tmp).join("kanbrain"), |root| root.join(TEMP_CACHE));
422    Ok(cache.join(kind).join(format!("{hash:016x}")))
423}
424
425/// The folder of the system's temporary folder where brains there keep what the user's cache would.
426const TEMP_CACHE: &str = "kanbrain-cache";
427
428/// The 64-bit FNV-1a hash of `bytes`: for what must come out the same from every build, which std's hasher doesn't
429/// promise across Rust versions.
430pub(crate) fn fnv1a(bytes: &[u8]) -> u64 {
431    bytes.iter().fold(0xcbf2_9ce4_8422_2325, |hash, &byte| (hash ^ u64::from(byte)).wrapping_mul(0x0100_0000_01b3))
432}
433
434/// Deletes what brains in the system's temporary folder left in its cache once each is gone, as a test's or a
435/// try-out's is: nothing can ask for it after. Run as one of them is locked (`lock`, its lock file), so that cache
436/// tidies itself as throwaway brains come and go; the user's own is left alone. A lock file names its brain on its
437/// first line; one that doesn't yet (being written, or from an older version) is kept.
438fn forget_gone_temp_brains(lock: &Path) {
439    let Some(locks) = lock.parent().filter(|locks| locks.parent().is_some_and(|c| c.ends_with(TEMP_CACHE))) else {
440        return;
441    };
442    let Ok(entries) = fs::read_dir(locks) else { return };
443    for entry in entries.flatten() {
444        let path = entry.path();
445        let named = fs::read_to_string(&path).ok().and_then(|text| Some(PathBuf::from(text.strip_suffix('\n')?)));
446        if named.is_some_and(|brain| !brain.exists()) {
447            // Another `kb` may be tidying too: whichever gets there first deletes them.
448            let _ = fs::remove_file(&path);
449            if let (Some(cache), Some(key)) = (locks.parent(), path.file_stem()) {
450                let _ = fs::remove_dir_all(cache.join("backups").join(key));
451            }
452        }
453    }
454}
455
456/// How old a write's temporary file has to be to count as a crash's leftover: no write takes that long.
457const CUT_WRITE_AGE: Duration = Duration::from_secs(60 * 60);
458
459/// Deletes the temporary files of writes a crash cut short (see [`write_file`]) in the brain in `dir` and its
460/// folders, best effort. Each has a name of its own, so no later write replaces it. Run once its lock is taken,
461/// when nothing else writes there; the age spares writes made without the lock, as `init` does.
462fn forget_cut_writes(dir: &Path) {
463    let folders = fs::read_dir(dir).into_iter().flatten().flatten().filter(|e| e.file_type().is_ok_and(|t| t.is_dir()));
464    let files = std::iter::once(dir.to_path_buf())
465        .chain(folders.map(|folder| folder.path()))
466        .filter_map(|folder| fs::read_dir(folder).ok())
467        .flatten()
468        .flatten();
469    for file in files {
470        let name = file.file_name();
471        let name = name.to_string_lossy();
472        let age = file.metadata().and_then(|m| m.modified()).ok().and_then(|at| at.elapsed().ok());
473        if is_cut_write(&name) && age.is_some_and(|age| age > CUT_WRITE_AGE) {
474            let _ = fs::remove_file(file.path());
475        }
476    }
477}
478
479/// Whether `name` is a temporary file of [`write_file`]'s: `.NAME.PID-N.tmp`, or `.NAME.tmp` as versions before 0.4.0
480/// named them, where NAME is a file kanbrain writes (`*.md`, `format`, `last-id`). A central brain may be a notes
481/// vault, where other programs leave hidden `.tmp` files of their own.
482fn is_cut_write(name: &str) -> bool {
483    let Some(written) = name.strip_prefix('.').and_then(|n| n.strip_suffix(".tmp")) else { return false };
484    let written = match written.rsplit_once('.') {
485        Some((file, write)) if write.split_once('-').is_some_and(|(pid, n)| is_number(pid) && is_number(n)) => file,
486        _ => written,
487    };
488    Path::new(written).extension().is_some_and(|ext| ext == "md")
489        || written == crate::migrate::FORMAT_FILE
490        || written == LAST_ID_FILE
491}
492
493/// Whether `text` is digits only, and some.
494fn is_number(text: &str) -> bool {
495    !text.is_empty() && text.bytes().all(|b| b.is_ascii_digit())
496}
497
498/// What the cache keeps about the brain in `dir`, its lock file and its backups, to delete once the brain is: nothing
499/// asks for them after. Found while the brain is still there, since the key comes from its canonical path.
500pub(crate) fn cache_of(dir: &Path) -> Result<[PathBuf; 2]> {
501    Ok([lock_path(dir)?, cache_dir_of(dir, "backups")?])
502}
503
504/// Reads a text file people may edit, without the byte-order mark some editors (Notepad, older Windows ones) put
505/// first: left in, it hides a file's first line, an entry's `# title` among them.
506pub(crate) fn read_text(path: &Path) -> std::io::Result<String> {
507    let text = fs::read_to_string(path)?;
508    Ok(match text.strip_prefix('\u{feff}') {
509        Some(rest) => rest.to_string(),
510        None => text,
511    })
512}
513
514/// Writes `text` to `path`, creating its folder if missing. It goes to a hidden temporary file beside it
515/// first, flushed to disk, then takes the file's place in one rename: a crash or a full disk can never leave a
516/// file half written, be it an entry, `tags.md` or a repository's `CLAUDE.md`. The temporary file's name is this
517/// write's own, so two programs writing the same file can't swap halves of each other's. The watcher ignores
518/// hidden files.
519pub(crate) fn write_file(path: &Path, text: &str) -> Result<()> {
520    static WRITES: AtomicU32 = AtomicU32::new(0);
521    let dir = path.parent().filter(|d| !d.as_os_str().is_empty()).unwrap_or(Path::new("."));
522    fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?;
523    let name = path.file_name().with_context(|| format!("{} isn't a file", path.display()))?;
524    let write = WRITES.fetch_add(1, Ordering::Relaxed);
525    let tmp = dir.join(format!(".{}.{}-{write}.tmp", name.to_string_lossy(), std::process::id()));
526    let written = fs::File::create(&tmp).and_then(|mut file| {
527        file.write_all(text.as_bytes())?;
528        file.sync_all()
529    });
530    if let Err(e) = written.and_then(|()| rename(&tmp, path)) {
531        let _ = remove_file(&tmp);
532        return Err(e).with_context(|| format!("writing {}", path.display()));
533    }
534    Ok(())
535}
536
537/// How long to wait before each new try at a file another program holds, 1.27 s in all. Tools that do the same wait
538/// longer (rustup up to about 28 s for antiviruses, rust-lang/rustup#1870; Go's `cmd/internal/robustio` 2 s), but
539/// the app writes on its UI thread, which a long wait would freeze.
540const BUSY_DELAYS: [Duration; 7] = [
541    Duration::from_millis(10),
542    Duration::from_millis(20),
543    Duration::from_millis(40),
544    Duration::from_millis(80),
545    Duration::from_millis(160),
546    Duration::from_millis(320),
547    Duration::from_millis(640),
548];
549
550/// The waits of [`retry_while_busy`] on this system. Only Windows has any: there a file another program has open
551/// (an antivirus, the search indexer, an editor) can't be replaced or deleted, and the rename
552/// or delete fails (see [`busy`]) until it lets go. Elsewhere it can, so a denied permission is a real one and fails
553/// at once.
554fn busy_delays() -> &'static [Duration] {
555    if cfg!(windows) { &BUSY_DELAYS } else { &[] }
556}
557
558/// Runs `op`, and runs it again after each of `delays` while it fails as a file another program holds makes it fail
559/// on Windows (see [`busy_delays`]). Any other error, or the last try's, is returned.
560fn retry_while_busy<T>(delays: &[Duration], mut op: impl FnMut() -> std::io::Result<T>) -> std::io::Result<T> {
561    let mut delays = delays.iter();
562    loop {
563        match op() {
564            Err(e) if busy(&e) => match delays.next() {
565                Some(delay) => std::thread::sleep(*delay),
566                None => return Err(e),
567            },
568            result => return result,
569        }
570    }
571}
572
573/// Whether `error` may be a file another program holds: a denied permission (`ERROR_ACCESS_DENIED` on Windows), or on
574/// Windows `ERROR_SHARING_VIOLATION`, which std leaves uncategorized (32 is another error elsewhere).
575fn busy(error: &std::io::Error) -> bool {
576    const ERROR_SHARING_VIOLATION: i32 = 32;
577    error.kind() == ErrorKind::PermissionDenied
578        || (cfg!(windows) && matches!(error.raw_os_error(), Some(ERROR_SHARING_VIOLATION)))
579}
580
581/// Deletes the file at `path`, waiting a little for a program holding it to let go (see [`retry_while_busy`]).
582pub(crate) fn remove_file(path: &Path) -> std::io::Result<()> {
583    retry_while_busy(busy_delays(), || fs::remove_file(path))
584}
585
586/// Renames `from` to `to`, replacing it, waiting a little for a program holding either to let go (see
587/// [`retry_while_busy`]).
588pub(crate) fn rename(from: &Path, to: &Path) -> std::io::Result<()> {
589    retry_while_busy(busy_delays(), || fs::rename(from, to))
590}
591
592/// The name `task`'s file is saved under: `0005-fix-login.md`.
593pub(crate) fn file_name(task: &Task) -> String {
594    format!("{:04}-{}.md", task.id, slugify(&task.title))
595}
596
597/// Whether `name` starts as [`file_name`] names an entry: four digits or more, `-`, a lowercase ASCII letter (a slug's).
598/// A slug starting with a digit is left out, so that a notes vault's `2026-10-15.md` isn't taken for one.
599pub(crate) fn entry_named(name: &str) -> bool {
600    let digits = name.chars().take_while(char::is_ascii_digit).count();
601    digits >= 4
602        && name[digits..].strip_prefix('-').is_some_and(|rest| rest.starts_with(|c: char| c.is_ascii_lowercase()))
603}
604
605/// Whether two file names in one folder are the same but for case, which a case-insensitive file system (macOS,
606/// Windows) takes for one file.
607fn same_ignoring_case(a: &Path, b: &Path) -> bool {
608    a.file_name()
609        .zip(b.file_name())
610        .is_some_and(|(a, b)| a.to_string_lossy().eq_ignore_ascii_case(&b.to_string_lossy()))
611}
612
613/// The nearest `.brain/` folder in `start` or one of its parents.
614pub fn find_project(start: &Path) -> Option<PathBuf> {
615    start.ancestors().map(|dir| dir.join(PROJECT_DIR)).find(|dir| dir.is_dir())
616}
617
618/// Task files are the `.md` files in the folder, except the tag colors, the project's card and the
619/// central brain's list of projects.
620fn is_task_file(path: &Path) -> bool {
621    let other = [TAGS_FILE, projects::PROJECT_FILE, projects::REGISTRY_FILE];
622    path.extension().is_some_and(|e| e == "md") && path.file_name().is_some_and(|n| !other.iter().any(|o| n == *o))
623}
624
625#[cfg(test)]
626#[path = "../tests/store.rs"]
627mod tests;