tftio-org-gdocs 0.1.3

Sync org-mode documents to Google Docs and pull reviewer comments back into org-mode
Documentation
//! Stable section ids and their surgical insertion (P2).
//!
//! Comments anchor to sections by `:CUSTOM_ID:` (DI-8), so every body heading
//! needs a stable id. This module derives `sec-<slug>` ids from heading titles
//! and inserts them into each heading's property drawer **in place** — the only
//! body mutation the tool is allowed (DI-2). It is idempotent: a heading that
//! already has a `:CUSTOM_ID:` is left untouched, so re-running changes nothing.

/// Derive a stable, human-readable section id (`sec-<slug>`) from a heading
/// title. Deterministic: equal titles yield equal ids (DI-8).
#[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}")
}

/// Return `body` with `:CUSTOM_ID:` inserted into every heading that lacks one.
///
/// Idempotent and body-preserving: only property-drawer lines are added; prose,
/// spacing, and headings themselves are copied verbatim.
#[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
}

/// Emit a heading, its planning lines, and a property drawer that is guaranteed
/// to contain a `:CUSTOM_ID:`. Returns the index of the next unconsumed line.
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(&section_id(title));
        out.push_str("\n:END:\n");
        index
    }
}

/// Copy an existing property drawer, inserting `:CUSTOM_ID:` if it has none.
/// Returns the index just past the drawer's `:END:`.
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(&section_id(title));
        out.push('\n');
    }
    out.push_str(&drawer);
    if let Some(&end) = lines.get(index) {
        out.push_str(end);
        index += 1;
    }
    index
}

/// Append a newline to `out` if it has content not already newline-terminated.
fn ensure_trailing_newline(out: &mut String) {
    if !out.is_empty() && !out.ends_with('\n') {
        out.push('\n');
    }
}

/// If `line` is a heading (`*`+ then a space), return its title with leading
/// stars and trailing `:tag:` clusters removed.
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())
}

/// Remove a trailing org tag cluster (`:a:b:`) from a heading title, if present.
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
        }
    })
}

/// Whether `token` is an org tag cluster like `:rust:kb:`.
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))
}

/// Characters permitted in an org tag.
fn is_tag_char(ch: char) -> bool {
    ch.is_alphanumeric() || matches!(ch, '_' | '@' | '#' | '%')
}

/// Whether a line is an org planning line (`SCHEDULED:`/`DEADLINE:`/`CLOSED:`).
fn is_planning_line(line: &str) -> bool {
    let trimmed = line.trim_start();
    ["SCHEDULED:", "DEADLINE:", "CLOSED:"]
        .iter()
        .any(|keyword| starts_with_ignore_case(trimmed, keyword))
}

/// Whether a line opens a property drawer (`:PROPERTIES:`).
fn is_properties_open(line: &str) -> bool {
    line.trim().eq_ignore_ascii_case(":PROPERTIES:")
}

/// Whether a line closes a property drawer (`:END:`).
fn is_properties_end(line: &str) -> bool {
    line.trim().eq_ignore_ascii_case(":END:")
}

/// Whether a drawer line declares a `:CUSTOM_ID:` property.
fn line_has_custom_id(line: &str) -> bool {
    starts_with_ignore_case(line.trim_start(), ":CUSTOM_ID:")
}

/// Case-insensitive ASCII prefix test that avoids slicing on non-boundaries.
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);
    }
}