Skip to main content

org_gdocs/
clean.rs

1//! Q3 — the `clean` command: archive the comments the operator marked `DONE`.
2//!
3//! Once a `DONE` comment has been resolved in Google (by `push`), its `TODO`
4//! heading is dead weight in the org file. `clean` cuts those subtrees out of
5//! `** Active Comments` (P6 [`crate::comments_meta::clean_section`]) and rewrites
6//! the file, leaving every non-`DONE` heading and the entire body untouched.
7//!
8//! This is a pure, offline transform — no network, no clock. Guarantees:
9//!
10//! - **Selective.** Only `DONE` comment subtrees are removed; `TODO` headings
11//!   (including operator notes and clocking) are preserved verbatim (A3).
12//! - **DI-2 — the body is sacrosanct.** The body is reproduced byte-for-byte;
13//!   `clean` rewrites only the machine region, and when nothing is `DONE` it is a
14//!   strict no-op on the whole file.
15
16use crate::comments_meta::{self, CommentState};
17use crate::envelope;
18use crate::error::Result;
19use crate::orgfile;
20use crate::sexp::Sexp;
21use crate::syncstate::SyncState;
22
23/// The tool-owned metadata heading that opens the machine region.
24const METADATA_HEADING: &str = "* GDOC_METADATA :noexport:";
25
26/// The result of a clean: how many comments were archived and the new content.
27#[derive(Debug, Clone)]
28pub struct CleanOutcome {
29    /// Number of `DONE` comment subtrees removed.
30    pub removed: usize,
31    /// The new file content to write back (body preserved, `DONE` subtrees cut).
32    pub new_content: String,
33}
34
35/// Archive every `DONE` comment in `content`, returning the rewritten file.
36///
37/// When the file has no machine region or no `DONE` comments, the content is
38/// returned byte-for-byte unchanged.
39///
40/// # Errors
41///
42/// Returns [`crate::error::Error::Sexp`] when the existing `** Sync State` block
43/// is present but malformed.
44pub fn clean(content: &str) -> Result<CleanOutcome> {
45    let (_, machine) = orgfile::split(content);
46    let Some(machine) = machine else {
47        return Ok(unchanged(content));
48    };
49
50    let removed = comments_meta::parse_entries(machine)
51        .iter()
52        .filter(|entry| entry.state == CommentState::Done)
53        .count();
54    if removed == 0 {
55        return Ok(unchanged(content));
56    }
57
58    // Preserve the Sync State (pure machine state, regenerated canonically) and
59    // drop the DONE subtrees from Active Comments. The body is untouched.
60    let sync = SyncState::parse_block(machine)?;
61    let cleaned_active = comments_meta::clean_section(machine);
62    let region = format!(
63        "{METADATA_HEADING}\n{}{cleaned_active}",
64        sync.render_block()
65    );
66    let new_content = orgfile::replace_metadata(content, &region);
67
68    Ok(CleanOutcome {
69        removed,
70        new_content,
71    })
72}
73
74/// A no-op outcome that returns `content` verbatim.
75fn unchanged(content: &str) -> CleanOutcome {
76    CleanOutcome {
77        removed: 0,
78        new_content: content.to_owned(),
79    }
80}
81
82/// Build the success envelope for a completed clean (A5).
83#[must_use]
84pub fn envelope(outcome: &CleanOutcome) -> Sexp {
85    envelope::ok(
86        "clean",
87        vec![(
88            "removed",
89            Sexp::int(i64::try_from(outcome.removed).unwrap_or(i64::MAX)),
90        )],
91    )
92}
93
94#[cfg(test)]
95mod tests {
96    use super::clean;
97    use crate::orgfile;
98
99    /// A linked file with one `TODO` and one `DONE` comment under Active Comments.
100    const WITH_DONE: &str = "#+GDOC_ID: DOC1\n#+TITLE: Spec\n\
101        * Intro\n:PROPERTIES:\n:CUSTOM_ID: sec-intro\n:END:\nBody prose.\n\n\
102        * GDOC_METADATA :noexport:\n** Sync State\n#+begin_src emacs-lisp\n\
103        (gdoc-sync-state 1 (positions (pos \"sec-intro\" 1 heading)) (collaborators))\n\
104        #+end_src\n** Active Comments\n\
105        *** TODO Alice: keep\n:PROPERTIES:\n:COMMENT_ID: C1\n:END:\n: operator note\n\n\
106        *** DONE Bob: drop\n:PROPERTIES:\n:COMMENT_ID: C2\n:END:\n";
107
108    #[test]
109    fn clean_removes_done_keeps_todo_and_preserves_body() {
110        let outcome = clean(WITH_DONE).expect("clean succeeds");
111
112        assert_eq!(outcome.removed, 1);
113        let written = &outcome.new_content;
114        // TODO (and its operator note) survives; DONE is gone.
115        assert!(written.contains(":COMMENT_ID: C1\n"));
116        assert!(written.contains(": operator note\n"));
117        assert!(!written.contains(":COMMENT_ID: C2\n"));
118        assert!(!written.contains("DONE"));
119        // The body is byte-for-byte identical (DI-2) — clean never touches prose.
120        assert_eq!(orgfile::body(written), orgfile::body(WITH_DONE));
121    }
122
123    #[test]
124    fn clean_with_no_done_is_a_strict_noop() {
125        let todo_only = "#+GDOC_ID: DOC1\n* Intro\nBody.\n\n\
126            * GDOC_METADATA :noexport:\n** Sync State\n#+begin_src emacs-lisp\n\
127            (gdoc-sync-state 1 (positions) (collaborators))\n#+end_src\n\
128            ** Active Comments\n*** TODO Alice: open\n:PROPERTIES:\n:COMMENT_ID: C1\n:END:\n";
129        let outcome = clean(todo_only).expect("clean succeeds");
130        assert_eq!(outcome.removed, 0);
131        // No DONE comments → the whole file is returned unchanged.
132        assert_eq!(outcome.new_content, todo_only);
133    }
134
135    #[test]
136    fn clean_without_a_machine_region_is_a_noop() {
137        let body_only = "* Intro\nBody.\n";
138        let outcome = clean(body_only).expect("clean succeeds");
139        assert_eq!(outcome.removed, 0);
140        assert_eq!(outcome.new_content, body_only);
141    }
142}