use crate::index::{self, Entry};
use crate::notes;
use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Default, PartialEq)]
pub struct Report {
pub links: usize,
pub notes: Vec<PathBuf>,
}
impl Report {
pub fn describe(&self) -> Option<String> {
if self.links == 0 {
return None;
}
Some(format!(
"{} updated in {}",
plural(self.links, "link"),
plural(self.notes.len(), "note")
))
}
}
fn plural(n: usize, word: &str) -> String {
if n == 1 {
format!("1 {word}")
} else {
format!("{n} {word}s")
}
}
struct Rename {
old: PathBuf,
new: PathBuf,
stem: String,
before: Vec<Entry>,
after: Vec<Entry>,
}
impl Rename {
fn retarget(&self, raw: &str) -> Option<String> {
let reached_old = index::resolve(&self.before, raw).is_some_and(|e| e.path == self.old);
if !reached_old {
return None;
}
let (head, tail) = match raw.rfind('/') {
Some(i) => (&raw[..=i], &raw[i + 1..]),
None => ("", raw),
};
let suffix = if tail.to_lowercase().ends_with(".md") {
".md"
} else {
""
};
let target = format!("{head}{}{suffix}", self.stem);
index::resolve(&self.after, &target)
.is_some_and(|e| e.path == self.new)
.then_some(target)
}
fn rewrite_line(&self, line: &str) -> (String, usize) {
let src: Vec<char> = line.chars().collect();
let mut out = String::new();
let mut done = 0;
let mut at = 0;
for w in crate::md::wikilinks(line) {
let (from, to) = target_span(&src, &w);
let raw: String = src[from..to].iter().collect();
let Some(target) = self.retarget(&raw) else {
continue;
};
out.extend(&src[at..from]);
out.push_str(&target);
at = to;
done += 1;
}
out.extend(&src[at..]);
(out, done)
}
fn rewrite(&self, body: &str) -> Option<(String, usize)> {
let front = notes::front_matter_range(body).map_or(0, |r| r.end);
let mut out = body[..front].to_string();
let mut done = 0;
let mut fenced = false;
for line in body[front..].split_inclusive('\n') {
let trimmed = line.trim_start();
if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
fenced = !fenced;
}
if fenced {
out.push_str(line);
continue;
}
let (rewritten, n) = self.rewrite_line(line);
out.push_str(&rewritten);
done += n;
}
(done > 0).then_some((out, done))
}
}
fn target_span(src: &[char], w: &crate::md::Wikilink) -> (usize, usize) {
let body = w.start + 2;
let close = w.end - 2;
let mut end = (body..close)
.find(|&k| matches!(src[k], '|' | '#'))
.unwrap_or(close);
let mut start = body;
while start < end && src[start].is_whitespace() {
start += 1;
}
while end > start && src[end - 1].is_whitespace() {
end -= 1;
}
(start, end)
}
fn notes_under(roots: &[PathBuf]) -> Vec<(PathBuf, String)> {
let mut seen: HashSet<PathBuf> = HashSet::new();
let mut out = Vec::new();
for root in roots {
let root = fs::canonicalize(root).unwrap_or_else(|_| root.clone());
let mut stack = vec![(root.clone(), 0usize)];
while let Some((dir, depth)) = stack.pop() {
if depth > index::MAX_DEPTH || out.len() >= index::MAX_FILES {
continue;
}
let Ok(read) = fs::read_dir(&dir) else {
continue;
};
for entry in read.flatten() {
let path = entry.path();
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
if !index::skip_dir(name) {
stack.push((path, depth + 1));
}
continue;
}
if name.starts_with('.') || !name.ends_with(".md") {
continue;
}
if out.len() >= index::MAX_FILES || !seen.insert(path.clone()) {
continue;
}
let rel = path
.strip_prefix(&root)
.unwrap_or(&path)
.to_string_lossy()
.into_owned();
out.push((path, rel));
}
}
}
out
}
fn views(found: &[(PathBuf, String)], old: &Path, new: &Path) -> (Vec<Entry>, Vec<Entry>) {
let mut before = Vec::new();
let mut after = Vec::new();
for (path, rel) in found {
let entry = Entry {
path: path.clone(),
title: index::title_at(path),
rel: rel.clone(),
folder: String::new(),
modified: std::time::SystemTime::UNIX_EPOCH,
};
if path == new {
let mut moved = entry.clone();
moved.path = old.to_path_buf();
moved.rel = sibling_rel(rel, old);
before.push(moved);
} else {
before.push(entry.clone());
}
after.push(entry);
}
(before, after)
}
fn sibling_rel(rel: &str, old: &Path) -> String {
let name = old
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
match rel.rfind('/') {
Some(i) => format!("{}{name}", &rel[..=i]),
None => name,
}
}
fn write_atomic(path: &Path, body: &str) -> std::io::Result<()> {
let name = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
let tmp = path.with_file_name(format!(".{name}.tmp"));
fs::write(&tmp, body)?;
fs::rename(&tmp, path)
}
pub fn retarget(old: &Path, new: &Path, roots: &[PathBuf]) -> Report {
let new = fs::canonicalize(new).unwrap_or_else(|_| new.to_path_buf());
let old = match (old.parent(), old.file_name()) {
(Some(dir), Some(name)) => fs::canonicalize(dir)
.unwrap_or_else(|_| dir.to_path_buf())
.join(name),
_ => old.to_path_buf(),
};
let Some(stem) = new.file_stem().map(|s| s.to_string_lossy().into_owned()) else {
return Report::default();
};
let found = notes_under(roots);
let (before, after) = views(&found, &old, &new);
let rename = Rename {
old,
new,
stem,
before,
after,
};
let mut report = Report::default();
for (path, _) in found {
if path == rename.new {
continue;
}
let Ok(body) = fs::read_to_string(&path) else {
continue;
};
let Some((rewritten, n)) = rename.rewrite(&body) else {
continue;
};
if write_atomic(&path, &rewritten).is_ok() {
report.links += n;
report.notes.push(path);
}
}
report
}
#[cfg(test)]
mod tests {
use super::*;
fn tmpdir(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("catcher-links-{name}"));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
fs::canonicalize(&dir).unwrap()
}
fn write(dir: &Path, rel: &str, body: &str) -> PathBuf {
let path = dir.join(rel);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(&path, body).unwrap();
path
}
fn read(path: &Path) -> String {
fs::read_to_string(path).unwrap()
}
fn renamed(dir: &Path, from: &str, to: &str) -> Report {
let (old, new) = (dir.join(from), dir.join(to));
fs::rename(&old, &new).unwrap();
retarget(&old, &new, &[dir.to_path_buf()])
}
#[test]
fn a_bare_link_to_the_old_name_points_at_the_new_one() {
let dir = tmpdir("bare");
write(&dir, "groceries.md", "# Groceries\n");
let other = write(&dir, "other.md", "see [[groceries]] later\n");
let r = renamed(&dir, "groceries.md", "shopping.md");
assert_eq!(read(&other), "see [[shopping]] later\n");
assert_eq!(r.links, 1);
assert_eq!(r.notes, vec![other]);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn the_alias_and_the_heading_survive_the_rewrite() {
let dir = tmpdir("forms");
write(&dir, "groceries.md", "# Groceries\n");
let other = write(
&dir,
"other.md",
"[[groceries|the list]] [[groceries#Fruit]] [[groceries#Fruit|fruit]] [[ groceries ]]\n",
);
renamed(&dir, "groceries.md", "shopping.md");
assert_eq!(
read(&other),
"[[shopping|the list]] [[shopping#Fruit]] [[shopping#Fruit|fruit]] [[ shopping ]]\n"
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn a_link_is_matched_without_its_case_or_its_md_suffix() {
let dir = tmpdir("case");
write(&dir, "Story-Matrix.md", "# Story Matrix\n");
let other = write(
&dir,
"other.md",
"[[story-matrix]] and [[Story-Matrix.md]]\n",
);
renamed(&dir, "Story-Matrix.md", "matrix.md");
assert_eq!(read(&other), "[[matrix]] and [[matrix.md]]\n");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn a_path_shaped_link_keeps_its_folder() {
let dir = tmpdir("path");
write(&dir, "stories/spec.md", "# Spec\n");
let other = write(&dir, "other.md", "[[stories/spec]]\n");
let r = renamed(&dir, "stories/spec.md", "stories/plan.md");
assert_eq!(read(&other), "[[stories/plan]]\n");
assert_eq!(r.links, 1);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn a_link_that_resolved_to_another_note_of_the_same_name_is_left_alone() {
let dir = tmpdir("shadow");
write(&dir, "spec.md", "# Spec\n");
write(&dir, "work/spec.md", "# Work Spec\n");
let other = write(&dir, "other.md", "[[spec]] and [[work/spec]]\n");
let r = renamed(&dir, "work/spec.md", "work/plan.md");
assert_eq!(read(&other), "[[spec]] and [[work/plan]]\n");
assert_eq!(r.links, 1);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn a_link_by_title_follows_the_file_too() {
let dir = tmpdir("title");
write(&dir, "groceries.md", "# Groceries\n");
let other = write(&dir, "other.md", "[[Groceries]]\n");
renamed(&dir, "groceries.md", "shopping.md");
assert_eq!(read(&other), "[[shopping]]\n");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn links_are_counted_per_link_and_notes_per_note() {
let dir = tmpdir("count");
write(&dir, "groceries.md", "# Groceries\n");
write(&dir, "a.md", "[[groceries]] twice [[groceries|x]]\n");
write(&dir, "b.md", "once [[groceries#h]]\n");
write(&dir, "c.md", "nothing here\n");
let r = renamed(&dir, "groceries.md", "shopping.md");
assert_eq!(r.links, 3);
assert_eq!(r.notes.len(), 2);
assert_eq!(r.describe().as_deref(), Some("3 links updated in 2 notes"));
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn a_report_of_nothing_says_nothing_and_one_of_one_is_singular() {
assert_eq!(Report::default().describe(), None);
let r = Report {
links: 1,
notes: vec![PathBuf::from("a.md")],
};
assert_eq!(r.describe().as_deref(), Some("1 link updated in 1 note"));
}
#[test]
fn front_matter_and_fenced_code_are_stepped_over() {
let dir = tmpdir("fence");
write(&dir, "groceries.md", "# Groceries\n");
let body = "---\nsee: \"[[groceries]]\"\n---\n[[groceries]]\n```\n[[groceries]]\n```\n";
let other = write(&dir, "other.md", body);
let r = renamed(&dir, "groceries.md", "shopping.md");
assert_eq!(
read(&other),
"---\nsee: \"[[groceries]]\"\n---\n[[shopping]]\n```\n[[groceries]]\n```\n"
);
assert_eq!(r.links, 1);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn the_renamed_note_itself_and_untouched_notes_are_not_written() {
let dir = tmpdir("untouched");
write(&dir, "groceries.md", "# Groceries\nme: [[groceries]]\n");
let other = write(&dir, "other.md", "no links\n");
let r = renamed(&dir, "groceries.md", "shopping.md");
assert_eq!(
read(&dir.join("shopping.md")),
"# Groceries\nme: [[groceries]]\n"
);
assert_eq!(read(&other), "no links\n");
assert_eq!(r, Report::default());
assert!(!dir.join(".other.md.tmp").exists());
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn a_rewrite_that_would_land_on_a_different_note_is_not_made() {
let dir = tmpdir("taken");
write(&dir, "plan.md", "# Plan\n");
write(&dir, "work/spec.md", "# Spec\n");
let other = write(&dir, "other.md", "[[spec]]\n");
let r = renamed(&dir, "work/spec.md", "work/plan.md");
assert_eq!(read(&other), "[[spec]]\n");
assert_eq!(r, Report::default());
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn a_collision_suffix_is_followed() {
let dir = tmpdir("suffix");
write(&dir, "groceries.md", "# Groceries\n");
write(&dir, "shopping.md", "# Shopping\n");
let other = write(&dir, "other.md", "[[groceries]]\n");
let r = renamed(&dir, "groceries.md", "shopping-2.md");
assert_eq!(read(&other), "[[shopping-2]]\n");
assert_eq!(r.links, 1);
let _ = fs::remove_dir_all(&dir);
}
}