pub const URL_SCHEME: &str = "piki";
pub fn heading_slug(text: &str) -> String {
let mut slug = String::new();
let mut pending_dash = false;
for c in text.chars() {
if c.is_alphanumeric() {
if pending_dash && !slug.is_empty() {
slug.push('-');
}
pending_dash = false;
slug.extend(c.to_lowercase());
} else if c.is_whitespace() || c == '-' || c == '_' {
pending_dash = true;
}
}
slug
}
pub fn heading_anchors<S: AsRef<str>>(heading_texts: &[S]) -> Vec<String> {
use std::collections::HashMap;
let mut counts: HashMap<String, usize> = HashMap::new();
let mut anchors = Vec::with_capacity(heading_texts.len());
for text in heading_texts {
let base = heading_slug(text.as_ref());
let seen = counts.entry(base.clone()).or_insert(0);
let anchor = if *seen == 0 {
base.clone()
} else {
format!("{base}-{seen}")
};
*seen += 1;
anchors.push(anchor);
}
anchors
}
pub fn split_target(dest: &str) -> (&str, Option<&str>) {
match dest.find('#') {
Some(i) => (&dest[..i], Some(&dest[i + 1..])),
None => (dest, None),
}
}
pub fn build_piki_url(note: &str, anchor: Option<&str>) -> String {
let mut url = format!("{URL_SCHEME}://{}", encode_path(note));
if let Some(anchor) = anchor.filter(|a| !a.is_empty()) {
url.push('#');
url.push_str(&encode_component(anchor));
}
url
}
pub fn normalize_link_target(input: &str) -> String {
match strip_scheme(input.trim()) {
Some(rest) => percent_decode(rest),
None => input.to_string(),
}
}
fn strip_scheme(s: &str) -> Option<&str> {
let lower = s.to_ascii_lowercase();
if lower.starts_with("piki://") {
Some(&s["piki://".len()..])
} else if lower.starts_with("piki:") {
Some(&s["piki:".len()..])
} else {
None
}
}
fn is_unreserved(b: u8) -> bool {
b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~')
}
fn encode_path(s: &str) -> String {
encode_with(s, |b| is_unreserved(b) || b == b'/')
}
fn encode_component(s: &str) -> String {
encode_with(s, is_unreserved)
}
fn encode_with(s: &str, keep: impl Fn(u8) -> bool) -> String {
let mut out = String::with_capacity(s.len());
for &b in s.as_bytes() {
if keep(b) {
out.push(b as char);
} else {
out.push('%');
out.push_str(&format!("{b:02X}"));
}
}
out
}
fn percent_decode(s: &str) -> String {
let bytes = s.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%'
&& i + 2 < bytes.len()
&& let (Some(hi), Some(lo)) = (hex_val(bytes[i + 1]), hex_val(bytes[i + 2]))
{
out.push(hi * 16 + lo);
i += 3;
continue;
}
out.push(bytes[i]);
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
fn hex_val(b: u8) -> Option<u8> {
match b {
b'0'..=b'9' => Some(b - b'0'),
b'a'..=b'f' => Some(b - b'a' + 10),
b'A'..=b'F' => Some(b - b'A' + 10),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn slug_basics() {
assert_eq!(heading_slug("Hello World"), "hello-world");
assert_eq!(heading_slug("Security Model"), "security-model");
assert_eq!(heading_slug(" Trailing spaces "), "trailing-spaces");
assert_eq!(heading_slug("Notes: Meeting!"), "notes-meeting");
assert_eq!(heading_slug("Q4 — Budget (2026)"), "q4-budget-2026");
assert_eq!(heading_slug("under_score and-dash"), "under-score-and-dash");
assert_eq!(heading_slug("multiple spaces"), "multiple-spaces");
assert_eq!(heading_slug("---"), "");
assert_eq!(heading_slug(""), "");
}
#[test]
fn anchors_disambiguate_duplicates() {
let headings = ["Notes", "Details", "Notes", "notes"];
let anchors = heading_anchors(&headings);
assert_eq!(anchors, vec!["notes", "details", "notes-1", "notes-2"]);
}
#[test]
fn split_target_splits_on_first_hash() {
assert_eq!(split_target("note"), ("note", None));
assert_eq!(split_target("note#sec"), ("note", Some("sec")));
assert_eq!(
split_target("path/to/note#sec-tion"),
("path/to/note", Some("sec-tion"))
);
assert_eq!(split_target("note#a#b"), ("note", Some("a#b")));
assert_eq!(split_target("note#"), ("note", Some("")));
}
#[test]
fn build_url_roundtrips_through_normalize() {
let url = build_piki_url("work/auth-refactor", Some("security-model"));
assert_eq!(url, "piki://work/auth-refactor#security-model");
assert_eq!(
normalize_link_target(&url),
"work/auth-refactor#security-model"
);
let url = build_piki_url("frontpage", None);
assert_eq!(url, "piki://frontpage");
assert_eq!(normalize_link_target(&url), "frontpage");
assert_eq!(build_piki_url("frontpage", Some("")), "piki://frontpage");
}
#[test]
fn build_url_percent_encodes_spaces() {
let url = build_piki_url("Notes: Meeting", Some("agenda"));
assert_eq!(url, "piki://Notes%3A%20Meeting#agenda");
assert_eq!(normalize_link_target(&url), "Notes: Meeting#agenda");
}
#[test]
fn normalize_leaves_non_piki_untouched() {
assert_eq!(normalize_link_target("note#sec"), "note#sec");
assert_eq!(normalize_link_target("path/to/note"), "path/to/note");
assert_eq!(
normalize_link_target("https://example.com/x"),
"https://example.com/x"
);
assert_eq!(normalize_link_target(" note "), " note ");
}
#[test]
fn normalize_handles_scheme_case_and_missing_slashes() {
assert_eq!(normalize_link_target("PIKI://frontpage"), "frontpage");
assert_eq!(normalize_link_target("piki:frontpage#top"), "frontpage#top");
}
#[test]
fn percent_decode_tolerates_stray_percent() {
assert_eq!(percent_decode("100%"), "100%");
assert_eq!(percent_decode("a%2"), "a%2");
assert_eq!(percent_decode("a%zz"), "a%zz");
assert_eq!(percent_decode("%41%42"), "AB");
}
}