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;
use std::path::{Path, PathBuf};
const TAGS_FILE: &str = "tags.md";
pub const PROJECT_DIR: &str = ".brain";
const CONFIG_FILE: &str = "config.md";
const LAST_ID_FILE: &str = "last-id";
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) => Self::at(projects::find(&projects::central_dir()?, label)?.dir),
}
}
pub fn project(&self) -> Option<String> {
if let Some((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();
fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
Ok(Self { dir, journal: RefCell::new(None) })
}
pub fn lock(&self) -> Result<BrainLock> {
let path = lock_path(&self.dir)?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?;
}
let 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()))?;
Ok(BrainLock(file))
}
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 fn load(&self) -> Result<(Vec<Task>, Vec<String>)> {
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 =
fs::read_to_string(&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 name = format!("{:04}-{}.md", task.id, slugify(&task.title));
let path = self.dir.join(&name);
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 {
fs::read_to_string(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 fs::read_to_string(&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())
}
}
#[derive(Debug)]
pub struct BrainLock(#[expect(dead_code, reason = "held only for its lock, released on drop")] fs::File);
fn lock_path(dir: &Path) -> Result<PathBuf> {
let dir = dir.canonicalize().with_context(|| format!("reading {}", dir.display()))?;
let hash = dir
.as_os_str()
.as_encoded_bytes()
.iter()
.fold(0xcbf2_9ce4_8422_2325_u64, |hash, &byte| (hash ^ u64::from(byte)).wrapping_mul(0x0100_0000_01b3));
let cache = dirs::cache_dir().unwrap_or_else(std::env::temp_dir);
Ok(cache.join("kanbrain").join("locks").join(format!("{hash:016x}.lock")))
}
pub(crate) fn write_file(path: &Path, text: &str) -> Result<()> {
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 tmp = dir.join(format!(".{}.tmp", name.to_string_lossy()));
fs::write(&tmp, text).with_context(|| format!("writing {}", tmp.display()))?;
fs::rename(&tmp, path).with_context(|| format!("writing {}", path.display()))
}
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, CONFIG_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;