#![allow(clippy::missing_errors_doc)]
use std::collections::HashMap;
use std::path::Path;
use anyhow::{Context, Result};
use serde::Serialize;
use crate::case_index::CaseInsensitiveIndex;
use crate::discovery::canonicalize_vault_dir;
use crate::discovery::resolve_target;
use crate::index::VaultIndex;
use crate::link_graph::{FileLinks, normalize_target};
use crate::link_rewrite::{Replacement, RewritePlan, apply_replacements, execute_plans};
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 BrokenLinkInfo {
pub source: String,
pub line: usize,
pub target: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct BrokenLinkReport {
pub total_links: usize,
pub broken: Vec<BrokenLinkInfo>,
pub case_mismatches: Vec<FixPlan>,
}
#[derive(Debug, Clone, Serialize)]
pub struct FixPlan {
pub source: String,
pub line: usize,
pub old_target: String,
pub new_target: String,
pub strategy: FixStrategy,
pub confidence: f64,
}
#[derive(Debug, Clone, Serialize)]
pub enum FixStrategy {
CaseInsensitive,
ExtensionMismatch,
ShortestPath,
FuzzyMatch,
LinkCaseMismatch,
}
#[derive(Debug, Clone, Serialize)]
pub struct FixReport {
pub fixes: Vec<FixPlan>,
pub unfixable: Vec<BrokenLinkInfo>,
}
enum LinkResolution {
Resolved(Option<String>),
CaseMismatch(String),
Broken,
}
fn classify_link(
canonical_dir: &Path,
resolved_target: &str,
site_prefix: Option<&str>,
case_index: Option<&CaseInsensitiveIndex>,
) -> LinkResolution {
let exact = resolve_target(canonical_dir, resolved_target, site_prefix, None);
if let Some(exact_str) = exact {
if let Some(idx) = case_index
&& let Some(canonical_path) =
resolve_target(canonical_dir, resolved_target, site_prefix, Some(idx))
{
let canonical_fwd = canonical_path.replace('\\', "/");
let exact_fwd = exact_str.replace('\\', "/");
if exact_fwd != canonical_fwd {
return LinkResolution::Resolved(Some(canonical_fwd));
}
}
return LinkResolution::Resolved(None);
}
if let Some(idx) = case_index
&& let Some(canonical_path) =
resolve_target(canonical_dir, resolved_target, site_prefix, Some(idx))
{
return LinkResolution::CaseMismatch(canonical_path.replace('\\', "/"));
}
LinkResolution::Broken
}
#[allow(dead_code)] pub(crate) fn detect_broken_links(
dir: &Path,
file_links: &[FileLinks],
site_prefix: Option<&str>,
case_index: Option<&CaseInsensitiveIndex>,
) -> BrokenLinkReport {
let Ok(canonical) = canonicalize_vault_dir(dir) else {
return BrokenLinkReport {
total_links: 0,
broken: Vec::new(),
case_mismatches: Vec::new(),
};
};
let mut total_links = 0usize;
let mut broken: Vec<BrokenLinkInfo> = Vec::new();
let mut case_mismatches: Vec<FixPlan> = Vec::new();
for fl in file_links {
let source_str = fl.source.to_string_lossy().replace('\\', "/");
for (line, link) in &fl.links {
total_links += 1;
let resolved_target = match link.kind {
LinkKind::Wikilink => link.target.clone(),
LinkKind::Markdown => {
if link.target.starts_with('/') {
link.target.clone()
} else if link.target.contains('/') || link.target.contains('\\') {
normalize_target(Path::new(&source_str), &link.target)
} else {
link.target.clone()
}
}
};
match classify_link(&canonical, &resolved_target, site_prefix, case_index) {
LinkResolution::Resolved(None) => {}
LinkResolution::Resolved(Some(canonical_str))
| LinkResolution::CaseMismatch(canonical_str) => {
case_mismatches.push(FixPlan {
source: source_str.clone(),
line: *line,
old_target: link.target.clone(),
new_target: canonical_str,
strategy: FixStrategy::LinkCaseMismatch,
confidence: 1.0,
});
}
LinkResolution::Broken => {
broken.push(BrokenLinkInfo {
source: source_str.clone(),
line: *line,
target: link.target.clone(),
});
}
}
}
}
broken.sort_by(|a, b| a.source.cmp(&b.source).then_with(|| a.line.cmp(&b.line)));
case_mismatches.sort_by(|a, b| a.source.cmp(&b.source).then_with(|| a.line.cmp(&b.line)));
BrokenLinkReport {
total_links,
broken,
case_mismatches,
}
}
pub fn detect_broken_links_from_index(
dir: &Path,
index: &dyn VaultIndex,
site_prefix: Option<&str>,
case_index: Option<&CaseInsensitiveIndex>,
) -> BrokenLinkReport {
let Ok(canonical) = canonicalize_vault_dir(dir) else {
return BrokenLinkReport {
total_links: 0,
broken: Vec::new(),
case_mismatches: Vec::new(),
};
};
let mut total_links = 0usize;
let mut broken: Vec<BrokenLinkInfo> = Vec::new();
let mut case_mismatches: Vec<FixPlan> = Vec::new();
for entry in index.entries() {
for (line, link) in &entry.links {
total_links += 1;
let resolved_target = match link.kind {
LinkKind::Wikilink => link.target.clone(),
LinkKind::Markdown => {
if link.target.starts_with('/') {
link.target.clone()
} else if link.target.contains('/') || link.target.contains('\\') {
normalize_target(Path::new(&entry.rel_path), &link.target)
} else {
link.target.clone()
}
}
};
match classify_link(&canonical, &resolved_target, site_prefix, case_index) {
LinkResolution::Resolved(None) => {}
LinkResolution::Resolved(Some(canonical_str))
| LinkResolution::CaseMismatch(canonical_str) => {
case_mismatches.push(FixPlan {
source: entry.rel_path.clone(),
line: *line,
old_target: link.target.clone(),
new_target: canonical_str,
strategy: FixStrategy::LinkCaseMismatch,
confidence: 1.0,
});
}
LinkResolution::Broken => {
broken.push(BrokenLinkInfo {
source: entry.rel_path.clone(),
line: *line,
target: link.target.clone(),
});
}
}
}
}
broken.sort_by(|a, b| a.source.cmp(&b.source).then_with(|| a.line.cmp(&b.line)));
case_mismatches.sort_by(|a, b| a.source.cmp(&b.source).then_with(|| a.line.cmp(&b.line)));
BrokenLinkReport {
total_links,
broken,
case_mismatches,
}
}
pub struct LinkMatcher {
files: Vec<String>,
lower_to_idx: HashMap<String, usize>,
exact_to_idx: HashMap<String, usize>,
stem_to_indices: HashMap<String, Vec<usize>>,
threshold: f64,
}
pub(crate) struct MatchResult {
pub matched_file: String,
pub strategy: FixStrategy,
pub confidence: f64,
}
impl LinkMatcher {
pub fn new(files: Vec<String>, threshold: f64) -> Self {
let mut lower_to_idx = HashMap::with_capacity(files.len());
let mut exact_to_idx = HashMap::with_capacity(files.len());
let mut stem_to_indices: HashMap<String, Vec<usize>> = HashMap::new();
for (i, f) in files.iter().enumerate() {
exact_to_idx.entry(f.clone()).or_insert(i);
let alt = if f.to_ascii_lowercase().ends_with(".md") {
f.strip_suffix(".md")
.or_else(|| f.strip_suffix(".MD"))
.map(std::string::ToString::to_string)
} else {
Some(format!("{f}.md"))
};
if let Some(a) = alt {
exact_to_idx.entry(a).or_insert(i);
}
let lower = f.to_ascii_lowercase();
lower_to_idx.entry(lower.clone()).or_insert(i);
if let Some(stem) = lower.strip_suffix(".md") {
lower_to_idx.entry(stem.to_string()).or_insert(i);
}
let fname = f.rsplit('/').next().unwrap_or(f.as_str());
let fstem = fname.strip_suffix(".md").unwrap_or(fname);
stem_to_indices
.entry(fstem.to_ascii_lowercase())
.or_default()
.push(i);
}
Self {
files,
lower_to_idx,
exact_to_idx,
stem_to_indices,
threshold,
}
}
pub fn from_index(index: &dyn VaultIndex, threshold: f64) -> Self {
let files: Vec<String> = index.entries().iter().map(|e| e.rel_path.clone()).collect();
Self::new(files, threshold)
}
fn is_self_link(source: &str, candidate: &str) -> bool {
fn strip_md(s: &str) -> &str {
if s.len() >= 3 && s[s.len() - 3..].eq_ignore_ascii_case(".md") {
&s[..s.len() - 3]
} else {
s
}
}
strip_md(source).eq_ignore_ascii_case(strip_md(candidate))
}
pub(crate) fn find_match(&self, raw_target: &str, source: &str) -> Option<MatchResult> {
const TIE_DELTA: f64 = 0.01;
let target_filename = raw_target.rsplit('/').next().unwrap_or(raw_target);
let target_stem = target_filename
.strip_suffix(".md")
.unwrap_or(target_filename);
let target_lower = raw_target.to_ascii_lowercase();
let exact_alt = if std::path::Path::new(&target_lower)
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("md"))
{
raw_target[..raw_target.len() - 3].to_string()
} else {
format!("{raw_target}.md")
};
if let Some(&idx) = self.lower_to_idx.get(&target_lower) {
let candidate = &self.files[idx];
if *candidate != exact_alt && !Self::is_self_link(source, candidate) {
return Some(MatchResult {
matched_file: candidate.clone(),
strategy: FixStrategy::CaseInsensitive,
confidence: 1.0,
});
}
}
if let Some(&idx) = self.exact_to_idx.get(&exact_alt)
&& !Self::is_self_link(source, &self.files[idx])
{
return Some(MatchResult {
matched_file: self.files[idx].clone(),
strategy: FixStrategy::ExtensionMismatch,
confidence: 1.0,
});
}
let target_stem_lower = target_stem.to_ascii_lowercase();
if let Some(indices) = self.stem_to_indices.get(&target_stem_lower)
&& indices.len() == 1
&& !Self::is_self_link(source, &self.files[indices[0]])
{
return Some(MatchResult {
matched_file: self.files[indices[0]].clone(),
strategy: FixStrategy::ShortestPath,
confidence: 0.95,
});
}
let mut best_score = self.threshold;
let mut second_score = 0.0_f64;
let mut best_idx: Option<usize> = None;
for (i, candidate) in self.files.iter().enumerate() {
if Self::is_self_link(source, candidate) {
continue;
}
let fname = candidate.rsplit('/').next().unwrap_or(candidate.as_str());
let fstem = fname.strip_suffix(".md").unwrap_or(fname);
let score = strsim::jaro_winkler(target_stem, fstem);
if score > best_score {
second_score = best_score;
best_score = score;
best_idx = Some(i);
} else if score > second_score {
second_score = score;
}
}
if best_score - second_score <= TIE_DELTA {
return None;
}
best_idx.map(|idx| MatchResult {
matched_file: self.files[idx].clone(),
strategy: FixStrategy::FuzzyMatch,
confidence: best_score,
})
}
}
pub fn plan_fixes(broken: &[BrokenLinkInfo], matcher: &LinkMatcher) -> FixReport {
let mut fixes = Vec::new();
let mut unfixable = Vec::new();
for info in broken {
if let Some(result) = matcher.find_match(&info.target, &info.source) {
fixes.push(FixPlan {
source: info.source.clone(),
line: info.line,
old_target: info.target.clone(),
new_target: result.matched_file,
strategy: result.strategy,
confidence: result.confidence,
});
} else {
unfixable.push(info.clone());
}
}
FixReport { fixes, unfixable }
}
pub fn apply_fixes(
dir: &Path,
fixes: &[FixPlan],
site_prefix: Option<&str>,
) -> Result<Vec<RewritePlan>> {
let mut by_source: HashMap<&str, Vec<&FixPlan>> = HashMap::new();
for fix in fixes {
by_source.entry(fix.source.as_str()).or_default().push(fix);
}
let mut plans: Vec<RewritePlan> = Vec::new();
for (source_rel, file_fixes) in &by_source {
let abs_path = dir.join(source_rel.replace('\\', "/"));
let meta = std::fs::metadata(&abs_path)
.with_context(|| format!("failed to stat {}", abs_path.display()))?;
let file_size = meta.len();
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 file_mtime = meta
.modified()
.with_context(|| format!("failed to read mtime for {}", abs_path.display()))
.map(|t| (t, file_size))
.ok();
let content = std::fs::read_to_string(&abs_path)
.with_context(|| format!("reading {}", abs_path.display()))?;
let replacements =
build_replacements_for_file(&content, source_rel, file_fixes, site_prefix);
if !replacements.is_empty() {
let rewritten_content = apply_replacements(&content, &replacements);
plans.push(RewritePlan {
path: abs_path,
rel_path: source_rel.to_string(),
replacements,
rewritten_content,
mtime: file_mtime,
});
}
}
execute_plans(dir, &plans)?;
Ok(plans)
}
fn build_replacements_for_file(
content: &str,
source_rel: &str,
fixes: &[&FixPlan],
_site_prefix: Option<&str>,
) -> Vec<Replacement> {
let mut fixes_by_line: HashMap<usize, Vec<&FixPlan>> = HashMap::new();
for fix in fixes {
fixes_by_line.entry(fix.line).or_default().push(fix);
}
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 is_comment_fence(line) {
in_comment_fence = !in_comment_fence;
continue;
}
if in_comment_fence {
continue;
}
if fence.process_line(line) {
continue;
}
let Some(line_fixes) = fixes_by_line.get(&line_num) else {
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 normalized_span_target = match span.kind {
LinkKind::Wikilink => span.link.target.clone(),
LinkKind::Markdown => {
if span.link.target.starts_with('/') {
span.link.target.clone()
} else if span.link.target.contains('/') || span.link.target.contains('\\') {
normalize_target(Path::new(source_rel), &span.link.target)
} else {
span.link.target.clone()
}
}
};
let Some(fix) = line_fixes.iter().find(|f| {
f.old_target == normalized_span_target || f.old_target == span.link.target
}) else {
continue;
};
let new_target_text = match span.kind {
LinkKind::Wikilink => {
fix.new_target
.strip_suffix(".md")
.unwrap_or(&fix.new_target)
.to_string()
}
LinkKind::Markdown => {
let orig_had_md = fix.old_target.to_ascii_lowercase().ends_with(".md");
if orig_had_md {
fix.new_target.clone()
} else {
fix.new_target
.strip_suffix(".md")
.unwrap_or(&fix.new_target)
.to_string()
}
}
};
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_text,
&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
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::path::PathBuf;
use tempfile::TempDir;
fn make_files(names: &[&str]) -> Vec<String> {
names.iter().map(std::string::ToString::to_string).collect()
}
fn broken(source: &str, line: usize, target: &str) -> BrokenLinkInfo {
BrokenLinkInfo {
source: source.to_string(),
line,
target: target.to_string(),
}
}
fn vault_with_files(files: &[(&str, &str)]) -> TempDir {
let dir = TempDir::new().unwrap();
for (rel, content) in files {
let path = dir
.path()
.join(rel.replace('/', std::path::MAIN_SEPARATOR_STR));
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(&path, content).unwrap();
}
dir
}
#[test]
fn matcher_case_insensitive() {
let matcher = LinkMatcher::new(make_files(&["Auth.md"]), 0.8);
let result = matcher.find_match("auth", "__test__").unwrap();
assert_eq!(result.matched_file, "Auth.md");
assert!(matches!(result.strategy, FixStrategy::CaseInsensitive));
assert!((result.confidence - 1.0).abs() < f64::EPSILON);
}
#[test]
fn matcher_extension_mismatch_add_md() {
let matcher = LinkMatcher::new(make_files(&["notes/foo.md"]), 0.8);
let result = matcher.find_match("notes/foo", "__test__").unwrap();
assert_eq!(result.matched_file, "notes/foo.md");
assert!(matches!(result.strategy, FixStrategy::ExtensionMismatch));
}
#[test]
fn matcher_extension_mismatch_strip_md() {
let matcher = LinkMatcher::new(make_files(&["foo"]), 0.8);
let result = matcher.find_match("foo.md", "__test__").unwrap();
assert_eq!(result.matched_file, "foo");
assert!(matches!(result.strategy, FixStrategy::ExtensionMismatch));
}
#[test]
fn matcher_shortest_path_unique_stem() {
let matcher = LinkMatcher::new(make_files(&["sub/deep/bar.md"]), 0.8);
let result = matcher.find_match("bar", "__test__").unwrap();
assert_eq!(result.matched_file, "sub/deep/bar.md");
assert!(matches!(result.strategy, FixStrategy::ShortestPath));
assert!((result.confidence - 0.95).abs() < f64::EPSILON);
}
#[test]
fn matcher_shortest_path_ambiguous_skipped() {
let matcher = LinkMatcher::new(make_files(&["a/bar.md", "b/bar.md"]), 0.99);
let result = matcher.find_match("bar", "__test__");
if let Some(r) = result {
assert!(!matches!(r.strategy, FixStrategy::ShortestPath));
}
}
#[test]
fn matcher_fuzzy_match() {
let matcher = LinkMatcher::new(make_files(&["authentication.md"]), 0.7);
let result = matcher.find_match("authentcation", "__test__").unwrap();
assert_eq!(result.matched_file, "authentication.md");
assert!(matches!(result.strategy, FixStrategy::FuzzyMatch));
assert!(result.confidence >= 0.7);
}
#[test]
fn matcher_no_match() {
let matcher = LinkMatcher::new(make_files(&["completely-unrelated.md"]), 0.95);
assert!(matcher.find_match("xyz-abc-notexist", "__test__").is_none());
}
#[test]
fn matcher_rejects_self_link_fuzzy() {
let matcher = LinkMatcher::new(make_files(&["sort-by-property-value.md"]), 0.7);
assert!(
matcher
.find_match("sort-reverse", "sort-by-property-value.md")
.is_none(),
"should not match source file via fuzzy"
);
}
#[test]
fn matcher_rejects_self_link_picks_next_best() {
let matcher = LinkMatcher::new(
make_files(&["sort-by-property-value.md", "sort-reverse.md"]),
0.7,
);
let result = matcher
.find_match("sort-reverse", "sort-by-property-value.md")
.unwrap();
assert_eq!(result.matched_file, "sort-reverse.md");
}
#[test]
fn matcher_rejects_self_link_case_insensitive() {
let matcher = LinkMatcher::new(make_files(&["Auth.md"]), 0.8);
assert!(matcher.find_match("auth", "Auth.md").is_none());
}
#[test]
fn matcher_rejects_self_link_extension_mismatch() {
let matcher = LinkMatcher::new(make_files(&["notes/foo.md"]), 0.8);
assert!(matcher.find_match("notes/foo.md", "notes/foo").is_none());
}
#[test]
fn matcher_rejects_self_link_shortest_path() {
let matcher = LinkMatcher::new(make_files(&["sub/bar.md"]), 0.8);
assert!(matcher.find_match("bar", "sub/bar.md").is_none());
}
#[test]
fn matcher_self_link_among_ambiguous_stems_picks_other() {
let matcher = LinkMatcher::new(make_files(&["a/bar.md", "b/bar.md"]), 0.8);
let result = matcher.find_match("bar", "a/bar.md").unwrap();
assert_eq!(result.matched_file, "b/bar.md");
}
#[test]
fn plan_fixes_self_link_is_unfixable() {
let matcher = LinkMatcher::new(make_files(&["sort-by-property-value.md"]), 0.7);
let broken_links = vec![broken("sort-by-property-value.md", 10, "sort-reverse")];
let report = plan_fixes(&broken_links, &matcher);
assert!(report.fixes.is_empty(), "self-link should not be a fix");
assert_eq!(report.unfixable.len(), 1);
}
#[test]
fn plan_fixes_produces_fix_and_unfixable() {
let matcher = LinkMatcher::new(make_files(&["Auth.md"]), 0.95);
let broken_links = vec![
broken("index.md", 1, "auth"),
broken("index.md", 5, "totally-nonexistent"),
];
let report = plan_fixes(&broken_links, &matcher);
assert_eq!(report.fixes.len(), 1);
assert_eq!(report.fixes[0].new_target, "Auth.md");
assert_eq!(report.unfixable.len(), 1);
}
#[test]
fn detect_broken_links_finds_missing() {
use crate::link_graph::FileLinks;
use crate::links::{Link, LinkKind};
let tmp = vault_with_files(&[("index.md", "[[existing]]"), ("existing.md", "")]);
let file_links = vec![FileLinks {
source: PathBuf::from("index.md"),
links: vec![
(
1,
Link {
target: "existing".to_string(),
label: None,
kind: LinkKind::Wikilink,
},
),
(
2,
Link {
target: "missing".to_string(),
label: None,
kind: LinkKind::Wikilink,
},
),
],
}];
let report = detect_broken_links(tmp.path(), &file_links, None, None);
assert_eq!(report.total_links, 2);
assert_eq!(report.broken.len(), 1);
assert_eq!(report.broken[0].target, "missing");
}
#[test]
fn detect_broken_links_sorted() {
use crate::link_graph::FileLinks;
use crate::links::{Link, LinkKind};
let tmp = vault_with_files(&[("a.md", ""), ("b.md", "")]);
let file_links = vec![
FileLinks {
source: PathBuf::from("b.md"),
links: vec![(
3,
Link {
target: "gone".to_string(),
label: None,
kind: LinkKind::Wikilink,
},
)],
},
FileLinks {
source: PathBuf::from("a.md"),
links: vec![
(
5,
Link {
target: "also-gone".to_string(),
label: None,
kind: LinkKind::Wikilink,
},
),
(
1,
Link {
target: "nope".to_string(),
label: None,
kind: LinkKind::Wikilink,
},
),
],
},
];
let report = detect_broken_links(tmp.path(), &file_links, None, None);
assert_eq!(report.broken.len(), 3);
assert_eq!(report.broken[0].source, "a.md");
assert_eq!(report.broken[0].line, 1);
assert_eq!(report.broken[1].source, "a.md");
assert_eq!(report.broken[1].line, 5);
assert_eq!(report.broken[2].source, "b.md");
assert_eq!(report.broken[2].line, 3);
}
#[test]
fn apply_fixes_rewrites_wikilink() {
let tmp = vault_with_files(&[
("index.md", "See [[wrongname]] for details.\n"),
("correct-name.md", ""),
]);
let fixes = vec![FixPlan {
source: "index.md".to_string(),
line: 1,
old_target: "wrongname".to_string(),
new_target: "correct-name.md".to_string(),
strategy: FixStrategy::FuzzyMatch,
confidence: 0.9,
}];
let plans = apply_fixes(tmp.path(), &fixes, None).unwrap();
assert_eq!(plans.len(), 1);
let written = fs::read_to_string(tmp.path().join("index.md")).unwrap();
assert!(
written.contains("[[correct-name]]"),
"expected wikilink stem, got: {written}"
);
}
#[test]
fn apply_fixes_rewrites_markdown_link() {
let tmp = vault_with_files(&[
("index.md", "See [text](wrong.md) for details.\n"),
("correct.md", ""),
]);
let fixes = vec![FixPlan {
source: "index.md".to_string(),
line: 1,
old_target: "wrong.md".to_string(),
new_target: "correct.md".to_string(),
strategy: FixStrategy::CaseInsensitive,
confidence: 1.0,
}];
let plans = apply_fixes(tmp.path(), &fixes, None).unwrap();
assert_eq!(plans.len(), 1);
let written = fs::read_to_string(tmp.path().join("index.md")).unwrap();
assert!(
written.contains("[text](correct.md)"),
"expected rewritten link, got: {written}"
);
}
#[test]
fn detect_broken_links_emits_case_mismatch_with_index() {
use crate::case_index::CaseInsensitiveIndex;
use crate::link_graph::FileLinks;
use crate::links::{Link, LinkKind};
let tmp = vault_with_files(&[("web/foo.md", ""), ("source.md", "[[Web/Foo]]")]);
let mut idx = CaseInsensitiveIndex::new();
idx.insert("web/foo.md");
let file_links = vec![FileLinks {
source: PathBuf::from("source.md"),
links: vec![(
1,
Link {
target: "Web/Foo".to_string(),
label: None,
kind: LinkKind::Wikilink,
},
)],
}];
let report_no_idx = detect_broken_links(tmp.path(), &file_links, None, None);
assert_eq!(report_no_idx.total_links, 1);
assert!(
report_no_idx.case_mismatches.is_empty(),
"case_mismatches must always be empty when no index is provided"
);
let report_with_idx = detect_broken_links(tmp.path(), &file_links, None, Some(&idx));
assert_eq!(report_with_idx.total_links, 1);
let total_classified = report_with_idx.broken.len() + report_with_idx.case_mismatches.len();
assert!(
total_classified <= 1,
"each link must appear at most once across broken + case_mismatches"
);
}
#[test]
fn detect_broken_links_case_mismatch_has_correct_strategy() {
use crate::case_index::CaseInsensitiveIndex;
use crate::link_graph::FileLinks;
use crate::links::{Link, LinkKind};
let tmp = vault_with_files(&[("web/foo.md", ""), ("source.md", "")]);
let mut idx = CaseInsensitiveIndex::new();
idx.insert("web/foo.md");
let file_links = vec![FileLinks {
source: PathBuf::from("source.md"),
links: vec![(
1,
Link {
target: "Web/Foo".to_string(),
label: None,
kind: LinkKind::Wikilink,
},
)],
}];
let report = detect_broken_links(tmp.path(), &file_links, None, Some(&idx));
for fix in &report.case_mismatches {
assert!(
matches!(fix.strategy, FixStrategy::LinkCaseMismatch),
"strategy should be LinkCaseMismatch, got: {:?}",
fix.strategy
);
assert!(
(fix.confidence - 1.0).abs() < f64::EPSILON,
"confidence should be 1.0"
);
assert_eq!(
fix.old_target, "Web/Foo",
"old_target should preserve original casing"
);
}
}
#[test]
fn detect_broken_links_no_index_no_case_mismatches() {
use crate::link_graph::FileLinks;
use crate::links::{Link, LinkKind};
let tmp = vault_with_files(&[("web/foo.md", ""), ("source.md", "")]);
let file_links = vec![FileLinks {
source: PathBuf::from("source.md"),
links: vec![(
1,
Link {
target: "Web/Foo".to_string(),
label: None,
kind: LinkKind::Wikilink,
},
)],
}];
let report = detect_broken_links(tmp.path(), &file_links, None, None);
assert!(
report.case_mismatches.is_empty(),
"case_mismatches must be empty when no index is provided"
);
}
}