use serde::{Deserialize, Serialize};
use std::path::Path;
use ts_rs::TS;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, TS)]
#[ts(export, export_to = "bindings/")]
#[serde(rename_all = "snake_case")]
pub enum LinkFormat {
#[default]
MarkdownRoot,
MarkdownRelative,
PlainRelative,
PlainCanonical,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PathType {
WorkspaceRoot,
Relative,
Ambiguous,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParsedLink {
pub title: Option<String>,
pub path: String,
pub path_type: PathType,
}
impl ParsedLink {
pub fn new(path: String, path_type: PathType) -> Self {
Self {
title: None,
path,
path_type,
}
}
pub fn with_title(title: String, path: String, path_type: PathType) -> Self {
Self {
title: Some(title),
path,
path_type,
}
}
}
pub fn parse_link(value: &str) -> ParsedLink {
let value = value.trim();
if let Some(parsed) = try_parse_markdown_link(value) {
return parsed;
}
let path_type = determine_path_type(value);
let path = if path_type == PathType::WorkspaceRoot {
value.strip_prefix('/').unwrap_or(value).to_string()
} else {
value.to_string()
};
ParsedLink::new(path, path_type)
}
fn try_parse_markdown_link(value: &str) -> Option<ParsedLink> {
if !value.starts_with('[') {
return None;
}
let close_bracket = value.find(']')?;
if !value[close_bracket..].starts_with("](") {
return None;
}
let path_start = close_bracket + 2;
let rest = &value[path_start..];
let (raw_path, title) = if rest.starts_with('<') {
let close_angle = rest.find('>')?;
if rest.get(close_angle + 1..close_angle + 2) != Some(")") {
return None;
}
(
rest[1..close_angle].to_string(),
value[1..close_bracket].to_string(),
)
} else {
let close_paren = find_closing_paren(rest)?;
(
rest[..close_paren].to_string(),
value[1..close_bracket].to_string(),
)
};
let path_type = determine_path_type(&raw_path);
let path = if path_type == PathType::WorkspaceRoot {
raw_path.strip_prefix('/').unwrap_or(&raw_path).to_string()
} else {
raw_path
};
Some(ParsedLink::with_title(title, path, path_type))
}
fn find_closing_paren(s: &str) -> Option<usize> {
let mut depth = 0;
for (i, c) in s.char_indices() {
match c {
'(' => depth += 1,
')' => {
if depth == 0 {
return Some(i);
}
depth -= 1;
}
_ => {}
}
}
None
}
fn determine_path_type(path: &str) -> PathType {
if path.starts_with('/') {
PathType::WorkspaceRoot
} else if path.starts_with("../") || path.starts_with("./") || path == ".." || path == "." {
PathType::Relative
} else {
PathType::Ambiguous
}
}
pub fn to_canonical(parsed: &ParsedLink, current_file_path: &Path) -> String {
match parsed.path_type {
PathType::WorkspaceRoot => {
parsed.path.clone()
}
PathType::Relative | PathType::Ambiguous => {
let file_dir = current_file_path.parent().unwrap_or(Path::new(""));
let resolved = file_dir.join(&parsed.path);
normalize_path(&resolved)
}
}
}
fn normalize_path(path: &Path) -> String {
use std::path::Component;
let mut normalized: Vec<&str> = Vec::new();
for component in path.components() {
match component {
Component::ParentDir => {
if !normalized.is_empty() && normalized.last() != Some(&"..") {
normalized.pop();
} else {
normalized.push("..");
}
}
Component::CurDir => {
}
Component::Normal(s) => {
if let Some(s) = s.to_str() {
normalized.push(s);
}
}
_ => {}
}
}
if normalized.is_empty() {
String::new()
} else {
normalized.join("/")
}
}
fn needs_angle_brackets(path: &str) -> bool {
path.contains(' ') || path.contains('(') || path.contains(')')
}
fn format_markdown_url(path: &str) -> String {
if needs_angle_brackets(path) {
format!("<{}>", path)
} else {
path.to_string()
}
}
pub fn format_link(canonical_path: &str, title: &str) -> String {
let url = format!("/{}", canonical_path);
format!("[{}]({})", title, format_markdown_url(&url))
}
pub fn format_link_with_format(
canonical_path: &str,
title: &str,
format: LinkFormat,
from_canonical_path: &str,
) -> String {
match format {
LinkFormat::MarkdownRoot => {
let url = format!("/{}", canonical_path);
format!("[{}]({})", title, format_markdown_url(&url))
}
LinkFormat::MarkdownRelative => {
let relative = compute_relative_path(from_canonical_path, canonical_path);
format!("[{}]({})", title, format_markdown_url(&relative))
}
LinkFormat::PlainRelative => compute_relative_path(from_canonical_path, canonical_path),
LinkFormat::PlainCanonical => canonical_path.to_string(),
}
}
pub fn compute_relative_path(from_path: &str, to_path: &str) -> String {
let from_dir = Path::new(from_path).parent().unwrap_or(Path::new(""));
let to_path = Path::new(to_path);
let from_components: Vec<&str> = from_dir
.components()
.filter_map(|c| c.as_os_str().to_str())
.collect();
let to_components: Vec<&str> = to_path
.components()
.filter_map(|c| c.as_os_str().to_str())
.collect();
let common_len = from_components
.iter()
.zip(to_components.iter())
.take_while(|(a, b)| a == b)
.count();
let ups = from_components.len().saturating_sub(common_len);
let downs = &to_components[common_len..];
let mut result_parts: Vec<&str> = vec![".."; ups];
for part in downs {
result_parts.push(part);
}
if result_parts.is_empty() {
to_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(to_path.to_str().unwrap_or(""))
.to_string()
} else {
result_parts.join("/")
}
}
pub fn convert_link(
link: &str,
target_format: LinkFormat,
current_file_path: &str,
title_resolver: Option<&dyn Fn(&str) -> String>,
) -> String {
let parsed = parse_link(link);
let file_path = Path::new(current_file_path);
let canonical = to_canonical(&parsed, file_path);
let title = parsed.title.unwrap_or_else(|| {
title_resolver
.map(|r| r(&canonical))
.unwrap_or_else(|| path_to_title(&canonical))
});
format_link_with_format(&canonical, &title, target_format, current_file_path)
}
pub fn convert_links(
contents: &[String],
target_format: LinkFormat,
current_file_path: &str,
title_resolver: Option<&dyn Fn(&str) -> String>,
) -> Vec<String> {
contents
.iter()
.map(|link| convert_link(link, target_format, current_file_path, title_resolver))
.collect()
}
pub fn path_to_title(path: &str) -> String {
let filename = Path::new(path)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or(path);
let spaced: String = filename
.chars()
.map(|c| if c == '_' || c == '-' { ' ' } else { c })
.collect();
spaced
.split_whitespace()
.map(|word| {
let mut chars: Vec<char> = word.chars().collect();
if let Some(first) = chars.first_mut() {
*first = first.to_ascii_uppercase();
}
chars.into_iter().collect::<String>()
})
.collect::<Vec<_>>()
.join(" ")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_markdown_link_with_root_path() {
let link = parse_link("[Utility Index](/Utility/utility_index.md)");
assert_eq!(link.title, Some("Utility Index".to_string()));
assert_eq!(link.path, "Utility/utility_index.md");
assert_eq!(link.path_type, PathType::WorkspaceRoot);
}
#[test]
fn test_parse_markdown_link_with_relative_path() {
let link = parse_link("[Parent](../index.md)");
assert_eq!(link.title, Some("Parent".to_string()));
assert_eq!(link.path, "../index.md");
assert_eq!(link.path_type, PathType::Relative);
}
#[test]
fn test_parse_markdown_link_with_ambiguous_path() {
let link = parse_link("[Child](child.md)");
assert_eq!(link.title, Some("Child".to_string()));
assert_eq!(link.path, "child.md");
assert_eq!(link.path_type, PathType::Ambiguous);
}
#[test]
fn test_parse_markdown_link_with_angle_brackets() {
let link = parse_link("[Creative Writing](</Creative Writing/Creative Writing.md>)");
assert_eq!(link.title, Some("Creative Writing".to_string()));
assert_eq!(link.path, "Creative Writing/Creative Writing.md");
assert_eq!(link.path_type, PathType::WorkspaceRoot);
}
#[test]
fn test_parse_markdown_link_with_angle_brackets_relative() {
let link = parse_link("[My File](<../My Folder/my file.md>)");
assert_eq!(link.title, Some("My File".to_string()));
assert_eq!(link.path, "../My Folder/my file.md");
assert_eq!(link.path_type, PathType::Relative);
}
#[test]
fn test_parse_markdown_link_with_spaces_no_angle_brackets() {
let link = parse_link("[Daily Index](/Daily/daily_index.md)");
assert_eq!(link.title, Some("Daily Index".to_string()));
assert_eq!(link.path, "Daily/daily_index.md");
assert_eq!(link.path_type, PathType::WorkspaceRoot);
}
#[test]
fn test_parse_markdown_link_with_parentheses_in_path() {
let link = parse_link("[Explanation (1.1)](/Archive/Explanation (1.1).md)");
assert_eq!(link.title, Some("Explanation (1.1)".to_string()));
assert_eq!(link.path, "Archive/Explanation (1.1).md");
assert_eq!(link.path_type, PathType::WorkspaceRoot);
}
#[test]
fn test_parse_markdown_link_with_nested_parentheses() {
let link = parse_link("[File (a (b))](/path/file (a (b)).md)");
assert_eq!(link.title, Some("File (a (b))".to_string()));
assert_eq!(link.path, "path/file (a (b)).md");
assert_eq!(link.path_type, PathType::WorkspaceRoot);
}
#[test]
fn test_parse_markdown_link_with_plus_in_parens() {
let link = parse_link("[The Cross (+)](/Archive/The Cross (+).md)");
assert_eq!(link.title, Some("The Cross (+)".to_string()));
assert_eq!(link.path, "Archive/The Cross (+).md");
assert_eq!(link.path_type, PathType::WorkspaceRoot);
}
#[test]
fn test_format_link_with_parentheses_uses_angle_brackets() {
let link = format_link("Archive/Explanation (1.1).md", "Explanation (1.1)");
assert_eq!(link, "[Explanation (1.1)](</Archive/Explanation (1.1).md>)");
}
#[test]
fn test_parse_markdown_link_with_multiple_periods() {
let link = parse_link("[Test File](/path/test....md)");
assert_eq!(link.title, Some("Test File".to_string()));
assert_eq!(link.path, "path/test....md");
assert_eq!(link.path_type, PathType::WorkspaceRoot);
let link = parse_link("[Notes](/my...folder/notes.md)");
assert_eq!(link.path, "my...folder/notes.md");
}
#[test]
fn test_parse_markdown_link_with_period_at_end_of_title() {
let link = parse_link("[Test...](/path/test.md)");
assert_eq!(link.title, Some("Test...".to_string()));
assert_eq!(link.path, "path/test.md");
}
#[test]
fn test_format_link_with_spaces_uses_angle_brackets() {
let link = format_link("My Folder/my file.md", "My File");
assert_eq!(link, "[My File](</My Folder/my file.md>)");
}
#[test]
fn test_format_link_without_spaces_no_angle_brackets() {
let link = format_link("Folder/file.md", "File");
assert_eq!(link, "[File](/Folder/file.md)");
}
#[test]
fn test_parse_plain_root_path() {
let link = parse_link("/Utility/file.md");
assert_eq!(link.title, None);
assert_eq!(link.path, "Utility/file.md");
assert_eq!(link.path_type, PathType::WorkspaceRoot);
}
#[test]
fn test_parse_plain_relative_path() {
let link = parse_link("../parent.md");
assert_eq!(link.title, None);
assert_eq!(link.path, "../parent.md");
assert_eq!(link.path_type, PathType::Relative);
}
#[test]
fn test_parse_plain_ambiguous_path() {
let link = parse_link("child.md");
assert_eq!(link.title, None);
assert_eq!(link.path, "child.md");
assert_eq!(link.path_type, PathType::Ambiguous);
}
#[test]
fn test_parse_dotslash_relative() {
let link = parse_link("./sibling.md");
assert_eq!(link.path, "./sibling.md");
assert_eq!(link.path_type, PathType::Relative);
}
#[test]
fn test_to_canonical_workspace_root() {
let link = parse_link("[Title](/Utility/file.md)");
let canonical = to_canonical(&link, Path::new("Other/entry.md"));
assert_eq!(canonical, "Utility/file.md");
}
#[test]
fn test_to_canonical_relative_parent() {
let link = parse_link("../index.md");
let canonical = to_canonical(&link, Path::new("Folder/Sub/entry.md"));
assert_eq!(canonical, "Folder/index.md");
}
#[test]
fn test_to_canonical_relative_sibling() {
let link = parse_link("./sibling.md");
let canonical = to_canonical(&link, Path::new("Folder/entry.md"));
assert_eq!(canonical, "Folder/sibling.md");
}
#[test]
fn test_to_canonical_ambiguous() {
let link = parse_link("child.md");
let canonical = to_canonical(&link, Path::new("Folder/index.md"));
assert_eq!(canonical, "Folder/child.md");
}
#[test]
fn test_to_canonical_deep_relative() {
let link = parse_link("../../root.md");
let canonical = to_canonical(&link, Path::new("A/B/C/file.md"));
assert_eq!(canonical, "A/root.md");
}
#[test]
fn test_format_link() {
let link = format_link("Utility/utility_index.md", "Utility Index");
assert_eq!(link, "[Utility Index](/Utility/utility_index.md)");
}
#[test]
fn test_format_link_root_file() {
let link = format_link("README.md", "README");
assert_eq!(link, "[README](/README.md)");
}
#[test]
fn test_path_to_title_underscore() {
assert_eq!(path_to_title("utility_index.md"), "Utility Index");
}
#[test]
fn test_path_to_title_hyphen() {
assert_eq!(path_to_title("my-file.md"), "My File");
}
#[test]
fn test_path_to_title_with_path() {
assert_eq!(path_to_title("Folder/sub_file.md"), "Sub File");
}
#[test]
fn test_path_to_title_number() {
assert_eq!(path_to_title("2025.md"), "2025");
}
#[test]
fn test_path_to_title_uppercase() {
assert_eq!(path_to_title("README.md"), "README");
}
#[test]
fn test_roundtrip_link() {
let original = "[Daily Index](/Daily/daily_index.md)";
let parsed = parse_link(original);
let canonical = to_canonical(&parsed, Path::new("Other/file.md"));
let title = parsed.title.unwrap_or_else(|| path_to_title(&canonical));
let formatted = format_link(&canonical, &title);
assert_eq!(formatted, "[Daily Index](/Daily/daily_index.md)");
}
#[test]
fn test_roundtrip_relative_to_canonical_to_formatted() {
let relative = "../parent_index.md";
let parsed = parse_link(relative);
let canonical = to_canonical(&parsed, Path::new("Folder/child.md"));
let title = path_to_title(&canonical);
let formatted = format_link(&canonical, &title);
assert_eq!(canonical, "parent_index.md");
assert_eq!(formatted, "[Parent Index](/parent_index.md)");
}
#[test]
fn test_compute_relative_path_same_directory() {
assert_eq!(compute_relative_path("Folder/a.md", "Folder/b.md"), "b.md");
}
#[test]
fn test_compute_relative_path_child_directory() {
assert_eq!(
compute_relative_path("Folder/index.md", "Folder/Sub/child.md"),
"Sub/child.md"
);
}
#[test]
fn test_compute_relative_path_parent_directory() {
assert_eq!(
compute_relative_path("Folder/Sub/child.md", "Folder/index.md"),
"../index.md"
);
}
#[test]
fn test_compute_relative_path_sibling_directory() {
assert_eq!(
compute_relative_path("A/file.md", "B/file.md"),
"../B/file.md"
);
}
#[test]
fn test_compute_relative_path_root_from_subdir() {
assert_eq!(
compute_relative_path("Folder/file.md", "README.md"),
"../README.md"
);
}
#[test]
fn test_compute_relative_path_deep_to_root() {
assert_eq!(
compute_relative_path("A/B/C/file.md", "README.md"),
"../../../README.md"
);
}
#[test]
fn test_format_link_with_format_markdown_root() {
let link = format_link_with_format(
"Folder/target.md",
"Target",
LinkFormat::MarkdownRoot,
"Other/source.md",
);
assert_eq!(link, "[Target](/Folder/target.md)");
}
#[test]
fn test_format_link_with_format_markdown_relative_same_dir() {
let link = format_link_with_format(
"Folder/target.md",
"Target",
LinkFormat::MarkdownRelative,
"Folder/source.md",
);
assert_eq!(link, "[Target](target.md)");
}
#[test]
fn test_format_link_with_format_markdown_relative_parent() {
let link = format_link_with_format(
"Folder/target.md",
"Target",
LinkFormat::MarkdownRelative,
"Folder/Sub/source.md",
);
assert_eq!(link, "[Target](../target.md)");
}
#[test]
fn test_format_link_with_format_plain_relative() {
let link = format_link_with_format(
"Folder/target.md",
"Target",
LinkFormat::PlainRelative,
"Folder/source.md",
);
assert_eq!(link, "target.md");
}
#[test]
fn test_format_link_with_format_plain_canonical() {
let link = format_link_with_format(
"Folder/target.md",
"Target",
LinkFormat::PlainCanonical,
"Other/source.md",
);
assert_eq!(link, "Folder/target.md");
}
#[test]
fn test_link_format_default() {
assert_eq!(LinkFormat::default(), LinkFormat::MarkdownRoot);
}
#[test]
fn test_link_format_serialize() {
assert_eq!(
serde_json::to_string(&LinkFormat::MarkdownRoot).unwrap(),
"\"markdown_root\""
);
assert_eq!(
serde_json::to_string(&LinkFormat::MarkdownRelative).unwrap(),
"\"markdown_relative\""
);
assert_eq!(
serde_json::to_string(&LinkFormat::PlainRelative).unwrap(),
"\"plain_relative\""
);
assert_eq!(
serde_json::to_string(&LinkFormat::PlainCanonical).unwrap(),
"\"plain_canonical\""
);
}
#[test]
fn test_link_format_deserialize() {
assert_eq!(
serde_json::from_str::<LinkFormat>("\"markdown_root\"").unwrap(),
LinkFormat::MarkdownRoot
);
assert_eq!(
serde_json::from_str::<LinkFormat>("\"markdown_relative\"").unwrap(),
LinkFormat::MarkdownRelative
);
assert_eq!(
serde_json::from_str::<LinkFormat>("\"plain_relative\"").unwrap(),
LinkFormat::PlainRelative
);
assert_eq!(
serde_json::from_str::<LinkFormat>("\"plain_canonical\"").unwrap(),
LinkFormat::PlainCanonical
);
}
#[test]
fn test_convert_relative_to_markdown_root() {
let result = convert_link(
"../parent.md",
LinkFormat::MarkdownRoot,
"Folder/child.md",
None,
);
assert_eq!(result, "[Parent](/parent.md)");
}
#[test]
fn test_convert_markdown_root_to_plain_relative() {
let result = convert_link(
"[Title](/Folder/file.md)",
LinkFormat::PlainRelative,
"Folder/other.md",
None,
);
assert_eq!(result, "file.md");
}
#[test]
fn test_convert_preserves_title() {
let result = convert_link(
"[Custom Title](/Folder/file.md)",
LinkFormat::MarkdownRelative,
"Other/source.md",
None,
);
assert_eq!(result, "[Custom Title](../Folder/file.md)");
}
#[test]
fn test_convert_plain_canonical_to_markdown_root() {
let result = convert_link(
"Sub/file.md",
LinkFormat::MarkdownRoot,
"Folder/source.md",
None,
);
assert_eq!(result, "[File](/Folder/Sub/file.md)");
}
#[test]
fn test_convert_with_title_resolver() {
let resolver = |path: &str| format!("Resolved: {}", path);
let result = convert_link(
"../file.md",
LinkFormat::MarkdownRoot,
"Folder/source.md",
Some(&resolver),
);
assert_eq!(result, "[Resolved: file.md](/file.md)");
}
#[test]
fn test_convert_links_batch() {
let contents = vec![
"../parent.md".to_string(),
"sibling.md".to_string(),
"child/index.md".to_string(),
];
let result = convert_links(&contents, LinkFormat::MarkdownRoot, "Folder/index.md", None);
assert_eq!(
result,
vec".to_string(),
"[Sibling](/Folder/sibling.md)".to_string(),
"[Index](/Folder/child/index.md)".to_string(),
]
);
}
#[test]
fn test_convert_markdown_root_to_markdown_root() {
let result = convert_link(
"[Ideas](/Projects/ideas.md)",
LinkFormat::MarkdownRoot,
"Projects/Work/notes.md",
None,
);
assert_eq!(result, "[Ideas](/Projects/ideas.md)");
}
#[test]
fn test_convert_markdown_root_to_markdown_relative() {
let result = convert_link(
"[Ideas](/Projects/ideas.md)",
LinkFormat::MarkdownRelative,
"Projects/Work/notes.md",
None,
);
assert_eq!(result, "[Ideas](../ideas.md)");
}
#[test]
fn test_convert_markdown_root_to_plain_relative_bidirectional() {
let result = convert_link(
"[Ideas](/Projects/ideas.md)",
LinkFormat::PlainRelative,
"Projects/Work/notes.md",
None,
);
assert_eq!(result, "../ideas.md");
}
#[test]
fn test_convert_markdown_root_to_plain_canonical() {
let result = convert_link(
"[Ideas](/Projects/ideas.md)",
LinkFormat::PlainCanonical,
"Projects/Work/notes.md",
None,
);
assert_eq!(result, "Projects/ideas.md");
}
#[test]
fn test_convert_markdown_relative_to_markdown_root() {
let result = convert_link(
"[Ideas](../ideas.md)",
LinkFormat::MarkdownRoot,
"Projects/Work/notes.md",
None,
);
assert_eq!(result, "[Ideas](/Projects/ideas.md)");
}
#[test]
fn test_convert_markdown_relative_to_markdown_relative() {
let result = convert_link(
"[Ideas](../ideas.md)",
LinkFormat::MarkdownRelative,
"Projects/Work/notes.md",
None,
);
assert_eq!(result, "[Ideas](../ideas.md)");
}
#[test]
fn test_convert_markdown_relative_to_plain_relative() {
let result = convert_link(
"[Ideas](../ideas.md)",
LinkFormat::PlainRelative,
"Projects/Work/notes.md",
None,
);
assert_eq!(result, "../ideas.md");
}
#[test]
fn test_convert_markdown_relative_to_plain_canonical() {
let result = convert_link(
"[Ideas](../ideas.md)",
LinkFormat::PlainCanonical,
"Projects/Work/notes.md",
None,
);
assert_eq!(result, "Projects/ideas.md");
}
#[test]
fn test_convert_plain_relative_to_markdown_root() {
let result = convert_link(
"../ideas.md",
LinkFormat::MarkdownRoot,
"Projects/Work/notes.md",
None,
);
assert_eq!(result, "[Ideas](/Projects/ideas.md)");
}
#[test]
fn test_convert_plain_relative_to_markdown_relative() {
let result = convert_link(
"../ideas.md",
LinkFormat::MarkdownRelative,
"Projects/Work/notes.md",
None,
);
assert_eq!(result, "[Ideas](../ideas.md)");
}
#[test]
fn test_convert_plain_relative_to_plain_relative() {
let result = convert_link(
"../ideas.md",
LinkFormat::PlainRelative,
"Projects/Work/notes.md",
None,
);
assert_eq!(result, "../ideas.md");
}
#[test]
fn test_convert_plain_relative_to_plain_canonical() {
let result = convert_link(
"../ideas.md",
LinkFormat::PlainCanonical,
"Projects/Work/notes.md",
None,
);
assert_eq!(result, "Projects/ideas.md");
}
#[test]
fn test_convert_plain_canonical_to_markdown_root_from_root() {
let result = convert_link(
"Projects/ideas.md",
LinkFormat::MarkdownRoot,
"README.md",
None,
);
assert_eq!(result, "[Ideas](/Projects/ideas.md)");
}
#[test]
fn test_convert_plain_canonical_to_markdown_relative_from_root() {
let result = convert_link(
"Projects/ideas.md",
LinkFormat::MarkdownRelative,
"README.md",
None,
);
assert_eq!(result, "[Ideas](Projects/ideas.md)");
}
#[test]
fn test_convert_plain_canonical_to_plain_relative_from_root() {
let result = convert_link(
"Projects/ideas.md",
LinkFormat::PlainRelative,
"README.md",
None,
);
assert_eq!(result, "Projects/ideas.md");
}
#[test]
fn test_convert_plain_canonical_to_plain_canonical_from_root() {
let result = convert_link(
"Projects/ideas.md",
LinkFormat::PlainCanonical,
"README.md",
None,
);
assert_eq!(result, "Projects/ideas.md");
}
#[test]
fn test_roundtrip_markdown_root_through_plain_relative() {
let original = "[My Document](/Folder/Sub/document.md)";
let current_file = "Folder/index.md";
let as_relative = convert_link(original, LinkFormat::PlainRelative, current_file, None);
assert_eq!(as_relative, "Sub/document.md");
let back = convert_link(&as_relative, LinkFormat::MarkdownRoot, current_file, None);
assert_eq!(back, "[Document](/Folder/Sub/document.md)");
}
#[test]
fn test_roundtrip_markdown_relative_through_plain_canonical() {
let original = "[Notes](../notes.md)";
let current_file = "Projects/Work/tasks.md";
let as_canonical = convert_link(original, LinkFormat::PlainCanonical, current_file, None);
assert_eq!(as_canonical, "Projects/notes.md");
}
#[test]
fn test_convert_same_directory_markdown_root_to_relative() {
let result = convert_link(
"[Sibling](/Folder/sibling.md)",
LinkFormat::PlainRelative,
"Folder/current.md",
None,
);
assert_eq!(result, "sibling.md");
}
#[test]
fn test_convert_same_directory_relative_to_markdown_root() {
let result = convert_link(
"sibling.md",
LinkFormat::MarkdownRoot,
"Folder/current.md",
None,
);
assert_eq!(result, "[Sibling](/Folder/sibling.md)");
}
#[test]
fn test_convert_from_root_to_subdir_markdown_root() {
let result = convert_link(
"[Child](/Projects/child.md)",
LinkFormat::PlainRelative,
"README.md",
None,
);
assert_eq!(result, "Projects/child.md");
}
#[test]
fn test_convert_from_subdir_to_root_markdown_root() {
let result = convert_link(
"[Root](/README.md)",
LinkFormat::PlainRelative,
"Projects/Work/deep.md",
None,
);
assert_eq!(result, "../../README.md");
}
#[test]
fn test_convert_deep_nested_markdown_root_to_relative() {
let result = convert_link(
"[Target](/A/B/C/target.md)",
LinkFormat::PlainRelative,
"X/Y/Z/source.md",
None,
);
assert_eq!(result, "../../../A/B/C/target.md");
}
#[test]
fn test_convert_deep_nested_relative_to_markdown_root() {
let result = convert_link(
"../../../A/B/C/target.md",
LinkFormat::MarkdownRoot,
"X/Y/Z/source.md",
None,
);
assert_eq!(result, "[Target](/A/B/C/target.md)");
}
#[test]
fn test_convert_mixed_format_contents_to_markdown_root() {
let contents = vec".to_string(), "../relative.md".to_string(), "sibling.md".to_string(), ];
let result = convert_links(
&contents,
LinkFormat::MarkdownRoot,
"Folder/Sub/index.md",
None,
);
assert_eq!(
result,
vec".to_string(),
"[Relative](/Folder/relative.md)".to_string(),
"[Sibling](/Folder/Sub/sibling.md)".to_string(),
]
);
}
#[test]
fn test_convert_markdown_root_contents_to_plain_relative() {
let contents = vec".to_string(),
"[Sibling](/Folder/Sub/sibling.md)".to_string(),
"[Deep](/Folder/Sub/Deep/file.md)".to_string(),
];
let result = convert_links(
&contents,
LinkFormat::PlainRelative,
"Folder/Sub/index.md",
None,
);
assert_eq!(
result,
vec![
"../parent.md".to_string(),
"sibling.md".to_string(),
"Deep/file.md".to_string(),
]
);
}
#[test]
fn test_title_preserved_through_all_markdown_formats() {
let original = "[Custom Title](/path/to/file.md)";
let current = "other/file.md";
let to_relative = convert_link(original, LinkFormat::MarkdownRelative, current, None);
assert!(to_relative.starts_with("[Custom Title]"));
let back_to_root = convert_link(&to_relative, LinkFormat::MarkdownRoot, current, None);
assert!(back_to_root.starts_with("[Custom Title]"));
}
#[test]
fn test_title_generated_for_plain_to_markdown() {
let result = convert_link(
"../my-important-file.md",
LinkFormat::MarkdownRoot,
"Folder/index.md",
None,
);
assert_eq!(result, "[My Important File](/my-important-file.md)");
}
#[test]
fn test_full_file_conversion_workflow() {
use crate::frontmatter;
use serde_yaml::Value;
let original_content = r#"---
title: Projects Index
part_of: "[Root](/README.md)"
contents:
- "[Work](/Projects/Work/index.md)"
- "[Personal](/Projects/Personal/index.md)"
---
# Projects
This is the projects index.
"#;
let parsed = frontmatter::parse_or_empty(original_content).unwrap();
let mut fm = parsed.frontmatter.clone();
let current_file = "Projects/index.md";
if let Some(part_of_value) = fm.get("part_of") {
if let Some(part_of_str) = part_of_value.as_str() {
let converted =
convert_link(part_of_str, LinkFormat::PlainRelative, current_file, None);
assert_eq!(converted, "../README.md");
fm.insert("part_of".to_string(), Value::String(converted));
}
}
if let Some(contents_value) = fm.get("contents") {
if let Some(contents_seq) = contents_value.as_sequence() {
let mut new_contents = Vec::new();
for item in contents_seq {
if let Some(item_str) = item.as_str() {
let converted =
convert_link(item_str, LinkFormat::PlainRelative, current_file, None);
new_contents.push(Value::String(converted));
}
}
fm.insert("contents".to_string(), Value::Sequence(new_contents));
}
}
let new_content = frontmatter::serialize(&fm, &parsed.body).unwrap();
assert!(new_content.contains("part_of: ../README.md"));
assert!(new_content.contains("Work/index.md"));
assert!(new_content.contains("Personal/index.md"));
let parsed2 = frontmatter::parse_or_empty(&new_content).unwrap();
let mut fm2 = parsed2.frontmatter.clone();
if let Some(part_of_value) = fm2.get("part_of") {
if let Some(part_of_str) = part_of_value.as_str() {
let converted =
convert_link(part_of_str, LinkFormat::MarkdownRoot, current_file, None);
assert_eq!(converted, "[README](/README.md)");
fm2.insert("part_of".to_string(), Value::String(converted));
}
}
if let Some(contents_value) = fm2.get("contents") {
if let Some(contents_seq) = contents_value.as_sequence() {
let mut new_contents = Vec::new();
for item in contents_seq {
if let Some(item_str) = item.as_str() {
let converted =
convert_link(item_str, LinkFormat::MarkdownRoot, current_file, None);
new_contents.push(Value::String(converted));
}
}
fm2.insert("contents".to_string(), Value::Sequence(new_contents));
}
}
let final_content = frontmatter::serialize(&fm2, &parsed2.body).unwrap();
assert!(final_content.contains("[README](/README.md)"));
assert!(final_content.contains("[Index](/Projects/Work/index.md)"));
assert!(final_content.contains("[Index](/Projects/Personal/index.md)"));
}
#[test]
fn test_detect_link_format_change() {
let markdown_root_link = "[Title](/Folder/file.md)";
let current_file = "Other/source.md";
let same_format = convert_link(
markdown_root_link,
LinkFormat::MarkdownRoot,
current_file,
None,
);
assert_eq!(same_format, markdown_root_link);
let different_format = convert_link(
markdown_root_link,
LinkFormat::PlainRelative,
current_file,
None,
);
assert_ne!(different_format, markdown_root_link);
assert_eq!(different_format, "../Folder/file.md");
}
#[test]
fn test_parse_markdown_link_extracts_clean_path_for_normalization() {
let raw_value = "[Archived documents](</Archive/Archived documents.md>)";
let parsed = parse_link(raw_value);
assert_eq!(parsed.path, "Archive/Archived documents.md");
assert_eq!(parsed.title, Some("Archived documents".to_string()));
assert_eq!(parsed.path_type, PathType::WorkspaceRoot);
let path = std::path::Path::new(&parsed.path);
let components: Vec<_> = path.components().collect();
assert_eq!(components.len(), 2); }
#[test]
fn test_parse_markdown_link_with_multiple_spaces_in_path() {
let raw_value = "[Creative Writing](</Creative Writing/Creative Writing.md>)";
let parsed = parse_link(raw_value);
assert_eq!(parsed.path, "Creative Writing/Creative Writing.md");
assert_eq!(parsed.title, Some("Creative Writing".to_string()));
let path = std::path::Path::new(&parsed.path);
let components: Vec<_> = path.components().collect();
assert_eq!(components.len(), 2);
}
#[test]
fn test_parse_various_frontmatter_contents_formats() {
let test_cases = [
("./Archive/file.md", "./Archive/file.md", PathType::Relative),
("../parent.md", "../parent.md", PathType::Relative),
("child.md", "child.md", PathType::Ambiguous),
(
"/Archive/file.md",
"Archive/file.md",
PathType::WorkspaceRoot,
),
(
"[Title](/Archive/file.md)",
"Archive/file.md",
PathType::WorkspaceRoot,
),
("[Title](../parent.md)", "../parent.md", PathType::Relative),
(
"[Archived documents](</Archive/Archived documents.md>)",
"Archive/Archived documents.md",
PathType::WorkspaceRoot,
),
(
"[My File](<../My Folder/my file.md>)",
"../My Folder/my file.md",
PathType::Relative,
),
];
for (input, expected_path, expected_type) in test_cases {
let parsed = parse_link(input);
assert_eq!(parsed.path, expected_path, "Failed for input: {}", input);
assert_eq!(
parsed.path_type, expected_type,
"Wrong path type for input: {}",
input
);
}
}
#[test]
fn test_roundtrip_markdown_link_with_spaces() {
let original = "[Archived documents](</Archive/Archived documents.md>)";
let parsed = parse_link(original);
let title = parsed.title.clone().unwrap();
let formatted = format_link(&parsed.path, &title);
assert_eq!(formatted, original);
}
#[test]
fn test_path_extraction_does_not_include_markdown_syntax() {
let test_cases = [
"[Title](/path.md)",
"[Title](</path with spaces.md>)",
"[Title](<../relative path.md>)",
];
for input in test_cases {
let parsed = parse_link(input);
assert!(
!parsed.path.contains('['),
"Path contains '[' for input: {}",
input
);
assert!(
!parsed.path.contains(']'),
"Path contains ']' for input: {}",
input
);
assert!(
!parsed.path.contains('<'),
"Path contains '<' for input: {}",
input
);
assert!(
!parsed.path.contains('>'),
"Path contains '>' for input: {}",
input
);
assert!(
!parsed.path.starts_with('('),
"Path starts with '(' for input: {}",
input
);
assert!(
!parsed.path.ends_with(')'),
"Path ends with ')' for input: {}",
input
);
}
}
}