1use crate::comments_meta::{self, CommentState};
17use crate::envelope;
18use crate::error::Result;
19use crate::orgfile;
20use crate::sexp::Sexp;
21use crate::syncstate::SyncState;
22
23const METADATA_HEADING: &str = "* GDOC_METADATA :noexport:";
25
26#[derive(Debug, Clone)]
28pub struct CleanOutcome {
29 pub removed: usize,
31 pub new_content: String,
33}
34
35pub 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 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, ®ion);
67
68 Ok(CleanOutcome {
69 removed,
70 new_content,
71 })
72}
73
74fn unchanged(content: &str) -> CleanOutcome {
76 CleanOutcome {
77 removed: 0,
78 new_content: content.to_owned(),
79 }
80}
81
82#[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 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 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 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 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}