Skip to main content

code_moniker_workspace/notes/
store.rs

1use std::path::{Path, PathBuf};
2use std::sync::{Arc, RwLock};
3use std::time::{Duration, SystemTime, UNIX_EPOCH};
4
5use serde::{Deserialize, Serialize};
6
7use super::model::{Note, NoteKind, NoteStatus};
8
9pub const NOTES_DIR: &str = ".code-moniker";
10pub const NOTES_FILE: &str = ".code-moniker/notes.toml";
11
12#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
13#[serde(deny_unknown_fields)]
14pub struct NotesDocument {
15	#[serde(default)]
16	pub notes: Vec<Note>,
17}
18
19#[derive(Clone, Debug, Default, Eq, PartialEq)]
20pub struct NoteChanges {
21	pub moniker: Option<String>,
22	pub kind: Option<NoteKind>,
23	pub title: Option<String>,
24	pub body: Option<String>,
25}
26
27#[derive(Clone, Debug, Eq, PartialEq)]
28pub struct NotesWatchTarget {
29	pub path: PathBuf,
30	pub notes_path: PathBuf,
31}
32
33#[derive(Clone, Default)]
34pub struct WorkspaceNotes {
35	state: Arc<RwLock<NotesDocument>>,
36}
37
38impl WorkspaceNotes {
39	pub fn new(document: NotesDocument) -> Self {
40		Self {
41			state: Arc::new(RwLock::new(document)),
42		}
43	}
44
45	pub fn reload(&self, paths: &[PathBuf]) -> anyhow::Result<()> {
46		let root = notes_root_for_paths(paths)?;
47		let store = NotesStore::load(&root)?;
48		self.publish(store.document().clone());
49		Ok(())
50	}
51
52	pub fn publish(&self, notes: NotesDocument) {
53		if let Ok(mut state) = self.state.write() {
54			*state = notes;
55		}
56	}
57
58	pub fn snapshot(&self) -> anyhow::Result<NotesDocument> {
59		self.state
60			.read()
61			.map(|notes| notes.clone())
62			.map_err(|_| anyhow::anyhow!("workspace notes snapshot is unavailable"))
63	}
64
65	pub fn mutate<F, T>(&self, paths: &[PathBuf], mutate: F) -> anyhow::Result<T>
66	where
67		F: FnOnce(&mut NotesDocument) -> anyhow::Result<T>,
68	{
69		let root = notes_root_for_paths(paths)?;
70		let mut store = NotesStore::load(&root)?;
71		let result = mutate(store.document_mut())?;
72		store.save()?;
73		self.publish(store.document().clone());
74		Ok(result)
75	}
76}
77
78impl NotesDocument {
79	pub fn notes(&self) -> &[Note] {
80		&self.notes
81	}
82
83	pub fn get(&self, id: &str) -> Option<&Note> {
84		self.notes.iter().find(|note| note.id.as_str() == id)
85	}
86
87	pub fn insert(&mut self, note: Note) -> anyhow::Result<()> {
88		if self.notes.iter().any(|item| item.id == note.id) {
89			anyhow::bail!("note id `{}` already exists", note.id.0);
90		}
91		self.notes.push(note);
92		self.sort_notes();
93		Ok(())
94	}
95
96	pub fn update(
97		&mut self,
98		id: &str,
99		changes: NoteChanges,
100		updated_at: impl Into<String>,
101	) -> anyhow::Result<&Note> {
102		let note = self
103			.notes
104			.iter_mut()
105			.find(|note| note.id.as_str() == id)
106			.ok_or_else(|| anyhow::anyhow!("note id `{id}` does not exist"))?;
107		if let Some(moniker) = changes.moniker {
108			note.moniker = moniker;
109		}
110		if let Some(kind) = changes.kind {
111			note.kind = kind;
112		}
113		if let Some(title) = changes.title {
114			note.title = title;
115		}
116		if let Some(body) = changes.body {
117			note.body = body;
118		}
119		note.updated_at = updated_at.into();
120		self.sort_notes();
121		self.get(id)
122			.ok_or_else(|| anyhow::anyhow!("note id `{id}` does not exist after update"))
123	}
124
125	pub fn transition(
126		&mut self,
127		id: &str,
128		status: NoteStatus,
129		updated_at: impl Into<String>,
130	) -> anyhow::Result<&Note> {
131		let note = self
132			.notes
133			.iter_mut()
134			.find(|note| note.id.as_str() == id)
135			.ok_or_else(|| anyhow::anyhow!("note id `{id}` does not exist"))?;
136		note.transition_to(status, updated_at)?;
137		self.sort_notes();
138		self.get(id)
139			.ok_or_else(|| anyhow::anyhow!("note id `{id}` does not exist after transition"))
140	}
141
142	pub fn delete(&mut self, id: &str) -> anyhow::Result<Note> {
143		let Some(index) = self.notes.iter().position(|note| note.id.as_str() == id) else {
144			anyhow::bail!("note id `{id}` does not exist");
145		};
146		Ok(self.notes.remove(index))
147	}
148
149	fn sort_notes(&mut self) {
150		self.notes.sort_by(|left, right| {
151			left.status
152				.cmp(&right.status)
153				.then_with(|| left.updated_at.cmp(&right.updated_at).reverse())
154				.then_with(|| left.id.cmp(&right.id))
155		});
156	}
157}
158
159#[derive(Clone, Debug, Eq, PartialEq)]
160pub struct NotesStore {
161	path: PathBuf,
162	loaded_modified: Option<SystemTime>,
163	document: NotesDocument,
164}
165
166impl NotesStore {
167	pub fn load(root: &Path) -> anyhow::Result<Self> {
168		let path = notes_path(root);
169		let (document, loaded_modified) = if path.exists() {
170			let loaded_modified = std::fs::metadata(&path)?.modified().ok();
171			let text = std::fs::read_to_string(&path)?;
172			(toml::from_str(&text)?, loaded_modified)
173		} else {
174			(NotesDocument::default(), None)
175		};
176		Ok(Self {
177			path,
178			loaded_modified,
179			document,
180		})
181	}
182
183	pub fn path(&self) -> &Path {
184		&self.path
185	}
186
187	pub fn document(&self) -> &NotesDocument {
188		&self.document
189	}
190
191	pub fn document_mut(&mut self) -> &mut NotesDocument {
192		&mut self.document
193	}
194
195	pub fn notes(&self) -> &[Note] {
196		self.document.notes()
197	}
198
199	pub fn insert(&mut self, note: Note) -> anyhow::Result<()> {
200		self.document.insert(note)
201	}
202
203	pub fn save(&mut self) -> anyhow::Result<()> {
204		if let Some(parent) = self.path.parent() {
205			std::fs::create_dir_all(parent)?;
206		}
207		let _lock = self.acquire_save_lock()?;
208		self.ensure_not_stale()?;
209		let text = toml::to_string_pretty(&self.document)?;
210		let tmp = self.temp_path();
211		std::fs::write(&tmp, text)?;
212		std::fs::rename(&tmp, &self.path)?;
213		self.loaded_modified = std::fs::metadata(&self.path)?.modified().ok();
214		Ok(())
215	}
216
217	fn acquire_save_lock(&self) -> anyhow::Result<SaveLock> {
218		let path = self.lock_path();
219		for _ in 0..50 {
220			match std::fs::OpenOptions::new()
221				.write(true)
222				.create_new(true)
223				.open(&path)
224			{
225				Ok(_file) => return Ok(SaveLock { path }),
226				Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
227					std::thread::sleep(Duration::from_millis(10));
228				}
229				Err(error) => return Err(error.into()),
230			}
231		}
232		anyhow::bail!(
233			"notes file is locked by another writer: {}",
234			self.path.display()
235		);
236	}
237
238	fn ensure_not_stale(&self) -> anyhow::Result<()> {
239		let current = match std::fs::metadata(&self.path) {
240			Ok(metadata) => metadata.modified().ok(),
241			Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
242			Err(error) => return Err(error.into()),
243		};
244		if current != self.loaded_modified {
245			anyhow::bail!(
246				"notes file changed since load: reload `{}` before saving",
247				self.path.display()
248			);
249		}
250		Ok(())
251	}
252
253	fn temp_path(&self) -> PathBuf {
254		let nonce = SystemTime::now()
255			.duration_since(UNIX_EPOCH)
256			.map(|duration| duration.as_nanos())
257			.unwrap_or_default();
258		self.path
259			.with_extension(format!("toml.{}.{}.tmp", std::process::id(), nonce))
260	}
261
262	fn lock_path(&self) -> PathBuf {
263		self.path.with_extension("toml.lock")
264	}
265}
266
267struct SaveLock {
268	path: PathBuf,
269}
270
271impl Drop for SaveLock {
272	fn drop(&mut self) {
273		let _ = std::fs::remove_file(&self.path);
274	}
275}
276
277pub fn notes_path(root: &Path) -> PathBuf {
278	root.join(NOTES_FILE)
279}
280
281pub fn notes_dir(root: &Path) -> PathBuf {
282	root.join(NOTES_DIR)
283}
284
285pub fn notes_watch_targets_for_paths(paths: &[PathBuf]) -> anyhow::Result<Vec<NotesWatchTarget>> {
286	let root = notes_root_for_paths(paths)?;
287	let notes_path = notes_path(&root);
288	let notes_watch_path = notes_path
289		.parent()
290		.map(Path::to_path_buf)
291		.filter(|path| path.exists())
292		.unwrap_or_else(|| notes_dir(&root));
293	let path = if notes_watch_path.exists() {
294		notes_watch_path
295	} else {
296		notes_watch_path
297			.parent()
298			.map(Path::to_path_buf)
299			.unwrap_or(notes_watch_path)
300	};
301	Ok(vec![NotesWatchTarget { path, notes_path }])
302}
303
304pub fn notes_root_for_paths(paths: &[PathBuf]) -> anyhow::Result<PathBuf> {
305	let roots = paths
306		.iter()
307		.map(|path| root_for_path(path.as_path()))
308		.collect::<Vec<_>>();
309	let Some(first) = roots.first() else {
310		anyhow::bail!("notes require at least one workspace root");
311	};
312	let mut common = first.clone();
313	for root in roots.iter().skip(1) {
314		while !root.starts_with(&common) {
315			if !common.pop() {
316				anyhow::bail!("cannot find common root for notes");
317			}
318		}
319	}
320	Ok(common)
321}
322
323fn root_for_path(path: &Path) -> PathBuf {
324	if path.is_dir() {
325		path.to_path_buf()
326	} else {
327		path.parent()
328			.unwrap_or_else(|| Path::new("."))
329			.to_path_buf()
330	}
331}
332
333#[cfg(test)]
334mod tests {
335	use super::*;
336	use crate::notes::{NoteAuthor, NoteId, NoteKind, NoteStatus};
337
338	#[test]
339	fn missing_notes_file_loads_empty_document() {
340		let temp = tempfile::tempdir().expect("tempdir");
341
342		let store = NotesStore::load(temp.path()).expect("load notes");
343
344		assert!(store.notes().is_empty());
345		assert_eq!(store.path(), &notes_path(temp.path()));
346	}
347
348	#[test]
349	fn save_creates_notes_file_and_round_trips() {
350		let temp = tempfile::tempdir().expect("tempdir");
351		let mut store = NotesStore::load(temp.path()).expect("load notes");
352		store.insert(sample_note("note_1")).expect("insert note");
353
354		store.save().expect("save notes");
355		let loaded = NotesStore::load(temp.path()).expect("reload notes");
356
357		assert_eq!(loaded.notes(), store.notes());
358		assert!(notes_dir(temp.path()).is_dir());
359	}
360
361	#[test]
362	fn duplicate_note_ids_are_rejected() {
363		let temp = tempfile::tempdir().expect("tempdir");
364		let mut store = NotesStore::load(temp.path()).expect("load notes");
365		store.insert(sample_note("note_1")).expect("insert note");
366
367		let error = store.insert(sample_note("note_1")).unwrap_err();
368
369		assert!(format!("{error:#}").contains("already exists"));
370	}
371
372	#[test]
373	fn stale_save_is_rejected() {
374		let temp = tempfile::tempdir().expect("tempdir");
375		let mut first = NotesStore::load(temp.path()).expect("load first");
376		first.insert(sample_note("note_1")).expect("insert first");
377		first.save().expect("save first");
378		let mut stale = NotesStore::load(temp.path()).expect("load stale");
379		let mut fresh = NotesStore::load(temp.path()).expect("load fresh");
380		fresh.insert(sample_note("note_2")).expect("insert fresh");
381		fresh.save().expect("save fresh");
382		stale.insert(sample_note("note_3")).expect("insert stale");
383
384		let error = stale.save().unwrap_err();
385
386		assert!(format!("{error:#}").contains("changed since load"));
387	}
388
389	#[test]
390	fn invalid_note_kind_is_rejected() {
391		let temp = tempfile::tempdir().expect("tempdir");
392		std::fs::create_dir_all(notes_dir(temp.path())).expect("mkdir");
393		std::fs::write(
394			notes_path(temp.path()),
395			r#"
396			[[notes]]
397			id = "note_1"
398			moniker = "code+moniker://./lang:rs/module:example"
399			kind = "bug"
400			status = "pending"
401			title = "Bad kind"
402			body = "Body"
403			created_by = "user"
404			created_at = "2026-06-02T00:00:00Z"
405			updated_at = "2026-06-02T00:00:00Z"
406			"#,
407		)
408		.expect("write notes");
409
410		let error = NotesStore::load(temp.path()).unwrap_err();
411
412		assert!(format!("{error:#}").contains("unknown variant"));
413	}
414
415	fn sample_note(id: &str) -> Note {
416		Note {
417			id: NoteId::new(id),
418			moniker: "code+moniker://./lang:rs/module:example".to_string(),
419			kind: NoteKind::Todo,
420			status: NoteStatus::Pending,
421			title: "Title".to_string(),
422			body: "Body".to_string(),
423			created_by: NoteAuthor::User,
424			created_at: "2026-06-02T00:00:00Z".to_string(),
425			updated_at: "2026-06-02T00:00:00Z".to_string(),
426		}
427	}
428}