use okf::links::{extract_citations, extract_links, Link, LinkKind};
use okf::{ConceptId, Document};
#[test]
fn classify_link_kinds() {
assert_eq!(Link::classify("/tables/users.md"), LinkKind::Absolute);
assert_eq!(Link::classify("./other.md"), LinkKind::Relative);
assert_eq!(Link::classify("../sibling.md"), LinkKind::Relative);
assert_eq!(Link::classify("https://example.com"), LinkKind::External);
assert_eq!(Link::classify("mailto:a@b.com"), LinkKind::External);
assert_eq!(Link::classify("#section"), LinkKind::Anchor);
}
#[test]
fn extract_inline_links() {
let body = "See [customers](/tables/customers.md) and [docs](https://example.com \"title\").";
let links = extract_links(body);
assert_eq!(links.len(), 2);
assert_eq!(links[0].text, "customers");
assert_eq!(links[0].target, "/tables/customers.md");
assert_eq!(links[0].kind, LinkKind::Absolute);
assert_eq!(links[1].target, "https://example.com");
}
#[test]
fn links_inside_code_are_ignored() {
let body = "Real [a](/a.md).\n\n```\nNot a [link](/b.md) in code.\n```\n\nInline `[c](/c.md)` ignored.\n";
let links = extract_links(body);
let targets: Vec<_> = links.iter().map(|l| l.target.as_str()).collect();
assert_eq!(targets, vec!["/a.md"]);
}
#[test]
fn resolve_absolute_link() {
let source = ConceptId::parse("tables/orders").unwrap();
let link = Link {
text: "customers".into(),
target: "/tables/customers.md".into(),
kind: LinkKind::Absolute,
};
assert_eq!(
link.resolve(&source),
Some(ConceptId::parse("tables/customers").unwrap())
);
}
#[test]
fn resolve_relative_link() {
let source = ConceptId::parse("tables/orders").unwrap();
let link = Link {
text: "neighbor".into(),
target: "./customers.md".into(),
kind: LinkKind::Relative,
};
assert_eq!(
link.resolve(&source),
Some(ConceptId::parse("tables/customers").unwrap())
);
let up = Link {
text: "up".into(),
target: "../datasets/sales.md".into(),
kind: LinkKind::Relative,
};
assert_eq!(
up.resolve(&source),
Some(ConceptId::parse("datasets/sales").unwrap())
);
}
#[test]
fn protocol_relative_url_is_external() {
assert_eq!(Link::classify("//cdn.example.com/x.js"), LinkKind::External);
}
#[test]
fn any_uri_scheme_is_external() {
assert_eq!(
Link::classify("bigquery:acme.sales.orders"),
LinkKind::External
);
assert_eq!(
Link::classify("urn:acme:metric:revenue"),
LinkKind::External
);
assert_eq!(Link::classify("s3://bucket/key"), LinkKind::External);
assert_eq!(Link::classify("./notes/a:b.md"), LinkKind::Relative);
assert_eq!(Link::classify("/tables/orders.md"), LinkKind::Absolute);
assert_eq!(Link::classify("2026:notes.md"), LinkKind::Relative);
}
#[test]
fn field_paths_try_both_readings_of_a_relative_target() {
let from = ConceptId::parse("computations/revenue").unwrap();
assert_eq!(
okf::links::field_path_candidates("references/skills/run-on-bq.md", &from),
vec![
"computations/references/skills/run-on-bq.md".to_string(),
"references/skills/run-on-bq.md".to_string(),
]
);
assert_eq!(
okf::links::field_path_candidates("/references/x.py", &from),
vec!["references/x.py".to_string()]
);
assert!(okf::links::field_path_candidates("https://example.com/x", &from).is_empty());
}
#[test]
fn absolute_link_normalizes_dot_segments() {
let source = ConceptId::parse("a/b").unwrap();
let link = Link {
text: "x".into(),
target: "/tables/../datasets/sales.md".into(),
kind: LinkKind::Absolute,
};
assert_eq!(
link.resolve(&source),
Some(ConceptId::parse("datasets/sales").unwrap())
);
}
#[test]
fn external_links_do_not_resolve() {
let source = ConceptId::parse("a").unwrap();
let link = Link {
text: "x".into(),
target: "https://example.com".into(),
kind: LinkKind::External,
};
assert_eq!(link.resolve(&source), None);
}
#[test]
fn citations_section_parsed() {
let body = "Prose.\n\n# Citations\n\n[1] [BigQuery schema](https://bq.example/schema)\n[2] [Runbook](https://wiki.acme.internal/runbook)\n";
let citations = extract_citations(body);
assert_eq!(citations.len(), 2);
assert_eq!(citations[0].number, 1);
assert_eq!(citations[0].text.as_deref(), Some("BigQuery schema"));
assert_eq!(
citations[0].target.as_deref(),
Some("https://bq.example/schema")
);
assert_eq!(citations[1].number, 2);
}
#[test]
fn citations_stop_at_next_heading() {
let body = "# Citations\n[1] [a](https://a)\n\n# Other\n[2] [b](https://b)\n";
let citations = extract_citations(body);
assert_eq!(citations.len(), 1);
}
#[test]
fn document_links_and_citations_integration() {
let doc = Document::parse(
"---\ntype: BigQuery Table\n---\n\nJoined with [customers](/tables/customers.md).\n\n# Citations\n[1] [BQ](https://bq)\n",
)
.unwrap();
assert_eq!(doc.links().len(), 2);
let internal = doc
.links()
.into_iter()
.filter(|l| l.kind == LinkKind::Absolute);
assert_eq!(internal.count(), 1);
assert_eq!(doc.citations().len(), 1);
}
#[test]
fn angle_bracket_destinations_are_unwrapped() {
let body = "See [notes](</tables/my notes.md>) and [t](<https://example.com> \"title\").";
let links = extract_links(body);
assert_eq!(links[0].target, "/tables/my notes.md");
assert_eq!(links[0].kind, LinkKind::Absolute);
assert_eq!(links[1].target, "https://example.com");
}
#[test]
fn a_bare_space_in_a_destination_is_not_mistaken_for_a_title() {
let links = extract_links("See [notes](/tables/my notes.md).");
assert_eq!(links[0].target, "/tables/my notes.md");
}
#[test]
fn resolves_links_to_names_with_spaces_and_emoji() {
let source = ConceptId::parse("tables/orders").unwrap();
for target in [
"/tables/my notes.md",
"</tables/my notes.md>",
"./my notes.md",
"/tables/rocket\u{1f680}.md",
] {
let link = extract_links(&format!("[x]({target})")).remove(0);
assert!(
link.resolve(&source).is_some(),
"did not resolve: {target:?}"
);
}
}
#[test]
fn percent_encoded_targets_offer_both_readings() {
let source = ConceptId::parse("tables/orders").unwrap();
let link = extract_links("[x](/tables/my%20notes.md)").remove(0);
let ids: Vec<String> = link
.resolve_all(&source)
.iter()
.map(ConceptId::to_string)
.collect();
assert_eq!(ids, vec!["tables/my%20notes", "tables/my notes"]);
}
#[test]
fn a_target_with_no_escapes_has_a_single_reading() {
let source = ConceptId::parse("a").unwrap();
let link = extract_links("[x](/tables/customers.md)").remove(0);
assert_eq!(link.resolve_all(&source).len(), 1);
let link = extract_links("[x](/tables/100%.md)").remove(0);
assert_eq!(link.resolve_all(&source).len(), 1);
}