Skip to main content

org_gdocs/
comments_meta.rs

1//! P6 — the `** Active Comments` subtree: human-facing `TODO`/`DONE` headings,
2//! one per pulled Google comment, keyed by `:COMMENT_ID:`.
3//!
4//! Under `* GDOC_METADATA`, this section sits after `** Sync State`. Each comment
5//! is a level-3 heading carrying its id, author, date, and anchored section in a
6//! property drawer, plus the quoted document text and any replies in the body.
7//!
8//! Three operations back the sync flows:
9//!
10//! - **parse** ([`parse_entries`]) extracts `(id, state, section)` from existing
11//!   headings — push reads `DONE` to resolve in Google; pull reads the known ids
12//!   to avoid re-inserting.
13//! - **render/merge** ([`render_section`]) appends *new* comments as `TODO`
14//!   headings. **Merge-by-id (A3):** existing headings are preserved byte-for-byte
15//!   and never reordered, so operator notes and clocking survive.
16//! - **clean** ([`clean_section`]) drops the `DONE` subtrees and keeps the rest
17//!   verbatim.
18//!
19//! Line scanning is **org-block-aware**: lines inside a `#+begin_…`/`#+end_…`
20//! block (e.g. a quoted snippet that happens to start with `***`) are never
21//! mistaken for headings. Parsing is total (EI-4) — non-conforming headings are
22//! skipped, never panicked on.
23
24use crate::google::drive::{Author, Comment};
25
26/// The level-2 heading that opens the comment section.
27const SECTION_HEADING: &str = "** Active Comments";
28
29/// The TODO/DONE state of a comment heading.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum CommentState {
32    /// An open comment (rendered by pull; the operator has not addressed it).
33    Todo,
34    /// A comment the operator marked `DONE`; push resolves it in Google.
35    Done,
36}
37
38/// An operator-authored reply (`**** REPLY` subheading) under a comment that has
39/// not yet been posted to Google.
40///
41/// Whether a reply has been posted is tracked in the sync state (by `comment_id` +
42/// `content`), not by mutating this subtree — so the operator's text is never
43/// rewritten (A3) and the bookkeeping stays in machine state (DI-3).
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct PendingReply {
46    /// The `:COMMENT_ID:` of the comment this reply answers.
47    pub comment_id: String,
48    /// The reply text (the subheading's body, whitespace-trimmed).
49    pub content: String,
50}
51
52/// The machine-relevant fields parsed from one comment heading.
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct CommentEntry {
55    /// The Drive comment id (`:COMMENT_ID:`).
56    pub id: String,
57    /// Whether the heading is `TODO` or `DONE`.
58    pub state: CommentState,
59    /// The anchored section's `CUSTOM_ID` (`:COMMENT_SECTION:`), or `None` for a
60    /// document-level comment.
61    pub section: Option<String>,
62}
63
64/// Parse every comment heading in the `** Active Comments` section of `region`.
65///
66/// Headings without a `:COMMENT_ID:` are skipped (operator-authored content is
67/// not treated as a comment). Total and panic-free.
68#[must_use]
69pub fn parse_entries(region: &str) -> Vec<CommentEntry> {
70    let Some(body) = section_body(region) else {
71        return Vec::new();
72    };
73    let (_, blocks) = split_blocks(body);
74    blocks
75        .iter()
76        .filter_map(|block| parse_block(block))
77        .collect()
78}
79
80/// Render the full `** Active Comments` subtree: the heading, the existing body
81/// preserved verbatim (A3), then each `new` comment whose id is not already
82/// present appended as a `TODO` heading.
83///
84/// Each new entry pairs a [`Comment`] with the `CUSTOM_ID` it anchors to (or
85/// `None` for document-level).
86#[must_use]
87pub fn render_section(region: &str, new: &[(&Comment, Option<&str>)]) -> String {
88    let known = known_ids(region);
89    let mut out = String::from(SECTION_HEADING);
90    out.push('\n');
91    if let Some(body) = section_body(region) {
92        out.push_str(body);
93    }
94    for (comment, section) in new {
95        if known.iter().any(|id| id == &comment.id) {
96            continue;
97        }
98        ensure_trailing_blank(&mut out);
99        out.push_str(&render_comment(comment, *section));
100    }
101    out
102}
103
104/// Render the `** Active Comments` subtree with every `DONE` comment subtree
105/// removed; non-`DONE` blocks and any preamble are kept verbatim.
106#[must_use]
107pub fn clean_section(region: &str) -> String {
108    let mut out = String::from(SECTION_HEADING);
109    out.push('\n');
110    if let Some(body) = section_body(region) {
111        let (preamble, blocks) = split_blocks(body);
112        out.push_str(preamble);
113        for block in blocks {
114            if block_state(block) != CommentState::Done {
115                out.push_str(block);
116            }
117        }
118    }
119    out
120}
121
122/// Render a single new comment as a `TODO` heading (used by pull-merge).
123#[must_use]
124pub fn render_comment(comment: &Comment, section: Option<&str>) -> String {
125    let mut out = String::new();
126    out.push_str("*** TODO ");
127    out.push_str(&author_label(&comment.author));
128    out.push_str(": ");
129    out.push_str(&one_line(&comment.content));
130    out.push('\n');
131
132    out.push_str(":PROPERTIES:\n");
133    push_property(&mut out, "COMMENT_ID", &comment.id);
134    if let Some(name) = &comment.author.display_name {
135        push_property(&mut out, "COMMENT_AUTHOR", name);
136    }
137    if let Some(email) = &comment.author.email {
138        push_property(&mut out, "COMMENT_EMAIL", email);
139    }
140    if let Some(date) = &comment.created_time {
141        push_property(&mut out, "COMMENT_DATE", date);
142    }
143    if let Some(section) = section {
144        push_property(&mut out, "COMMENT_SECTION", section);
145    }
146    out.push_str(":END:\n");
147
148    if let Some(quote) = comment.quoted_text.as_deref() {
149        if !quote.trim().is_empty() {
150            out.push_str("#+begin_quote\n");
151            out.push_str(quote);
152            if !quote.ends_with('\n') {
153                out.push('\n');
154            }
155            out.push_str("#+end_quote\n");
156        }
157    }
158    for reply in &comment.replies {
159        out.push_str(&author_label(&reply.author));
160        out.push_str(": ");
161        out.push_str(&one_line(&reply.content));
162        out.push('\n');
163    }
164    out
165}
166
167/// Extract every operator-authored `**** REPLY` subheading from the comments in
168/// `region`, paired with the id of the comment it answers.
169///
170/// This reads the operator's authored replies; deciding which are already posted
171/// (and thus must not be re-posted) is the caller's job, against the sync state.
172/// Total and panic-free.
173#[must_use]
174pub fn pending_replies(region: &str) -> Vec<PendingReply> {
175    let Some(body) = section_body(region) else {
176        return Vec::new();
177    };
178    let (_, blocks) = split_blocks(body);
179    let mut out = Vec::new();
180    for block in &blocks {
181        let Some(comment_id) = drawer_value(block, "COMMENT_ID") else {
182            continue;
183        };
184        for content in block_reply_contents(block) {
185            out.push(PendingReply {
186                comment_id: comment_id.clone(),
187                content,
188            });
189        }
190    }
191    out
192}
193
194/// The trimmed body text of each `**** REPLY` subheading within one comment block.
195///
196/// Block-aware: a `#+begin_…` region inside the comment (e.g. the quoted text) is
197/// skipped so a stray heading-like line in a quote is not mistaken for a reply.
198fn block_reply_contents(block: &str) -> Vec<String> {
199    let mut replies: Vec<Vec<&str>> = Vec::new();
200    let mut current: Option<Vec<&str>> = None;
201    let mut depth: i32 = 0;
202    for line in block.lines() {
203        let stripped = line.trim_end_matches(['\n', '\r']);
204        if depth == 0 && is_reply_heading(stripped) {
205            replies.extend(current.take());
206            current = Some(Vec::new());
207        } else if depth == 0 && heading_level(stripped) > 0 {
208            // Any other heading ends the reply currently being collected.
209            replies.extend(current.take());
210        } else if let Some(lines) = current.as_mut() {
211            lines.push(stripped);
212        }
213        adjust_depth(stripped, &mut depth);
214    }
215    replies.extend(current.take());
216    replies
217        .iter()
218        .filter_map(|lines| join_reply_lines(lines))
219        .collect()
220}
221
222/// Join a reply subheading's body lines into trimmed text, dropping any property
223/// drawer; `None` when the reply has no text.
224fn join_reply_lines(lines: &[&str]) -> Option<String> {
225    let text = lines
226        .iter()
227        .filter(|line| !is_drawer_line(line))
228        .copied()
229        .collect::<Vec<_>>()
230        .join("\n");
231    let trimmed = text.trim();
232    (!trimmed.is_empty()).then(|| trimmed.to_owned())
233}
234
235/// Whether a line is part of a property drawer (`:PROPERTIES:`, `:END:`, or a
236/// `:KEY:`/`:KEY: value` entry) — excluded from reply text.
237fn is_drawer_line(line: &str) -> bool {
238    line.trim()
239        .strip_prefix(':')
240        .is_some_and(|rest| rest.contains(':'))
241}
242
243/// Whether `line` is a level-4 `REPLY` subheading (the operator's reply marker).
244fn is_reply_heading(line: &str) -> bool {
245    heading_level(line) == 4
246        && line
247            .get(4..)
248            .map(str::trim_start)
249            .and_then(|rest| rest.split_whitespace().next())
250            .is_some_and(|word| word.eq_ignore_ascii_case("REPLY"))
251}
252
253/// The heading level of `line` (count of leading `*` when followed by a space), or
254/// 0 when it is not a heading.
255fn heading_level(line: &str) -> usize {
256    let level = star_level(line);
257    if level > 0 && line.get(level..).is_some_and(|rest| rest.starts_with(' ')) {
258        level
259    } else {
260        0
261    }
262}
263
264// ── Parsing helpers ─────────────────────────────────────────────────────────
265
266/// The ids of all parseable comment headings in `region`.
267fn known_ids(region: &str) -> Vec<String> {
268    parse_entries(region)
269        .into_iter()
270        .map(|entry| entry.id)
271        .collect()
272}
273
274/// Parse one comment block into a [`CommentEntry`], or `None` if it lacks an id.
275fn parse_block(block: &str) -> Option<CommentEntry> {
276    let id = drawer_value(block, "COMMENT_ID")?;
277    let state = block_state(block);
278    let section = drawer_value(block, "COMMENT_SECTION");
279    Some(CommentEntry { id, state, section })
280}
281
282/// The TODO/DONE state of a comment block, read from its heading keyword.
283fn block_state(block: &str) -> CommentState {
284    let heading = block.lines().next().unwrap_or_default();
285    match todo_keyword(heading) {
286        Some("DONE") => CommentState::Done,
287        _ => CommentState::Todo,
288    }
289}
290
291/// The TODO keyword of a heading (the first all-uppercase token after the stars),
292/// or `None` when the heading has no keyword.
293fn todo_keyword(heading: &str) -> Option<&str> {
294    let after_stars = heading.trim_start_matches('*').strip_prefix(' ')?;
295    let token = after_stars.split_whitespace().next()?;
296    let is_keyword = !token.is_empty() && token.chars().all(|ch| ch.is_ascii_uppercase());
297    is_keyword.then_some(token)
298}
299
300/// The value of a `:KEY: value` property line within `block` (case-insensitive
301/// key), if present.
302fn drawer_value(block: &str, key: &str) -> Option<String> {
303    block.lines().find_map(|line| {
304        let rest = line.trim().strip_prefix(':')?;
305        let (found, value) = rest.split_once(':')?;
306        found
307            .eq_ignore_ascii_case(key)
308            .then(|| value.trim().to_owned())
309    })
310}
311
312// ── Section + block boundaries (org-block-aware) ────────────────────────────
313
314/// The verbatim body of the `** Active Comments` section (everything between its
315/// heading line and the next level-≤2 heading or end of input), if present.
316fn section_body(region: &str) -> Option<&str> {
317    let (start, end) = section_bounds(region)?;
318    region.get(start..end)
319}
320
321/// Byte bounds `(body_start, body_end)` of the `** Active Comments` section body.
322fn section_bounds(region: &str) -> Option<(usize, usize)> {
323    let mut offset = 0;
324    let mut body_start: Option<usize> = None;
325    let mut depth: i32 = 0;
326    for line in region.split_inclusive('\n') {
327        let stripped = line.trim_end_matches(['\n', '\r']);
328        match body_start {
329            None => {
330                if depth == 0 && stripped.trim() == SECTION_HEADING {
331                    body_start = Some(offset + line.len());
332                }
333            }
334            Some(start) => {
335                if depth == 0 && is_heading_at_most_2(stripped) {
336                    return Some((start, offset));
337                }
338            }
339        }
340        adjust_depth(stripped, &mut depth);
341        offset += line.len();
342    }
343    body_start.map(|start| (start, region.len()))
344}
345
346/// Split a section body into `(preamble, blocks)`, where each block is the
347/// verbatim text of one `*** ` comment subtree. Block starts inside `#+begin_…`
348/// blocks are ignored (a quoted `***` line is not a heading).
349fn split_blocks(body: &str) -> (&str, Vec<&str>) {
350    let mut starts = Vec::new();
351    let mut offset = 0;
352    let mut depth: i32 = 0;
353    for line in body.split_inclusive('\n') {
354        let stripped = line.trim_end_matches(['\n', '\r']);
355        if depth == 0 && is_comment_heading(stripped) {
356            starts.push(offset);
357        }
358        adjust_depth(stripped, &mut depth);
359        offset += line.len();
360    }
361
362    let first = starts.first().copied().unwrap_or(body.len());
363    let preamble = body.get(..first).unwrap_or("");
364    let mut blocks = Vec::with_capacity(starts.len());
365    for (index, &start) in starts.iter().enumerate() {
366        let end = starts.get(index + 1).copied().unwrap_or(body.len());
367        if let Some(block) = body.get(start..end) {
368            blocks.push(block);
369        }
370    }
371    (preamble, blocks)
372}
373
374/// Update the org-block nesting `depth` for a line.
375fn adjust_depth(line: &str, depth: &mut i32) {
376    if opens_block(line) {
377        *depth += 1;
378    } else if closes_block(line) {
379        *depth = depth.saturating_sub(1);
380    }
381}
382
383fn opens_block(line: &str) -> bool {
384    let trimmed = line.trim_start();
385    trimmed
386        .get(..8)
387        .is_some_and(|prefix| prefix.eq_ignore_ascii_case("#+begin_"))
388}
389
390fn closes_block(line: &str) -> bool {
391    let trimmed = line.trim_start();
392    trimmed
393        .get(..6)
394        .is_some_and(|prefix| prefix.eq_ignore_ascii_case("#+end_"))
395}
396
397/// Number of leading `*` characters on a line.
398fn star_level(line: &str) -> usize {
399    line.chars().take_while(|&ch| ch == '*').count()
400}
401
402/// Whether `line` is a heading of level 1 or 2 (a section boundary).
403fn is_heading_at_most_2(line: &str) -> bool {
404    let level = star_level(line);
405    (level == 1 || level == 2) && line.get(level..).is_some_and(|rest| rest.starts_with(' '))
406}
407
408/// Whether `line` is a level-3 heading (a comment-block start).
409fn is_comment_heading(line: &str) -> bool {
410    star_level(line) == 3 && line.get(3..).is_some_and(|rest| rest.starts_with(' '))
411}
412
413// ── Rendering helpers ───────────────────────────────────────────────────────
414
415/// A display label for an author: name, else email, else `Unknown`.
416fn author_label(author: &Author) -> String {
417    author
418        .display_name
419        .clone()
420        .or_else(|| author.email.clone())
421        .unwrap_or_else(|| "Unknown".to_owned())
422}
423
424/// Collapse all runs of whitespace (including newlines) to single spaces so the
425/// text is safe to place on a single heading or reply line.
426fn one_line(text: &str) -> String {
427    text.split_whitespace().collect::<Vec<_>>().join(" ")
428}
429
430/// Append a `:KEY: value` property line.
431fn push_property(out: &mut String, key: &str, value: &str) {
432    out.push(':');
433    out.push_str(key);
434    out.push_str(": ");
435    out.push_str(value);
436    out.push('\n');
437}
438
439/// Ensure `out` ends with a blank line, so an appended block is visually separated.
440fn ensure_trailing_blank(out: &mut String) {
441    if !out.ends_with('\n') {
442        out.push('\n');
443    }
444    if !out.ends_with("\n\n") {
445        out.push('\n');
446    }
447}
448
449#[cfg(test)]
450mod tests {
451    use super::{
452        CommentEntry, CommentState, PendingReply, clean_section, parse_entries, pending_replies,
453        render_comment, render_section,
454    };
455    use crate::google::drive::{Author, Comment, Reply};
456
457    fn comment(id: &str, content: &str) -> Comment {
458        Comment {
459            id: id.to_owned(),
460            author: Author {
461                display_name: Some("Alice".to_owned()),
462                email: Some("alice@example.com".to_owned()),
463            },
464            content: content.to_owned(),
465            created_time: Some("2026-06-01T12:00:00+00:00".to_owned()),
466            resolved: false,
467            anchor: None,
468            quoted_text: Some("the projected sentence".to_owned()),
469            replies: vec![Reply {
470                author: Author {
471                    display_name: Some("Bob".to_owned()),
472                    email: None,
473                },
474                content: "Agreed".to_owned(),
475                created_time: None,
476            }],
477        }
478    }
479
480    /// A machine region with a `** Sync State` block ahead of the comments, to
481    /// exercise block-aware section detection.
482    fn region_with(active: &str) -> String {
483        format!(
484            "* GDOC_METADATA :noexport:\n** Sync State\n#+begin_src emacs-lisp\n(gdoc-sync-state 1 (positions) (collaborators))\n#+end_src\n{active}"
485        )
486    }
487
488    #[test]
489    fn renders_a_todo_heading_with_drawer_quote_and_reply() {
490        let rendered = render_comment(&comment("C1", "Please clarify."), Some("sec-intro"));
491        assert!(rendered.starts_with("*** TODO Alice: Please clarify.\n"));
492        assert!(rendered.contains(":COMMENT_ID: C1\n"));
493        assert!(rendered.contains(":COMMENT_SECTION: sec-intro\n"));
494        assert!(rendered.contains(":COMMENT_EMAIL: alice@example.com\n"));
495        assert!(rendered.contains("#+begin_quote\nthe projected sentence\n#+end_quote\n"));
496        assert!(rendered.contains("Bob: Agreed\n"));
497    }
498
499    #[test]
500    fn parses_mixed_todo_and_done_with_sections() {
501        let active = "** Active Comments\n\
502            *** TODO Alice: open\n:PROPERTIES:\n:COMMENT_ID: C1\n:COMMENT_SECTION: sec-intro\n:END:\n\n\
503            *** DONE Bob: handled\n:PROPERTIES:\n:COMMENT_ID: C2\n:END:\n";
504        let entries = parse_entries(&region_with(active));
505        assert_eq!(
506            entries,
507            vec![
508                CommentEntry {
509                    id: "C1".to_owned(),
510                    state: CommentState::Todo,
511                    section: Some("sec-intro".to_owned()),
512                },
513                CommentEntry {
514                    id: "C2".to_owned(),
515                    state: CommentState::Done,
516                    section: None,
517                },
518            ]
519        );
520    }
521
522    #[test]
523    fn parse_skips_headings_without_a_comment_id() {
524        let active = "** Active Comments\n*** TODO operator's own note\nsome text\n";
525        assert!(parse_entries(&region_with(active)).is_empty());
526    }
527
528    #[test]
529    fn quoted_heading_like_line_does_not_break_parsing() {
530        // The quote body contains a line starting with `***`; block-awareness must
531        // keep it inside the single comment block.
532        let active = "** Active Comments\n\
533            *** TODO Alice: see below\n:PROPERTIES:\n:COMMENT_ID: C1\n:END:\n\
534            #+begin_quote\n*** not a heading\n#+end_quote\n";
535        let entries = parse_entries(&region_with(active));
536        assert_eq!(entries.len(), 1);
537        assert_eq!(entries[0].id, "C1");
538    }
539
540    #[test]
541    fn merge_appends_new_and_preserves_existing_verbatim() {
542        // Existing block carries an operator annotation that must survive (A3).
543        let active = "** Active Comments\n\
544            *** TODO Alice: existing\n:PROPERTIES:\n:COMMENT_ID: C1\n:END:\n: operator note — clocking\n";
545        let region = region_with(active);
546
547        let new_comment = comment("C2", "fresh");
548        let merged = render_section(&region, &[(&new_comment, Some("sec-two"))]);
549
550        // Existing block (incl. the operator note) preserved byte-for-byte.
551        assert!(merged.contains(":COMMENT_ID: C1\n:END:\n: operator note — clocking\n"));
552        // New comment appended.
553        assert!(merged.contains(":COMMENT_ID: C2\n"));
554        assert!(merged.contains(":COMMENT_SECTION: sec-two\n"));
555    }
556
557    #[test]
558    fn merge_is_idempotent_for_known_ids() {
559        let active =
560            "** Active Comments\n*** TODO Alice: existing\n:PROPERTIES:\n:COMMENT_ID: C1\n:END:\n";
561        let region = region_with(active);
562        let existing = comment("C1", "existing");
563        let merged = render_section(&region, &[(&existing, Some("sec-intro"))]);
564        // C1 appears exactly once — not re-inserted.
565        assert_eq!(merged.matches(":COMMENT_ID: C1\n").count(), 1);
566    }
567
568    #[test]
569    fn merge_creates_section_when_absent() {
570        let region = "* GDOC_METADATA :noexport:\n** Sync State\n#+begin_src emacs-lisp\n(gdoc-sync-state 1 (positions) (collaborators))\n#+end_src\n";
571        let new_comment = comment("C1", "first");
572        let merged = render_section(region, &[(&new_comment, None)]);
573        assert!(merged.starts_with("** Active Comments\n"));
574        assert!(merged.contains(":COMMENT_ID: C1\n"));
575    }
576
577    #[test]
578    fn clean_removes_only_done_subtrees() {
579        let active = "** Active Comments\n\
580            *** TODO Alice: keep\n:PROPERTIES:\n:COMMENT_ID: C1\n:END:\n\n\
581            *** DONE Bob: drop\n:PROPERTIES:\n:COMMENT_ID: C2\n:END:\n";
582        let cleaned = clean_section(&region_with(active));
583        assert!(cleaned.contains(":COMMENT_ID: C1\n"));
584        assert!(!cleaned.contains(":COMMENT_ID: C2\n"));
585        assert!(!cleaned.contains("DONE"));
586    }
587
588    #[test]
589    fn no_section_yields_empty_parse() {
590        let region = "* GDOC_METADATA :noexport:\n** Sync State\n#+begin_src emacs-lisp\n(gdoc-sync-state 1 (positions) (collaborators))\n#+end_src\n";
591        assert!(parse_entries(region).is_empty());
592    }
593
594    #[test]
595    fn pending_replies_extracts_operator_authored_replies() {
596        let active = "** Active Comments\n\
597            *** TODO Alice: please clarify\n:PROPERTIES:\n:COMMENT_ID: C1\n:END:\n\
598            #+begin_quote\nthe quoted text\n#+end_quote\n\
599            **** REPLY\nClarified in the next paragraph.\n\n\
600            *** TODO Bob: typo\n:PROPERTIES:\n:COMMENT_ID: C2\n:END:\n\
601            **** REPLY\nFixed,\nthanks.\n";
602        let replies = pending_replies(&region_with(active));
603        assert_eq!(
604            replies,
605            vec![
606                PendingReply {
607                    comment_id: "C1".to_owned(),
608                    content: "Clarified in the next paragraph.".to_owned(),
609                },
610                PendingReply {
611                    comment_id: "C2".to_owned(),
612                    content: "Fixed,\nthanks.".to_owned(),
613                },
614            ]
615        );
616    }
617
618    #[test]
619    fn pending_replies_ignores_comments_without_a_reply_and_quoted_text() {
620        // A comment with only a quote (no REPLY subheading) yields nothing, and a
621        // `****`-like line inside the quote is not mistaken for a reply.
622        let active = "** Active Comments\n\
623            *** TODO Alice: see below\n:PROPERTIES:\n:COMMENT_ID: C1\n:END:\n\
624            #+begin_quote\n**** not a reply, just quoted\n#+end_quote\n";
625        assert!(pending_replies(&region_with(active)).is_empty());
626    }
627}