1use 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#[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#[derive(Debug)]
58pub enum ParsedFile {
59 Memo { fm: Frontmatter, body: String },
61 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
74pub 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 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 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 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 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 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 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 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 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 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 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 pub fn list_memo_files(&self) -> Vec<PathBuf> {
234 walk_md(&self.paths.memos_root())
235 }
236
237 pub fn list_trash_files(&self) -> Vec<PathBuf> {
239 walk_md(&self.paths.trash_root())
240 }
241}
242
243enum FrontmatterSplit<'a> {
245 None { body: &'a str },
247 Unclosed,
249 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 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
291fn 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
314fn 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
330fn 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}