1use 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
12const TAGS_FILE: &str = "tags.md";
14
15pub const PROJECT_DIR: &str = ".brain";
17
18const CONFIG_FILE: &str = "config.md";
21
22const LAST_ID_FILE: &str = "last-id";
26
27const REPORTS_DIR: &str = "reports";
29
30#[derive(Debug)]
32pub struct Store {
33 dir: PathBuf,
35 journal: RefCell<Option<Vec<Change>>>,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct Change {
42 pub path: PathBuf,
44 pub before: Option<String>,
46 pub after: Option<String>,
48}
49
50#[derive(Debug, PartialEq, Eq)]
52pub enum UndoError {
53 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
67pub 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 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 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 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 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 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 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 pub fn record(&self) {
162 *self.journal.borrow_mut() = Some(Vec::new());
163 }
164
165 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 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 pub fn dir(&self) -> &Path {
186 &self.dir
187 }
188
189 pub fn reports_dir(&self) -> PathBuf {
191 self.dir.join(REPORTS_DIR)
192 }
193
194 pub fn load(&self) -> Result<(Vec<Task>, Vec<String>)> {
196 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 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 if let Some(old) = task.path.replace(path)
231 && Some(&old) != task.path.as_ref()
232 {
233 let _ = fs::remove_file(old);
235 }
236 Ok(())
237 }
238
239 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 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 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 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 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 static HELD: RefCell<Vec<PathBuf>> = const { RefCell::new(Vec::new()) };
283}
284
285#[derive(Debug)]
287pub struct BrainLock {
288 file: Option<fs::File>,
290 path: PathBuf,
292}
293
294impl Drop for BrainLock {
295 fn drop(&mut self) {
296 if self.file.is_some() {
298 HELD.with(|held| held.borrow_mut().retain(|p| *p != self.path));
299 }
300 }
301}
302
303fn lock_path(dir: &Path) -> Result<PathBuf> {
305 Ok(cache_dir_of(dir, "locks")?.with_extension("lock"))
306}
307
308pub(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
322pub(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
334pub fn find_project(start: &Path) -> Option<PathBuf> {
336 start.ancestors().map(|dir| dir.join(PROJECT_DIR)).find(|dir| dir.is_dir())
337}
338
339fn 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;