tftio-org-gdocs 0.1.3

Sync org-mode documents to Google Docs and pull reviewer comments back into org-mode
Documentation
//! Two-region model of a synced org file: the user's **body** and the
//! tool-owned **machine region**, split at the `* GDOC_METADATA` heading.
//!
//! Every function here is a pure, total transformation of file text. The body
//! (everything before the boundary) is preserved **byte-for-byte** by
//! construction (domain invariant DI-2): writeback only ever rewrites a single
//! `#+KEYWORD:` line or the metadata tail. The only sanctioned body mutation
//! — inserting a `:CUSTOM_ID:` property — lives in [`crate::project`] (P2), not
//! here.
//!
//! `#+KEYWORD:` matching is case-insensitive, as org keywords are.

/// Level-1 heading, sans tags, that begins the tool-owned machine region.
pub const METADATA_HEADING: &str = "* GDOC_METADATA";

/// Whether a newline-stripped line is the `GDOC_METADATA` heading (with or
/// without trailing tags, e.g. `* GDOC_METADATA :noexport:`).
fn is_metadata_heading(line: &str) -> bool {
    line == METADATA_HEADING || line.starts_with(concat!("* GDOC_METADATA", " "))
}

/// Whether a line is a top-of-file org keyword line (`#+KEY: ...`).
fn is_keyword_line(line: &str) -> bool {
    line.trim_start().starts_with("#+")
}

/// Whether a newline-stripped line begins a heading (`* `, `** `, ...).
fn is_heading_line(line: &str) -> bool {
    let stars = line.chars().take_while(|&c| c == '*').count();
    stars > 0 && line[stars..].starts_with(' ')
}

/// Byte offset where the `GDOC_METADATA` heading begins, if the file has one.
#[must_use]
pub fn metadata_boundary(text: &str) -> Option<usize> {
    let mut offset = 0;
    for line in text.split_inclusive('\n') {
        if is_metadata_heading(line.trim_end_matches(['\n', '\r'])) {
            return Some(offset);
        }
        offset += line.len();
    }
    None
}

/// Split into `(body, Some(machine_region))`, or `(whole_text, None)` when the
/// file has no `GDOC_METADATA` section yet.
#[must_use]
pub fn split(text: &str) -> (&str, Option<&str>) {
    metadata_boundary(text).map_or((text, None), |offset| {
        (&text[..offset], Some(&text[offset..]))
    })
}

/// The user-owned body region (everything before the machine region).
#[must_use]
pub fn body(text: &str) -> &str {
    split(text).0
}

/// Extract the value of a `#+KEY:` keyword from the body region, if present.
#[must_use]
pub fn read_keyword(text: &str, key: &str) -> Option<String> {
    body(text).lines().find_map(|line| keyword_value(line, key))
}

/// If `line` is `#+KEY: value` (case-insensitive key), return the trimmed value.
fn keyword_value(line: &str, key: &str) -> Option<String> {
    let rest = line.trim_start().strip_prefix("#+")?;
    let (found_key, value) = rest.split_once(':')?;
    found_key
        .eq_ignore_ascii_case(key)
        .then(|| value.trim().to_owned())
}

/// Set `#+KEY: value` in the body region.
///
/// An existing keyword line is rewritten in place; otherwise the keyword is
/// inserted after the last top-of-file keyword line (or prepended when the file
/// has none). Only the affected line changes; all other bytes are preserved.
#[must_use]
pub fn upsert_keyword(text: &str, key: &str, value: &str) -> String {
    let rendered = format!("#+{key}: {value}");
    let lines: Vec<&str> = text.split_inclusive('\n').collect();

    if let Some(index) = lines
        .iter()
        .position(|line| keyword_value(line, key).is_some())
    {
        return rebuild_replacing(&lines, index, &rendered);
    }

    let insert_at = keyword_insertion_index(&lines);
    rebuild_inserting(&lines, insert_at, &rendered)
}

/// Index (into `split_inclusive('\n')` lines) at which to insert a new keyword:
/// just after the last leading keyword line that precedes the first heading.
fn keyword_insertion_index(lines: &[&str]) -> usize {
    let mut insert_at = 0;
    for (index, line) in lines.iter().enumerate() {
        let trimmed = line.trim_end_matches(['\n', '\r']);
        if is_heading_line(trimmed) {
            break;
        }
        if is_keyword_line(trimmed) {
            insert_at = index + 1;
        }
    }
    insert_at
}

/// Rebuild text replacing the content of `lines[index]` with `rendered`,
/// preserving that line's original trailing newline (or lack thereof).
fn rebuild_replacing(lines: &[&str], index: usize, rendered: &str) -> String {
    let mut out = String::with_capacity(text_len(lines));
    for (current, line) in lines.iter().enumerate() {
        if current == index {
            out.push_str(rendered);
            if line.ends_with('\n') {
                out.push('\n');
            }
        } else {
            out.push_str(line);
        }
    }
    out
}

/// Rebuild text inserting `rendered` (with a trailing newline) before
/// `lines[index]`.
fn rebuild_inserting(lines: &[&str], index: usize, rendered: &str) -> String {
    let mut out = String::with_capacity(text_len(lines) + rendered.len() + 1);
    for (current, line) in lines.iter().enumerate() {
        if current == index {
            out.push_str(rendered);
            out.push('\n');
        }
        out.push_str(line);
    }
    if index >= lines.len() {
        out.push_str(rendered);
        out.push('\n');
    }
    out
}

/// Total byte length of the collected lines (== original text length).
fn text_len(lines: &[&str]) -> usize {
    lines.iter().map(|line| line.len()).sum()
}

/// Replace the machine region with `region`, preserving every body byte.
///
/// The machine region spans the `* GDOC_METADATA` heading to end of file. When
/// the file has none yet, `region` is appended, separated from the body by a
/// blank line. `region` is expected to start with the `* GDOC_METADATA` heading.
#[must_use]
pub fn replace_metadata(text: &str, region: &str) -> String {
    metadata_boundary(text).map_or_else(
        || {
            let mut out = String::with_capacity(text.len() + region.len() + 2);
            out.push_str(text);
            if !out.is_empty() && !out.ends_with('\n') {
                out.push('\n');
            }
            if !out.ends_with("\n\n") {
                out.push('\n');
            }
            out.push_str(region);
            out
        },
        |offset| {
            let mut out = String::with_capacity(offset + region.len());
            out.push_str(&text[..offset]);
            out.push_str(region);
            out
        },
    )
}

#[cfg(test)]
mod tests {
    use super::{body, metadata_boundary, read_keyword, replace_metadata, split, upsert_keyword};

    const SAMPLE: &str = "#+TITLE: Doc\n#+GDOC_ID: abc123\n\n* Intro\nbody text\n\n* GDOC_METADATA :noexport:\n** Sync State\n#+begin_src json\n{}\n#+end_src\n";

    #[test]
    fn boundary_found_at_metadata_heading() {
        let offset = metadata_boundary(SAMPLE).expect("has metadata");
        assert!(SAMPLE[offset..].starts_with("* GDOC_METADATA :noexport:"));
    }

    #[test]
    fn split_preserves_concatenation() {
        let (b, meta) = split(SAMPLE);
        let mut joined = String::from(b);
        joined.push_str(meta.expect("has metadata"));
        assert_eq!(joined, SAMPLE);
        assert!(b.ends_with("body text\n\n"));
    }

    #[test]
    fn no_metadata_yields_whole_body() {
        let text = "* Intro\nbody\n";
        assert_eq!(metadata_boundary(text), None);
        assert_eq!(body(text), text);
        assert_eq!(split(text), (text, None));
    }

    #[test]
    fn read_keyword_is_case_insensitive_and_body_scoped() {
        assert_eq!(read_keyword(SAMPLE, "GDOC_ID").as_deref(), Some("abc123"));
        assert_eq!(read_keyword(SAMPLE, "gdoc_id").as_deref(), Some("abc123"));
        assert_eq!(read_keyword(SAMPLE, "GDOC_URL"), None);
    }

    #[test]
    fn upsert_existing_keyword_changes_only_that_line() {
        let updated = upsert_keyword(SAMPLE, "GDOC_ID", "xyz789");
        assert_eq!(read_keyword(&updated, "GDOC_ID").as_deref(), Some("xyz789"));
        // Everything except the one line is byte-identical.
        assert_eq!(
            updated.replace("#+GDOC_ID: xyz789", "#+GDOC_ID: abc123"),
            SAMPLE
        );
    }

    #[test]
    fn upsert_new_keyword_inserts_after_leading_keywords() {
        let updated = upsert_keyword(SAMPLE, "GDOC_URL", "https://x");
        assert_eq!(
            read_keyword(&updated, "GDOC_URL").as_deref(),
            Some("https://x")
        );
        // Inserted within the top keyword block, before the first heading.
        let url_pos = updated.find("#+GDOC_URL:").expect("present");
        let intro_pos = updated.find("* Intro").expect("present");
        assert!(url_pos < intro_pos);
        // Body content untouched.
        assert!(updated.contains("\n* Intro\nbody text\n"));
    }

    #[test]
    fn upsert_into_keywordless_file_prepends() {
        let text = "* Intro\nbody\n";
        let updated = upsert_keyword(text, "GDOC_ID", "abc");
        assert_eq!(updated, "#+GDOC_ID: abc\n* Intro\nbody\n");
    }

    #[test]
    fn replace_metadata_preserves_body_bytes() {
        let new_region =
            "* GDOC_METADATA :noexport:\n** Sync State\n#+begin_src json\n{\"v\":1}\n#+end_src\n";
        let updated = replace_metadata(SAMPLE, new_region);
        let (original_body, _) = split(SAMPLE);
        let (new_body, new_meta) = split(&updated);
        assert_eq!(new_body, original_body);
        assert_eq!(new_meta, Some(new_region));
    }

    #[test]
    fn replace_metadata_appends_when_absent() {
        let text = "#+TITLE: Doc\n\n* Intro\nbody\n";
        let region = "* GDOC_METADATA :noexport:\n** Sync State\n";
        let updated = replace_metadata(text, region);
        assert!(updated.starts_with("#+TITLE: Doc\n\n* Intro\nbody\n"));
        assert!(updated.ends_with(region));
        assert_eq!(
            metadata_boundary(&updated).map(|o| &updated[o..]),
            Some(region)
        );
    }

    #[test]
    fn noop_writeback_round_trips_body() {
        // Simulate a no-op sync: re-set an existing keyword to its current value
        // and replace the metadata with the identical region.
        let id = read_keyword(SAMPLE, "GDOC_ID").expect("id");
        let once = upsert_keyword(SAMPLE, "GDOC_ID", &id);
        let (_, meta) = split(&once);
        let twice = replace_metadata(&once, meta.expect("meta"));
        assert_eq!(twice, SAMPLE);
    }
}