1use 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
14pub(crate) const TAGS_FILE: &str = "tags.md";
16
17pub const PROJECT_DIR: &str = ".brain";
19
20pub(crate) const LAST_ID_FILE: &str = "last-id";
24
25pub(crate) const REPORTS_DIR: &str = "reports";
27
28#[derive(Debug)]
30pub struct Store {
31 dir: PathBuf,
33 journal: RefCell<Option<Vec<Change>>>,
35}
36
37#[derive(Debug, Clone, PartialEq, Eq)]
39#[non_exhaustive]
40pub struct Skipped {
41 pub file: String,
43 pub why: SkipReason,
45}
46
47#[derive(Debug, Clone, PartialEq, Eq)]
49#[non_exhaustive]
50pub enum SkipReason {
51 NoId,
53 Broken(String),
55 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#[derive(Debug, Clone, PartialEq, Eq)]
76pub struct Change {
77 pub path: PathBuf,
79 pub before: Option<String>,
81 pub after: Option<String>,
83}
84
85#[derive(Debug, PartialEq, Eq)]
87pub enum UndoError {
88 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
102pub 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 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 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(¢ral, label) {
145 Ok(repo) => repo,
146 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 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 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 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 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 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 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 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 pub fn record(&self) {
225 *self.journal.borrow_mut() = Some(Vec::new());
226 }
227
228 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 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 pub fn dir(&self) -> &Path {
249 &self.dir
250 }
251
252 pub fn reports_dir(&self) -> PathBuf {
254 self.dir.join(REPORTS_DIR)
255 }
256
257 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 #[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 pub fn load_entries(&self) -> Result<(Vec<Task>, Vec<Skipped>)> {
277 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 pub fn save(&self, task: &mut Task) -> Result<()> {
307 let path = self.dir.join(file_name(task));
308 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 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 if let Some(old) = task.path.replace(path)
326 && Some(&old) != task.path.as_ref()
327 {
328 let _ = remove_file(&old);
330 }
331 Ok(())
332 }
333
334 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 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 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 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 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 static HELD: RefCell<Vec<PathBuf>> = const { RefCell::new(Vec::new()) };
378}
379
380#[derive(Debug)]
382pub struct BrainLock {
383 file: Option<fs::File>,
385 path: PathBuf,
387}
388
389impl Drop for BrainLock {
390 fn drop(&mut self) {
391 if self.file.is_some() {
393 HELD.with(|held| held.borrow_mut().retain(|p| *p != self.path));
394 }
395 }
396}
397
398fn lock_path(dir: &Path) -> Result<PathBuf> {
400 Ok(cache_dir_of(dir, "locks")?.with_extension("lock"))
401}
402
403pub(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 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
425const TEMP_CACHE: &str = "kanbrain-cache";
427
428pub(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
434fn 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 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
456const CUT_WRITE_AGE: Duration = Duration::from_secs(60 * 60);
458
459fn 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
479fn 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
493fn is_number(text: &str) -> bool {
495 !text.is_empty() && text.bytes().all(|b| b.is_ascii_digit())
496}
497
498pub(crate) fn cache_of(dir: &Path) -> Result<[PathBuf; 2]> {
501 Ok([lock_path(dir)?, cache_dir_of(dir, "backups")?])
502}
503
504pub(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
514pub(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
537const 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
550fn busy_delays() -> &'static [Duration] {
555 if cfg!(windows) { &BUSY_DELAYS } else { &[] }
556}
557
558fn 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
573fn 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
581pub(crate) fn remove_file(path: &Path) -> std::io::Result<()> {
583 retry_while_busy(busy_delays(), || fs::remove_file(path))
584}
585
586pub(crate) fn rename(from: &Path, to: &Path) -> std::io::Result<()> {
589 retry_while_busy(busy_delays(), || fs::rename(from, to))
590}
591
592pub(crate) fn file_name(task: &Task) -> String {
594 format!("{:04}-{}.md", task.id, slugify(&task.title))
595}
596
597pub(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
605fn 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
613pub fn find_project(start: &Path) -> Option<PathBuf> {
615 start.ancestors().map(|dir| dir.join(PROJECT_DIR)).find(|dir| dir.is_dir())
616}
617
618fn 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;