use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LinkTarget {
Url(String),
LocalFile(PathBuf),
Anchor(String),
Footnote(String),
FootnoteBack(String),
}
impl LinkTarget {
pub fn parse(url: &str, base_dir: Option<&Path>) -> Self {
if let Some(fragment) = url.strip_prefix('#') {
return LinkTarget::Anchor(fragment.to_owned());
}
if let Some(stripped) = url.strip_prefix("file://") {
return LinkTarget::LocalFile(PathBuf::from(stripped));
}
if has_url_scheme(url) {
return LinkTarget::Url(url.to_owned());
}
let path = PathBuf::from(url);
let resolved = if path.is_absolute() {
path
} else if let Some(dir) = base_dir {
dir.join(path)
} else {
path
};
LinkTarget::LocalFile(resolved)
}
#[allow(dead_code)]
pub fn is_markdown_file(&self) -> bool {
match self {
LinkTarget::LocalFile(path) => path
.extension()
.and_then(|e| e.to_str())
.map(|e| {
let lower = e.to_ascii_lowercase();
lower == "md" || lower == "markdown"
})
.unwrap_or(false),
_ => false,
}
}
}
fn has_url_scheme(url: &str) -> bool {
let Some((scheme, _rest)) = url.split_once(':') else {
return false;
};
if scheme.len() < 2 {
return false;
}
let valid_first = scheme
.chars()
.next()
.is_some_and(|c| c.is_ascii_alphabetic());
let valid_rest = scheme
.chars()
.skip(1)
.all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '-' || c == '.');
valid_first && valid_rest
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn anchor_hash_fragment_classifies_as_anchor() {
assert_eq!(
LinkTarget::parse("#heading", None),
LinkTarget::Anchor("heading".to_owned())
);
}
#[test]
fn empty_fragment_is_anchor() {
assert_eq!(
LinkTarget::parse("#", None),
LinkTarget::Anchor(String::new())
);
}
#[test]
fn https_scheme_classifies_as_url() {
assert_eq!(
LinkTarget::parse("https://example.com/page", None),
LinkTarget::Url("https://example.com/page".to_owned())
);
}
#[test]
fn mailto_scheme_classifies_as_url() {
assert_eq!(
LinkTarget::parse("mailto:a@b.c", None),
LinkTarget::Url("mailto:a@b.c".to_owned())
);
}
#[test]
fn file_scheme_classifies_as_local_file() {
assert_eq!(
LinkTarget::parse("file:///abs/path.md", None),
LinkTarget::LocalFile(PathBuf::from("/abs/path.md"))
);
}
#[test]
fn relative_path_resolves_against_base_dir() {
let base = PathBuf::from("/home/user/docs");
assert_eq!(
LinkTarget::parse("./sibling.md", Some(&base)),
LinkTarget::LocalFile(base.join("./sibling.md"))
);
assert_eq!(
LinkTarget::parse("../other.md", Some(&base)),
LinkTarget::LocalFile(base.join("../other.md"))
);
}
#[test]
fn bare_filename_without_base_stays_relative() {
assert_eq!(
LinkTarget::parse("foo.md", None),
LinkTarget::LocalFile(PathBuf::from("foo.md"))
);
}
#[test]
fn absolute_path_is_not_rejoined_with_base() {
let base = PathBuf::from("/home/user/docs");
assert_eq!(
LinkTarget::parse("/etc/hosts.md", Some(&base)),
LinkTarget::LocalFile(PathBuf::from("/etc/hosts.md"))
);
}
#[test]
fn windows_drive_letter_is_not_a_url_scheme() {
let classified = LinkTarget::parse("C:/Users/name/doc.md", None);
assert!(matches!(classified, LinkTarget::LocalFile(_)));
}
#[test]
fn is_markdown_file_matches_md_and_markdown() {
let md = LinkTarget::LocalFile(PathBuf::from("foo.md"));
let markdown = LinkTarget::LocalFile(PathBuf::from("bar.Markdown"));
let other = LinkTarget::LocalFile(PathBuf::from("baz.txt"));
assert!(md.is_markdown_file());
assert!(markdown.is_markdown_file());
assert!(!other.is_markdown_file());
assert!(!LinkTarget::Anchor("x".into()).is_markdown_file());
assert!(!LinkTarget::Url("https://x".into()).is_markdown_file());
}
}