use crate::numeric::count_u32;
use crate::text::frontmatter::{WikiLink, normalize_keyword, normalize_vault_path};
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::hash::BuildHasher;
mod resolver;
pub use resolver::LinkResolver;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResolvedLink {
pub from_path: String,
pub to_path: String,
pub alias: Option<String>,
pub heading: Option<String>,
pub raw_target: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct NoteReference {
pub vault_path: String,
pub title: Option<String>,
pub aliases: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LinkEdge {
pub from_path: String,
pub to_path: String,
pub resolved: bool,
pub raw_target: String,
pub alias: Option<String>,
pub heading: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LinkGraphStats {
pub total_links: u32,
pub resolved_links: u32,
pub unresolved_links: u32,
pub unique_targets: u32,
pub isolated_nodes: u32,
}
#[must_use]
pub fn resolve_wiki_link_target(target: &str, notes: &[NoteReference]) -> Option<String> {
let normalized_target = normalize_keyword(&normalize_vault_path(target));
let normalized_stem = normalized_target
.strip_suffix(".md")
.unwrap_or(&normalized_target)
.to_string();
let normalized_with_ext = if has_markdown_extension(&normalized_target) {
normalized_target.clone()
} else {
format!("{normalized_target}.md")
};
let target_contains_path = normalized_stem.contains('/');
let mut suffix_matches = Vec::new();
for note in notes {
let normalized_path = normalize_keyword(&normalize_vault_path(¬e.vault_path));
let normalized_path_stem = normalized_path
.strip_suffix(".md")
.unwrap_or(&normalized_path)
.to_string();
if normalized_path == normalized_target
|| normalized_path == normalized_with_ext
|| normalized_path_stem == normalized_stem
{
return Some(note.vault_path.clone());
}
let suffix_matches_note = if target_contains_path {
path_has_component_suffix(&normalized_path_stem, &normalized_stem)
} else {
basename(&normalized_path_stem) == normalized_stem
};
if suffix_matches_note {
suffix_matches.push(note.vault_path.clone());
}
}
if suffix_matches.len() == 1 {
return suffix_matches.into_iter().next();
}
for note in notes {
let normalized_title = note
.title
.as_ref()
.map(|t| normalize_keyword(t))
.unwrap_or_default();
let normalized_aliases: HashSet<String> = note
.aliases
.iter()
.map(|alias| normalize_keyword(alias))
.collect();
let matches_title =
normalized_title == normalized_target || normalized_title == normalized_stem;
let matches_alias = normalized_aliases.contains(&normalized_target)
|| normalized_aliases.contains(&normalized_stem);
if matches_title || matches_alias {
return Some(note.vault_path.clone());
}
}
None
}
fn basename(path_stem: &str) -> &str {
path_stem.rsplit('/').next().unwrap_or(path_stem)
}
fn path_has_component_suffix(path_stem: &str, target_stem: &str) -> bool {
path_stem
.strip_suffix(target_stem)
.is_some_and(|prefix| prefix.is_empty() || prefix.ends_with('/'))
}
fn has_markdown_extension(path: &str) -> bool {
std::path::Path::new(path)
.extension()
.is_some_and(|extension| extension.eq_ignore_ascii_case("md"))
}
#[must_use]
pub fn resolve_wiki_links(
from_path: &str,
links: &[WikiLink],
notes: &[NoteReference],
) -> Vec<ResolvedLink> {
let mut resolved = Vec::new();
for link in links {
let to_path = resolve_wiki_link_target(&link.target, notes);
if let Some(to_path) = to_path {
resolved.push(ResolvedLink {
from_path: from_path.to_string(),
to_path,
alias: link.alias.clone(),
heading: link.heading.clone(),
raw_target: link.raw_target.clone(),
});
}
}
resolved
}
#[must_use]
pub fn build_link_edges(from_path: &str, resolved: &[ResolvedLink]) -> Vec<LinkEdge> {
resolved
.iter()
.map(|r| LinkEdge {
from_path: from_path.to_string(),
to_path: r.to_path.clone(),
resolved: true,
raw_target: r.raw_target.clone(),
alias: r.alias.clone(),
heading: r.heading.clone(),
})
.collect()
}
#[must_use]
pub fn compute_backlinks(edges: &[LinkEdge]) -> std::collections::BTreeMap<String, Vec<String>> {
let mut backlinks: std::collections::BTreeMap<String, std::collections::BTreeSet<String>> =
std::collections::BTreeMap::new();
for edge in edges {
if edge.resolved {
backlinks
.entry(edge.to_path.clone())
.or_default()
.insert(edge.from_path.clone());
}
}
backlinks
.into_iter()
.map(|(k, v)| (k, v.into_iter().collect()))
.collect()
}
#[must_use]
pub fn find_unresolved_links<S: BuildHasher>(
from_path: &str,
links: &[WikiLink],
notes: &[NoteReference],
ignored_link_targets: &HashSet<String, S>,
) -> Vec<ResolvedLink> {
let mut unresolved = Vec::new();
for link in links {
if resolve_wiki_link_target(&link.target, notes).is_none()
&& !is_ignored_link_target(&link.target, ignored_link_targets)
{
unresolved.push(ResolvedLink {
from_path: from_path.to_string(),
to_path: String::new(),
alias: link.alias.clone(),
heading: link.heading.clone(),
raw_target: link.raw_target.clone(),
});
}
}
unresolved
}
fn is_ignored_link_target<S: BuildHasher>(
target: &str,
ignored_link_targets: &HashSet<String, S>,
) -> bool {
let normalized_target = normalize_keyword(&normalize_vault_path(target));
if ignored_link_targets.contains(&normalized_target) {
return true;
}
normalized_target
.rsplit('/')
.next()
.is_some_and(|name| ignored_link_targets.contains(name))
}
#[must_use]
pub fn compute_link_stats(edges: &[LinkEdge], note_paths: &[String]) -> LinkGraphStats {
let total_links = count_u32(edges.len());
let resolved_links = count_u32(edges.iter().filter(|e| e.resolved).count());
let unresolved_links = count_u32(edges.iter().filter(|e| !e.resolved).count());
let unique_targets: std::collections::BTreeSet<String> = edges
.iter()
.filter(|e| e.resolved)
.map(|e| e.to_path.clone())
.collect();
let sources_with_outgoing: std::collections::BTreeSet<String> =
edges.iter().map(|e| e.from_path.clone()).collect();
let isolated_nodes = note_paths
.iter()
.filter(|p| !sources_with_outgoing.contains(p.as_str()))
.count();
LinkGraphStats {
total_links,
resolved_links,
unresolved_links,
unique_targets: count_u32(unique_targets.len()),
isolated_nodes: count_u32(isolated_nodes),
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests;