tftio-org-gdocs 0.1.3

Sync org-mode documents to Google Docs and pull reviewer comments back into org-mode
Documentation
//! Q3 — the `clean` command: archive the comments the operator marked `DONE`.
//!
//! Once a `DONE` comment has been resolved in Google (by `push`), its `TODO`
//! heading is dead weight in the org file. `clean` cuts those subtrees out of
//! `** Active Comments` (P6 [`crate::comments_meta::clean_section`]) and rewrites
//! the file, leaving every non-`DONE` heading and the entire body untouched.
//!
//! This is a pure, offline transform — no network, no clock. Guarantees:
//!
//! - **Selective.** Only `DONE` comment subtrees are removed; `TODO` headings
//!   (including operator notes and clocking) are preserved verbatim (A3).
//! - **DI-2 — the body is sacrosanct.** The body is reproduced byte-for-byte;
//!   `clean` rewrites only the machine region, and when nothing is `DONE` it is a
//!   strict no-op on the whole file.

use crate::comments_meta::{self, CommentState};
use crate::envelope;
use crate::error::Result;
use crate::orgfile;
use crate::sexp::Sexp;
use crate::syncstate::SyncState;

/// The tool-owned metadata heading that opens the machine region.
const METADATA_HEADING: &str = "* GDOC_METADATA :noexport:";

/// The result of a clean: how many comments were archived and the new content.
#[derive(Debug, Clone)]
pub struct CleanOutcome {
    /// Number of `DONE` comment subtrees removed.
    pub removed: usize,
    /// The new file content to write back (body preserved, `DONE` subtrees cut).
    pub new_content: String,
}

/// Archive every `DONE` comment in `content`, returning the rewritten file.
///
/// When the file has no machine region or no `DONE` comments, the content is
/// returned byte-for-byte unchanged.
///
/// # Errors
///
/// Returns [`crate::error::Error::Sexp`] when the existing `** Sync State` block
/// is present but malformed.
pub fn clean(content: &str) -> Result<CleanOutcome> {
    let (_, machine) = orgfile::split(content);
    let Some(machine) = machine else {
        return Ok(unchanged(content));
    };

    let removed = comments_meta::parse_entries(machine)
        .iter()
        .filter(|entry| entry.state == CommentState::Done)
        .count();
    if removed == 0 {
        return Ok(unchanged(content));
    }

    // Preserve the Sync State (pure machine state, regenerated canonically) and
    // drop the DONE subtrees from Active Comments. The body is untouched.
    let sync = SyncState::parse_block(machine)?;
    let cleaned_active = comments_meta::clean_section(machine);
    let region = format!(
        "{METADATA_HEADING}\n{}{cleaned_active}",
        sync.render_block()
    );
    let new_content = orgfile::replace_metadata(content, &region);

    Ok(CleanOutcome {
        removed,
        new_content,
    })
}

/// A no-op outcome that returns `content` verbatim.
fn unchanged(content: &str) -> CleanOutcome {
    CleanOutcome {
        removed: 0,
        new_content: content.to_owned(),
    }
}

/// Build the success envelope for a completed clean (A5).
#[must_use]
pub fn envelope(outcome: &CleanOutcome) -> Sexp {
    envelope::ok(
        "clean",
        vec![(
            "removed",
            Sexp::int(i64::try_from(outcome.removed).unwrap_or(i64::MAX)),
        )],
    )
}

#[cfg(test)]
mod tests {
    use super::clean;
    use crate::orgfile;

    /// A linked file with one `TODO` and one `DONE` comment under Active Comments.
    const WITH_DONE: &str = "#+GDOC_ID: DOC1\n#+TITLE: Spec\n\
        * Intro\n:PROPERTIES:\n:CUSTOM_ID: sec-intro\n:END:\nBody prose.\n\n\
        * GDOC_METADATA :noexport:\n** Sync State\n#+begin_src emacs-lisp\n\
        (gdoc-sync-state 1 (positions (pos \"sec-intro\" 1 heading)) (collaborators))\n\
        #+end_src\n** Active Comments\n\
        *** TODO Alice: keep\n:PROPERTIES:\n:COMMENT_ID: C1\n:END:\n: operator note\n\n\
        *** DONE Bob: drop\n:PROPERTIES:\n:COMMENT_ID: C2\n:END:\n";

    #[test]
    fn clean_removes_done_keeps_todo_and_preserves_body() {
        let outcome = clean(WITH_DONE).expect("clean succeeds");

        assert_eq!(outcome.removed, 1);
        let written = &outcome.new_content;
        // TODO (and its operator note) survives; DONE is gone.
        assert!(written.contains(":COMMENT_ID: C1\n"));
        assert!(written.contains(": operator note\n"));
        assert!(!written.contains(":COMMENT_ID: C2\n"));
        assert!(!written.contains("DONE"));
        // The body is byte-for-byte identical (DI-2) — clean never touches prose.
        assert_eq!(orgfile::body(written), orgfile::body(WITH_DONE));
    }

    #[test]
    fn clean_with_no_done_is_a_strict_noop() {
        let todo_only = "#+GDOC_ID: DOC1\n* Intro\nBody.\n\n\
            * GDOC_METADATA :noexport:\n** Sync State\n#+begin_src emacs-lisp\n\
            (gdoc-sync-state 1 (positions) (collaborators))\n#+end_src\n\
            ** Active Comments\n*** TODO Alice: open\n:PROPERTIES:\n:COMMENT_ID: C1\n:END:\n";
        let outcome = clean(todo_only).expect("clean succeeds");
        assert_eq!(outcome.removed, 0);
        // No DONE comments → the whole file is returned unchanged.
        assert_eq!(outcome.new_content, todo_only);
    }

    #[test]
    fn clean_without_a_machine_region_is_a_noop() {
        let body_only = "* Intro\nBody.\n";
        let outcome = clean(body_only).expect("clean succeeds");
        assert_eq!(outcome.removed, 0);
        assert_eq!(outcome.new_content, body_only);
    }
}