use std::path::{Component, Path};
pub fn hash_bytes(content: &[u8]) -> String {
format!("b3:{}", blake3::hash(content).to_hex())
}
pub fn is_uri(target: &str) -> bool {
match url::Url::parse(target) {
Ok(url) => {
if url.has_authority() {
return true;
}
matches!(
url.scheme(),
"mailto" | "tel" | "data" | "urn" | "javascript"
)
}
Err(_) => false,
}
}
pub fn normalize_relative_path(path: &str) -> String {
let mut prefix = String::new();
let mut parts: Vec<String> = Vec::new();
for component in Path::new(path).components() {
match component {
Component::Prefix(p) => prefix.push_str(&p.as_os_str().to_string_lossy()),
Component::RootDir => prefix.push('/'),
Component::CurDir => {}
Component::ParentDir => {
if parts.last().is_some_and(|p| p != "..") {
parts.pop();
} else {
parts.push("..".to_string());
}
}
Component::Normal(c) => parts.push(c.to_string_lossy().to_string()),
}
}
format!("{prefix}{}", parts.join("/"))
}
pub fn relative_from(source_file: &str, target: &str) -> String {
let source_dir: Vec<&str> = source_file
.split('/')
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.split_last()
.map(|(_, dir)| dir.to_vec())
.unwrap_or_default();
let target_parts: Vec<&str> = target.split('/').filter(|s| !s.is_empty()).collect();
let shared = source_dir
.iter()
.zip(&target_parts)
.take_while(|(a, b)| a == b)
.count();
let mut parts: Vec<String> = vec!["..".to_string(); source_dir.len() - shared];
parts.extend(target_parts[shared..].iter().map(|s| s.to_string()));
if parts.first().is_some_and(|p| p != "..") {
return format!("./{}", parts.join("/"));
}
parts.join("/")
}
pub fn resolve_link(source_file: &str, raw_target: &str) -> String {
let source_path = Path::new(source_file);
let source_dir = source_path.parent().unwrap_or(Path::new(""));
let joined = source_dir.join(raw_target);
normalize_relative_path(&joined.to_string_lossy())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalize_simple() {
assert_eq!(normalize_relative_path("a/b/c"), "a/b/c");
}
#[test]
fn normalize_dot_and_dotdot() {
assert_eq!(normalize_relative_path("./a/./b"), "a/b");
assert_eq!(normalize_relative_path("a/b/../c"), "a/c");
}
#[test]
fn relative_from_walks_up_to_shared_root() {
assert_eq!(
relative_from("docs/taxonomy.md", "predicated/artifact/src/lib.rs"),
"../predicated/artifact/src/lib.rs"
);
assert_eq!(relative_from("a/b/c.md", "a/d.md"), "../d.md");
assert_eq!(relative_from("a/b/c.md", "x.md"), "../../x.md");
}
#[test]
fn relative_from_same_directory_keeps_dot_prefix() {
assert_eq!(relative_from("docs/a.md", "docs/b.md"), "./b.md");
assert_eq!(relative_from("a.md", "b.md"), "./b.md");
assert_eq!(relative_from("docs/a.md", "docs/sub/b.md"), "./sub/b.md");
}
#[test]
fn relative_from_round_trips_through_resolve_link() {
for (source, target) in [
("docs/taxonomy.md", "predicated/artifact/src/lib.rs"),
("docs/a.md", "docs/b.md"),
("a/b/c.md", "x.md"),
("README.md", "src/lib.rs"),
("a/b/c.md", "a/b/d/e.md"),
] {
let suggestion = relative_from(source, target);
assert_eq!(
resolve_link(source, &suggestion),
target,
"{source} + {suggestion} should resolve to {target}"
);
}
}
#[test]
fn normalize_preserves_leading_dotdot() {
assert_eq!(normalize_relative_path("../a"), "../a");
assert_eq!(normalize_relative_path("../../a"), "../../a");
assert_eq!(
normalize_relative_path("guides/../../README.md"),
"../README.md"
);
}
#[test]
fn normalize_preserves_absolute_root() {
assert_eq!(normalize_relative_path("/docs/x.md"), "/docs/x.md");
assert_eq!(normalize_relative_path("/a/../b"), "/b");
}
#[test]
fn resolve_relative_to_source() {
assert_eq!(resolve_link("index.md", "setup.md"), "setup.md");
assert_eq!(
resolve_link("guides/intro.md", "setup.md"),
"guides/setup.md"
);
assert_eq!(resolve_link("guides/intro.md", "../config.md"), "config.md");
}
#[test]
fn is_uri_detects_schemes() {
assert!(is_uri("http://example.com"));
assert!(is_uri("https://example.com"));
assert!(is_uri("mailto:user@example.com"));
assert!(is_uri("tel:+1234567890"));
assert!(is_uri("ssh://git@github.com"));
}
#[test]
fn is_uri_rejects_paths_and_bare_schemes() {
assert!(!is_uri("setup.md"));
assert!(!is_uri("./relative/path.md"));
assert!(!is_uri("../parent.md"));
assert!(!is_uri(""));
assert!(!is_uri("path/with:colon.md"));
assert!(!is_uri("name: foo bar bazz"));
assert!(!is_uri("status: draft"));
}
#[test]
fn hash_has_prefix() {
assert!(hash_bytes(b"hello").starts_with("b3:"));
}
}