use std::path::{Component, Path, PathBuf};
use crate::model::{AttachmentError, AttachmentResult, Node};
pub fn extract_link_targets(nodes: &[Node]) -> Vec<String> {
let mut out = Vec::new();
for node in nodes {
match node {
Node::Link { url, .. } => out.push(url.clone()),
Node::Paragraph { text }
| Node::Text { text }
| Node::Quote { text }
| Node::Heading { text, .. } => {
extract_inline_link_targets(text, &mut out);
}
Node::List { items } => {
for item in items {
out.extend(extract_link_targets(item));
}
}
_ => {}
}
}
out
}
fn extract_inline_link_targets(text: &str, out: &mut Vec<String>) {
for link in crate::inline_link::scan_inline_links(text) {
out.push(link.target.to_string());
}
}
fn starts_with_ignore_ascii_case(haystack: &[u8], needle: &[u8]) -> bool {
haystack.len() >= needle.len()
&& haystack[..needle.len()]
.iter()
.zip(needle)
.all(|(h, n)| h.to_ascii_lowercase() == *n)
}
fn contains_scheme_separator(target: &[u8]) -> bool {
target.windows(3).any(|w| w == b"://")
}
pub(crate) fn is_external_target(target: &str) -> bool {
let bytes = target.as_bytes();
starts_with_ignore_ascii_case(bytes, b"http://")
|| starts_with_ignore_ascii_case(bytes, b"https://")
|| starts_with_ignore_ascii_case(bytes, b"mailto:")
|| starts_with_ignore_ascii_case(bytes, b"file://")
|| contains_scheme_separator(bytes)
}
pub(crate) fn extract_attachment_relative(target: &str) -> Option<&str> {
if target.starts_with("attachments/") {
return Some(target);
}
if let Some(pos) = target.find("/attachments/") {
let start = pos + 1;
return target.get(start..);
}
if let Some(pos) = target.find("attachments/") {
return target.get(pos..);
}
None
}
pub(crate) fn sanitize_relative_path(rel: &str) -> AttachmentResult<PathBuf> {
let rel = rel.trim();
if rel.is_empty() {
return Err(AttachmentError::Empty);
}
let path = Path::new(rel);
if path.is_absolute() {
return Err(AttachmentError::EscapesRoot(rel.to_string()));
}
for comp in path.components() {
match comp {
Component::Prefix(_) | Component::RootDir | Component::ParentDir => {
return Err(AttachmentError::EscapesRoot(rel.to_string()));
}
_ => {}
}
}
Ok(path.to_path_buf())
}
pub(crate) fn extract_uuid_like(input: &str) -> Option<&str> {
let bytes = input.as_bytes();
if bytes.len() < 36 {
return None;
}
for i in 0..=bytes.len() - 36 {
let Some(cand) = input.get(i..i + 36) else {
continue;
};
let ok = cand.chars().enumerate().all(|(idx, c)| match idx {
8 | 13 | 18 | 23 => c == '-',
_ => c.is_ascii_hexdigit(),
});
if ok {
return Some(cand);
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn extract_link_targets_finds_link_nodes() {
let nodes = vec![
Node::Link {
text: "page".into(),
url: "page:abc".into(),
},
Node::Paragraph {
text: "plain text".into(),
},
];
assert_eq!(extract_link_targets(&nodes), vec!["page:abc"]);
}
#[test]
fn extract_link_targets_finds_inline_markdown_links() {
let nodes = vec and [api](page:xyz)".into(),
}];
let targets = extract_link_targets(&nodes);
assert_eq!(targets, vec!["https://docs.rs", "page:xyz"]);
}
#[test]
fn extract_link_targets_finds_wiki_links() {
let nodes = vec![Node::Text {
text: "see also [[My Page]] and [[Other]]".into(),
}];
let targets = extract_link_targets(&nodes);
assert_eq!(targets, vec!["My Page", "Other"]);
}
#[test]
fn extract_link_targets_handles_headings_and_quotes() {
let nodes = vec![
Node::Heading {
level: 2,
text: "about [[Topic]]".into(),
},
Node::Quote {
text: "see [ref](page:ref-id)".into(),
},
];
let targets = extract_link_targets(&nodes);
assert_eq!(targets, vec!["Topic", "page:ref-id"]);
}
#[test]
fn extract_link_targets_recurses_into_lists() {
let nodes = vec![Node::List {
items: vec![vec![Node::Paragraph {
text: "item with [[link]]".into(),
}]],
}];
let targets = extract_link_targets(&nodes);
assert_eq!(targets, vec!["link"]);
}
#[test]
fn extract_link_targets_ignores_code_and_unknown() {
let nodes = vec".into(),
},
Node::Unknown {
typ: "x".into(),
raw: json!({}),
},
];
assert!(extract_link_targets(&nodes).is_empty());
}
#[test]
fn extract_attachment_relative_covers_all_branches() {
assert_eq!(
extract_attachment_relative("attachments/image.png"),
Some("attachments/image.png")
);
assert_eq!(
extract_attachment_relative("abc/attachments/image.png"),
Some("attachments/image.png")
);
assert_eq!(
extract_attachment_relative("data_attachments/image.png"),
Some("attachments/image.png")
);
assert_eq!(extract_attachment_relative("image.png"), None);
assert_eq!(extract_attachment_relative(""), None);
}
#[test]
fn extract_attachment_relative_nested_paths() {
assert_eq!(
extract_attachment_relative("a/b/c/attachments/deep.png"),
Some("attachments/deep.png")
);
assert_eq!(
extract_attachment_relative("x/attachments/attachments/file.txt"),
Some("attachments/attachments/file.txt")
);
}
#[test]
fn extract_attachment_relative_no_filename() {
assert_eq!(
extract_attachment_relative("attachments/"),
Some("attachments/")
);
assert_eq!(
extract_attachment_relative("foo/attachments/"),
Some("attachments/")
);
}
#[test]
fn extract_attachment_relative_only_keyword() {
assert_eq!(extract_attachment_relative("attachments"), None);
}
#[test]
fn is_external_target_common_schemes() {
assert!(is_external_target("http://example.com"));
assert!(is_external_target("https://example.com"));
assert!(is_external_target("mailto:user@example.com"));
assert!(is_external_target("file:///home/user/doc.txt"));
assert!(is_external_target("ftp://files.example.com"));
assert!(is_external_target("ssh://host.example.com"));
}
#[test]
fn is_external_target_mixed_case() {
assert!(is_external_target("HTTP://EXAMPLE.COM"));
assert!(is_external_target("Https://Example.Com"));
assert!(is_external_target("MAILTO:USER@EXAMPLE.COM"));
assert!(is_external_target("File://localhost/path"));
assert!(is_external_target("hTtPs://mixed.case"));
assert!(is_external_target("FTP://FILES.EXAMPLE.COM"));
}
#[test]
fn is_external_target_rejects_non_urls() {
assert!(!is_external_target("attachments/image.png"));
assert!(!is_external_target("page:some-id"));
assert!(!is_external_target("title:My Page"));
assert!(!is_external_target("just some text"));
assert!(!is_external_target(""));
assert!(!is_external_target(" "));
assert!(!is_external_target("not-a-scheme"));
}
fn inline_targets(text: &str) -> Vec<String> {
let mut out = Vec::new();
extract_inline_link_targets(text, &mut out);
out
}
#[test]
fn inline_unclosed_bracket() {
assert!(inline_targets("[unclosed(").is_empty());
}
#[test]
fn inline_unclosed_paren() {
assert!(inline_targets("[text](").is_empty());
}
#[test]
fn inline_empty_target() {
assert!(inline_targets("[]()").is_empty());
}
#[test]
fn inline_nested_brackets() {
assert!(inline_targets("[text with [inner] brackets](url)").is_empty());
}
#[test]
fn inline_consecutive_links() {
assert_eq!(inline_targets("[a](b)[c](d)"), vec!["b", "d"],);
}
#[test]
fn inline_empty_wikilink() {
assert!(inline_targets("[[]]").is_empty());
}
#[test]
fn inline_unclosed_wikilink() {
assert!(inline_targets("[[").is_empty());
}
#[test]
fn inline_wikilink_unclosed_single_bracket() {
assert!(inline_targets("[[text").is_empty());
}
#[test]
fn inline_unicode_link_target() {
assert_eq!(inline_targets("[名前](ページ)"), vec!["ページ"],);
}
#[test]
fn inline_unicode_wikilink() {
assert_eq!(inline_targets("[[日本語ページ]]"), vec!["日本語ページ"],);
}
#[test]
fn inline_whitespace_only_target() {
assert!(inline_targets("[label]( )").is_empty());
}
#[test]
fn inline_whitespace_only_wikilink() {
assert!(inline_targets("[[ ]]").is_empty());
}
#[test]
fn inline_target_trimmed() {
assert_eq!(inline_targets("[label]( url )"), vec!["url"],);
}
#[test]
fn inline_mixed_links_and_wikilinks() {
assert_eq!(
inline_targets("see [[wiki]] and [md](target) done"),
vec!["wiki", "target"],
);
}
#[test]
fn inline_bracket_without_paren() {
assert!(inline_targets("[text]").is_empty());
assert!(inline_targets("[text] rest").is_empty());
}
#[test]
fn inline_empty_input() {
assert!(inline_targets("").is_empty());
}
#[test]
fn inline_no_links() {
assert!(inline_targets("just plain text").is_empty());
}
#[test]
fn is_external_target_boundary_inputs() {
assert!(is_external_target("://bare"));
assert!(is_external_target("x://y"));
assert!(is_external_target("h323://voip.example.com"));
assert!(is_external_target("http://"));
assert!(is_external_target("https://"));
assert!(is_external_target("mailto:"));
}
}