use aho_corasick::{AhoCorasick, AhoCorasickBuilder, MatchKind};
use papaya::HashMap as ConcurrentHashMap;
use regex::Regex;
use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Instant;
use walkdir::WalkDir;
use crate::link_index::InboundLink;
use crate::repo::should_ignore;
#[derive(Clone)]
struct InboundLinkCacheEntry {
links: Vec<InboundLink>,
computed_at: std::time::Instant,
size_bytes: usize,
}
pub struct InboundLinkCache {
cache: ConcurrentHashMap<String, InboundLinkCacheEntry>,
current_size: AtomicUsize,
max_size: usize,
ttl_seconds: u64,
}
impl InboundLinkCache {
pub fn new(max_size_bytes: usize, ttl_seconds: u64) -> Self {
Self {
cache: ConcurrentHashMap::new(),
current_size: AtomicUsize::new(0),
max_size: max_size_bytes,
ttl_seconds,
}
}
pub fn get(&self, url_path: &str) -> Option<Vec<InboundLink>> {
if self.max_size == 0 {
return None;
}
let guard = self.cache.pin();
if let Some(entry) = guard.get(url_path) {
if entry.computed_at.elapsed().as_secs() < self.ttl_seconds {
tracing::debug!("inbound link cache hit: {}", url_path);
return Some(entry.links.clone());
} else {
tracing::debug!("inbound link cache expired: {}", url_path);
}
}
None
}
pub fn insert(&self, url_path: String, links: Vec<InboundLink>) {
if self.max_size == 0 {
return;
}
let size_bytes = url_path.len()
+ links
.iter()
.map(|l| {
l.from.len()
+ l.text.len()
+ l.anchor.as_ref().map(|a| a.len()).unwrap_or(0)
+ 32
})
.sum::<usize>()
+ std::mem::size_of::<InboundLinkCacheEntry>();
let entry = InboundLinkCacheEntry {
links,
computed_at: Instant::now(),
size_bytes,
};
self.cache.pin().insert(url_path.clone(), entry);
let new_size = self.current_size.fetch_add(size_bytes, Ordering::Relaxed) + size_bytes;
tracing::debug!("inbound links cached: {} ({} bytes)", url_path, size_bytes);
if new_size > self.max_size {
self.evict_oldest(new_size - self.max_size);
}
}
fn evict_oldest(&self, target_bytes: usize) {
let guard = self.cache.pin();
let mut entries: Vec<(String, Instant, usize)> = guard
.iter()
.map(|(k, v)| (k.clone(), v.computed_at, v.size_bytes))
.collect();
entries.sort_by_key(|(_, computed_at, _)| *computed_at);
let mut freed = 0usize;
let mut evict_count = 0usize;
for (url, _, size) in entries {
if freed >= target_bytes {
break;
}
if guard.remove(&url).is_some() {
freed += size;
evict_count += 1;
self.current_size.fetch_sub(size, Ordering::Relaxed);
}
}
if evict_count > 0 {
tracing::debug!(
"inbound link cache evicted {} entries ({} bytes freed)",
evict_count,
freed
);
}
}
pub fn invalidate_all(&self) {
let guard = self.cache.pin();
let keys: Vec<String> = guard.iter().map(|(k, _)| k.clone()).collect();
for key in keys {
guard.remove(&key);
}
self.current_size.store(0, Ordering::Relaxed);
tracing::debug!("inbound link cache invalidated");
}
}
fn compute_relative_path(source_folder: &str, target_path: &str) -> String {
let source = source_folder.trim_start_matches('/').trim_end_matches('/');
let target = target_path.trim_start_matches('/').trim_end_matches('/');
let source_parts: Vec<&str> = if source.is_empty() {
vec![]
} else {
source.split('/').collect()
};
let target_parts: Vec<&str> = if target.is_empty() {
vec![]
} else {
target.split('/').collect()
};
let common_len = source_parts
.iter()
.zip(target_parts.iter())
.take_while(|(a, b)| a == b)
.count();
let ups_needed = source_parts.len() - common_len;
let mut result_parts: Vec<&str> = vec![".."; ups_needed];
result_parts.extend(&target_parts[common_len..]);
if result_parts.is_empty() {
".".to_string()
} else {
result_parts.join("/")
}
}
fn compute_patterns_for_folder(source_folder: &str, target_url_path: &str) -> Vec<String> {
let mut patterns = HashSet::new();
let target_normalized = target_url_path
.trim_start_matches('/')
.trim_end_matches('/');
if target_normalized.is_empty() {
return vec![];
}
let abs_path = format!("/{}", target_normalized);
add_pattern_variants(&mut patterns, &abs_path);
let relative = compute_relative_path(source_folder, target_url_path);
if relative != "." {
add_pattern_variants(&mut patterns, &relative);
if !relative.starts_with("../") && !relative.starts_with("./") {
add_pattern_variants(&mut patterns, &format!("./{}", relative));
}
}
patterns.into_iter().collect()
}
fn add_pattern_variants(patterns: &mut HashSet<String>, base: &str) {
let normalized = base.trim_end_matches('/');
patterns.insert(normalized.to_string());
patterns.insert(format!("{}/", normalized));
patterns.insert(format!("{}.md", normalized));
patterns.insert(format!("{}#", normalized));
}
fn build_folder_patterns(
target_url_path: &str,
all_folders: &HashSet<String>,
) -> HashMap<String, Vec<String>> {
all_folders
.iter()
.map(|folder| {
let patterns = compute_patterns_for_folder(folder, target_url_path);
(folder.clone(), patterns)
})
.collect()
}
fn build_extraction_regex(patterns: &[String]) -> Option<Regex> {
if patterns.is_empty() {
return None;
}
let escaped_patterns: Vec<String> = patterns
.iter()
.map(|p| {
let base = p
.trim_end_matches('/')
.trim_end_matches(".md")
.trim_end_matches('#');
regex::escape(base)
})
.collect();
let unique_patterns: HashSet<String> = escaped_patterns.into_iter().collect();
let pattern_alternation = unique_patterns.into_iter().collect::<Vec<_>>().join("|");
let pattern = format!(
r#"\[([^\]]*)\]\((?:{})(?:\.md)?(?:/)?(?:#([^)]*))?\)"#,
pattern_alternation
);
Regex::new(&pattern).ok()
}
fn build_wiki_extraction_regex(patterns: &[String]) -> Option<Regex> {
if patterns.is_empty() {
return None;
}
let escaped_patterns: Vec<String> = patterns
.iter()
.map(|p| {
let base = p
.trim_end_matches('/')
.trim_end_matches(".md")
.trim_end_matches('#');
regex::escape(base)
})
.collect();
let unique_patterns: HashSet<String> = escaped_patterns.into_iter().collect();
let pattern_alternation = unique_patterns.into_iter().collect::<Vec<_>>().join("|");
let pattern = format!(
r#"(?i)\[\[(?:{})(?:\.md)?(?:/)?(?:#([^\]|]*))?(?:\|([^\]]*))?\]\]"#,
pattern_alternation
);
Regex::new(&pattern).ok()
}
fn build_ref_extraction_regex(patterns: &[String]) -> Option<Regex> {
if patterns.is_empty() {
return None;
}
let escaped_patterns: Vec<String> = patterns
.iter()
.map(|p| {
let base = p
.trim_end_matches('/')
.trim_end_matches(".md")
.trim_end_matches('#');
regex::escape(base)
})
.collect();
let unique_patterns: HashSet<String> = escaped_patterns.into_iter().collect();
let pattern_alternation = unique_patterns.into_iter().collect::<Vec<_>>().join("|");
let pattern = format!(
r#"\[([^\]]+)\]:\s*(?:{})(?:\.md)?(?:/)?(?:#\S*)?"#,
pattern_alternation
);
Regex::new(&pattern).ok()
}
pub fn find_inbound_links(
target_url_path: &str,
root_dir: &Path,
markdown_extensions: &[String],
ignore_dirs: &[String],
ignore_globs: &[String],
) -> Vec<InboundLink> {
let start = Instant::now();
let mut inbound_links = Vec::new();
let target_normalized = target_url_path.trim_end_matches('/');
let target_segments = target_normalized.trim_start_matches('/');
if target_segments.is_empty() {
return inbound_links;
}
let mut folder_files: HashMap<String, Vec<(PathBuf, String)>> = HashMap::new();
for entry in WalkDir::new(root_dir)
.follow_links(true)
.into_iter()
.filter_entry(|e| {
let path = e.path();
if path.is_dir()
&& let Some(name) = path.file_name().and_then(|n| n.to_str())
{
return !ignore_dirs.contains(&name.to_string());
}
true
})
.filter_map(|e| e.ok())
{
let path = entry.path();
if !path.is_file() {
continue;
}
let extension = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_lowercase();
if !markdown_extensions.contains(&extension) {
continue;
}
if should_ignore(path, ignore_dirs, ignore_globs) {
continue;
}
let source_url_path = compute_url_path(path, root_dir, markdown_extensions);
let folder_url_path = get_folder_url_path(&source_url_path);
if source_url_path.trim_end_matches('/') == target_normalized {
continue;
}
folder_files
.entry(folder_url_path)
.or_default()
.push((path.to_path_buf(), source_url_path));
}
let all_folders: HashSet<String> = folder_files.keys().cloned().collect();
let folder_patterns = build_folder_patterns(target_url_path, &all_folders);
let mut folder_automatons: HashMap<String, Option<AhoCorasick>> = HashMap::new();
for (folder, patterns) in &folder_patterns {
if patterns.is_empty() {
folder_automatons.insert(folder.clone(), None);
} else {
match AhoCorasickBuilder::new()
.ascii_case_insensitive(true)
.match_kind(MatchKind::LeftmostFirst)
.build(patterns)
{
Ok(ac) => {
folder_automatons.insert(folder.clone(), Some(ac));
}
Err(e) => {
tracing::warn!("Failed to build Aho-Corasick for folder {}: {}", folder, e);
folder_automatons.insert(folder.clone(), None);
}
}
}
}
let mut folder_link_regexes: HashMap<String, Option<Regex>> = HashMap::new();
let mut folder_wiki_regexes: HashMap<String, Option<Regex>> = HashMap::new();
let mut folder_ref_regexes: HashMap<String, Option<Regex>> = HashMap::new();
for (folder, patterns) in &folder_patterns {
folder_link_regexes.insert(folder.clone(), build_extraction_regex(patterns));
folder_wiki_regexes.insert(folder.clone(), build_wiki_extraction_regex(patterns));
folder_ref_regexes.insert(folder.clone(), build_ref_extraction_regex(patterns));
}
let mut files_scanned = 0;
for (folder, files) in &folder_files {
let automaton = folder_automatons.get(folder).and_then(|a| a.as_ref());
let Some(ac) = automaton else {
continue;
};
let link_regex = folder_link_regexes.get(folder).and_then(|r| r.as_ref());
let wiki_regex = folder_wiki_regexes.get(folder).and_then(|r| r.as_ref());
let ref_regex = folder_ref_regexes.get(folder).and_then(|r| r.as_ref());
for (path, source_url_path) in files {
files_scanned += 1;
let content = match fs::read_to_string(path) {
Ok(c) => c,
Err(_) => continue,
};
if !ac.is_match(&content) {
continue;
}
let mut found_link = false;
if let Some(regex) = link_regex {
for cap in regex.captures_iter(&content) {
let text = cap.get(1).map(|m| m.as_str()).unwrap_or("");
let anchor = cap.get(2).map(|m| format!("#{}", m.as_str()));
inbound_links.push(InboundLink {
from: source_url_path.clone(),
text: text.to_string(),
anchor,
});
found_link = true;
}
}
if let Some(regex) = wiki_regex {
for cap in regex.captures_iter(&content) {
let anchor = cap.get(1).and_then(|m| {
let s = m.as_str();
if s.is_empty() {
None
} else {
Some(format!("#{}", s))
}
});
let text = cap
.get(2)
.map(|m| m.as_str().trim())
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.unwrap_or_else(|| {
target_segments
.split('/')
.next_back()
.unwrap_or(target_segments)
.to_string()
});
let link = InboundLink {
from: source_url_path.clone(),
text,
anchor,
};
if !inbound_links.contains(&link) {
inbound_links.push(link);
found_link = true;
}
}
}
if !found_link && let Some(regex) = ref_regex {
for cap in regex.captures_iter(&content) {
let ref_name = cap.get(1).map(|m| m.as_str()).unwrap_or("");
let use_pattern = format!(r#"\[([^\]]*)\]\[{}\]"#, regex::escape(ref_name));
if let Ok(use_regex) = Regex::new(&use_pattern) {
for use_cap in use_regex.captures_iter(&content) {
let text = use_cap.get(1).map(|m| m.as_str()).unwrap_or("");
let link = InboundLink {
from: source_url_path.clone(),
text: text.to_string(),
anchor: None,
};
if !inbound_links.contains(&link) {
inbound_links.push(link);
}
}
}
}
}
}
}
let mut seen_sources: HashSet<String> = HashSet::new();
let deduplicated_links: Vec<InboundLink> = inbound_links
.into_iter()
.filter(|link| seen_sources.insert(link.from.clone()))
.collect();
tracing::debug!(
"Scanned {} files for inbound links to {} in {:?}, found {}",
files_scanned,
target_url_path,
start.elapsed(),
deduplicated_links.len()
);
deduplicated_links
}
fn get_folder_url_path(file_url_path: &str) -> String {
let trimmed = file_url_path.trim_end_matches('/');
if let Some(pos) = trimmed.rfind('/') {
format!("{}/", &trimmed[..pos])
} else {
"/".to_string()
}
}
fn compute_url_path(file_path: &Path, root_dir: &Path, markdown_extensions: &[String]) -> String {
let relative = file_path.strip_prefix(root_dir).unwrap_or(file_path);
let mut url_path = String::from("/");
for component in relative.components() {
if let std::path::Component::Normal(name) = component {
let name_str = name.to_string_lossy();
url_path.push_str(&name_str);
url_path.push('/');
}
}
for ext in markdown_extensions {
let suffix = format!(".{}/", ext);
if url_path.ends_with(&suffix) {
url_path = url_path[..url_path.len() - suffix.len()].to_string();
url_path.push('/');
break;
}
}
url_path
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_compute_relative_path_same_directory() {
assert_eq!(compute_relative_path("/a/", "/a/1"), "1");
assert_eq!(compute_relative_path("/a/", "/a/1/"), "1");
}
#[test]
fn test_compute_relative_path_subdirectory() {
assert_eq!(compute_relative_path("/a/", "/a/b/1"), "b/1");
assert_eq!(compute_relative_path("/a/", "/a/b/c/1"), "b/c/1");
}
#[test]
fn test_compute_relative_path_parent_directory() {
assert_eq!(compute_relative_path("/a/b/", "/a/1"), "../1");
assert_eq!(compute_relative_path("/a/b/c/", "/a/1"), "../../1");
}
#[test]
fn test_compute_relative_path_sibling_directory() {
assert_eq!(compute_relative_path("/a/", "/b/1"), "../b/1");
assert_eq!(compute_relative_path("/a/", "/b/c/1"), "../b/c/1");
}
#[test]
fn test_compute_relative_path_deep_nesting() {
assert_eq!(
compute_relative_path("/a/b/c/", "/d/e/f/1"),
"../../../d/e/f/1"
);
}
#[test]
fn test_compute_relative_path_from_root() {
assert_eq!(compute_relative_path("/", "/a/b/1"), "a/b/1");
}
#[test]
fn test_compute_relative_path_to_root_level() {
assert_eq!(compute_relative_path("/a/b/", "/1"), "../../1");
}
#[test]
fn test_compute_patterns_for_folder_root() {
let patterns = compute_patterns_for_folder("/", "/a/b/c/1/");
assert!(patterns.contains(&"/a/b/c/1".to_string()));
assert!(patterns.contains(&"/a/b/c/1/".to_string()));
assert!(patterns.contains(&"/a/b/c/1.md".to_string()));
assert!(patterns.contains(&"/a/b/c/1#".to_string()));
assert!(patterns.contains(&"a/b/c/1".to_string()));
assert!(patterns.contains(&"a/b/c/1/".to_string()));
assert!(patterns.contains(&"./a/b/c/1".to_string()));
assert!(patterns.contains(&"./a/b/c/1/".to_string()));
}
#[test]
fn test_compute_patterns_for_folder_same_directory() {
let patterns = compute_patterns_for_folder("/a/b/", "/a/b/c/1/");
assert!(patterns.contains(&"/a/b/c/1".to_string()));
assert!(patterns.contains(&"c/1".to_string()));
assert!(patterns.contains(&"./c/1".to_string()));
}
#[test]
fn test_compute_patterns_for_folder_sibling() {
let patterns = compute_patterns_for_folder("/d/", "/a/b/c/1/");
assert!(patterns.contains(&"/a/b/c/1".to_string()));
assert!(patterns.contains(&"../a/b/c/1".to_string()));
assert!(patterns.contains(&"../a/b/c/1/".to_string()));
}
#[test]
fn test_compute_patterns_for_folder_deeper_sibling() {
let patterns = compute_patterns_for_folder("/d/e/", "/a/b/c/1/");
assert!(patterns.contains(&"/a/b/c/1".to_string()));
assert!(patterns.contains(&"../../a/b/c/1".to_string()));
}
#[test]
fn test_get_folder_url_path_basic() {
assert_eq!(get_folder_url_path("/a/b/c/"), "/a/b/");
assert_eq!(get_folder_url_path("/a/"), "/");
assert_eq!(get_folder_url_path("/a/b/"), "/a/");
}
#[test]
fn test_compute_url_path_basic() {
let root = Path::new("/home/user/notes");
let file = Path::new("/home/user/notes/docs/guide.md");
let extensions = vec!["md".to_string()];
let url = compute_url_path(file, root, &extensions);
assert_eq!(url, "/docs/guide/");
}
#[test]
fn test_compute_url_path_nested() {
let root = Path::new("/notes");
let file = Path::new("/notes/a/b/c/page.md");
let extensions = vec!["md".to_string()];
let url = compute_url_path(file, root, &extensions);
assert_eq!(url, "/a/b/c/page/");
}
#[test]
fn test_inbound_link_cache_basic() {
let cache = InboundLinkCache::new(1024 * 1024, 60);
let links = vec![InboundLink {
from: "/other/".to_string(),
text: "Link text".to_string(),
anchor: None,
}];
cache.insert("/docs/".to_string(), links.clone());
let retrieved = cache.get("/docs/");
assert!(retrieved.is_some());
assert_eq!(retrieved.unwrap().len(), 1);
}
#[test]
fn test_inbound_link_cache_disabled() {
let cache = InboundLinkCache::new(0, 60);
let links = vec![InboundLink {
from: "/other/".to_string(),
text: "Link".to_string(),
anchor: None,
}];
cache.insert("/docs/".to_string(), links);
assert!(cache.get("/docs/").is_none());
}
#[test]
fn test_find_inbound_links_basic() {
let temp_dir = TempDir::new().unwrap();
let target_path = temp_dir.path().join("target.md");
fs::write(&target_path, "# Target Page\n\nThis is the target.").unwrap();
let source_path = temp_dir.path().join("source.md");
fs::write(
&source_path,
"# Source Page\n\nHere is a [link to target](target/).",
)
.unwrap();
let extensions = vec!["md".to_string()];
let ignore_dirs: Vec<String> = vec![];
let ignore_globs: Vec<String> = vec![];
let links = find_inbound_links(
"/target/",
temp_dir.path(),
&extensions,
&ignore_dirs,
&ignore_globs,
);
assert_eq!(links.len(), 1);
assert_eq!(links[0].from, "/source/");
assert_eq!(links[0].text, "link to target");
}
#[test]
fn test_find_inbound_links_with_anchor() {
let temp_dir = TempDir::new().unwrap();
fs::write(temp_dir.path().join("target.md"), "# Target").unwrap();
fs::write(
temp_dir.path().join("source.md"),
"Link: [section link](target/#section)",
)
.unwrap();
let links = find_inbound_links("/target/", temp_dir.path(), &["md".to_string()], &[], &[]);
assert_eq!(links.len(), 1);
assert_eq!(links[0].anchor, Some("#section".to_string()));
}
#[test]
fn test_find_inbound_links_wiki_style_basic() {
let temp_dir = TempDir::new().unwrap();
fs::write(temp_dir.path().join("Japan.md"), "# Japan").unwrap();
fs::write(temp_dir.path().join("source.md"), "See also: [[Japan]]").unwrap();
let links = find_inbound_links("/Japan/", temp_dir.path(), &["md".to_string()], &[], &[]);
assert_eq!(links.len(), 1);
assert_eq!(links[0].from, "/source/");
assert_eq!(links[0].text, "Japan");
}
#[test]
fn test_find_inbound_links_wiki_style_with_display_text() {
let temp_dir = TempDir::new().unwrap();
fs::write(temp_dir.path().join("Japan.md"), "# Japan").unwrap();
fs::write(
temp_dir.path().join("source.md"),
"Visit [[Japan|the Land of the Rising Sun]].",
)
.unwrap();
let links = find_inbound_links("/Japan/", temp_dir.path(), &["md".to_string()], &[], &[]);
assert_eq!(links.len(), 1);
assert_eq!(links[0].text, "the Land of the Rising Sun");
}
#[test]
fn test_find_inbound_links_wiki_style_with_anchor() {
let temp_dir = TempDir::new().unwrap();
fs::write(temp_dir.path().join("Japan.md"), "# Japan").unwrap();
fs::write(temp_dir.path().join("source.md"), "See [[Japan#History]].").unwrap();
let links = find_inbound_links("/Japan/", temp_dir.path(), &["md".to_string()], &[], &[]);
assert_eq!(links.len(), 1);
assert_eq!(links[0].anchor, Some("#History".to_string()));
}
#[test]
fn test_find_inbound_links_wiki_style_case_insensitive() {
let temp_dir = TempDir::new().unwrap();
fs::write(temp_dir.path().join("Japan.md"), "# Japan").unwrap();
fs::write(
temp_dir.path().join("source.md"),
"See [[japan]] for details.",
)
.unwrap();
let links = find_inbound_links("/Japan/", temp_dir.path(), &["md".to_string()], &[], &[]);
assert_eq!(links.len(), 1);
}
#[test]
fn test_find_inbound_links_mixed_markdown_and_wiki() {
let temp_dir = TempDir::new().unwrap();
fs::write(temp_dir.path().join("target.md"), "# Target").unwrap();
fs::write(
temp_dir.path().join("source.md"),
"See [standard](target/) and [[target]].",
)
.unwrap();
let links = find_inbound_links("/target/", temp_dir.path(), &["md".to_string()], &[], &[]);
assert_eq!(links.len(), 1);
}
#[test]
fn test_find_inbound_links_multiple_sources() {
let temp_dir = TempDir::new().unwrap();
fs::write(temp_dir.path().join("target.md"), "# Target").unwrap();
fs::write(temp_dir.path().join("source1.md"), "See [link](target/).").unwrap();
fs::write(
temp_dir.path().join("source2.md"),
"Also see [another link](target/).",
)
.unwrap();
let links = find_inbound_links("/target/", temp_dir.path(), &["md".to_string()], &[], &[]);
assert_eq!(links.len(), 2);
}
#[test]
fn test_find_inbound_links_relative_path_from_subfolder() {
let temp_dir = TempDir::new().unwrap();
let tricks_dir = temp_dir.path().join("coins").join("tricks");
fs::create_dir_all(&tricks_dir).unwrap();
fs::write(tricks_dir.join("3-fly.md"), "# 3 Fly Trick").unwrap();
fs::write(
temp_dir.path().join("coins").join("overview.md"),
"Check out [3 Fly](tricks/3-fly/) for more.",
)
.unwrap();
let links = find_inbound_links(
"/coins/tricks/3-fly/",
temp_dir.path(),
&["md".to_string()],
&[],
&[],
);
assert_eq!(links.len(), 1);
assert_eq!(links[0].from, "/coins/overview/");
assert_eq!(links[0].text, "3 Fly");
}
#[test]
fn test_find_inbound_links_relative_path_with_parent_traversal() {
let temp_dir = TempDir::new().unwrap();
let coins_tricks_dir = temp_dir.path().join("coins").join("tricks");
let cards_dir = temp_dir.path().join("cards");
fs::create_dir_all(&coins_tricks_dir).unwrap();
fs::create_dir_all(&cards_dir).unwrap();
fs::write(coins_tricks_dir.join("3-fly.md"), "# 3 Fly Trick").unwrap();
fs::write(
cards_dir.join("overview.md"),
"See also [3 Fly](../coins/tricks/3-fly/) coin trick.",
)
.unwrap();
let links = find_inbound_links(
"/coins/tricks/3-fly/",
temp_dir.path(),
&["md".to_string()],
&[],
&[],
);
assert_eq!(links.len(), 1);
assert_eq!(links[0].from, "/cards/overview/");
assert_eq!(links[0].text, "3 Fly");
}
#[test]
fn test_find_inbound_links_absolute_path() {
let temp_dir = TempDir::new().unwrap();
let coins_tricks_dir = temp_dir.path().join("coins").join("tricks");
let cards_dir = temp_dir.path().join("cards");
fs::create_dir_all(&coins_tricks_dir).unwrap();
fs::create_dir_all(&cards_dir).unwrap();
fs::write(coins_tricks_dir.join("3-fly.md"), "# 3 Fly Trick").unwrap();
fs::write(
cards_dir.join("overview.md"),
"See also [3 Fly](/coins/tricks/3-fly/) coin trick.",
)
.unwrap();
let links = find_inbound_links(
"/coins/tricks/3-fly/",
temp_dir.path(),
&["md".to_string()],
&[],
&[],
);
assert_eq!(links.len(), 1);
assert_eq!(links[0].from, "/cards/overview/");
}
#[test]
fn test_find_inbound_links_deep_relative_path() {
let temp_dir = TempDir::new().unwrap();
let target_dir = temp_dir.path().join("a").join("b").join("c");
let source_dir = temp_dir.path().join("d").join("e").join("f");
fs::create_dir_all(&target_dir).unwrap();
fs::create_dir_all(&source_dir).unwrap();
fs::write(target_dir.join("target.md"), "# Target").unwrap();
fs::write(
source_dir.join("source.md"),
"Link: [target](../../../a/b/c/target/)",
)
.unwrap();
let links = find_inbound_links(
"/a/b/c/target/",
temp_dir.path(),
&["md".to_string()],
&[],
&[],
);
assert_eq!(links.len(), 1);
assert_eq!(links[0].from, "/d/e/f/source/");
}
#[test]
fn test_find_inbound_links_with_dot_slash_prefix() {
let temp_dir = TempDir::new().unwrap();
let tricks_dir = temp_dir.path().join("coins").join("tricks");
fs::create_dir_all(&tricks_dir).unwrap();
fs::write(tricks_dir.join("3-fly.md"), "# 3 Fly").unwrap();
fs::write(
temp_dir.path().join("coins").join("index.md"),
"See [3 Fly](./tricks/3-fly/) for more.",
)
.unwrap();
let links = find_inbound_links(
"/coins/tricks/3-fly/",
temp_dir.path(),
&["md".to_string()],
&[],
&[],
);
assert_eq!(links.len(), 1);
assert_eq!(links[0].from, "/coins/index/");
}
#[test]
fn test_find_inbound_links_relative_with_md_extension() {
let temp_dir = TempDir::new().unwrap();
let tricks_dir = temp_dir.path().join("coins").join("tricks");
fs::create_dir_all(&tricks_dir).unwrap();
fs::write(tricks_dir.join("3-fly.md"), "# 3 Fly").unwrap();
fs::write(
temp_dir.path().join("coins").join("index.md"),
"See [3 Fly](tricks/3-fly.md) for more.",
)
.unwrap();
let links = find_inbound_links(
"/coins/tricks/3-fly/",
temp_dir.path(),
&["md".to_string()],
&[],
&[],
);
assert_eq!(links.len(), 1);
assert_eq!(links[0].from, "/coins/index/");
}
}