#![allow(clippy::missing_errors_doc)]
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use serde::Serialize;
use crate::case_index::CaseInsensitiveIndex;
use crate::discovery::{canonicalize_vault_dir, ensure_within_vault};
use crate::link_graph::{LinkGraph, normalize_target, relative_path_between, strip_site_prefix};
use crate::links::{LinkKind, extract_link_spans_with_original};
use crate::scanner::{
FenceTracker, MAX_FILE_SIZE, is_comment_fence, strip_inline_code, strip_inline_comments,
};
#[derive(Debug, Clone, Serialize)]
pub struct Replacement {
pub line: usize,
#[serde(skip)]
pub byte_offset: usize,
pub old_text: String,
pub new_text: String,
}
#[derive(Debug)]
pub struct RewritePlan {
pub path: PathBuf,
pub rel_path: String,
pub replacements: Vec<Replacement>,
pub rewritten_content: String,
pub mtime: Option<(std::time::SystemTime, u64)>,
}
pub fn plan_mv(
dir: &Path,
old_rel: &str,
new_rel: &str,
site_prefix: Option<&str>,
) -> Result<Vec<RewritePlan>> {
let build = LinkGraph::build(dir, site_prefix, None).context("building link graph")?;
for (path, msg) in &build.warnings {
eprintln!("warning: skipping {}: {msg}", path.display());
}
let graph = build.graph;
let case_index = build.case_index;
let old_stem = old_rel.strip_suffix(".md").unwrap_or(old_rel);
let new_stem = new_rel.strip_suffix(".md").unwrap_or(new_rel);
let old_dir = parent_dir(old_rel);
let new_dir = parent_dir(new_rel);
let dir_changed = old_dir != new_dir;
let backlinks = graph.backlinks_case_insensitive(old_rel);
let old_rel_norm = old_rel.replace('\\', "/");
let mut by_source: HashMap<PathBuf, Vec<_>> = HashMap::new();
for entry in backlinks {
let source_norm = entry.source.to_string_lossy().replace('\\', "/");
if source_norm == old_rel_norm {
continue;
}
by_source
.entry(entry.source.clone())
.or_default()
.push(entry);
}
let mut plans: HashMap<PathBuf, RewritePlan> = HashMap::new();
for source_rel in by_source.keys() {
let abs_path = dir.join(source_rel);
let source_rel_str = source_rel.to_string_lossy().replace('\\', "/");
let meta = std::fs::metadata(&abs_path)
.with_context(|| format!("failed to stat {}", abs_path.display()))?;
let file_size = meta.len();
let file_mtime = Some(
meta.modified()
.with_context(|| format!("failed to read mtime for {}", abs_path.display()))
.map(|t| (t, file_size))?,
);
if file_size > MAX_FILE_SIZE {
eprintln!(
"warning: skipping {} ({} MiB exceeds {} MiB limit)",
abs_path.display(),
file_size / (1024 * 1024),
MAX_FILE_SIZE / (1024 * 1024)
);
continue;
}
let content = std::fs::read_to_string(&abs_path)
.with_context(|| format!("reading {}", abs_path.display()))?;
let replacements = plan_inbound_rewrites(
&content,
&source_rel_str,
old_rel,
old_stem,
new_rel,
new_stem,
site_prefix,
Some(&case_index),
);
if !replacements.is_empty() {
let rewritten_content = apply_replacements(&content, &replacements);
plans.insert(
source_rel.clone(),
RewritePlan {
path: abs_path,
rel_path: source_rel_str,
replacements,
rewritten_content,
mtime: file_mtime,
},
);
}
}
let old_abs = dir.join(old_rel);
let old_meta = std::fs::metadata(&old_abs)
.with_context(|| format!("failed to stat {}", old_abs.display()))?;
let old_file_size = old_meta.len();
let old_file_mtime = old_meta
.modified()
.with_context(|| format!("failed to read mtime for {}", old_abs.display()))
.map(|t| (t, old_file_size))?;
if old_file_size > MAX_FILE_SIZE {
eprintln!(
"warning: skipping outbound rewrite for {} ({} MiB exceeds {} MiB limit)",
old_abs.display(),
old_file_size / (1024 * 1024),
MAX_FILE_SIZE / (1024 * 1024)
);
return Ok(plans.into_values().collect());
}
let content = std::fs::read_to_string(&old_abs)
.with_context(|| format!("reading {}", old_abs.display()))?;
let outbound_replacements = plan_outbound_rewrites(&content, old_rel, new_rel, dir_changed);
if !outbound_replacements.is_empty() {
let moved_key = PathBuf::from(old_rel.replace('\\', "/"));
let new_abs = dir.join(new_rel);
if let Some(existing) = plans.get_mut(&moved_key) {
existing.path = new_abs;
existing.rel_path = new_rel.to_string();
existing.replacements.extend(outbound_replacements);
existing.rewritten_content = apply_replacements(&content, &existing.replacements);
existing.mtime = Some(old_file_mtime);
} else {
let rewritten_content = apply_replacements(&content, &outbound_replacements);
plans.insert(
moved_key,
RewritePlan {
path: new_abs,
rel_path: new_rel.to_string(),
replacements: outbound_replacements,
rewritten_content,
mtime: Some(old_file_mtime),
},
);
}
}
Ok(plans.into_values().collect())
}
fn split_target_fragment(target: &str) -> (&str, &str) {
match target.find('#') {
Some(idx) => (&target[..idx], &target[idx..]),
None => (target, ""),
}
}
fn should_rewrite_outbound_target(target: &str) -> bool {
if target.is_empty() || target.starts_with('#') {
return false;
}
let (path_part, _) = split_target_fragment(target);
if path_part.is_empty() {
return false;
}
if path_part.starts_with('/') {
return false;
}
if let Some(colon) = path_part.find(':') {
let scheme = &path_part[..colon];
let is_drive_letter = colon == 1
&& scheme
.chars()
.next()
.is_some_and(|c| c.is_ascii_alphabetic())
&& path_part
.as_bytes()
.get(colon + 1)
.is_some_and(|b| *b == b'/' || *b == b'\\');
if !is_drive_letter
&& !scheme.is_empty()
&& scheme
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '-' || c == '.')
{
return false;
}
}
let has_path_sep = path_part.contains('/') || path_part.contains('\\');
let is_md = std::path::Path::new(path_part)
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("md"));
if !has_path_sep && !is_md {
return false;
}
true
}
pub fn execute_plans(vault_dir: &Path, plans: &[RewritePlan]) -> Result<()> {
let canonical_vault = canonicalize_vault_dir(vault_dir)
.context("failed to canonicalize vault directory for write safety check")?;
for plan in plans {
let within = ensure_within_vault(&canonical_vault, &plan.path)
.with_context(|| format!("could not verify {} is within vault", plan.path.display()))?;
anyhow::ensure!(
within,
"refusing to write outside vault: {}",
plan.path.display()
);
if let Some(expected_mtime) = plan.mtime {
crate::frontmatter::check_mtime(&plan.path, expected_mtime)?;
}
crate::fs_util::atomic_write(&plan.path, plan.rewritten_content.as_bytes())
.with_context(|| format!("writing {}", plan.path.display()))?;
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn plan_inbound_rewrites(
content: &str,
source_rel: &str,
old_rel: &str,
old_stem: &str,
new_rel: &str,
new_stem: &str,
site_prefix: Option<&str>,
case_index: Option<&CaseInsensitiveIndex>,
) -> Vec<Replacement> {
let mut replacements = Vec::new();
let mut fence = FenceTracker::new();
let mut in_comment_fence = false;
let mut in_frontmatter = false;
let mut frontmatter_done = false;
let mut line_num = 0usize;
for line in content.split('\n') {
line_num += 1;
if !frontmatter_done {
if line_num == 1 && line.trim() == "---" {
in_frontmatter = true;
continue;
}
if in_frontmatter {
if line.trim() == "---" {
in_frontmatter = false;
frontmatter_done = true;
}
continue;
}
frontmatter_done = true;
}
if in_comment_fence {
if is_comment_fence(line) {
in_comment_fence = false;
}
continue;
}
if fence.process_line(line) {
continue;
}
if is_comment_fence(line) {
in_comment_fence = true;
continue;
}
let stripped_code = strip_inline_code(line);
let cleaned = strip_inline_comments(stripped_code.as_ref());
let spans = extract_link_spans_with_original(&cleaned, line);
for span in spans {
let matches = match span.kind {
LinkKind::Wikilink => {
let t = &span.link.target;
if !(t.contains('/') || t.contains('\\')) {
false
} else if t == old_stem || t == old_rel {
true
} else if let Some(idx) = case_index {
let t_norm = t.replace('\\', "/").to_ascii_lowercase();
let canonical = idx.lookup_unique(&t_norm).or_else(|| {
let with_md = format!("{t_norm}.md");
idx.lookup_unique(&with_md)
});
canonical == Some(old_rel) || canonical == Some(old_stem)
} else {
false
}
}
LinkKind::Markdown => {
let norm = if span.link.target.starts_with('/') {
strip_site_prefix(&span.link.target, site_prefix)
} else {
normalize_target(Path::new(source_rel), &span.link.target)
};
if norm == old_rel || norm == old_stem {
true
} else if let Some(idx) = case_index {
let norm_lower = norm.to_ascii_lowercase();
let canonical = idx.lookup_unique(&norm_lower).or_else(|| {
let with_md = format!("{norm_lower}.md");
idx.lookup_unique(&with_md)
});
canonical == Some(old_rel) || canonical == Some(old_stem)
} else {
false
}
}
};
if !matches {
continue;
}
let new_target = match span.kind {
LinkKind::Wikilink => new_stem.to_string(),
LinkKind::Markdown => {
if span.link.target.starts_with('/') {
let target = if std::path::Path::new(&span.link.target)
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("md"))
{
new_rel
} else {
new_stem
};
match site_prefix {
Some(prefix) => format!("/{prefix}/{target}"),
None => format!("/{target}"),
}
} else {
relative_path_between(source_rel, new_rel)
}
}
};
let old_text = line[span.full_start..span.full_end].to_string();
let new_text = format!(
"{}{}{}",
&line[span.full_start..span.target_start],
new_target,
&line[span.target_end..span.full_end]
);
if old_text != new_text {
replacements.push(Replacement {
line: line_num,
byte_offset: span.full_start,
old_text,
new_text,
});
}
}
}
replacements
}
fn plan_outbound_rewrites(
content: &str,
old_rel: &str,
new_rel: &str,
dir_changed: bool,
) -> Vec<Replacement> {
let mut replacements = Vec::new();
let mut fence = FenceTracker::new();
let mut in_comment_fence = false;
let mut in_frontmatter = false;
let mut frontmatter_done = false;
let mut line_num = 0usize;
for line in content.split('\n') {
line_num += 1;
if !frontmatter_done {
if line_num == 1 && line.trim() == "---" {
in_frontmatter = true;
continue;
}
if in_frontmatter {
if line.trim() == "---" {
in_frontmatter = false;
frontmatter_done = true;
}
continue;
}
frontmatter_done = true;
}
if in_comment_fence {
if is_comment_fence(line) {
in_comment_fence = false;
}
continue;
}
if fence.process_line(line) {
continue;
}
if is_comment_fence(line) {
in_comment_fence = true;
continue;
}
let stripped_code = strip_inline_code(line);
let cleaned = strip_inline_comments(stripped_code.as_ref());
let spans = extract_link_spans_with_original(&cleaned, line);
for span in spans {
if span.kind != LinkKind::Markdown {
continue;
}
if !should_rewrite_outbound_target(&span.link.target) {
continue;
}
let (target_path, target_fragment) = split_target_fragment(&span.link.target);
let resolved = normalize_target(Path::new(old_rel), target_path);
let target_after_move = if resolved == old_rel {
new_rel.to_string()
} else {
if !dir_changed {
continue;
}
resolved
};
let new_target = format!(
"{}{}",
relative_path_between(new_rel, &target_after_move),
target_fragment
);
let original_target = &line[span.target_start..span.target_end];
if new_target == original_target {
continue;
}
let old_text = line[span.full_start..span.full_end].to_string();
let new_text = format!(
"{}{}{}",
&line[span.full_start..span.target_start],
new_target,
&line[span.target_end..span.full_end]
);
replacements.push(Replacement {
line: line_num,
byte_offset: span.full_start,
old_text,
new_text,
});
}
}
replacements
}
pub(crate) fn apply_replacements(content: &str, replacements: &[Replacement]) -> String {
let mut by_line: HashMap<usize, Vec<&Replacement>> = HashMap::new();
for r in replacements {
by_line.entry(r.line).or_default().push(r);
}
let ends_with_newline = content.ends_with('\n');
let lines: Vec<&str> = content.split('\n').collect();
let mut out = String::with_capacity(content.len());
for (idx, &raw_line) in lines.iter().enumerate() {
let line_num = idx + 1;
let is_last = idx + 1 == lines.len();
let mut line = raw_line.to_string();
if let Some(repls) = by_line.get(&line_num) {
let mut sorted: Vec<&&Replacement> = repls.iter().collect();
sorted.sort_by_key(|r| std::cmp::Reverse(r.byte_offset));
for r in sorted {
let pos = r.byte_offset;
let end = pos + r.old_text.len();
if end <= line.len() && line[pos..end] == *r.old_text {
line = format!("{}{}{}", &line[..pos], r.new_text, &line[end..]);
}
}
}
out.push_str(&line);
if !is_last {
out.push('\n');
}
}
if ends_with_newline && !out.ends_with('\n') {
out.push('\n');
}
out
}
fn parent_dir(rel: &str) -> &str {
match rel.rfind('/') {
Some(pos) => &rel[..pos],
None => "",
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn create_vault(files: &[(&str, &str)]) -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
for (name, content) in files {
let path = dir.path().join(name);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(&path, content).unwrap();
}
dir
}
#[test]
fn plan_mv_bare_wikilink_not_rewritten() {
let vault = create_vault(&[
("a.md", "---\ntitle: A\n---\nSee [[b]] for details\n"),
("b.md", "---\ntitle: B\n---\nContent\n"),
]);
let plans = plan_mv(vault.path(), "b.md", "archive/b.md", None).unwrap();
assert!(
plans.is_empty(),
"bare wikilink [[b]] should not be rewritten"
);
}
#[test]
fn plan_mv_bare_wikilink_with_alias_not_rewritten() {
let vault = create_vault(&[("a.md", "See [[b|my note]] here\n"), ("b.md", "Content\n")]);
let plans = plan_mv(vault.path(), "b.md", "sub/b.md", None).unwrap();
assert!(
plans.is_empty(),
"bare wikilink [[b|my note]] should not be rewritten"
);
}
#[test]
fn plan_mv_bare_wikilink_with_fragment_not_rewritten() {
let vault = create_vault(&[("a.md", "See [[b#section]] here\n"), ("b.md", "Content\n")]);
let plans = plan_mv(vault.path(), "b.md", "sub/b.md", None).unwrap();
assert!(
plans.is_empty(),
"bare wikilink [[b#section]] should not be rewritten"
);
}
#[test]
fn plan_mv_inbound_wikilink_with_path() {
let vault = create_vault(&[
(
"a.md",
"---\ntitle: A\n---\nSee [[backlog/item]] for details\n",
),
("backlog/item.md", "---\ntitle: Item\n---\nContent\n"),
]);
let plans = plan_mv(
vault.path(),
"backlog/item.md",
"backlog/done/item.md",
None,
)
.unwrap();
assert_eq!(plans.len(), 1);
assert_eq!(plans[0].rel_path, "a.md");
assert_eq!(plans[0].replacements.len(), 1);
assert_eq!(plans[0].replacements[0].old_text, "[[backlog/item]]");
assert_eq!(plans[0].replacements[0].new_text, "[[backlog/done/item]]");
}
#[test]
fn plan_mv_inbound_wikilink_with_path_and_alias() {
let vault = create_vault(&[
("a.md", "See [[sub/b|my note]] here\n"),
("sub/b.md", "Content\n"),
]);
let plans = plan_mv(vault.path(), "sub/b.md", "archive/b.md", None).unwrap();
assert_eq!(plans.len(), 1);
assert_eq!(plans[0].replacements[0].old_text, "[[sub/b|my note]]");
assert_eq!(plans[0].replacements[0].new_text, "[[archive/b|my note]]");
}
#[test]
fn plan_mv_inbound_wikilink_with_path_and_fragment() {
let vault = create_vault(&[
("a.md", "See [[sub/b#section]] here\n"),
("sub/b.md", "Content\n"),
]);
let plans = plan_mv(vault.path(), "sub/b.md", "archive/b.md", None).unwrap();
assert_eq!(plans.len(), 1);
assert_eq!(plans[0].replacements[0].old_text, "[[sub/b#section]]");
assert_eq!(plans[0].replacements[0].new_text, "[[archive/b#section]]");
}
#[test]
fn plan_mv_inbound_markdown_link() {
let vault = create_vault(&[("a.md", "See [note](b.md) here\n"), ("b.md", "Content\n")]);
let plans = plan_mv(vault.path(), "b.md", "sub/b.md", None).unwrap();
assert_eq!(plans.len(), 1);
assert_eq!(plans[0].replacements[0].old_text, "[note](b.md)");
assert_eq!(plans[0].replacements[0].new_text, "[note](sub/b.md)");
}
#[test]
fn plan_mv_outbound_relative_link() {
let vault = create_vault(&[("a.md", "Content A\n"), ("b.md", "See [note](a.md) here\n")]);
let plans = plan_mv(vault.path(), "b.md", "sub/b.md", None).unwrap();
let moved_plan = plans.iter().find(|p| p.rel_path == "sub/b.md").unwrap();
assert_eq!(moved_plan.replacements[0].old_text, "[note](a.md)");
assert_eq!(moved_plan.replacements[0].new_text, "[note](../a.md)");
}
#[test]
fn plan_mv_outbound_wikilink_unchanged() {
let vault = create_vault(&[("a.md", "Content A\n"), ("b.md", "See [[a]] here\n")]);
let plans = plan_mv(vault.path(), "b.md", "sub/b.md", None).unwrap();
let moved_plan = plans.iter().find(|p| p.rel_path == "b.md");
assert!(moved_plan.is_none());
}
#[test]
fn plan_mv_links_in_code_block_untouched() {
let vault = create_vault(&[
(
"a.md",
"---\ntitle: A\n---\n```\n[[sub/b]]\n```\nReal [[sub/b]]\n",
),
("sub/b.md", "Content\n"),
]);
let plans = plan_mv(vault.path(), "sub/b.md", "archive/b.md", None).unwrap();
assert_eq!(plans.len(), 1);
assert_eq!(plans[0].replacements.len(), 1);
assert_eq!(plans[0].replacements[0].line, 7);
}
#[test]
fn plan_mv_links_in_inline_code_untouched() {
let vault = create_vault(&[
("a.md", "Use `[[sub/b]]` and real [[sub/b]]\n"),
("sub/b.md", "Content\n"),
]);
let plans = plan_mv(vault.path(), "sub/b.md", "archive/b.md", None).unwrap();
assert_eq!(plans.len(), 1);
assert_eq!(plans[0].replacements.len(), 1);
assert_eq!(plans[0].replacements[0].old_text, "[[sub/b]]");
}
#[test]
fn plan_mv_no_links_empty_result() {
let vault = create_vault(&[("a.md", "No links here\n"), ("b.md", "Content\n")]);
let plans = plan_mv(vault.path(), "b.md", "sub/b.md", None).unwrap();
assert!(plans.is_empty());
}
#[test]
fn plan_mv_multiple_links_one_line() {
let vault = create_vault(&[
("a.md", "See [[sub/b]] and [[sub/b|alias]]\n"),
("sub/b.md", "Content\n"),
]);
let plans = plan_mv(vault.path(), "sub/b.md", "archive/b.md", None).unwrap();
assert_eq!(plans.len(), 1);
assert_eq!(plans[0].replacements.len(), 2);
}
#[test]
fn execute_plans_writes_files() {
let vault = create_vault(&[("a.md", "See [[sub/b]] here\n"), ("sub/b.md", "Content\n")]);
let plans = plan_mv(vault.path(), "sub/b.md", "archive/b.md", None).unwrap();
execute_plans(vault.path(), &plans).unwrap();
let content = fs::read_to_string(vault.path().join("a.md")).unwrap();
assert!(content.contains("[[archive/b]]"));
assert!(!content.contains("[[sub/b]]"));
}
#[test]
fn apply_replacements_preserves_trailing_newline() {
let content = "line one\nline two\n";
let repls = vec![Replacement {
line: 1,
byte_offset: 5,
old_text: "one".to_string(),
new_text: "ONE".to_string(),
}];
let result = apply_replacements(content, &repls);
assert_eq!(result, "line ONE\nline two\n");
}
#[test]
fn apply_replacements_no_trailing_newline() {
let content = "line one\nline two";
let repls = vec![Replacement {
line: 2,
byte_offset: 5,
old_text: "two".to_string(),
new_text: "TWO".to_string(),
}];
let result = apply_replacements(content, &repls);
assert_eq!(result, "line one\nline TWO");
}
#[test]
fn plan_mv_frontmatter_links_untouched() {
let vault = create_vault(&[
("a.md", "---\nrelated: \"[[sub/b]]\"\n---\nBody [[sub/b]]\n"),
("sub/b.md", "Content\n"),
]);
let plans = plan_mv(vault.path(), "sub/b.md", "archive/b.md", None).unwrap();
assert_eq!(plans.len(), 1);
assert_eq!(plans[0].replacements.len(), 1);
assert_eq!(plans[0].replacements[0].line, 4); }
#[test]
fn plan_mv_same_directory_no_outbound_changes() {
let vault = create_vault(&[("a.md", "Content A\n"), ("b.md", "See [note](a.md) here\n")]);
let plans = plan_mv(vault.path(), "b.md", "c.md", None).unwrap();
let moved_plan = plans.iter().find(|p| p.rel_path == "b.md");
assert!(moved_plan.is_none());
}
#[test]
fn plan_mv_inbound_markdown_link_from_subdir() {
let vault = create_vault(&[
("sub/a.md", "See [note](../b.md) here\n"),
("b.md", "Content\n"),
]);
let plans = plan_mv(vault.path(), "b.md", "archive/b.md", None).unwrap();
assert_eq!(plans.len(), 1);
assert_eq!(plans[0].rel_path, "sub/a.md");
assert_eq!(plans[0].replacements[0].old_text, "[note](../b.md)");
assert_eq!(plans[0].replacements[0].new_text, "[note](../archive/b.md)");
}
#[test]
fn plan_mv_bare_markdown_link_from_subdir_not_false_positive() {
let vault = create_vault(&[
("sub/a.md", "See [note](b.md) here\n"),
("sub/b.md", "Content sub\n"),
("b.md", "Content root\n"),
]);
let plans = plan_mv(vault.path(), "b.md", "archive/b.md", None).unwrap();
let sub_plan = plans.iter().find(|p| p.rel_path == "sub/a.md");
assert!(sub_plan.is_none(), "false positive: {plans:?}");
}
#[test]
fn plan_mv_bare_wikilink_with_md_extension_not_rewritten() {
let vault = create_vault(&[("a.md", "See [[b.md]] here\n"), ("b.md", "Content\n")]);
let plans = plan_mv(vault.path(), "b.md", "sub/b.md", None).unwrap();
assert!(
plans.is_empty(),
"bare wikilink [[b.md]] should not be rewritten"
);
}
#[test]
fn plan_mv_wikilink_with_path_and_md_extension() {
let vault = create_vault(&[
("a.md", "See [[sub/b.md]] here\n"),
("sub/b.md", "Content\n"),
]);
let plans = plan_mv(vault.path(), "sub/b.md", "archive/b.md", None).unwrap();
assert_eq!(plans.len(), 1);
assert_eq!(plans[0].replacements[0].old_text, "[[sub/b.md]]");
assert_eq!(plans[0].replacements[0].new_text, "[[archive/b]]");
}
#[test]
fn plan_mv_inbound_absolute_link_with_site_prefix() {
let vault = create_vault(&[
(
"a.md",
"See [settings](/docs/configure/settings.md) for details\n",
),
("configure/settings.md", "# Settings\n"),
]);
let plans = plan_mv(
vault.path(),
"configure/settings.md",
"config/settings.md",
Some("docs"),
)
.unwrap();
assert_eq!(plans.len(), 1, "should produce one rewrite plan");
assert_eq!(plans[0].rel_path, "a.md");
assert_eq!(plans[0].replacements.len(), 1);
assert_eq!(
plans[0].replacements[0].old_text,
"[settings](/docs/configure/settings.md)"
);
assert_eq!(
plans[0].replacements[0].new_text,
"[settings](/docs/config/settings.md)"
);
}
#[test]
fn plan_mv_inbound_absolute_link_without_site_prefix() {
let vault = create_vault(&[
("index.md", "See [page](/page.md) here\n"),
("page.md", "# Page\n"),
]);
let plans = plan_mv(vault.path(), "page.md", "archive/page.md", None).unwrap();
assert_eq!(plans.len(), 1);
assert_eq!(plans[0].rel_path, "index.md");
assert_eq!(plans[0].replacements[0].old_text, "[page](/page.md)");
assert_eq!(
plans[0].replacements[0].new_text,
"[page](/archive/page.md)"
);
}
#[test]
fn plan_mv_inbound_absolute_link_no_false_positive_wrong_prefix() {
let vault = create_vault(&[
("a.md", "See [other](/other/page.md) here\n"),
("configure/settings.md", "# Settings\n"),
("other/page.md", "# Other\n"),
]);
let plans = plan_mv(
vault.path(),
"configure/settings.md",
"config/settings.md",
Some("docs"),
)
.unwrap();
let a_plan = plans.iter().find(|p| p.rel_path == "a.md");
assert!(
a_plan.is_none(),
"absolute link to a different file must not be rewritten: {plans:?}"
);
}
#[test]
fn plan_mv_inbound_absolute_stem_match() {
let vault = create_vault(&[
(
"a.md",
"See [settings](/docs/configure/settings) for details\n",
),
("configure/settings.md", "# Settings\n"),
]);
let plans = plan_mv(
vault.path(),
"configure/settings.md",
"config/settings.md",
Some("docs"),
)
.unwrap();
assert_eq!(plans.len(), 1);
assert_eq!(
plans[0].replacements[0].old_text,
"[settings](/docs/configure/settings)"
);
assert_eq!(
plans[0].replacements[0].new_text,
"[settings](/docs/config/settings)"
);
}
#[test]
fn plan_mv_self_link_same_directory() {
let vault = create_vault(&[("self.md", "A link to [me](self.md).\n")]);
let plans = plan_mv(vault.path(), "self.md", "other.md", None).unwrap();
assert_eq!(plans.len(), 1);
let plan = &plans[0];
assert_eq!(plan.rel_path, "other.md");
assert_eq!(plan.path, vault.path().join("other.md"));
assert_eq!(plan.replacements.len(), 1);
assert_eq!(plan.replacements[0].old_text, "[me](self.md)");
assert_eq!(plan.replacements[0].new_text, "[me](other.md)");
}
#[test]
fn execute_plans_self_link_same_directory_e2e() {
let vault = create_vault(&[("self.md", "A link to [me](self.md).\n")]);
let plans = plan_mv(vault.path(), "self.md", "other.md", None).unwrap();
fs::rename(vault.path().join("self.md"), vault.path().join("other.md")).unwrap();
execute_plans(vault.path(), &plans).unwrap();
let content = fs::read_to_string(vault.path().join("other.md")).unwrap();
assert!(content.contains("[me](other.md)"), "got: {content:?}");
assert!(!content.contains("self.md"));
}
#[test]
fn plan_mv_outbound_skips_site_absolute_links() {
let vault = create_vault(&[
(
"games/anatomy/index.md",
"See [MDN](/en-US/docs/Web/API/Web_Workers).\n",
),
("games/anatomy-renamed/.gitkeep", ""),
]);
let plans = plan_mv(
vault.path(),
"games/anatomy/index.md",
"games/anatomy-renamed/index.md",
None,
)
.unwrap();
let moved_plan = plans
.iter()
.find(|p| p.rel_path == "games/anatomy-renamed/index.md");
assert!(
moved_plan.is_none(),
"site-absolute link must not trigger a rewrite: {plans:?}"
);
}
#[test]
fn plan_mv_outbound_skips_url_schemes() {
let vault = create_vault(&[(
"sub/note.md",
"See [link](https://example.com/x) and [mail](mailto:a@b.c).\n",
)]);
let plans = plan_mv(vault.path(), "sub/note.md", "archive/note.md", None).unwrap();
let moved_plan = plans.iter().find(|p| p.rel_path == "archive/note.md");
assert!(
moved_plan.is_none(),
"URL-scheme links must not be rewritten: {plans:?}"
);
}
#[test]
fn plan_mv_outbound_skips_fragment_only() {
let vault = create_vault(&[("sub/note.md", "Jump to [top](#top).\n")]);
let plans = plan_mv(vault.path(), "sub/note.md", "archive/note.md", None).unwrap();
let moved_plan = plans.iter().find(|p| p.rel_path == "archive/note.md");
assert!(
moved_plan.is_none(),
"fragment-only links must not be rewritten: {plans:?}"
);
}
#[test]
fn plan_mv_outbound_skips_bare_token_without_md_extension() {
let vault = create_vault(&[("sub/note.md", "See [obsidian](Note One) here.\n")]);
let plans = plan_mv(vault.path(), "sub/note.md", "archive/note.md", None).unwrap();
let moved_plan = plans.iter().find(|p| p.rel_path == "archive/note.md");
assert!(
moved_plan.is_none(),
"bare non-md link must not be rewritten: {plans:?}"
);
}
#[test]
fn plan_mv_outbound_rewrites_genuine_relative_link() {
let vault = create_vault(&[
("sub/a.md", "See [peer](peer.md).\n"),
("sub/peer.md", "peer\n"),
]);
let plans = plan_mv(vault.path(), "sub/a.md", "a.md", None).unwrap();
let moved_plan = plans
.iter()
.find(|p| p.rel_path == "a.md")
.expect("expected rewrite plan");
assert_eq!(moved_plan.replacements.len(), 1);
assert_eq!(moved_plan.replacements[0].old_text, "[peer](peer.md)");
assert_eq!(moved_plan.replacements[0].new_text, "[peer](sub/peer.md)");
}
#[test]
fn should_rewrite_outbound_target_rules() {
assert!(!should_rewrite_outbound_target(""));
assert!(!should_rewrite_outbound_target("/en-US/docs/x"));
assert!(!should_rewrite_outbound_target("/page.md"));
assert!(!should_rewrite_outbound_target("#anchor"));
assert!(!should_rewrite_outbound_target("https://a.b/c"));
assert!(!should_rewrite_outbound_target("mailto:a@b.c"));
assert!(!should_rewrite_outbound_target("tel:+1"));
assert!(!should_rewrite_outbound_target("Note One"));
assert!(!should_rewrite_outbound_target("plain-label"));
assert!(should_rewrite_outbound_target("../notes/x.md"));
assert!(should_rewrite_outbound_target("sub/x.md"));
assert!(should_rewrite_outbound_target("x.md"));
assert!(should_rewrite_outbound_target("sub/label"));
assert!(
should_rewrite_outbound_target("x.md#intro"),
"anchored .md link should be rewritable"
);
assert!(
should_rewrite_outbound_target("sub/x.md#section-1"),
"anchored nested .md link should be rewritable"
);
assert!(
!should_rewrite_outbound_target("#anchor-with-dashes"),
"fragment-only target must still be skipped"
);
assert!(
should_rewrite_outbound_target("C:/notes/x.md"),
"Windows drive-letter forward-slash path should be rewritable"
);
assert!(
should_rewrite_outbound_target("C:\\notes\\x.md"),
"Windows drive-letter backslash path should be rewritable"
);
}
#[test]
fn plan_mv_outbound_rewrites_anchored_self_link() {
let vault = create_vault(&[("self.md", "Jump to [intro](self.md#intro) in this file.\n")]);
let plans = plan_mv(vault.path(), "self.md", "other.md", None).unwrap();
assert_eq!(plans.len(), 1);
let plan = &plans[0];
assert_eq!(plan.rel_path, "other.md");
assert_eq!(plan.replacements.len(), 1);
assert_eq!(plan.replacements[0].old_text, "[intro](self.md#intro)");
assert_eq!(plan.replacements[0].new_text, "[intro](other.md#intro)");
}
#[test]
fn plan_mv_outbound_rewrites_anchored_relative_link_on_dir_change() {
let vault = create_vault(&[
("sub/a.md", "See [peer](peer.md#heading).\n"),
("sub/peer.md", "peer\n"),
]);
let plans = plan_mv(vault.path(), "sub/a.md", "a.md", None).unwrap();
let moved_plan = plans
.iter()
.find(|p| p.rel_path == "a.md")
.expect("expected rewrite plan");
assert_eq!(moved_plan.replacements.len(), 1);
assert_eq!(
moved_plan.replacements[0].old_text,
"[peer](peer.md#heading)"
);
assert_eq!(
moved_plan.replacements[0].new_text,
"[peer](sub/peer.md#heading)"
);
}
#[test]
fn execute_plans_rejects_path_outside_vault() {
let vault = create_vault(&[("a.md", "content\n")]);
let outside = tempfile::tempdir().unwrap();
let outside_path = outside.path().join("escaped.md");
fs::write(&outside_path, "original\n").unwrap();
let bad_plan = RewritePlan {
path: outside_path.clone(),
rel_path: "escaped.md".to_string(),
replacements: vec![],
rewritten_content: "malicious\n".to_string(),
mtime: None,
};
let result = execute_plans(vault.path(), &[bad_plan]);
assert!(result.is_err(), "must refuse to write outside vault");
let content = fs::read_to_string(&outside_path).unwrap();
assert_eq!(content, "original\n");
}
#[test]
fn plan_mv_case_insensitive_wikilink_inbound() {
let vault = create_vault(&[
(
"web/javascript/reference/iteration_protocols/index.md",
"# Iteration Protocols\n",
),
(
"promise/any/index.md",
"See [[Web/JavaScript/Reference/Iteration_protocols/index]]\n",
),
]);
let plans = plan_mv(
vault.path(),
"web/javascript/reference/iteration_protocols/index.md",
"web/javascript/reference/iteration_protocols_v2/index.md",
None,
)
.unwrap();
let promise_plan = plans.iter().find(|p| p.rel_path == "promise/any/index.md");
assert!(
promise_plan.is_some(),
"case-insensitive inbound link in promise/any/index.md should produce a rewrite plan; got plans: {plans:?}"
);
}
#[test]
fn plan_mv_case_insensitive_markdown_link_inbound() {
let vault = create_vault(&[
("web/foo.md", "Content\n"),
("other.md", "See [link](Web/Foo.md)\n"),
]);
let plans = plan_mv(vault.path(), "web/foo.md", "archive/foo.md", None).unwrap();
let other_plan = plans.iter().find(|p| p.rel_path == "other.md");
assert!(
other_plan.is_some(),
"case-insensitive inbound markdown link should produce a rewrite plan; got: {plans:?}"
);
}
#[test]
fn plan_mv_percent_percent_inside_code_fence_does_not_toggle_comment_mode() {
let vault = create_vault(&[
(
"a.md",
"---\ntitle: test\n---\n# Test\n\n```markdown\n%%\nThis is inside a code fence\n```\n\nSee [[sub/target]] for more.\n",
),
("sub/target.md", "Content\n"),
]);
let plans = plan_mv(vault.path(), "sub/target.md", "archive/target.md", None).unwrap();
let a_plan = plans.iter().find(|p| p.rel_path == "a.md");
assert!(
a_plan.is_some(),
"[[sub/target]] after a code fence containing %% should be found; got plans: {plans:?}"
);
let a_plan = a_plan.unwrap();
assert_eq!(
a_plan.replacements.len(),
1,
"exactly one replacement expected; got: {:?}",
a_plan.replacements
);
assert_eq!(a_plan.replacements[0].old_text, "[[sub/target]]");
assert_eq!(a_plan.replacements[0].new_text, "[[archive/target]]");
}
#[test]
fn plan_mv_outbound_percent_percent_inside_code_fence_does_not_toggle_comment_mode() {
let vault = create_vault(&[
("sub/a.md", "```markdown\n%%\n```\n\nSee [peer](peer.md).\n"),
("sub/peer.md", "peer\n"),
]);
let plans = plan_mv(vault.path(), "sub/a.md", "a.md", None).unwrap();
let moved_plan = plans
.iter()
.find(|p| p.rel_path == "a.md")
.expect("outbound link after %%-in-code-fence should be detected");
assert_eq!(moved_plan.replacements.len(), 1);
assert_eq!(moved_plan.replacements[0].old_text, "[peer](peer.md)");
assert_eq!(moved_plan.replacements[0].new_text, "[peer](sub/peer.md)");
}
#[test]
fn plan_mv_code_fence_inside_comment_does_not_break_parsing() {
let vault = create_vault(&[
(
"a.md",
"---\ntitle: test\n---\n# Intro\n\n%%\n```\ncode in comment\n```\n%%\n\nSee [[sub/target]] for more.\n",
),
("sub/target.md", "Content\n"),
]);
let plans = plan_mv(vault.path(), "sub/target.md", "archive/target.md", None).unwrap();
let a_plan = plans.iter().find(|p| p.rel_path == "a.md");
assert!(
a_plan.is_some(),
"[[sub/target]] after a comment containing a code fence should be found; got: {plans:?}"
);
let a_plan = a_plan.unwrap();
assert_eq!(a_plan.replacements.len(), 1);
assert_eq!(a_plan.replacements[0].old_text, "[[sub/target]]");
assert_eq!(a_plan.replacements[0].new_text, "[[archive/target]]");
}
#[test]
fn plan_mv_outbound_code_fence_inside_comment_does_not_break_parsing() {
let vault = create_vault(&[
(
"sub/a.md",
"%%\n```\ncode\n```\n%%\n\nSee [peer](peer.md).\n",
),
("sub/peer.md", "peer\n"),
]);
let plans = plan_mv(vault.path(), "sub/a.md", "a.md", None).unwrap();
let moved_plan = plans
.iter()
.find(|p| p.rel_path == "a.md")
.expect("outbound link after comment-with-code-fence should be detected");
assert_eq!(moved_plan.replacements.len(), 1);
assert_eq!(moved_plan.replacements[0].old_text, "[peer](peer.md)");
assert_eq!(moved_plan.replacements[0].new_text, "[peer](sub/peer.md)");
}
}