#[must_use]
pub fn section_id(title: &str) -> String {
let mut slug = String::new();
let mut pending_dash = false;
for ch in title.chars() {
if ch.is_ascii_alphanumeric() {
if pending_dash {
slug.push('-');
pending_dash = false;
}
slug.push(ch.to_ascii_lowercase());
} else if !slug.is_empty() {
pending_dash = true;
}
}
format!("sec-{slug}")
}
#[must_use]
pub fn ensure_section_ids(body: &str) -> String {
let lines: Vec<&str> = body.split_inclusive('\n').collect();
let mut out = String::with_capacity(body.len() + 64);
let mut index = 0;
while let Some(&line) = lines.get(index) {
if let Some(title) = heading_title(line.trim_end_matches(['\n', '\r'])) {
index = emit_heading_with_id(&lines, index, &title, &mut out);
} else {
out.push_str(line);
index += 1;
}
}
out
}
fn emit_heading_with_id(lines: &[&str], start: usize, title: &str, out: &mut String) -> usize {
if let Some(&heading) = lines.get(start) {
out.push_str(heading);
}
let mut index = start + 1;
while matches!(lines.get(index), Some(&line) if is_planning_line(line)) {
if let Some(&line) = lines.get(index) {
out.push_str(line);
}
index += 1;
}
if matches!(lines.get(index), Some(&line) if is_properties_open(line)) {
fill_existing_drawer(lines, index, title, out)
} else {
ensure_trailing_newline(out);
out.push_str(":PROPERTIES:\n:CUSTOM_ID: ");
out.push_str(§ion_id(title));
out.push_str("\n:END:\n");
index
}
}
fn fill_existing_drawer(lines: &[&str], start: usize, title: &str, out: &mut String) -> usize {
if let Some(&open) = lines.get(start) {
out.push_str(open);
}
let mut index = start + 1;
let mut drawer = String::new();
let mut has_custom_id = false;
while let Some(&line) = lines.get(index) {
if is_properties_end(line) {
break;
}
if line_has_custom_id(line) {
has_custom_id = true;
}
drawer.push_str(line);
index += 1;
}
if !has_custom_id {
out.push_str(":CUSTOM_ID: ");
out.push_str(§ion_id(title));
out.push('\n');
}
out.push_str(&drawer);
if let Some(&end) = lines.get(index) {
out.push_str(end);
index += 1;
}
index
}
fn ensure_trailing_newline(out: &mut String) {
if !out.is_empty() && !out.ends_with('\n') {
out.push('\n');
}
}
fn heading_title(line: &str) -> Option<String> {
let stars = line.chars().take_while(|&ch| ch == '*').count();
if stars == 0 {
return None;
}
let title = line.get(stars..)?.strip_prefix(' ')?.trim_start();
Some(strip_trailing_tags(title).trim().to_owned())
}
fn strip_trailing_tags(title: &str) -> &str {
title.rfind(char::is_whitespace).map_or(title, |split| {
let (head, tail) = title.split_at(split);
if is_tag_cluster(tail.trim()) {
head.trim_end()
} else {
title
}
})
}
fn is_tag_cluster(token: &str) -> bool {
token.len() >= 2
&& token.starts_with(':')
&& token.ends_with(':')
&& token
.trim_matches(':')
.split(':')
.all(|tag| !tag.is_empty() && tag.chars().all(is_tag_char))
}
fn is_tag_char(ch: char) -> bool {
ch.is_alphanumeric() || matches!(ch, '_' | '@' | '#' | '%')
}
fn is_planning_line(line: &str) -> bool {
let trimmed = line.trim_start();
["SCHEDULED:", "DEADLINE:", "CLOSED:"]
.iter()
.any(|keyword| starts_with_ignore_case(trimmed, keyword))
}
fn is_properties_open(line: &str) -> bool {
line.trim().eq_ignore_ascii_case(":PROPERTIES:")
}
fn is_properties_end(line: &str) -> bool {
line.trim().eq_ignore_ascii_case(":END:")
}
fn line_has_custom_id(line: &str) -> bool {
starts_with_ignore_case(line.trim_start(), ":CUSTOM_ID:")
}
fn starts_with_ignore_case(haystack: &str, prefix: &str) -> bool {
haystack
.get(..prefix.len())
.is_some_and(|head| head.eq_ignore_ascii_case(prefix))
}
#[cfg(test)]
mod tests {
use super::{ensure_section_ids, section_id};
#[test]
fn section_id_slugifies() {
assert_eq!(section_id("Intro"), "sec-intro");
assert_eq!(section_id("My Section!"), "sec-my-section");
assert_eq!(section_id("API v3 Notes"), "sec-api-v3-notes");
assert_eq!(section_id(" Leading/trailing "), "sec-leading-trailing");
}
#[test]
fn inserts_drawer_when_absent() {
let body = "* Intro\nbody text\n";
let out = ensure_section_ids(body);
assert_eq!(
out,
"* Intro\n:PROPERTIES:\n:CUSTOM_ID: sec-intro\n:END:\nbody text\n"
);
}
#[test]
fn inserts_into_existing_drawer_without_custom_id() {
let body = "* Intro\n:PROPERTIES:\n:OTHER: x\n:END:\nbody\n";
let out = ensure_section_ids(body);
assert_eq!(
out,
"* Intro\n:PROPERTIES:\n:CUSTOM_ID: sec-intro\n:OTHER: x\n:END:\nbody\n"
);
}
#[test]
fn leaves_existing_custom_id_untouched() {
let body = "* Intro\n:PROPERTIES:\n:CUSTOM_ID: sec-custom\n:END:\nbody\n";
assert_eq!(ensure_section_ids(body), body);
}
#[test]
fn is_idempotent() {
let body = "* One\ntext\n** Two :tag:\nmore\n";
let once = ensure_section_ids(body);
let twice = ensure_section_ids(&once);
assert_eq!(once, twice);
assert!(once.contains(":CUSTOM_ID: sec-one"));
assert!(once.contains(":CUSTOM_ID: sec-two"));
}
#[test]
fn preserves_heading_tags_and_prose() {
let body = "* My Title :rust:kb:\nprose stays\n";
let out = ensure_section_ids(body);
assert!(out.starts_with("* My Title :rust:kb:\n"));
assert!(out.contains(":CUSTOM_ID: sec-my-title\n"));
assert!(out.ends_with("prose stays\n"));
}
#[test]
fn skips_planning_line_before_drawer() {
let body = "* Task\nSCHEDULED: <2026-06-10>\nbody\n";
let out = ensure_section_ids(body);
assert_eq!(
out,
"* Task\nSCHEDULED: <2026-06-10>\n:PROPERTIES:\n:CUSTOM_ID: sec-task\n:END:\nbody\n"
);
}
#[test]
fn ignores_non_heading_lines() {
let body = "not a heading\n*bold not heading*\n";
assert_eq!(ensure_section_ids(body), body);
}
}