fix: make note renames crash-safe
This commit is contained in:
@@ -9,6 +9,7 @@ mod image;
|
||||
mod migration;
|
||||
mod parsing;
|
||||
mod rename;
|
||||
mod rename_transaction;
|
||||
mod title_sync;
|
||||
mod trash;
|
||||
mod views;
|
||||
@@ -415,6 +416,14 @@ pub fn scan_vault(
|
||||
));
|
||||
}
|
||||
|
||||
if let Err(err) = rename::recover_pending_rename_transactions(vault_path) {
|
||||
log::warn!(
|
||||
"Failed to recover pending rename transactions in {}: {}",
|
||||
vault_path.display(),
|
||||
err
|
||||
);
|
||||
}
|
||||
|
||||
let mut entries = Vec::new();
|
||||
scan_all_files(vault_path, git_dates, &mut entries);
|
||||
|
||||
|
||||
@@ -3,8 +3,10 @@ use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use tempfile::NamedTempFile;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
use super::rename_transaction::RenameWorkspace;
|
||||
use crate::frontmatter::{update_frontmatter_content, FrontmatterValue};
|
||||
|
||||
/// Result of a rename operation
|
||||
@@ -14,6 +16,14 @@ pub struct RenameResult {
|
||||
pub new_path: String,
|
||||
/// Number of other files updated (wiki link replacements)
|
||||
pub updated_files: usize,
|
||||
/// Number of linked-note rewrites that failed and need manual attention
|
||||
pub failed_updates: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct WikilinkUpdateSummary {
|
||||
updated_files: usize,
|
||||
failed_updates: usize,
|
||||
}
|
||||
|
||||
/// Convert a title to a filename slug (lowercase, hyphens, no special chars).
|
||||
@@ -95,10 +105,10 @@ fn update_wikilinks_in_vault(
|
||||
old_targets: &[&str],
|
||||
new_target: &str,
|
||||
exclude_path: &Path,
|
||||
) -> usize {
|
||||
) -> WikilinkUpdateSummary {
|
||||
let re = match build_wikilink_pattern(old_targets) {
|
||||
Some(r) => r,
|
||||
None => return 0,
|
||||
None => return WikilinkUpdateSummary::default(),
|
||||
};
|
||||
replace_wikilinks_in_files(collect_md_files(vault_path, exclude_path), &re, new_target)
|
||||
}
|
||||
@@ -107,23 +117,29 @@ fn replace_wikilinks_in_files(
|
||||
files: Vec<std::path::PathBuf>,
|
||||
re: &Regex,
|
||||
replacement: &str,
|
||||
) -> usize {
|
||||
files
|
||||
.iter()
|
||||
.filter(|path| rewrite_wikilinks_in_file(path, re, replacement))
|
||||
.count()
|
||||
) -> WikilinkUpdateSummary {
|
||||
let mut summary = WikilinkUpdateSummary::default();
|
||||
for path in files.iter() {
|
||||
match rewrite_wikilinks_in_file(path, re, replacement) {
|
||||
Ok(true) => summary.updated_files += 1,
|
||||
Ok(false) => {}
|
||||
Err(_) => summary.failed_updates += 1,
|
||||
}
|
||||
}
|
||||
summary
|
||||
}
|
||||
|
||||
fn rewrite_wikilinks_in_file(path: &Path, re: &Regex, replacement: &str) -> bool {
|
||||
let Ok(content) = fs::read_to_string(path) else {
|
||||
return false;
|
||||
};
|
||||
fn rewrite_wikilinks_in_file(path: &Path, re: &Regex, replacement: &str) -> Result<bool, String> {
|
||||
let content = fs::read_to_string(path)
|
||||
.map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
|
||||
|
||||
let Some(new_content) = replace_wikilinks_in_content(&content, re, replacement) else {
|
||||
return false;
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
fs::write(path, &new_content).is_ok()
|
||||
fs::write(path, &new_content)
|
||||
.map(|_| true)
|
||||
.map_err(|e| format!("Failed to write {}: {}", path.display(), e))
|
||||
}
|
||||
|
||||
/// Extract the value of the `title:` frontmatter field from raw content.
|
||||
@@ -168,28 +184,26 @@ fn to_path_stem<'a>(abs_path: &'a str, vault_prefix: &str) -> &'a str {
|
||||
.unwrap_or(abs_path)
|
||||
}
|
||||
|
||||
/// Determine a unique destination path, appending -2, -3, etc. if a file already exists.
|
||||
/// `exclude` is the source file being renamed — it should not be treated as a collision.
|
||||
fn unique_dest_path(dest_dir: &Path, filename: &str, exclude: &Path) -> std::path::PathBuf {
|
||||
let dest = dest_dir.join(filename);
|
||||
if !dest.exists() || dest == exclude {
|
||||
return dest;
|
||||
}
|
||||
let stem = Path::new(filename)
|
||||
.file_stem()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
let ext = Path::new(filename)
|
||||
.extension()
|
||||
.map(|s| format!(".{}", s.to_string_lossy()))
|
||||
.unwrap_or_default();
|
||||
let mut counter = 2;
|
||||
loop {
|
||||
let candidate = dest_dir.join(format!("{}-{}{}", stem, counter, ext));
|
||||
if !candidate.exists() || candidate == exclude {
|
||||
return candidate;
|
||||
}
|
||||
counter += 1;
|
||||
pub(crate) fn recover_pending_rename_transactions(vault: &Path) -> Result<(), String> {
|
||||
super::rename_transaction::recover_pending_rename_transactions(vault)
|
||||
}
|
||||
|
||||
fn persist_staged_note(staged: NamedTempFile, target_path: &Path) -> Result<(), String> {
|
||||
staged
|
||||
.persist(target_path)
|
||||
.map(|_| ())
|
||||
.map_err(|e| format!("Failed to replace {}: {}", target_path.display(), e.error))
|
||||
}
|
||||
|
||||
fn finalize_rename(vault: &Path, old_targets: &[&str], new_file: &Path) -> RenameResult {
|
||||
let new_path = new_file.to_string_lossy().to_string();
|
||||
let vault_prefix = format!("{}/", vault.to_string_lossy());
|
||||
let new_path_stem = to_path_stem(&new_path, &vault_prefix).to_string();
|
||||
let summary = update_wikilinks_in_vault(vault, old_targets, &new_path_stem, new_file);
|
||||
RenameResult {
|
||||
new_path,
|
||||
updated_files: summary.updated_files,
|
||||
failed_updates: summary.failed_updates,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,6 +223,66 @@ fn is_invalid_filename_stem(stem: &str) -> bool {
|
||||
stem == "." || stem == ".." || stem.contains('/') || stem.contains('\\')
|
||||
}
|
||||
|
||||
struct LoadedNote {
|
||||
content: String,
|
||||
filename: String,
|
||||
title: String,
|
||||
}
|
||||
|
||||
fn unchanged_result(path: &str) -> RenameResult {
|
||||
RenameResult {
|
||||
new_path: path.to_string(),
|
||||
updated_files: 0,
|
||||
failed_updates: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_new_title(new_title: &str) -> Result<&str, String> {
|
||||
let trimmed = new_title.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err("New title cannot be empty".to_string());
|
||||
}
|
||||
Ok(trimmed)
|
||||
}
|
||||
|
||||
fn ensure_existing_note(old_file: &Path, old_path: &str) -> Result<(), String> {
|
||||
if old_file.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
Err(format!("File does not exist: {}", old_path))
|
||||
}
|
||||
|
||||
fn load_note_for_title_rename(
|
||||
old_file: &Path,
|
||||
old_path: &str,
|
||||
old_title_hint: Option<&str>,
|
||||
) -> Result<LoadedNote, String> {
|
||||
let content =
|
||||
fs::read_to_string(old_file).map_err(|e| format!("Failed to read {}: {}", old_path, e))?;
|
||||
let filename = old_file
|
||||
.file_name()
|
||||
.map(|f| f.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
let fm_title = extract_fm_title_value(&content);
|
||||
let extracted_title = super::extract_title(fm_title.as_deref(), &content, &filename);
|
||||
|
||||
Ok(LoadedNote {
|
||||
content,
|
||||
filename,
|
||||
title: old_title_hint.unwrap_or(&extracted_title).to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn persist_title_only_update(
|
||||
workspace: &RenameWorkspace,
|
||||
old_file: &Path,
|
||||
updated_content: &str,
|
||||
old_path: &str,
|
||||
) -> Result<RenameResult, String> {
|
||||
persist_staged_note(workspace.stage_note_content(updated_content)?, old_file)?;
|
||||
Ok(unchanged_result(old_path))
|
||||
}
|
||||
|
||||
/// Rename a note: update its frontmatter title, rename the file, and update wiki links across the vault.
|
||||
///
|
||||
/// When `old_title_hint` is provided it is used instead of extracting the title from
|
||||
@@ -223,69 +297,47 @@ pub fn rename_note(
|
||||
let vault = Path::new(vault_path);
|
||||
let old_file = Path::new(old_path);
|
||||
|
||||
if !old_file.exists() {
|
||||
return Err(format!("File does not exist: {}", old_path));
|
||||
}
|
||||
let new_title = new_title.trim();
|
||||
if new_title.is_empty() {
|
||||
return Err("New title cannot be empty".to_string());
|
||||
}
|
||||
|
||||
let content =
|
||||
fs::read_to_string(old_file).map_err(|e| format!("Failed to read {}: {}", old_path, e))?;
|
||||
let old_filename = old_file
|
||||
.file_name()
|
||||
.map(|f| f.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
let fm_title = extract_fm_title_value(&content);
|
||||
let extracted_title = super::extract_title(fm_title.as_deref(), &content, &old_filename);
|
||||
let old_title = old_title_hint.unwrap_or(&extracted_title);
|
||||
recover_pending_rename_transactions(vault)?;
|
||||
ensure_existing_note(old_file, old_path)?;
|
||||
let new_title = validate_new_title(new_title)?;
|
||||
let loaded = load_note_for_title_rename(old_file, old_path, old_title_hint)?;
|
||||
|
||||
// Check both title and filename: even if the title in content matches,
|
||||
// the filename may still be stale (e.g. "untitled-note.md" after user changed H1).
|
||||
let expected_filename = format!("{}.md", title_to_slug(new_title));
|
||||
let title_unchanged = old_title == new_title;
|
||||
let filename_matches = old_filename == expected_filename;
|
||||
let title_unchanged = loaded.title == new_title;
|
||||
let filename_matches = loaded.filename == expected_filename;
|
||||
|
||||
if title_unchanged && filename_matches {
|
||||
return Ok(RenameResult {
|
||||
new_path: old_path.to_string(),
|
||||
updated_files: 0,
|
||||
});
|
||||
return Ok(unchanged_result(old_path));
|
||||
}
|
||||
|
||||
// Update content only if the title actually changed
|
||||
let updated_content = if title_unchanged {
|
||||
content.clone()
|
||||
loaded.content.clone()
|
||||
} else {
|
||||
update_note_title_in_content(&content, new_title)
|
||||
update_note_title_in_content(&loaded.content, new_title)
|
||||
};
|
||||
let workspace = RenameWorkspace::new(vault)?;
|
||||
|
||||
if filename_matches {
|
||||
return persist_title_only_update(&workspace, old_file, &updated_content, old_path);
|
||||
}
|
||||
|
||||
// Compute new path, handling collisions with numeric suffix
|
||||
let parent_dir = old_file
|
||||
.parent()
|
||||
.ok_or("Cannot determine parent directory")?;
|
||||
let new_file = unique_dest_path(parent_dir, &expected_filename, old_file);
|
||||
let new_path_str = new_file.to_string_lossy().to_string();
|
||||
|
||||
fs::write(&new_file, &updated_content)
|
||||
.map_err(|e| format!("Failed to write {}: {}", new_path_str, e))?;
|
||||
if old_file != new_file {
|
||||
fs::remove_file(old_file)
|
||||
.map_err(|e| format!("Failed to remove old file {}: {}", old_path, e))?;
|
||||
}
|
||||
|
||||
// Update wikilinks across the vault
|
||||
let committed = workspace
|
||||
.operation(old_path, old_file)
|
||||
.rename_with_candidates(
|
||||
workspace.stage_note_content(&updated_content)?,
|
||||
&expected_filename,
|
||||
parent_dir,
|
||||
)?;
|
||||
let vault_prefix = format!("{}/", vault.to_string_lossy());
|
||||
let old_path_stem = to_path_stem(old_path, &vault_prefix);
|
||||
let new_path_stem = to_path_stem(&new_path_str, &vault_prefix).to_string();
|
||||
let old_targets = collect_legacy_wikilink_targets(old_title, old_path_stem);
|
||||
let updated_files = update_wikilinks_in_vault(vault, &old_targets, &new_path_stem, &new_file);
|
||||
|
||||
Ok(RenameResult {
|
||||
new_path: new_path_str,
|
||||
updated_files,
|
||||
})
|
||||
let old_targets = collect_legacy_wikilink_targets(&loaded.title, old_path_stem);
|
||||
Ok(finalize_rename(vault, &old_targets, committed.new_file()))
|
||||
}
|
||||
|
||||
/// Rename only the file path stem while preserving title/frontmatter content.
|
||||
@@ -297,6 +349,8 @@ pub fn rename_note_filename(
|
||||
let vault = Path::new(vault_path);
|
||||
let old_file = Path::new(old_path);
|
||||
|
||||
recover_pending_rename_transactions(vault)?;
|
||||
|
||||
if !old_file.exists() {
|
||||
return Err(format!("File does not exist: {}", old_path));
|
||||
}
|
||||
@@ -313,40 +367,22 @@ pub fn rename_note_filename(
|
||||
let new_filename = format!("{}.md", normalized_stem);
|
||||
|
||||
if old_filename == new_filename {
|
||||
return Ok(RenameResult {
|
||||
new_path: old_path.to_string(),
|
||||
updated_files: 0,
|
||||
});
|
||||
return Ok(unchanged_result(old_path));
|
||||
}
|
||||
|
||||
let parent_dir = old_file
|
||||
.parent()
|
||||
.ok_or("Cannot determine parent directory")?;
|
||||
let new_file = parent_dir.join(&new_filename);
|
||||
if new_file.exists() && new_file != old_file {
|
||||
return Err("A note with that name already exists".to_string());
|
||||
}
|
||||
|
||||
fs::rename(old_file, &new_file).map_err(|e| {
|
||||
format!(
|
||||
"Failed to rename {} to {}: {}",
|
||||
old_path,
|
||||
new_file.to_string_lossy(),
|
||||
e
|
||||
)
|
||||
})?;
|
||||
let workspace = RenameWorkspace::new(vault)?;
|
||||
let committed = workspace
|
||||
.operation(old_path, old_file)
|
||||
.rename_exact(workspace.stage_note_content(&content)?, &new_file)?;
|
||||
|
||||
let vault_prefix = format!("{}/", vault.to_string_lossy());
|
||||
let old_path_stem = to_path_stem(old_path, &vault_prefix);
|
||||
let new_path = new_file.to_string_lossy().to_string();
|
||||
let new_path_stem = to_path_stem(&new_path, &vault_prefix).to_string();
|
||||
let old_targets = collect_legacy_wikilink_targets(&old_title, old_path_stem);
|
||||
let updated_files = update_wikilinks_in_vault(vault, &old_targets, &new_path_stem, &new_file);
|
||||
|
||||
Ok(RenameResult {
|
||||
new_path,
|
||||
updated_files,
|
||||
})
|
||||
Ok(finalize_rename(vault, &old_targets, committed.new_file()))
|
||||
}
|
||||
|
||||
/// Check if a filename matches the untitled pattern (e.g. "untitled-note-1234567890.md").
|
||||
@@ -455,8 +491,8 @@ pub fn update_wikilinks_for_renames(
|
||||
// The new file is the exclude target (don't rewrite wikilinks inside the renamed file itself)
|
||||
let new_file = vault.join(&rename.new_path);
|
||||
let old_targets = collect_legacy_wikilink_targets(&old_title, old_stem);
|
||||
let updated = update_wikilinks_in_vault(vault, &old_targets, new_stem, &new_file);
|
||||
total_updated += updated;
|
||||
let summary = update_wikilinks_in_vault(vault, &old_targets, new_stem, &new_file);
|
||||
total_updated += summary.updated_files;
|
||||
}
|
||||
|
||||
Ok(total_updated)
|
||||
@@ -477,6 +513,29 @@ mod tests {
|
||||
file.write_all(content.as_bytes()).unwrap();
|
||||
}
|
||||
|
||||
struct RenameTestRequest<'a> {
|
||||
path: &'a str,
|
||||
content: &'a str,
|
||||
new_title: &'a str,
|
||||
old_title_hint: Option<&'a str>,
|
||||
}
|
||||
|
||||
fn rename_test_note_file(
|
||||
vault: &Path,
|
||||
request: RenameTestRequest<'_>,
|
||||
) -> (std::path::PathBuf, RenameResult) {
|
||||
create_test_file(vault, request.path, request.content);
|
||||
let old_path = vault.join(request.path);
|
||||
let result = rename_note(
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
request.new_title,
|
||||
request.old_title_hint,
|
||||
)
|
||||
.unwrap();
|
||||
(old_path, result)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_title_to_slug() {
|
||||
assert_eq!(title_to_slug("Weekly Review"), "weekly-review");
|
||||
@@ -552,25 +611,6 @@ mod tests {
|
||||
assert!(project_content.contains("[[note/sprint-retrospective]]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rename_note_same_title_noop() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
create_test_file(vault, "note/my-note.md", "# My Note\n\nContent.\n");
|
||||
|
||||
let old_path = vault.join("note/my-note.md");
|
||||
let result = rename_note(
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
"My Note",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.new_path, old_path.to_str().unwrap());
|
||||
assert_eq!(result.updated_files, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rename_note_empty_title_error() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
@@ -588,54 +628,81 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rename_note_preserves_pipe_alias_in_wikilinks() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
create_test_file(vault, "note/weekly-review.md", "# Weekly Review\n");
|
||||
create_test_file(
|
||||
vault,
|
||||
"note/ref.md",
|
||||
"# Ref\n\nSee [[Weekly Review|my review]] for info.\n",
|
||||
);
|
||||
fn test_rename_note_noop_variants() {
|
||||
for old_title_hint in [None, Some("My Note")] {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
let (old_path, result) = rename_test_note_file(
|
||||
vault,
|
||||
RenameTestRequest {
|
||||
path: "note/my-note.md",
|
||||
content: "# My Note\n\nContent.\n",
|
||||
new_title: "My Note",
|
||||
old_title_hint,
|
||||
},
|
||||
);
|
||||
|
||||
let old_path = vault.join("note/weekly-review.md");
|
||||
let result = rename_note(
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
"Sprint Retro",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.updated_files, 1);
|
||||
let ref_content = fs::read_to_string(vault.join("note/ref.md")).unwrap();
|
||||
assert!(ref_content.contains("[[note/sprint-retro|my review]]"));
|
||||
assert_eq!(result.new_path, old_path.to_str().unwrap());
|
||||
assert_eq!(result.updated_files, 0);
|
||||
assert_eq!(result.failed_updates, 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rename_note_updates_filename_only_wikilinks_to_canonical_path() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
create_test_file(vault, "note/weekly-review.md", "# Weekly Review\n");
|
||||
create_test_file(
|
||||
vault,
|
||||
"note/ref.md",
|
||||
"# Ref\n\nSee [[weekly-review]] for info.\n",
|
||||
);
|
||||
fn test_rename_note_updates_legacy_wikilink_targets() {
|
||||
struct WikilinkRewriteCase<'a> {
|
||||
ref_content: &'a str,
|
||||
note_content: &'a str,
|
||||
new_title: &'a str,
|
||||
expected_link: &'a str,
|
||||
removed_link: Option<&'a str>,
|
||||
}
|
||||
|
||||
let old_path = vault.join("note/weekly-review.md");
|
||||
let result = rename_note(
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
"Sprint Retro",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let cases = [
|
||||
WikilinkRewriteCase {
|
||||
ref_content: "# Ref\n\nSee [[Weekly Review|my review]] for info.\n",
|
||||
note_content: "# Weekly Review\n",
|
||||
new_title: "Sprint Retro",
|
||||
expected_link: "[[note/sprint-retro|my review]]",
|
||||
removed_link: None,
|
||||
},
|
||||
WikilinkRewriteCase {
|
||||
ref_content: "# Ref\n\nSee [[weekly-review]] for info.\n",
|
||||
note_content: "# Weekly Review\n",
|
||||
new_title: "Sprint Retro",
|
||||
expected_link: "[[note/sprint-retro]]",
|
||||
removed_link: Some("[[weekly-review]]"),
|
||||
},
|
||||
WikilinkRewriteCase {
|
||||
ref_content: "See [[Weekly Review]] for details.\n",
|
||||
note_content: "---\nIs A: Note\n---\n# Weekly Review\n\nContent.\n",
|
||||
new_title: "Sprint Retrospective",
|
||||
expected_link: "[[note/sprint-retrospective]]",
|
||||
removed_link: Some("[[Weekly Review]]"),
|
||||
},
|
||||
];
|
||||
|
||||
assert_eq!(result.updated_files, 1);
|
||||
let ref_content = fs::read_to_string(vault.join("note/ref.md")).unwrap();
|
||||
assert!(ref_content.contains("[[note/sprint-retro]]"));
|
||||
assert!(!ref_content.contains("[[weekly-review]]"));
|
||||
for case in cases {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
create_test_file(vault, "note/ref.md", case.ref_content);
|
||||
let (_old_path, result) = rename_test_note_file(
|
||||
vault,
|
||||
RenameTestRequest {
|
||||
path: "note/weekly-review.md",
|
||||
content: case.note_content,
|
||||
new_title: case.new_title,
|
||||
old_title_hint: None,
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(result.updated_files, 1);
|
||||
let ref_content = fs::read_to_string(vault.join("note/ref.md")).unwrap();
|
||||
assert!(ref_content.contains(case.expected_link));
|
||||
if let Some(removed_link) = case.removed_link {
|
||||
assert!(!ref_content.contains(removed_link));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -909,36 +976,6 @@ mod tests {
|
||||
assert!(project_content.contains("[[note/sprint-retrospective]]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rename_note_without_hint_backward_compatible() {
|
||||
// Existing behavior: no hint, extracts title from H1
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
create_test_file(
|
||||
vault,
|
||||
"note/weekly-review.md",
|
||||
"---\nIs A: Note\n---\n# Weekly Review\n\nContent.\n",
|
||||
);
|
||||
create_test_file(
|
||||
vault,
|
||||
"note/other.md",
|
||||
"See [[Weekly Review]] for details.\n",
|
||||
);
|
||||
|
||||
let old_path = vault.join("note/weekly-review.md");
|
||||
let result = rename_note(
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
"Sprint Retrospective",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.updated_files, 1);
|
||||
let other_content = fs::read_to_string(vault.join("note/other.md")).unwrap();
|
||||
assert!(other_content.contains("[[note/sprint-retrospective]]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rename_note_does_not_modify_h1() {
|
||||
// H1 is body content — rename should only update frontmatter title, not H1
|
||||
@@ -975,22 +1012,53 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rename_note_hint_same_as_new_title_noop() {
|
||||
// If old_title_hint == new_title, should be a noop
|
||||
fn test_replace_wikilinks_in_files_reports_failed_updates() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
create_test_file(vault, "note/my-note.md", "# My Note\n\nContent.\n");
|
||||
create_test_file(vault, "note/ref.md", "See [[Old Note]] for details.\n");
|
||||
|
||||
let old_path = vault.join("note/my-note.md");
|
||||
let result = rename_note(
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
"My Note",
|
||||
Some("My Note"),
|
||||
let pattern = build_wikilink_pattern(&["Old Note"]).unwrap();
|
||||
let summary = replace_wikilinks_in_files(
|
||||
vec![vault.join("note/ref.md"), vault.join("note/missing.md")],
|
||||
&pattern,
|
||||
"note/new-note",
|
||||
);
|
||||
|
||||
assert_eq!(summary.updated_files, 1);
|
||||
assert_eq!(summary.failed_updates, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_recover_pending_rename_transactions_restores_backup_when_new_file_is_missing() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
let old_path = vault.join("note/original.md");
|
||||
let new_path = vault.join("note/renamed.md");
|
||||
|
||||
create_test_file(vault, "note/original.md", "# Original\n");
|
||||
|
||||
let txn_dir = vault.join(".tolaria-rename-txn");
|
||||
fs::create_dir_all(&txn_dir).unwrap();
|
||||
|
||||
let backup_path = txn_dir.join("rename-backup.bak");
|
||||
let manifest_path = txn_dir.join("rename-transaction.json");
|
||||
fs::rename(&old_path, &backup_path).unwrap();
|
||||
fs::write(
|
||||
&manifest_path,
|
||||
serde_json::json!({
|
||||
"old_path": old_path.to_string_lossy().to_string(),
|
||||
"new_path": new_path.to_string_lossy().to_string(),
|
||||
"backup_path": backup_path.to_string_lossy().to_string(),
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.new_path, old_path.to_str().unwrap());
|
||||
assert_eq!(result.updated_files, 0);
|
||||
recover_pending_rename_transactions(vault).unwrap();
|
||||
|
||||
assert!(old_path.exists());
|
||||
assert!(!new_path.exists());
|
||||
assert!(!backup_path.exists());
|
||||
assert!(!manifest_path.exists());
|
||||
}
|
||||
}
|
||||
|
||||
287
src-tauri/src/vault/rename_transaction.rs
Normal file
287
src-tauri/src/vault/rename_transaction.rs
Normal file
@@ -0,0 +1,287 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::io::{ErrorKind, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use tempfile::NamedTempFile;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct RenameTransaction {
|
||||
old_path: String,
|
||||
new_path: String,
|
||||
backup_path: String,
|
||||
}
|
||||
|
||||
pub(super) struct RenameWorkspace {
|
||||
dir: PathBuf,
|
||||
}
|
||||
|
||||
impl RenameWorkspace {
|
||||
pub(super) fn new(vault: &Path) -> Result<Self, String> {
|
||||
let dir = vault.join(".tolaria-rename-txn");
|
||||
fs::create_dir_all(&dir).map_err(|e| {
|
||||
format!(
|
||||
"Failed to create rename transaction dir {}: {}",
|
||||
dir.display(),
|
||||
e
|
||||
)
|
||||
})?;
|
||||
Ok(Self { dir })
|
||||
}
|
||||
|
||||
pub(super) fn stage_note_content(&self, content: &str) -> Result<NamedTempFile, String> {
|
||||
let mut staged = NamedTempFile::new_in(&self.dir)
|
||||
.map_err(|e| format!("Failed to create staged rename file: {}", e))?;
|
||||
staged
|
||||
.write_all(content.as_bytes())
|
||||
.map_err(|e| format!("Failed to write staged rename file: {}", e))?;
|
||||
staged
|
||||
.as_file_mut()
|
||||
.sync_all()
|
||||
.map_err(|e| format!("Failed to sync staged rename file: {}", e))?;
|
||||
Ok(staged)
|
||||
}
|
||||
|
||||
pub(super) fn operation<'a>(
|
||||
&self,
|
||||
old_path: &'a str,
|
||||
old_file: &'a Path,
|
||||
) -> RenameOperation<'a> {
|
||||
RenameOperation {
|
||||
old_path,
|
||||
old_file,
|
||||
backup_path: self.dir.join(format!("{}.bak", Uuid::new_v4())),
|
||||
manifest_path: self.dir.join(format!("{}.json", Uuid::new_v4())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct CommittedRename {
|
||||
new_file: PathBuf,
|
||||
manifest_path: PathBuf,
|
||||
backup_path: PathBuf,
|
||||
}
|
||||
|
||||
impl CommittedRename {
|
||||
pub(super) fn new_file(&self) -> &Path {
|
||||
&self.new_file
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for CommittedRename {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_file(&self.backup_path);
|
||||
let _ = fs::remove_file(&self.manifest_path);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct RenameOperation<'a> {
|
||||
old_path: &'a str,
|
||||
old_file: &'a Path,
|
||||
backup_path: PathBuf,
|
||||
manifest_path: PathBuf,
|
||||
}
|
||||
|
||||
impl<'a> RenameOperation<'a> {
|
||||
pub(super) fn rename_with_candidates(
|
||||
&self,
|
||||
staged: NamedTempFile,
|
||||
desired_filename: &str,
|
||||
parent_dir: &Path,
|
||||
) -> Result<CommittedRename, String> {
|
||||
let mut staged = staged;
|
||||
for attempt in 0.. {
|
||||
let candidate = parent_dir.join(candidate_filename(desired_filename, attempt));
|
||||
self.prepare(&candidate)?;
|
||||
|
||||
match staged.persist_noclobber(&candidate) {
|
||||
Ok(_) => return Ok(self.committed(candidate)),
|
||||
Err(err) if err.error.kind() == ErrorKind::AlreadyExists => {
|
||||
staged = err.file;
|
||||
self.rollback()?;
|
||||
}
|
||||
Err(err) => {
|
||||
self.rollback()?;
|
||||
return Err(format!(
|
||||
"Failed to create {}: {}",
|
||||
candidate.display(),
|
||||
err.error
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
pub(super) fn rename_exact(
|
||||
&self,
|
||||
staged: NamedTempFile,
|
||||
new_file: &Path,
|
||||
) -> Result<CommittedRename, String> {
|
||||
self.prepare(new_file)?;
|
||||
match staged.persist_noclobber(new_file) {
|
||||
Ok(_) => Ok(self.committed(new_file.to_path_buf())),
|
||||
Err(err) if err.error.kind() == ErrorKind::AlreadyExists => {
|
||||
self.rollback()?;
|
||||
Err("A note with that name already exists".to_string())
|
||||
}
|
||||
Err(err) => {
|
||||
self.rollback()?;
|
||||
Err(format!(
|
||||
"Failed to rename {} to {}: {}",
|
||||
self.old_path,
|
||||
new_file.to_string_lossy(),
|
||||
err.error
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn prepare(&self, new_file: &Path) -> Result<(), String> {
|
||||
self.write_manifest(new_file)?;
|
||||
self.move_into_backup()
|
||||
}
|
||||
|
||||
fn write_manifest(&self, new_file: &Path) -> Result<(), String> {
|
||||
let transaction = RenameTransaction {
|
||||
old_path: self.old_path.to_string(),
|
||||
new_path: new_file.to_string_lossy().to_string(),
|
||||
backup_path: self.backup_path.to_string_lossy().to_string(),
|
||||
};
|
||||
let data = serde_json::to_string(&transaction)
|
||||
.map_err(|e| format!("Failed to serialize rename transaction: {}", e))?;
|
||||
fs::write(&self.manifest_path, data).map_err(|e| {
|
||||
format!(
|
||||
"Failed to write rename transaction {}: {}",
|
||||
self.manifest_path.display(),
|
||||
e
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn move_into_backup(&self) -> Result<(), String> {
|
||||
fs::rename(self.old_file, &self.backup_path).map_err(|e| {
|
||||
format!(
|
||||
"Failed to move {} into rename backup {}: {}",
|
||||
self.old_file.display(),
|
||||
self.backup_path.display(),
|
||||
e
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn rollback(&self) -> Result<(), String> {
|
||||
if self.backup_path.exists() {
|
||||
if let Some(parent) = self.old_file.parent() {
|
||||
let _ = fs::create_dir_all(parent);
|
||||
}
|
||||
fs::rename(&self.backup_path, self.old_file).map_err(|e| {
|
||||
format!(
|
||||
"Failed to restore {} from {}: {}",
|
||||
self.old_file.display(),
|
||||
self.backup_path.display(),
|
||||
e
|
||||
)
|
||||
})?;
|
||||
}
|
||||
let _ = fs::remove_file(&self.manifest_path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn committed(&self, new_file: PathBuf) -> CommittedRename {
|
||||
CommittedRename {
|
||||
new_file,
|
||||
manifest_path: self.manifest_path.clone(),
|
||||
backup_path: self.backup_path.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn candidate_filename(filename: &str, attempt: usize) -> String {
|
||||
let stem = Path::new(filename)
|
||||
.file_stem()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
let ext = Path::new(filename)
|
||||
.extension()
|
||||
.map(|s| format!(".{}", s.to_string_lossy()))
|
||||
.unwrap_or_default();
|
||||
if attempt == 0 {
|
||||
return filename.to_string();
|
||||
}
|
||||
format!("{}-{}{}", stem, attempt + 1, ext)
|
||||
}
|
||||
|
||||
fn transaction_dir(vault: &Path) -> PathBuf {
|
||||
vault.join(".tolaria-rename-txn")
|
||||
}
|
||||
|
||||
pub(super) fn recover_pending_rename_transactions(vault: &Path) -> Result<(), String> {
|
||||
let txn_dir = transaction_dir(vault);
|
||||
if !txn_dir.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let entries = fs::read_dir(&txn_dir).map_err(|e| {
|
||||
format!(
|
||||
"Failed to read rename transaction dir {}: {}",
|
||||
txn_dir.display(),
|
||||
e
|
||||
)
|
||||
})?;
|
||||
|
||||
for entry in entries {
|
||||
let path = entry
|
||||
.map_err(|e| format!("Failed to read rename transaction entry: {}", e))?
|
||||
.path();
|
||||
if path.extension().and_then(|ext| ext.to_str()) != Some("json") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Ok(data) = fs::read_to_string(&path) else {
|
||||
let _ = fs::remove_file(&path);
|
||||
continue;
|
||||
};
|
||||
let Ok(transaction) = serde_json::from_str::<RenameTransaction>(&data) else {
|
||||
let _ = fs::remove_file(&path);
|
||||
continue;
|
||||
};
|
||||
recover_rename_transaction(&path, transaction)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn recover_rename_transaction(
|
||||
manifest_path: &Path,
|
||||
transaction: RenameTransaction,
|
||||
) -> Result<(), String> {
|
||||
let old_path = Path::new(&transaction.old_path);
|
||||
let new_path = Path::new(&transaction.new_path);
|
||||
let backup_path = Path::new(&transaction.backup_path);
|
||||
|
||||
if !backup_path.exists() {
|
||||
let _ = fs::remove_file(manifest_path);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if new_path.exists() || old_path.exists() {
|
||||
let _ = fs::remove_file(backup_path);
|
||||
let _ = fs::remove_file(manifest_path);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(parent) = old_path.parent() {
|
||||
let _ = fs::create_dir_all(parent);
|
||||
}
|
||||
fs::rename(backup_path, old_path).map_err(|e| {
|
||||
format!(
|
||||
"Failed to recover {} from {}: {}",
|
||||
old_path.display(),
|
||||
backup_path.display(),
|
||||
e
|
||||
)
|
||||
})?;
|
||||
let _ = fs::remove_file(manifest_path);
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user