use crate::content_graph::ContentGraph;
use super::parent_dir;
#[derive(Debug, PartialEq, Clone)]
pub enum ResolvedRef {
Found(String),
Unresolved,
}
pub fn resolve_reference(reference: &str, graph: &ContentGraph, from_path: &str) -> ResolvedRef {
match graph.resolve_path(reference, from_path) {
Some(path) => ResolvedRef::Found(path),
None => ResolvedRef::Unresolved,
}
}
pub fn relative_url(from_path: &str, to_path: &str) -> String {
let from_dir = to_pretty_url_dir(from_path);
let to_url_path = to_pretty_url_dir(to_path);
let from_parts: Vec<&str> = if from_dir.is_empty() {
vec![]
} else {
from_dir.split('/').collect()
};
let to_parts: Vec<&str> = if to_url_path.is_empty() {
vec![]
} else {
let trimmed = to_url_path.trim_end_matches('/');
if trimmed.is_empty() {
vec![]
} else {
trimmed.split('/').collect()
}
};
let common = from_parts
.iter()
.zip(to_parts.iter())
.take_while(|(a, b)| a == b)
.count();
let ups = from_parts.len() - common;
let remaining = &to_parts[common..];
let mut result = String::new();
if ups == 0 && remaining.is_empty() {
return "./".to_string();
}
for _ in 0..ups {
result.push_str("../");
}
for (i, part) in remaining.iter().enumerate() {
if i > 0 {
result.push('/');
}
result.push_str(part);
}
if !result.ends_with('/') {
result.push('/');
}
result
}
pub fn relative_asset_path(from_path: &str, to_path: &str) -> String {
let from_dir = parent_dir(from_path);
let from_parts: Vec<&str> = if from_dir.is_empty() {
vec![]
} else {
from_dir.split('/').collect()
};
let to_parts: Vec<&str> = if to_path.is_empty() {
vec![]
} else {
to_path.split('/').collect()
};
let common = from_parts
.iter()
.zip(to_parts.iter())
.take_while(|(a, b)| a == b)
.count();
let ups = from_parts.len() - common;
let remaining = &to_parts[common..];
let mut result = String::new();
for _ in 0..ups {
result.push_str("../");
}
for (i, part) in remaining.iter().enumerate() {
if i > 0 {
result.push('/');
}
push_encoded_segment(&mut result, part);
}
if result.is_empty() {
let filename = to_path.rsplit('/').next().unwrap_or(to_path);
let mut out = String::new();
push_encoded_segment(&mut out, filename);
out
} else {
result
}
}
pub fn percent_encode_path_segments(path: &str) -> String {
let mut out = String::with_capacity(path.len());
for (i, segment) in path.split('/').enumerate() {
if i > 0 {
out.push('/');
}
push_encoded_segment(&mut out, segment);
}
out
}
pub fn split_url_path(url: &str) -> (&str, &str) {
let q = url.find('?');
let h = url.find('#');
let cut = match (q, h) {
(Some(qi), Some(hi)) => Some(qi.min(hi)),
(Some(qi), None) => Some(qi),
(None, Some(hi)) => Some(hi),
(None, None) => None,
};
let Some(i) = cut else {
return (url, "");
};
#[allow(clippy::string_slice)]
(&url[..i], &url[i..])
}
pub fn percent_encode_url(url: &str) -> String {
let (path, suffix) = split_url_path(url);
if suffix.is_empty() {
percent_encode_path_segments(path)
} else {
let mut out = percent_encode_path_segments(path);
out.push_str(suffix);
out
}
}
fn push_encoded_segment(out: &mut String, segment: &str) {
for &b in segment.as_bytes() {
match b {
b'A'..=b'Z'
| b'a'..=b'z'
| b'0'..=b'9'
| b'-'
| b'.'
| b'_'
| b'~'
| b'!'
| b'$'
| b'&'
| b'\''
| b'+'
| b','
| b';'
| b'='
| b'@' => {
out.push(b as char);
}
_ => {
use std::fmt::Write;
let _ = write!(out, "%{:02X}", b);
}
}
}
}
pub(crate) fn to_pretty_url_dir(path: &str) -> String {
let without_ext = match path.rsplit_once('.') {
Some((head, _)) => head,
None => path,
};
let filename = without_ext.rsplit('/').next().unwrap_or(without_ext);
if filename == "index" {
let parent = parent_dir(without_ext);
parent.to_string()
} else {
without_ext.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::content_graph::ContentGraphBuilder;
fn sample_graph() -> ContentGraph {
let mut b = ContentGraphBuilder::new();
b.add_file("posts/hello.md", "/posts/hello");
b.add_file("posts/world.md", "/posts/world");
b.add_file("guides/hello.md", "/guides/hello");
b.add_file("projects/index.md", "/projects");
b.add_file("about.md", "/about");
b.add_file("images/photo.png", "/images/photo.png");
b.add_file("index.md", "/");
b.build()
}
#[test]
fn test_resolve_exact_relative() {
let graph = sample_graph();
assert_eq!(
resolve_reference("posts/hello.md", &graph, "posts/world.md"),
ResolvedRef::Found("posts/hello.md".into())
);
}
#[test]
fn test_resolve_filename_only() {
let graph = sample_graph();
assert_eq!(
resolve_reference("world", &graph, ""),
ResolvedRef::Found("posts/world.md".into())
);
}
#[test]
fn test_resolve_case_insensitive() {
let graph = sample_graph();
assert_eq!(
resolve_reference("World", &graph, ""),
ResolvedRef::Found("posts/world.md".into())
);
assert_eq!(
resolve_reference("ABOUT", &graph, ""),
ResolvedRef::Found("about.md".into())
);
}
#[test]
fn test_resolve_unresolved() {
let graph = sample_graph();
assert_eq!(
resolve_reference("nonexistent", &graph, "posts/hello.md"),
ResolvedRef::Unresolved
);
assert_eq!(
resolve_reference("missing/page.md", &graph, ""),
ResolvedRef::Unresolved
);
}
#[test]
fn test_resolve_image_reference() {
let graph = sample_graph();
assert_eq!(
resolve_reference("images/photo.png", &graph, "posts/hello.md"),
ResolvedRef::Found("images/photo.png".into())
);
}
#[test]
fn test_relative_url_same_dir() {
assert_eq!(relative_url("posts/a.md", "posts/b.md"), "../b/");
}
#[test]
fn test_relative_url_nested() {
assert_eq!(relative_url("posts/deep/a.md", "posts/b.md"), "../../b/");
}
#[test]
fn test_relative_url_sibling_dir() {
assert_eq!(relative_url("blog/a.md", "notes/b.md"), "../../notes/b/");
}
#[test]
fn test_relative_url_index() {
assert_eq!(relative_url("posts/a.md", "posts/index.md"), "../");
assert_eq!(relative_url("blog/a.md", "posts/index.md"), "../../posts/");
assert_eq!(relative_url("posts/a.md", "index.md"), "../../");
}
#[test]
fn test_relative_url_root_to_nested() {
assert_eq!(relative_url("index.md", "posts/hello.md"), "posts/hello/");
}
#[test]
fn test_relative_url_nested_to_root() {
assert_eq!(relative_url("posts/hello.md", "about.md"), "../../about/");
}
#[test]
fn test_relative_url_root_level_file() {
assert_eq!(
relative_url("guide.md", "notes/daily.md"),
"../notes/daily/"
);
}
#[test]
fn test_relative_url_index_from_dir() {
assert_eq!(relative_url("posts/index.md", "posts/a.md"), "a/");
}
#[test]
fn test_relative_asset_path_same_dir() {
assert_eq!(
relative_asset_path("posts/hello.md", "posts/photo.jpg"),
"photo.jpg"
);
}
#[test]
fn test_relative_asset_path_sibling_dir() {
assert_eq!(
relative_asset_path("posts/hello.md", "assets/photo.jpg"),
"../assets/photo.jpg"
);
}
#[test]
fn test_relative_asset_path_encodes_spaces() {
assert_eq!(
relative_asset_path("posts/hello.md", "assets/Pasted image 20260505.png"),
"../assets/Pasted%20image%2020260505.png"
);
}
#[test]
fn test_relative_asset_path_encodes_non_ascii() {
assert_eq!(
relative_asset_path("文字/article.md", "图片/摄影/_43A2045.jpg"),
"../%E5%9B%BE%E7%89%87/%E6%91%84%E5%BD%B1/_43A2045.jpg"
);
}
#[test]
fn test_relative_asset_path_preserves_unreserved() {
assert_eq!(
relative_asset_path("a.md", "img-1_v2.0~final.jpg"),
"img-1_v2.0~final.jpg"
);
}
#[test]
fn test_relative_asset_path_root_to_nested() {
assert_eq!(
relative_asset_path("index.md", "img/cover photo.png"),
"img/cover%20photo.png"
);
}
#[test]
fn percent_encode_url_preserves_query_string() {
assert_eq!(
percent_encode_url("../scale-compare.html?a=major_pent,major_blues&r=major_pent:D"),
"../scale-compare.html?a=major_pent,major_blues&r=major_pent:D"
);
}
#[test]
fn percent_encode_url_preserves_fragment() {
assert_eq!(
percent_encode_url("../doc.html#section-2"),
"../doc.html#section-2"
);
}
#[test]
fn percent_encode_url_preserves_query_and_fragment_together() {
assert_eq!(
percent_encode_url("../app.html?a=1#part"),
"../app.html?a=1#part"
);
}
#[test]
fn percent_encode_url_still_encodes_path_segments() {
assert_eq!(
percent_encode_url("../assets/Pasted image.png?v=2"),
"../assets/Pasted%20image.png?v=2"
);
assert_eq!(
percent_encode_url("../图片/cover.jpg?v=2"),
"../%E5%9B%BE%E7%89%87/cover.jpg?v=2"
);
}
#[test]
fn percent_encode_url_no_suffix_matches_path_encoder() {
let input = "../assets/Pasted image 20260505.png";
assert_eq!(
percent_encode_url(input),
percent_encode_path_segments(input)
);
}
#[test]
fn split_url_path_at_question_mark() {
assert_eq!(split_url_path("foo.html?a=1&b=2"), ("foo.html", "?a=1&b=2"));
}
#[test]
fn split_url_path_at_fragment() {
assert_eq!(split_url_path("doc.html#section"), ("doc.html", "#section"));
}
#[test]
fn split_url_path_picks_first_separator() {
assert_eq!(split_url_path("a.html?q=1#f"), ("a.html", "?q=1#f"));
assert_eq!(split_url_path("a.html#f?q=1"), ("a.html", "#f?q=1"));
}
#[test]
fn split_url_path_no_separator_returns_empty_suffix() {
assert_eq!(split_url_path("plain/path.png"), ("plain/path.png", ""));
assert_eq!(split_url_path(""), ("", ""));
}
}