use crate::store::hierarchy::Hierarchy;
use crate::store::node::{Node, NodeKind};
#[derive(Debug, Clone)]
pub struct LinkFinding {
pub loc: String,
pub target: String,
pub reason: String,
}
fn under_system_book(h: &Hierarchy, node: &Node) -> bool {
let mut cur = node;
loop {
if cur.kind == NodeKind::Book {
return cur.system_tag.is_some();
}
let Some(pid) = cur.parent_id else { return false };
match h.get(pid) {
Some(p) => cur = p,
None => return false,
}
}
}
pub fn check_internal(h: &Hierarchy) -> Vec<LinkFinding> {
let mut out = Vec::new();
for node in h.iter() {
if node.kind != NodeKind::Paragraph || under_system_book(h, node) {
continue;
}
for target in &node.linked_paragraphs {
if h.get(*target).is_none() {
out.push(LinkFinding {
loc: h.slug_path(node),
target: target.to_string(),
reason: "linked paragraph no longer exists".into(),
});
}
}
}
out
}
pub fn extract_urls(body: &str) -> Vec<String> {
let mut urls: Vec<String> = Vec::new();
for token in body.split(|c: char| {
c.is_whitespace() || matches!(c, '"' | '(' | ')' | '[' | ']' | '<' | '>' | '`' | '\\')
}) {
let pos = token.find("http://").or_else(|| token.find("https://"));
if let Some(pos) = pos {
let url = token[pos..].trim_end_matches(|c| matches!(c, '.' | ',' | ';' | ':' | '!' | '?'));
if url.len() > "https://".len() {
urls.push(url.to_string());
}
}
}
urls.sort();
urls.dedup();
urls
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extract_urls_finds_link_calls_and_bare_urls() {
let body = "See #link(\"https://example.com/a\")[the docs] and http://plain.example.org/b, \
also (https://example.com/a) again. No url here.";
let urls = extract_urls(body);
assert_eq!(
urls,
vec![
"http://plain.example.org/b".to_string(),
"https://example.com/a".to_string(), ]
);
}
#[test]
fn extract_urls_trims_trailing_punctuation() {
assert_eq!(extract_urls("read https://x.example/y."), vec!["https://x.example/y"]);
}
}