use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use jiff::civil::Date;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Note {
pub id: u64,
pub title: String,
#[serde(default)]
pub body: String,
pub created: Date,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub updated: Option<Date>,
}
impl Note {
pub fn new(id: u64, title: impl Into<String>, today: Date) -> Self {
Self {
id,
title: title.into(),
body: String::new(),
created: today,
updated: None,
}
}
pub fn shown_date(&self) -> Date {
self.updated.unwrap_or(self.created)
}
pub fn matches(&self, needle: &str) -> bool {
let needle = needle.trim().to_lowercase();
if needle.is_empty() {
return true;
}
self.title.to_lowercase().contains(&needle) || self.body.to_lowercase().contains(&needle)
}
}
#[derive(Debug, Default, Serialize, Deserialize)]
struct NoteFile {
#[serde(default, rename = "note")]
notes: Vec<Note>,
}
#[derive(Debug)]
pub struct NoteStore {
path: PathBuf,
notes: Vec<Note>,
dirty: bool,
next_id: u64,
pub last_error: Option<String>,
}
impl NoteStore {
pub fn load(path: impl Into<PathBuf>) -> Result<Self> {
let path = path.into();
let notes = if path.exists() {
let raw = std::fs::read_to_string(&path)
.with_context(|| format!("reading notes from {}", path.display()))?;
let parsed: NoteFile = toml::from_str(&raw)
.with_context(|| format!("parsing notes in {}", path.display()))?;
parsed.notes
} else {
Vec::new()
};
let next_id = notes.iter().map(|n| n.id).max().unwrap_or(0) + 1;
Ok(Self {
path,
notes,
dirty: false,
next_id,
last_error: None,
})
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn notes(&self) -> &[Note] {
&self.notes
}
pub fn add(&mut self, mut note: Note) -> u64 {
let id = self.next_id;
self.next_id += 1;
note.id = id;
self.notes.push(note);
self.dirty = true;
id
}
pub fn get(&self, id: u64) -> Option<&Note> {
self.notes.iter().find(|n| n.id == id)
}
pub fn with_note<F: FnOnce(&mut Note)>(&mut self, id: u64, today: Date, f: F) -> bool {
let Some(slot) = self.notes.iter_mut().find(|n| n.id == id) else {
return false;
};
let before = slot.clone();
f(slot);
if slot.title != before.title || slot.body != before.body {
slot.updated = Some(today);
self.dirty = true;
return true;
}
slot.updated = before.updated;
false
}
pub fn remove(&mut self, id: u64) -> bool {
let before = self.notes.len();
self.notes.retain(|n| n.id != id);
let removed = self.notes.len() != before;
self.dirty |= removed;
removed
}
pub fn view(&self, filter: &str) -> Vec<u64> {
let mut visible: Vec<&Note> = self.notes.iter().filter(|n| n.matches(filter)).collect();
visible.sort_by(|a, b| {
b.shown_date()
.cmp(&a.shown_date())
.then(b.id.cmp(&a.id))
});
visible.iter().map(|n| n.id).collect()
}
pub fn save(&mut self) -> Result<()> {
if !self.dirty {
return Ok(());
}
if let Some(parent) = self.path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("creating data directory {}", parent.display()))?;
}
let file = NoteFile {
notes: self.notes.clone(),
};
let body = toml::to_string_pretty(&file).context("serialising notes")?;
let contents = format!(
"# mirador notes. Safe to edit by hand or keep in version control.\n\
# Fields: id, title, body, created (YYYY-MM-DD), updated.\n\n{body}"
);
let tmp = self.path.with_extension("toml.tmp");
std::fs::write(&tmp, &contents).with_context(|| format!("writing {}", tmp.display()))?;
std::fs::rename(&tmp, &self.path)
.with_context(|| format!("replacing {} with {}", self.path.display(), tmp.display()))?;
self.dirty = false;
Ok(())
}
pub fn save_reporting(&mut self) {
match self.save() {
Ok(()) => self.last_error = None,
Err(e) => self.last_error = Some(format!("{e:#}")),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
struct TempDir(PathBuf);
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn store(name: &str) -> (NoteStore, TempDir) {
let dir = std::env::temp_dir().join(format!("mirador-note-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let store = NoteStore::load(dir.join("notes.toml")).unwrap();
(store, TempDir(dir))
}
fn today() -> Date {
Date::new(2026, 7, 25).unwrap()
}
#[test]
fn a_missing_file_loads_as_an_empty_store() {
let (s, _g) = store("missing");
assert!(s.notes().is_empty());
}
#[test]
fn notes_survive_a_save_and_reload() {
let (mut s, _g) = store("roundtrip");
let id = s.add(Note::new(0, "Groceries", today()));
s.with_note(id, today(), |n| n.body = "milk\neggs".into());
s.save().unwrap();
let reloaded = NoteStore::load(s.path()).unwrap();
assert_eq!(reloaded.notes().len(), 1);
let note = &reloaded.notes()[0];
assert_eq!(note.title, "Groceries");
assert_eq!(note.body, "milk\neggs", "newlines must survive TOML");
}
#[test]
fn editing_stamps_updated_but_opening_and_changing_nothing_does_not() {
let (mut s, _g) = store("stamp");
let id = s.add(Note::new(0, "Title", today()));
assert_eq!(s.get(id).unwrap().updated, None);
let later = Date::new(2026, 8, 1).unwrap();
assert!(!s.with_note(id, later, |_| {}), "no change, no stamp");
assert_eq!(s.get(id).unwrap().updated, None);
assert!(s.with_note(id, later, |n| n.body = "something".into()));
assert_eq!(s.get(id).unwrap().updated, Some(later));
}
#[test]
fn the_list_shows_the_most_recently_touched_note_first() {
let (mut s, _g) = store("order");
let old = s.add(Note::new(0, "old", Date::new(2026, 1, 1).unwrap()));
let new = s.add(Note::new(0, "new", Date::new(2026, 6, 1).unwrap()));
assert_eq!(s.view(""), vec![new, old]);
s.with_note(old, Date::new(2026, 12, 1).unwrap(), |n| {
n.body = "revised".into();
});
assert_eq!(s.view(""), vec![old, new]);
}
#[test]
fn filtering_searches_the_body_as_well_as_the_title() {
let (mut s, _g) = store("filter");
let id = s.add(Note::new(0, "Meeting", today()));
s.with_note(id, today(), |n| n.body = "ask about the invoice".into());
assert_eq!(s.view("invoice"), vec![id], "the body must be searchable");
assert_eq!(s.view("MEETING"), vec![id], "matching is case-insensitive");
assert!(s.view("nothing here").is_empty());
assert_eq!(s.view(" "), vec![id], "a blank filter matches everything");
}
#[test]
fn ids_are_never_reused_after_a_removal() {
let (mut s, _g) = store("ids");
let first = s.add(Note::new(0, "one", today()));
let second = s.add(Note::new(0, "two", today()));
assert!(s.remove(second));
let third = s.add(Note::new(0, "three", today()));
assert_ne!(third, second, "a reused id would alias the removed note");
assert_ne!(third, first);
}
#[test]
fn saving_is_a_no_op_when_nothing_changed() {
let (mut s, _g) = store("clean");
s.add(Note::new(0, "one", today()));
s.save().unwrap();
let stamp = std::fs::metadata(s.path()).unwrap().modified().unwrap();
s.save().unwrap();
assert_eq!(
std::fs::metadata(s.path()).unwrap().modified().unwrap(),
stamp,
"a clean store must not rewrite the file"
);
}
}