1use std::path::{Path, PathBuf};
40
41use serde::{Deserialize, Serialize};
42
43use crate::error::{Result, VaultdbError};
44use crate::writer;
45
46pub(crate) const JOURNAL_SUBDIR: &str = "rename-journal";
48
49#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
51pub struct RenameJournal {
52 pub source: PathBuf,
54 pub dest: PathBuf,
56 pub from_name: String,
59 pub to_name: String,
61 pub backlinks: Vec<PathBuf>,
65}
66
67fn journal_dir(vault_root: &Path) -> PathBuf {
68 vault_root.join(crate::lock::META_DIR).join(JOURNAL_SUBDIR)
69}
70
71pub(crate) fn write(vault_root: &Path, journal: &RenameJournal) -> Result<PathBuf> {
75 let dir = journal_dir(vault_root);
76 std::fs::create_dir_all(&dir).map_err(VaultdbError::Io)?;
77
78 let stamp = std::time::SystemTime::now()
84 .duration_since(std::time::SystemTime::UNIX_EPOCH)
85 .map(|d| d.as_nanos())
86 .unwrap_or(0);
87 let path = dir.join(format!("{:032}.json", stamp));
88 let serialized = serde_json::to_string_pretty(journal)
89 .map_err(|e| VaultdbError::Internal(format!("serialize rename journal: {}", e)))?;
90 writer::atomic_write(&path, &serialized)?;
91 Ok(path)
92}
93
94pub(crate) fn delete(journal_path: &Path) {
99 let _ = std::fs::remove_file(journal_path);
100}
101
102pub fn list_pending(vault_root: &Path) -> Result<Vec<PathBuf>> {
105 let dir = journal_dir(vault_root);
106 if !dir.is_dir() {
107 return Ok(Vec::new());
108 }
109 let mut paths: Vec<PathBuf> = Vec::new();
110 for entry in std::fs::read_dir(&dir).map_err(VaultdbError::Io)? {
111 let entry = entry.map_err(VaultdbError::Io)?;
112 let path = entry.path();
113 if path.extension().and_then(|s| s.to_str()) == Some("json") {
114 paths.push(path);
115 }
116 }
117 paths.sort();
118 Ok(paths)
119}
120
121pub fn replay(journal_path: &Path) -> Result<()> {
125 let raw = std::fs::read_to_string(journal_path).map_err(VaultdbError::Io)?;
126 let journal: RenameJournal = serde_json::from_str(&raw).map_err(|e| {
127 VaultdbError::Internal(format!(
128 "parse rename journal {}: {}",
129 journal_path.display(),
130 e
131 ))
132 })?;
133
134 let source_exists = journal.source.is_file();
135 let dest_exists = journal.dest.is_file();
136
137 match (source_exists, dest_exists) {
138 (true, false) => {
139 std::fs::rename(&journal.source, &journal.dest).map_err(VaultdbError::Io)?;
141 }
142 (false, true) => {
143 }
146 (false, false) => {
147 delete(journal_path);
150 return Ok(());
151 }
152 (true, true) => {
153 }
162 }
163
164 let mut last_err: Option<VaultdbError> = None;
165 for backlink in &journal.backlinks {
166 if !backlink.is_file() {
167 continue; }
169 let content = match std::fs::read_to_string(backlink) {
170 Ok(c) => c,
171 Err(e) => {
172 last_err = Some(VaultdbError::Io(e));
173 continue;
174 }
175 };
176 let new_content =
177 crate::mutation::rewrite_wikilinks(&content, &journal.from_name, &journal.to_name);
178 if new_content == content {
179 continue;
181 }
182 if let Err(e) = writer::atomic_write(backlink, &new_content) {
183 last_err = Some(VaultdbError::Io(e));
184 }
185 }
186
187 if let Some(err) = last_err {
190 return Err(err);
191 }
192 delete(journal_path);
193 Ok(())
194}
195
196pub fn replay_all(vault_root: &Path) -> Result<usize> {
200 let pending = list_pending(vault_root)?;
201 let mut count = 0;
202 for path in pending {
203 replay(&path)?;
204 count += 1;
205 }
206 Ok(count)
207}
208
209#[cfg(test)]
210mod tests {
211 use super::*;
212 use std::fs;
213 use tempfile::TempDir;
214
215 fn make_vault() -> TempDir {
216 let dir = TempDir::new().unwrap();
217 fs::create_dir(dir.path().join(".obsidian")).unwrap();
218 fs::create_dir(dir.path().join("notes")).unwrap();
219 dir
220 }
221
222 fn write_md(path: &Path, content: &str) {
223 fs::write(path, content).unwrap();
224 }
225
226 #[test]
227 fn write_and_list_journal() {
228 let dir = make_vault();
229 let journal = RenameJournal {
230 source: dir.path().join("notes/old.md"),
231 dest: dir.path().join("notes/new.md"),
232 from_name: "old".into(),
233 to_name: "new".into(),
234 backlinks: vec![dir.path().join("notes/other.md")],
235 };
236 let path = write(dir.path(), &journal).unwrap();
237 assert!(path.is_file());
238
239 let pending = list_pending(dir.path()).unwrap();
240 assert_eq!(pending.len(), 1);
241 assert_eq!(pending[0], path);
242 }
243
244 #[test]
245 fn replay_state_a_renames_then_rewrites() {
246 let dir = make_vault();
249 let source = dir.path().join("notes/Stanford.md");
250 let dest = dir.path().join("notes/Stanford University.md");
251 let other = dir.path().join("notes/Application.md");
252
253 write_md(&source, "---\nstatus: active\n---\nMain note.\n");
254 write_md(
255 &other,
256 "---\nrelated:\n - \"[[Stanford]]\"\n---\nApplied to [[Stanford]] last week.\n",
257 );
258
259 let journal = RenameJournal {
260 source: source.clone(),
261 dest: dest.clone(),
262 from_name: "Stanford".into(),
263 to_name: "Stanford University".into(),
264 backlinks: vec![other.clone()],
265 };
266 let journal_path = write(dir.path(), &journal).unwrap();
267
268 replay(&journal_path).unwrap();
269
270 assert!(!source.exists());
272 assert!(dest.is_file());
273
274 let other_content = fs::read_to_string(&other).unwrap();
276 assert!(other_content.contains("[[Stanford University]]"));
277 assert!(!other_content.contains("[[Stanford]]"));
278
279 assert!(!journal_path.exists());
281 }
282
283 #[test]
284 fn replay_state_b_finishes_partial_backlink_rewrites() {
285 let dir = make_vault();
288 let source = dir.path().join("notes/Stanford.md");
289 let dest = dir.path().join("notes/Stanford University.md");
290 let already = dir.path().join("notes/AlreadyRewritten.md");
291 let pending = dir.path().join("notes/StillOldName.md");
292
293 write_md(&dest, "---\n---\nMain note.\n");
295 write_md(&already, "Sees [[Stanford University]] only.\n");
296 write_md(&pending, "Sees [[Stanford]] and needs rewriting.\n");
297
298 let journal = RenameJournal {
299 source,
300 dest: dest.clone(),
301 from_name: "Stanford".into(),
302 to_name: "Stanford University".into(),
303 backlinks: vec![already.clone(), pending.clone()],
304 };
305 let journal_path = write(dir.path(), &journal).unwrap();
306
307 replay(&journal_path).unwrap();
308
309 let a = fs::read_to_string(&already).unwrap();
311 assert_eq!(a, "Sees [[Stanford University]] only.\n");
312
313 let p = fs::read_to_string(&pending).unwrap();
315 assert!(p.contains("[[Stanford University]]"));
316 assert!(!p.contains("[[Stanford]] "));
317
318 assert!(!journal_path.exists());
320 }
321
322 #[test]
323 fn replay_state_c_stale_journal_is_cleaned() {
324 let dir = make_vault();
327 let journal = RenameJournal {
328 source: dir.path().join("notes/Gone.md"),
329 dest: dir.path().join("notes/AlsoGone.md"),
330 from_name: "Gone".into(),
331 to_name: "AlsoGone".into(),
332 backlinks: vec![],
333 };
334 let journal_path = write(dir.path(), &journal).unwrap();
335
336 replay(&journal_path).unwrap();
337
338 assert!(!journal_path.exists());
340 }
341
342 #[test]
343 fn replay_is_idempotent_when_called_twice() {
344 let dir = make_vault();
348 let source = dir.path().join("notes/X.md");
349 let dest = dir.path().join("notes/Y.md");
350 write_md(&source, "Body.\n");
351
352 let journal = RenameJournal {
353 source: source.clone(),
354 dest: dest.clone(),
355 from_name: "X".into(),
356 to_name: "Y".into(),
357 backlinks: vec![],
358 };
359 write(dir.path(), &journal).unwrap();
360
361 let n1 = replay_all(dir.path()).unwrap();
362 let n2 = replay_all(dir.path()).unwrap();
363 assert_eq!(n1, 1);
364 assert_eq!(n2, 0);
365 assert!(dest.is_file());
366 }
367
368 #[test]
369 fn replay_all_processes_multiple_journals_in_order() {
370 let dir = make_vault();
371 let a = dir.path().join("notes/A.md");
372 let b = dir.path().join("notes/B.md");
373 let c = dir.path().join("notes/C.md");
374 write_md(&a, "Body.\n");
375
376 write(
378 dir.path(),
379 &RenameJournal {
380 source: a.clone(),
381 dest: b.clone(),
382 from_name: "A".into(),
383 to_name: "B".into(),
384 backlinks: vec![],
385 },
386 )
387 .unwrap();
388 std::thread::sleep(std::time::Duration::from_millis(2));
390 write(
392 dir.path(),
393 &RenameJournal {
394 source: b.clone(),
395 dest: c.clone(),
396 from_name: "B".into(),
397 to_name: "C".into(),
398 backlinks: vec![],
399 },
400 )
401 .unwrap();
402
403 let n = replay_all(dir.path()).unwrap();
404 assert_eq!(n, 2);
405 assert!(!a.exists());
406 assert!(!b.exists());
407 assert!(c.is_file(), "expected final state to be C");
408 }
409}