use crate::model::{Task, slugify};
use crate::projects;
use crate::tags::TagColors;
use anyhow::{Context, Result, bail};
use std::cell::RefCell;
use std::fs;
use std::io::{ErrorKind, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU32, Ordering};
use std::time::Duration;
pub(crate) const TAGS_FILE: &str = "tags.md";
pub const PROJECT_DIR: &str = ".brain";
pub(crate) const LAST_ID_FILE: &str = "last-id";
pub(crate) const REPORTS_DIR: &str = "reports";
#[derive(Debug)]
pub struct Store {
dir: PathBuf,
journal: RefCell<Option<Vec<Change>>>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Change {
pub path: PathBuf,
pub before: Option<String>,
pub after: Option<String>,
}
#[derive(Debug, PartialEq, Eq)]
pub enum UndoError {
ChangedSince(String),
}
impl std::fmt::Display for UndoError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::ChangedSince(name) => write!(f, "can't undo: {name} changed since"),
}
}
}
impl std::error::Error for UndoError {}
pub fn undo(changes: &[Change]) -> Result<()> {
for change in changes {
if fs::read_to_string(&change.path).ok() != change.after {
let name = change.path.file_name().unwrap_or_default().to_string_lossy().into_owned();
return Err(UndoError::ChangedSince(name).into());
}
}
for change in changes.iter().rev() {
match &change.before {
Some(text) => write_file(&change.path, text)?,
None => fs::remove_file(&change.path).with_context(|| format!("removing {}", change.path.display()))?,
}
}
Ok(())
}
impl Store {
pub fn open() -> Result<Self> {
let cwd = std::env::current_dir().context("reading the current directory")?;
match find_project(&cwd) {
Some(dir) => Self::at(dir),
None => Self::at(projects::central_dir()?),
}
}
pub fn open_named(name: Option<&str>) -> Result<Self> {
match name {
None => Self::open(),
Some(projects::CENTRAL) => Self::at(projects::central_dir()?),
Some(label) => {
let central = projects::central_dir()?;
let repo = match projects::find(¢ral, label) {
Ok(repo) => repo,
Err(_) if label.eq_ignore_ascii_case(projects::CENTRAL) => return Self::at(central),
Err(e) => return Err(e),
};
if !repo.dir.is_dir() {
bail!("{} is gone: `kb --project {label} deinit` takes it out", repo.dir.display());
}
Self::at(repo.dir)
}
}
}
pub fn project(&self) -> Option<String> {
if let Some(projects::RepoCard { project: name, .. }) = projects::read_repo_card(&self.dir) {
return Some(name);
}
if self.dir.file_name()? != PROJECT_DIR {
return None;
}
let root = self.dir.parent()?;
let root = if root.as_os_str().is_empty() { std::env::current_dir().ok()? } else { root.to_path_buf() };
Some(root.file_name()?.to_string_lossy().into_owned())
}
pub fn at(dir: impl Into<PathBuf>) -> Result<Self> {
let dir = dir.into();
if !dir.is_dir() {
fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
let format = format!("{}\n", crate::migrate::FORMAT);
write_file(&dir.join(crate::migrate::FORMAT_FILE), &format)?;
}
Ok(Self { dir, journal: RefCell::new(None) })
}
pub fn lock(&self) -> Result<BrainLock> {
let path = lock_path(&self.dir)?;
if HELD.with(|held| held.borrow().contains(&path)) {
return Ok(BrainLock { file: None, path });
}
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?;
}
let mut file = fs::OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.open(&path)
.with_context(|| format!("opening {}", path.display()))?;
fs4::FileExt::lock(&file).with_context(|| format!("locking {}", path.display()))?;
if file.metadata().is_ok_and(|m| m.len() == 0) {
let brain = self.dir.canonicalize().with_context(|| format!("reading {}", self.dir.display()))?;
file.write_all(format!("{}\n", brain.display()).as_bytes())
.with_context(|| format!("writing {}", path.display()))?;
}
HELD.with(|held| held.borrow_mut().push(path.clone()));
forget_gone_temp_brains(&path);
forget_cut_writes(&self.dir);
Ok(BrainLock { file: Some(file), path })
}
pub fn record(&self) {
*self.journal.borrow_mut() = Some(Vec::new());
}
pub fn recorded(&self) -> Vec<Change> {
let mut changes = self.journal.borrow_mut().take().unwrap_or_default();
for change in &mut changes {
change.after = fs::read_to_string(&change.path).ok();
}
changes.retain(|c| c.before != c.after);
changes
}
fn remember(&self, path: &Path) {
if let Some(journal) = self.journal.borrow_mut().as_mut()
&& !journal.iter().any(|c| c.path == path)
{
journal.push(Change { path: path.to_path_buf(), before: fs::read_to_string(path).ok(), after: None });
}
}
pub fn dir(&self) -> &Path {
&self.dir
}
pub fn reports_dir(&self) -> PathBuf {
self.dir.join(REPORTS_DIR)
}
pub(crate) fn file_ids(&self) -> std::collections::HashSet<u32> {
let Ok(entries) = fs::read_dir(&self.dir) else { return std::collections::HashSet::new() };
entries
.filter_map(|entry| entry.ok()?.path().file_name()?.to_str().map(str::to_string))
.filter(|name| is_task_file(Path::new(name)))
.filter_map(|name| name.split('-').next()?.parse().ok())
.collect()
}
pub fn load(&self) -> Result<(Vec<Task>, Vec<String>)> {
crate::migrate::check(&self.dir)?;
let mut tasks = Vec::new();
let mut warnings = Vec::new();
let entries = fs::read_dir(&self.dir).with_context(|| format!("reading {}", self.dir.display()))?;
for entry in entries {
let path = entry?.path();
if !is_task_file(&path) {
continue;
}
let name = path.file_name().unwrap_or_default().to_string_lossy().into_owned();
let parsed = read_text(&path).map_err(|e| e.to_string()).and_then(|text| Task::from_markdown(&text));
match parsed {
Ok(mut task) => {
task.path = Some(path);
tasks.push(task);
}
Err(e) => warnings.push(format!("{name}: {e}")),
}
}
Ok((tasks, warnings))
}
pub fn save(&self, task: &mut Task) -> Result<()> {
let path = self.dir.join(file_name(task));
let path = match &task.path {
Some(old) if old.parent() == Some(self.dir.as_path()) && same_ignoring_case(old, &path) => old.clone(),
_ => path,
};
if task.path.as_ref() != Some(&path) && path.exists() {
bail!("{} is there and couldn't be read as #{}: fix or move it, then try again", path.display(), task.id);
}
self.remember(&path);
if let Some(old) = &task.path {
self.remember(old);
}
write_file(&path, &task.to_markdown())?;
if let Some(old) = task.path.replace(path)
&& Some(&old) != task.path.as_ref()
{
let _ = fs::remove_file(old);
}
Ok(())
}
pub fn delete(&self, task: &Task) -> Result<()> {
let Some(path) = &task.path else { return Ok(()) };
if !path.starts_with(&self.dir) {
bail!("refusing to delete {}: not in {}", path.display(), self.dir.display());
}
self.remember(path);
fs::remove_file(path).with_context(|| format!("deleting {}", path.display()))
}
pub fn last_id(&self) -> u32 {
read_text(&self.dir.join(LAST_ID_FILE)).ok().and_then(|text| text.trim().parse().ok()).unwrap_or(0)
}
pub fn save_last_id(&self, id: u32) -> Result<()> {
let path = self.dir.join(LAST_ID_FILE);
write_file(&path, &format!("{id}\n"))
}
pub fn load_tag_colors(&self) -> Result<TagColors> {
let path = self.dir.join(TAGS_FILE);
match read_text(&path) {
Ok(text) => Ok(TagColors::parse(&text)),
Err(e) if e.kind() == ErrorKind::NotFound => Ok(TagColors::default()),
Err(e) => Err(e).with_context(|| format!("reading {}", path.display())),
}
}
pub fn save_tag_colors(&self, colors: &TagColors) -> Result<()> {
let path = self.dir.join(TAGS_FILE);
self.remember(&path);
write_file(&path, &colors.to_markdown())
}
}
thread_local! {
static HELD: RefCell<Vec<PathBuf>> = const { RefCell::new(Vec::new()) };
}
#[derive(Debug)]
pub struct BrainLock {
file: Option<fs::File>,
path: PathBuf,
}
impl Drop for BrainLock {
fn drop(&mut self) {
if self.file.is_some() {
HELD.with(|held| held.borrow_mut().retain(|p| *p != self.path));
}
}
}
fn lock_path(dir: &Path) -> Result<PathBuf> {
Ok(cache_dir_of(dir, "locks")?.with_extension("lock"))
}
pub(crate) fn cache_dir_of(dir: &Path, kind: &str) -> Result<PathBuf> {
let dir = dir.canonicalize().with_context(|| format!("reading {}", dir.display()))?;
let hash = fnv1a(dir.as_os_str().as_encoded_bytes());
let tmp = std::env::temp_dir();
let roots = [cfg!(unix).then(|| PathBuf::from("/tmp")), Some(tmp.clone())];
let cache = roots
.into_iter()
.flatten()
.filter_map(|root| root.canonicalize().ok())
.find(|root| dir.starts_with(root))
.map_or_else(|| dirs::cache_dir().unwrap_or(tmp).join("kanbrain"), |root| root.join(TEMP_CACHE));
Ok(cache.join(kind).join(format!("{hash:016x}")))
}
const TEMP_CACHE: &str = "kanbrain-cache";
pub(crate) fn fnv1a(bytes: &[u8]) -> u64 {
bytes.iter().fold(0xcbf2_9ce4_8422_2325, |hash, &byte| (hash ^ u64::from(byte)).wrapping_mul(0x0100_0000_01b3))
}
fn forget_gone_temp_brains(lock: &Path) {
let Some(locks) = lock.parent().filter(|locks| locks.parent().is_some_and(|c| c.ends_with(TEMP_CACHE))) else {
return;
};
let Ok(entries) = fs::read_dir(locks) else { return };
for entry in entries.flatten() {
let path = entry.path();
let named = fs::read_to_string(&path).ok().and_then(|text| Some(PathBuf::from(text.strip_suffix('\n')?)));
if named.is_some_and(|brain| !brain.exists()) {
let _ = fs::remove_file(&path);
if let (Some(cache), Some(key)) = (locks.parent(), path.file_stem()) {
let _ = fs::remove_dir_all(cache.join("backups").join(key));
}
}
}
}
const CUT_WRITE_AGE: Duration = Duration::from_secs(60 * 60);
fn forget_cut_writes(dir: &Path) {
let folders = fs::read_dir(dir).into_iter().flatten().flatten().filter(|e| e.file_type().is_ok_and(|t| t.is_dir()));
let files = std::iter::once(dir.to_path_buf())
.chain(folders.map(|folder| folder.path()))
.filter_map(|folder| fs::read_dir(folder).ok())
.flatten()
.flatten();
for file in files {
let name = file.file_name();
let name = name.to_string_lossy();
let age = file.metadata().and_then(|m| m.modified()).ok().and_then(|at| at.elapsed().ok());
if is_cut_write(&name) && age.is_some_and(|age| age > CUT_WRITE_AGE) {
let _ = fs::remove_file(file.path());
}
}
}
fn is_cut_write(name: &str) -> bool {
let Some(written) = name.strip_prefix('.').and_then(|n| n.strip_suffix(".tmp")) else { return false };
let written = match written.rsplit_once('.') {
Some((file, write)) if write.split_once('-').is_some_and(|(pid, n)| is_number(pid) && is_number(n)) => file,
_ => written,
};
Path::new(written).extension().is_some_and(|ext| ext == "md")
|| written == crate::migrate::FORMAT_FILE
|| written == LAST_ID_FILE
}
fn is_number(text: &str) -> bool {
!text.is_empty() && text.bytes().all(|b| b.is_ascii_digit())
}
pub(crate) fn cache_of(dir: &Path) -> Result<[PathBuf; 2]> {
Ok([lock_path(dir)?, cache_dir_of(dir, "backups")?])
}
pub(crate) fn read_text(path: &Path) -> std::io::Result<String> {
let text = fs::read_to_string(path)?;
Ok(match text.strip_prefix('\u{feff}') {
Some(rest) => rest.to_string(),
None => text,
})
}
pub(crate) fn write_file(path: &Path, text: &str) -> Result<()> {
static WRITES: AtomicU32 = AtomicU32::new(0);
let dir = path.parent().filter(|d| !d.as_os_str().is_empty()).unwrap_or(Path::new("."));
fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?;
let name = path.file_name().with_context(|| format!("{} isn't a file", path.display()))?;
let write = WRITES.fetch_add(1, Ordering::Relaxed);
let tmp = dir.join(format!(".{}.{}-{write}.tmp", name.to_string_lossy(), std::process::id()));
let written = fs::File::create(&tmp).and_then(|mut file| {
file.write_all(text.as_bytes())?;
file.sync_all()
});
if let Err(e) = written.and_then(|()| fs::rename(&tmp, path)) {
let _ = fs::remove_file(&tmp);
return Err(e).with_context(|| format!("writing {}", path.display()));
}
Ok(())
}
pub(crate) fn file_name(task: &Task) -> String {
format!("{:04}-{}.md", task.id, slugify(&task.title))
}
pub(crate) fn entry_named(name: &str) -> bool {
let digits = name.chars().take_while(char::is_ascii_digit).count();
digits >= 4
&& name[digits..].strip_prefix('-').is_some_and(|rest| rest.starts_with(|c: char| c.is_ascii_lowercase()))
}
fn same_ignoring_case(a: &Path, b: &Path) -> bool {
a.file_name()
.zip(b.file_name())
.is_some_and(|(a, b)| a.to_string_lossy().eq_ignore_ascii_case(&b.to_string_lossy()))
}
pub fn find_project(start: &Path) -> Option<PathBuf> {
start.ancestors().map(|dir| dir.join(PROJECT_DIR)).find(|dir| dir.is_dir())
}
fn is_task_file(path: &Path) -> bool {
let other = [TAGS_FILE, projects::PROJECT_FILE, projects::REGISTRY_FILE];
path.extension().is_some_and(|e| e == "md") && path.file_name().is_some_and(|n| !other.iter().any(|o| n == *o))
}
#[cfg(test)]
#[path = "../tests/store.rs"]
mod tests;