Skip to main content

oximemo_core/store/
files.rs

1//! Source-of-truth file store: TOML frontmatter `.md` files (§5.2).
2//!
3//! Parsing follows the strict rules in §5.2:
4//! 1. The first line must be exactly `+++` for frontmatter to exist.
5//! 2. Frontmatter runs up to the *second* `+++` line.
6//! 3. Everything after the second `+++` is the body.
7//! 4. A file whose first line is not `+++` is treated as body-only.
8//! 5. A TOML parse failure is a recoverable [`CoreError::Frontmatter`].
9//!
10//! Writes are atomic: payload goes to `<path>.tmp` and is renamed into place.
11
12use std::path::{Path, PathBuf};
13
14use serde::{Deserialize, Serialize};
15use time::OffsetDateTime;
16
17use crate::error::{CoreError, Result};
18use crate::hash;
19use crate::memo::{Memo, MemoId};
20use crate::paths::Paths;
21
22/// TOML frontmatter payload. Field order matches the on-disk example in §5.2.
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct Frontmatter {
25    pub id: MemoId,
26    #[serde(with = "time::serde::rfc3339")]
27    pub created_at: OffsetDateTime,
28    #[serde(with = "time::serde::rfc3339")]
29    pub updated_at: OffsetDateTime,
30    pub hash: crate::memo::MemoHash,
31    #[serde(default)]
32    pub favorite: bool,
33    #[serde(default = "crate::memo::default_category")]
34    pub category: String,
35    #[serde(default)]
36    pub tags: Vec<String>,
37    #[serde(default, with = "time::serde::rfc3339::option")]
38    pub deleted_at: Option<OffsetDateTime>,
39}
40
41impl Frontmatter {
42    pub fn from_memo(n: &Memo) -> Self {
43        Self {
44            id: n.id,
45            created_at: n.created_at,
46            updated_at: n.updated_at,
47            hash: n.hash.clone(),
48            favorite: n.favorite,
49            category: n.category.clone(),
50            tags: n.tags.clone(),
51            deleted_at: n.deleted_at,
52        }
53    }
54}
55
56/// Result of parsing a single file.
57#[derive(Debug)]
58pub enum ParsedFile {
59    /// A well-formed memo: valid frontmatter + body.
60    Memo { fm: Frontmatter, body: String },
61    /// A file with no frontmatter (first line was not `+++`). External/legacy.
62    BodyOnly { body: String },
63}
64
65impl ParsedFile {
66    pub fn body(&self) -> &str {
67        match self {
68            ParsedFile::Memo { body, .. } => body,
69            ParsedFile::BodyOnly { body } => body,
70        }
71    }
72}
73
74/// Filesystem operations on the vault.
75pub struct FileStore {
76    paths: Paths,
77}
78
79impl FileStore {
80    pub fn new(paths: Paths) -> Self {
81        Self { paths }
82    }
83
84    pub fn paths(&self) -> &Paths {
85        &self.paths
86    }
87
88    /// Serialize a memo to its on-disk representation (frontmatter + body).
89    pub fn serialize(memo: &Memo) -> Result<String> {
90        let fm = Frontmatter::from_memo(memo);
91        let toml = toml::to_string(&fm)?;
92        let mut out = String::with_capacity(toml.len() + memo.body.len() + 16);
93        out.push_str("+++\n");
94        out.push_str(&toml);
95        out.push_str("+++\n\n");
96        out.push_str(&memo.body);
97        Ok(out)
98    }
99
100    /// Parse raw file text. Never panics on malformed input.
101    pub fn parse(content: &str) -> Result<ParsedFile> {
102        match split_frontmatter(content) {
103            FrontmatterSplit::None { body } => Ok(ParsedFile::BodyOnly {
104                body: body.to_string(),
105            }),
106            FrontmatterSplit::Unclosed => Err(CoreError::Frontmatter {
107                path: PathBuf::new(),
108                reason: "missing closing `+++` delimiter".into(),
109            }),
110            FrontmatterSplit::Some { toml_text, body } => {
111                let fm: Frontmatter =
112                    toml::from_str(toml_text).map_err(|e| CoreError::Frontmatter {
113                        path: PathBuf::new(),
114                        reason: e.to_string(),
115                    })?;
116                Ok(ParsedFile::Memo {
117                    fm,
118                    body: body.to_string(),
119                })
120            }
121        }
122    }
123
124    /// Read and parse a file at an explicit path, attaching the path to any
125    /// frontmatter error for diagnostics.
126    pub fn read(&self, path: &Path) -> Result<ParsedFile> {
127        let content = std::fs::read_to_string(path)?;
128        Self::parse(&content).map_err(|e| match e {
129            CoreError::Frontmatter { reason, .. } => CoreError::Frontmatter {
130                path: path.to_path_buf(),
131                reason,
132            },
133            other => other,
134        })
135    }
136
137    /// Parse into a complete [`Memo`], recomputing the content hash from the
138    /// body. Returns `None` for body-only files (no identity to attach).
139    pub fn read_memo(&self, path: &Path) -> Result<Option<Memo>> {
140        let content = std::fs::read_to_string(path)?;
141        match Self::parse(&content)? {
142            ParsedFile::BodyOnly { .. } => Ok(None),
143            ParsedFile::Memo { fm, body } => {
144                let tags = crate::tags::extract_tags(&body);
145                let memo = Memo {
146                    id: fm.id,
147                    created_at: fm.created_at,
148                    updated_at: fm.updated_at,
149                    hash: hash::hash_memo(body.as_bytes(), fm.favorite, &fm.category),
150                    favorite: fm.favorite,
151                    category: fm.category,
152                    tags,
153                    body,
154                    deleted_at: fm.deleted_at,
155                };
156                Ok(Some(memo))
157            }
158        }
159    }
160
161    /// Atomically write a memo to its sharded path (or trash path when
162    /// `deleted_at` is set). Returns the path written.
163    pub fn write(&self, memo: &Memo) -> Result<PathBuf> {
164        let path = if memo.deleted_at.is_some() {
165            self.paths.trash_path(memo.id)
166        } else {
167            self.paths.memo_path(memo.id, memo.created_at)
168        };
169        let text = Self::serialize(memo)?;
170        atomic_write(&path, text.as_bytes())?;
171        Ok(path)
172    }
173
174    /// Move a memo's file from the live tree into the trash. Idempotent if the
175    /// file is already trashed. Returns the trash path.
176    pub fn move_to_trash(&self, memo: &Memo) -> Result<PathBuf> {
177        std::fs::create_dir_all(self.paths.trash_root())?;
178        let live = self.paths.memo_path(memo.id, memo.created_at);
179        let trash = self.paths.trash_path(memo.id);
180        if trash.exists() {
181            return Ok(trash);
182        }
183        if live.exists() {
184            std::fs::rename(&live, &trash)?;
185            // Durability (C2): persist the new trash entry and the removal
186            // from the live shard so a crash can't lose the rename.
187            if let Some(d) = trash.parent() {
188                fsync_dir(d)?;
189            }
190            if let Some(d) = live.parent() {
191                fsync_dir(d)?;
192            }
193        }
194        Ok(trash)
195    }
196
197    /// Restore a memo from the trash back to its live path.
198    pub fn restore_from_trash(&self, memo: &Memo) -> Result<PathBuf> {
199        let live = self.paths.memo_path(memo.id, memo.created_at);
200        let trash = self.paths.trash_path(memo.id);
201        if live.exists() {
202            return Ok(live);
203        }
204        if trash.exists() {
205            if let Some(parent) = live.parent() {
206                std::fs::create_dir_all(parent)?;
207            }
208            std::fs::rename(&trash, &live)?;
209            // Durability (C2): persist the restored entry and the removal
210            // from trash.
211            if let Some(d) = live.parent() {
212                fsync_dir(d)?;
213            }
214            if let Some(d) = trash.parent() {
215                fsync_dir(d)?;
216            }
217        }
218        Ok(live)
219    }
220
221    /// Hard-delete a trashed memo file. Returns true if a file was removed.
222    pub fn purge(&self, id: MemoId) -> Result<bool> {
223        let trash = self.paths.trash_path(id);
224        if trash.exists() {
225            std::fs::remove_file(&trash)?;
226            Ok(true)
227        } else {
228            Ok(false)
229        }
230    }
231
232    /// Walk the live memos tree, yielding every `.md` file.
233    pub fn list_memo_files(&self) -> Vec<PathBuf> {
234        walk_md(&self.paths.memos_root())
235    }
236
237    /// Walk the trash directory.
238    pub fn list_trash_files(&self) -> Vec<PathBuf> {
239        walk_md(&self.paths.trash_root())
240    }
241}
242
243/// Delimiter-aware split of file content into frontmatter + body.
244enum FrontmatterSplit<'a> {
245    /// First line was not `+++`: the whole content is body.
246    None { body: &'a str },
247    /// First line was `+++` but no second `+++` line exists.
248    Unclosed,
249    /// Both delimiters found.
250    Some { toml_text: &'a str, body: &'a str },
251}
252
253fn split_frontmatter(content: &str) -> FrontmatterSplit<'_> {
254    let first_nl = content.find('\n');
255    let first_line_end = first_nl.unwrap_or(content.len());
256    let first_line = content[..first_line_end].trim_end_matches('\r');
257    if first_line != "+++" {
258        return FrontmatterSplit::None { body: content };
259    }
260    let after_first = first_nl.map(|i| i + 1).unwrap_or(content.len());
261
262    let mut pos = after_first;
263    while pos < content.len() {
264        let rel = content[pos..].find('\n');
265        let line_end = rel.map(|r| pos + r).unwrap_or(content.len());
266        let line = content[pos..line_end].trim_end_matches('\r');
267        if line == "+++" {
268            let toml_text = &content[after_first..pos];
269            // Body begins after this line's newline; drop exactly one leading
270            // newline (the conventional blank separator) for a canonical body.
271            let body_start = if rel.is_some() {
272                line_end + 1
273            } else {
274                content.len()
275            };
276            let mut body = &content[body_start..];
277            if body.starts_with('\n') {
278                body = &body[1..];
279            }
280            return FrontmatterSplit::Some { toml_text, body };
281        }
282        pos = if rel.is_some() {
283            line_end + 1
284        } else {
285            content.len()
286        };
287    }
288    FrontmatterSplit::Unclosed
289}
290
291/// Atomic write: temp file in the same directory, fsync, rename over target.
292///
293/// Durability (C2): the file is fsync'd, then the parent *directory* is
294/// fsync'd so the rename survives power loss — otherwise a crash can leave the
295/// new content written but the directory entry pointing at the old (or no)
296/// name. Collision safety (C3): the temp name embeds pid + a per-process
297/// counter so two processes writing the same memo concurrently cannot stomp
298/// each other's temp file.
299fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> {
300    let parent = path.parent().unwrap_or_else(|| Path::new("."));
301    std::fs::create_dir_all(parent)?;
302    let tmp = unique_temp(path);
303    {
304        let mut file = std::fs::File::create(&tmp)?;
305        use std::io::Write;
306        file.write_all(bytes)?;
307        file.sync_all()?;
308    }
309    std::fs::rename(&tmp, path)?;
310    fsync_dir(parent)?;
311    Ok(())
312}
313
314/// Build a unique sibling temp path for `target`: `<target>.tmp.<pid>.<n>`.
315/// The extension is not `md`, so a stale temp file is never picked up by the
316/// memo walker (`walk_md`).
317fn unique_temp(target: &Path) -> PathBuf {
318    static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
319    let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
320    let suffix = format!("tmp.{}.{}", std::process::id(), n);
321    let mut name = target
322        .file_name()
323        .map(|s| s.to_os_string())
324        .unwrap_or_default();
325    name.push(".");
326    name.push(suffix);
327    target.with_file_name(name)
328}
329
330/// fsync a directory so a recent rename/create is durable across power loss.
331fn fsync_dir(dir: &Path) -> Result<()> {
332    let f = std::fs::File::open(dir)?;
333    f.sync_all()?;
334    Ok(())
335}
336
337fn walk_md(root: &Path) -> Vec<PathBuf> {
338    let mut out = Vec::new();
339    walk_md_into(root, &mut out);
340    out
341}
342
343fn walk_md_into(dir: &Path, out: &mut Vec<PathBuf>) {
344    let Ok(entries) = std::fs::read_dir(dir) else {
345        return;
346    };
347    for entry in entries.flatten() {
348        let path = entry.path();
349        let ft = match entry.file_type() {
350            Ok(ft) => ft,
351            Err(_) => continue,
352        };
353        if ft.is_dir() {
354            walk_md_into(&path, out);
355        } else if ft.is_file() && path.extension().is_some_and(|e| e == "md") {
356            out.push(path);
357        }
358    }
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364    use crate::hash;
365    use crate::memo::MemoId;
366
367    fn sample_memo(body: &str) -> Memo {
368        let id = MemoId::now();
369        let now = OffsetDateTime::now_utc();
370        Memo {
371            id,
372            created_at: now,
373            updated_at: now,
374            hash: hash::hash_memo(body.as_bytes(), false, "todo"),
375            favorite: false,
376            category: "todo".to_string(),
377            tags: vec!["idea".into()],
378            body: body.into(),
379            deleted_at: None,
380        }
381    }
382
383    #[test]
384    fn roundtrip_memo() {
385        let memo = sample_memo("hello world\nsecond line");
386        let text = FileStore::serialize(&memo).unwrap();
387        assert!(text.starts_with("+++\n"));
388        let parsed = FileStore::parse(&text).unwrap();
389        match parsed {
390            ParsedFile::Memo { fm, body } => {
391                assert_eq!(fm.id, memo.id);
392                assert_eq!(body, memo.body);
393            }
394            _ => panic!("expected memo"),
395        }
396    }
397
398    #[test]
399    fn body_only_file() {
400        let text = "just some text\nno frontmatter";
401        let parsed = FileStore::parse(text).unwrap();
402        assert!(matches!(parsed, ParsedFile::BodyOnly { .. }));
403    }
404
405    #[test]
406    fn unclosed_frontmatter_is_error() {
407        let text = "+++\nid = \"x\"\nbody without closer";
408        let err = FileStore::parse(text).unwrap_err();
409        assert!(matches!(err, CoreError::Frontmatter { .. }));
410    }
411
412    #[test]
413    fn body_with_plus_plus_plus_line() {
414        let memo = sample_memo("text\n+++\nmore text");
415        let text = FileStore::serialize(&memo).unwrap();
416        let parsed = FileStore::parse(&text).unwrap();
417        match parsed {
418            ParsedFile::Memo { body, .. } => assert_eq!(body, memo.body),
419            _ => panic!("expected memo"),
420        }
421    }
422}