code_moniker_workspace/notes/
store.rs1use 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(), ¬es_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}