use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::error::{Result, VaultdbError};
use crate::writer;
pub(crate) const JOURNAL_SUBDIR: &str = "rename-journal";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RenameJournal {
pub source: PathBuf,
pub dest: PathBuf,
pub from_name: String,
pub to_name: String,
pub backlinks: Vec<PathBuf>,
}
fn journal_dir(vault_root: &Path) -> PathBuf {
vault_root.join(crate::lock::META_DIR).join(JOURNAL_SUBDIR)
}
pub(crate) fn write(vault_root: &Path, journal: &RenameJournal) -> Result<PathBuf> {
let dir = journal_dir(vault_root);
std::fs::create_dir_all(&dir).map_err(VaultdbError::Io)?;
let stamp = std::time::SystemTime::now()
.duration_since(std::time::SystemTime::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let path = dir.join(format!("{:032}.json", stamp));
let serialized = serde_json::to_string_pretty(journal)
.map_err(|e| VaultdbError::Internal(format!("serialize rename journal: {}", e)))?;
writer::atomic_write(&path, &serialized)?;
Ok(path)
}
pub(crate) fn delete(journal_path: &Path) {
let _ = std::fs::remove_file(journal_path);
}
pub fn list_pending(vault_root: &Path) -> Result<Vec<PathBuf>> {
let dir = journal_dir(vault_root);
if !dir.is_dir() {
return Ok(Vec::new());
}
let mut paths: Vec<PathBuf> = Vec::new();
for entry in std::fs::read_dir(&dir).map_err(VaultdbError::Io)? {
let entry = entry.map_err(VaultdbError::Io)?;
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) == Some("json") {
paths.push(path);
}
}
paths.sort();
Ok(paths)
}
pub fn replay(journal_path: &Path) -> Result<()> {
let raw = std::fs::read_to_string(journal_path).map_err(VaultdbError::Io)?;
let journal: RenameJournal = serde_json::from_str(&raw).map_err(|e| {
VaultdbError::Internal(format!(
"parse rename journal {}: {}",
journal_path.display(),
e
))
})?;
let source_exists = journal.source.is_file();
let dest_exists = journal.dest.is_file();
match (source_exists, dest_exists) {
(true, false) => {
std::fs::rename(&journal.source, &journal.dest).map_err(VaultdbError::Io)?;
}
(false, true) => {
}
(false, false) => {
delete(journal_path);
return Ok(());
}
(true, true) => {
}
}
let mut last_err: Option<VaultdbError> = None;
for backlink in &journal.backlinks {
if !backlink.is_file() {
continue; }
let content = match std::fs::read_to_string(backlink) {
Ok(c) => c,
Err(e) => {
last_err = Some(VaultdbError::Io(e));
continue;
}
};
let new_content =
crate::mutation::rewrite_wikilinks(&content, &journal.from_name, &journal.to_name);
if new_content == content {
continue;
}
if let Err(e) = writer::atomic_write(backlink, &new_content) {
last_err = Some(VaultdbError::Io(e));
}
}
if let Some(err) = last_err {
return Err(err);
}
delete(journal_path);
Ok(())
}
pub fn replay_all(vault_root: &Path) -> Result<usize> {
let pending = list_pending(vault_root)?;
let mut count = 0;
for path in pending {
replay(&path)?;
count += 1;
}
Ok(count)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn make_vault() -> TempDir {
let dir = TempDir::new().unwrap();
fs::create_dir(dir.path().join(".obsidian")).unwrap();
fs::create_dir(dir.path().join("notes")).unwrap();
dir
}
fn write_md(path: &Path, content: &str) {
fs::write(path, content).unwrap();
}
#[test]
fn write_and_list_journal() {
let dir = make_vault();
let journal = RenameJournal {
source: dir.path().join("notes/old.md"),
dest: dir.path().join("notes/new.md"),
from_name: "old".into(),
to_name: "new".into(),
backlinks: vec![dir.path().join("notes/other.md")],
};
let path = write(dir.path(), &journal).unwrap();
assert!(path.is_file());
let pending = list_pending(dir.path()).unwrap();
assert_eq!(pending.len(), 1);
assert_eq!(pending[0], path);
}
#[test]
fn replay_state_a_renames_then_rewrites() {
let dir = make_vault();
let source = dir.path().join("notes/Stanford.md");
let dest = dir.path().join("notes/Stanford University.md");
let other = dir.path().join("notes/Application.md");
write_md(&source, "---\nstatus: active\n---\nMain note.\n");
write_md(
&other,
"---\nrelated:\n - \"[[Stanford]]\"\n---\nApplied to [[Stanford]] last week.\n",
);
let journal = RenameJournal {
source: source.clone(),
dest: dest.clone(),
from_name: "Stanford".into(),
to_name: "Stanford University".into(),
backlinks: vec![other.clone()],
};
let journal_path = write(dir.path(), &journal).unwrap();
replay(&journal_path).unwrap();
assert!(!source.exists());
assert!(dest.is_file());
let other_content = fs::read_to_string(&other).unwrap();
assert!(other_content.contains("[[Stanford University]]"));
assert!(!other_content.contains("[[Stanford]]"));
assert!(!journal_path.exists());
}
#[test]
fn replay_state_b_finishes_partial_backlink_rewrites() {
let dir = make_vault();
let source = dir.path().join("notes/Stanford.md");
let dest = dir.path().join("notes/Stanford University.md");
let already = dir.path().join("notes/AlreadyRewritten.md");
let pending = dir.path().join("notes/StillOldName.md");
write_md(&dest, "---\n---\nMain note.\n");
write_md(&already, "Sees [[Stanford University]] only.\n");
write_md(&pending, "Sees [[Stanford]] and needs rewriting.\n");
let journal = RenameJournal {
source,
dest: dest.clone(),
from_name: "Stanford".into(),
to_name: "Stanford University".into(),
backlinks: vec![already.clone(), pending.clone()],
};
let journal_path = write(dir.path(), &journal).unwrap();
replay(&journal_path).unwrap();
let a = fs::read_to_string(&already).unwrap();
assert_eq!(a, "Sees [[Stanford University]] only.\n");
let p = fs::read_to_string(&pending).unwrap();
assert!(p.contains("[[Stanford University]]"));
assert!(!p.contains("[[Stanford]] "));
assert!(!journal_path.exists());
}
#[test]
fn replay_state_c_stale_journal_is_cleaned() {
let dir = make_vault();
let journal = RenameJournal {
source: dir.path().join("notes/Gone.md"),
dest: dir.path().join("notes/AlsoGone.md"),
from_name: "Gone".into(),
to_name: "AlsoGone".into(),
backlinks: vec![],
};
let journal_path = write(dir.path(), &journal).unwrap();
replay(&journal_path).unwrap();
assert!(!journal_path.exists());
}
#[test]
fn replay_is_idempotent_when_called_twice() {
let dir = make_vault();
let source = dir.path().join("notes/X.md");
let dest = dir.path().join("notes/Y.md");
write_md(&source, "Body.\n");
let journal = RenameJournal {
source: source.clone(),
dest: dest.clone(),
from_name: "X".into(),
to_name: "Y".into(),
backlinks: vec![],
};
write(dir.path(), &journal).unwrap();
let n1 = replay_all(dir.path()).unwrap();
let n2 = replay_all(dir.path()).unwrap();
assert_eq!(n1, 1);
assert_eq!(n2, 0);
assert!(dest.is_file());
}
#[test]
fn replay_all_processes_multiple_journals_in_order() {
let dir = make_vault();
let a = dir.path().join("notes/A.md");
let b = dir.path().join("notes/B.md");
let c = dir.path().join("notes/C.md");
write_md(&a, "Body.\n");
write(
dir.path(),
&RenameJournal {
source: a.clone(),
dest: b.clone(),
from_name: "A".into(),
to_name: "B".into(),
backlinks: vec![],
},
)
.unwrap();
std::thread::sleep(std::time::Duration::from_millis(2));
write(
dir.path(),
&RenameJournal {
source: b.clone(),
dest: c.clone(),
from_name: "B".into(),
to_name: "C".into(),
backlinks: vec![],
},
)
.unwrap();
let n = replay_all(dir.path()).unwrap();
assert_eq!(n, 2);
assert!(!a.exists());
assert!(!b.exists());
assert!(c.is_file(), "expected final state to be C");
}
}