use std::collections::HashMap;
use std::path::Path;
use crate::model::Link;
use crate::parser::link_utils::{
extract_wiki_links, is_safe_path, normalize_case, normalize_path, split_anchor,
};
use percent_encoding::{percent_decode_str, NON_ALPHANUMERIC};
pub struct LinkResolver;
impl LinkResolver {
pub fn resolve(source_path: &str, target: &str) -> String {
let source = Path::new(source_path);
let parent = source.parent().unwrap_or(Path::new(""));
if target.starts_with("http://")
|| target.starts_with("https://")
|| target.starts_with("mailto:")
{
return target.to_string();
}
let decoded_target = percent_decode_str(target).decode_utf8_lossy().to_string();
let (path_part, anchor) = split_anchor(&decoded_target);
let resolved = if let Some(stripped) = path_part.strip_prefix('/') {
Path::new(stripped).to_path_buf()
} else {
parent.join(path_part)
};
let normalized = normalize_path(&resolved).unwrap_or_else(|| {
"INVALID_PATH_TRAVERSAL".to_string()
});
let result = normalized.replace('\\', "/");
if let Some(a) = anchor {
format!("{}#{}", result, a)
} else {
result
}
}
pub fn check_exists(target: &str, known_files: &[String]) -> bool {
if target.starts_with("http://")
|| target.starts_with("https://")
|| target.starts_with("mailto:")
{
return true;
}
let target_without_anchor = target.split('#').next().unwrap_or(target);
let normalized_target = normalize_case(target_without_anchor);
known_files
.iter()
.any(|f| normalize_case(f.as_str()) == normalized_target)
}
pub fn resolve_links(
source_path: &str,
raw_links: &[Link],
known_files: &[String],
) -> Vec<Link> {
raw_links
.iter()
.map(|link| {
if link.is_external {
return link.clone();
}
let resolved = Self::resolve(source_path, &link.target);
let (target_path, target_anchor) = split_anchor(&resolved);
let exists = Self::check_exists(target_path, known_files);
if !exists && !link.is_external {
eprintln!(
"Warning: Broken link in '{}': '{}' -> '{}' (target not found)",
source_path, link.raw, target_path
);
}
Link {
raw: link.raw.clone(),
target: target_path.to_string(),
target_anchor,
is_external: false,
exists_in_repository: exists,
}
})
.collect()
}
pub fn filter_self_references(source_path: &str, links: &[Link]) -> Vec<Link> {
let normalized_source = normalize_case(source_path);
links
.iter()
.filter(|link| {
let target_without_anchor = link.target.split('#').next().unwrap_or(&link.target);
normalize_case(target_without_anchor) != normalized_source
})
.cloned()
.collect()
}
pub fn would_create_cycle(
source_path: &str,
target_path: &str,
graph: &HashMap<String, Vec<String>>,
) -> bool {
let mut visited = HashMap::new();
let mut stack = vec![target_path.to_string()];
while let Some(current) = stack.pop() {
if current == source_path {
return true;
}
if visited.insert(current.clone(), true).is_none() {
if let Some(neighbors) = graph.get(¤t) {
stack.extend(neighbors.iter().cloned());
}
}
}
false
}
}
#[cfg(test)]
mod tests;