use crate::google::drive::{Author, Comment};
const SECTION_HEADING: &str = "** Active Comments";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CommentState {
Todo,
Done,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PendingReply {
pub comment_id: String,
pub content: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CommentEntry {
pub id: String,
pub state: CommentState,
pub section: Option<String>,
}
#[must_use]
pub fn parse_entries(region: &str) -> Vec<CommentEntry> {
let Some(body) = section_body(region) else {
return Vec::new();
};
let (_, blocks) = split_blocks(body);
blocks
.iter()
.filter_map(|block| parse_block(block))
.collect()
}
#[must_use]
pub fn render_section(region: &str, new: &[(&Comment, Option<&str>)]) -> String {
let known = known_ids(region);
let mut out = String::from(SECTION_HEADING);
out.push('\n');
if let Some(body) = section_body(region) {
out.push_str(body);
}
for (comment, section) in new {
if known.iter().any(|id| id == &comment.id) {
continue;
}
ensure_trailing_blank(&mut out);
out.push_str(&render_comment(comment, *section));
}
out
}
#[must_use]
pub fn clean_section(region: &str) -> String {
let mut out = String::from(SECTION_HEADING);
out.push('\n');
if let Some(body) = section_body(region) {
let (preamble, blocks) = split_blocks(body);
out.push_str(preamble);
for block in blocks {
if block_state(block) != CommentState::Done {
out.push_str(block);
}
}
}
out
}
#[must_use]
pub fn render_comment(comment: &Comment, section: Option<&str>) -> String {
let mut out = String::new();
out.push_str("*** TODO ");
out.push_str(&author_label(&comment.author));
out.push_str(": ");
out.push_str(&one_line(&comment.content));
out.push('\n');
out.push_str(":PROPERTIES:\n");
push_property(&mut out, "COMMENT_ID", &comment.id);
if let Some(name) = &comment.author.display_name {
push_property(&mut out, "COMMENT_AUTHOR", name);
}
if let Some(email) = &comment.author.email {
push_property(&mut out, "COMMENT_EMAIL", email);
}
if let Some(date) = &comment.created_time {
push_property(&mut out, "COMMENT_DATE", date);
}
if let Some(section) = section {
push_property(&mut out, "COMMENT_SECTION", section);
}
out.push_str(":END:\n");
if let Some(quote) = comment.quoted_text.as_deref() {
if !quote.trim().is_empty() {
out.push_str("#+begin_quote\n");
out.push_str(quote);
if !quote.ends_with('\n') {
out.push('\n');
}
out.push_str("#+end_quote\n");
}
}
for reply in &comment.replies {
out.push_str(&author_label(&reply.author));
out.push_str(": ");
out.push_str(&one_line(&reply.content));
out.push('\n');
}
out
}
#[must_use]
pub fn pending_replies(region: &str) -> Vec<PendingReply> {
let Some(body) = section_body(region) else {
return Vec::new();
};
let (_, blocks) = split_blocks(body);
let mut out = Vec::new();
for block in &blocks {
let Some(comment_id) = drawer_value(block, "COMMENT_ID") else {
continue;
};
for content in block_reply_contents(block) {
out.push(PendingReply {
comment_id: comment_id.clone(),
content,
});
}
}
out
}
fn block_reply_contents(block: &str) -> Vec<String> {
let mut replies: Vec<Vec<&str>> = Vec::new();
let mut current: Option<Vec<&str>> = None;
let mut depth: i32 = 0;
for line in block.lines() {
let stripped = line.trim_end_matches(['\n', '\r']);
if depth == 0 && is_reply_heading(stripped) {
replies.extend(current.take());
current = Some(Vec::new());
} else if depth == 0 && heading_level(stripped) > 0 {
replies.extend(current.take());
} else if let Some(lines) = current.as_mut() {
lines.push(stripped);
}
adjust_depth(stripped, &mut depth);
}
replies.extend(current.take());
replies
.iter()
.filter_map(|lines| join_reply_lines(lines))
.collect()
}
fn join_reply_lines(lines: &[&str]) -> Option<String> {
let text = lines
.iter()
.filter(|line| !is_drawer_line(line))
.copied()
.collect::<Vec<_>>()
.join("\n");
let trimmed = text.trim();
(!trimmed.is_empty()).then(|| trimmed.to_owned())
}
fn is_drawer_line(line: &str) -> bool {
line.trim()
.strip_prefix(':')
.is_some_and(|rest| rest.contains(':'))
}
fn is_reply_heading(line: &str) -> bool {
heading_level(line) == 4
&& line
.get(4..)
.map(str::trim_start)
.and_then(|rest| rest.split_whitespace().next())
.is_some_and(|word| word.eq_ignore_ascii_case("REPLY"))
}
fn heading_level(line: &str) -> usize {
let level = star_level(line);
if level > 0 && line.get(level..).is_some_and(|rest| rest.starts_with(' ')) {
level
} else {
0
}
}
fn known_ids(region: &str) -> Vec<String> {
parse_entries(region)
.into_iter()
.map(|entry| entry.id)
.collect()
}
fn parse_block(block: &str) -> Option<CommentEntry> {
let id = drawer_value(block, "COMMENT_ID")?;
let state = block_state(block);
let section = drawer_value(block, "COMMENT_SECTION");
Some(CommentEntry { id, state, section })
}
fn block_state(block: &str) -> CommentState {
let heading = block.lines().next().unwrap_or_default();
match todo_keyword(heading) {
Some("DONE") => CommentState::Done,
_ => CommentState::Todo,
}
}
fn todo_keyword(heading: &str) -> Option<&str> {
let after_stars = heading.trim_start_matches('*').strip_prefix(' ')?;
let token = after_stars.split_whitespace().next()?;
let is_keyword = !token.is_empty() && token.chars().all(|ch| ch.is_ascii_uppercase());
is_keyword.then_some(token)
}
fn drawer_value(block: &str, key: &str) -> Option<String> {
block.lines().find_map(|line| {
let rest = line.trim().strip_prefix(':')?;
let (found, value) = rest.split_once(':')?;
found
.eq_ignore_ascii_case(key)
.then(|| value.trim().to_owned())
})
}
fn section_body(region: &str) -> Option<&str> {
let (start, end) = section_bounds(region)?;
region.get(start..end)
}
fn section_bounds(region: &str) -> Option<(usize, usize)> {
let mut offset = 0;
let mut body_start: Option<usize> = None;
let mut depth: i32 = 0;
for line in region.split_inclusive('\n') {
let stripped = line.trim_end_matches(['\n', '\r']);
match body_start {
None => {
if depth == 0 && stripped.trim() == SECTION_HEADING {
body_start = Some(offset + line.len());
}
}
Some(start) => {
if depth == 0 && is_heading_at_most_2(stripped) {
return Some((start, offset));
}
}
}
adjust_depth(stripped, &mut depth);
offset += line.len();
}
body_start.map(|start| (start, region.len()))
}
fn split_blocks(body: &str) -> (&str, Vec<&str>) {
let mut starts = Vec::new();
let mut offset = 0;
let mut depth: i32 = 0;
for line in body.split_inclusive('\n') {
let stripped = line.trim_end_matches(['\n', '\r']);
if depth == 0 && is_comment_heading(stripped) {
starts.push(offset);
}
adjust_depth(stripped, &mut depth);
offset += line.len();
}
let first = starts.first().copied().unwrap_or(body.len());
let preamble = body.get(..first).unwrap_or("");
let mut blocks = Vec::with_capacity(starts.len());
for (index, &start) in starts.iter().enumerate() {
let end = starts.get(index + 1).copied().unwrap_or(body.len());
if let Some(block) = body.get(start..end) {
blocks.push(block);
}
}
(preamble, blocks)
}
fn adjust_depth(line: &str, depth: &mut i32) {
if opens_block(line) {
*depth += 1;
} else if closes_block(line) {
*depth = depth.saturating_sub(1);
}
}
fn opens_block(line: &str) -> bool {
let trimmed = line.trim_start();
trimmed
.get(..8)
.is_some_and(|prefix| prefix.eq_ignore_ascii_case("#+begin_"))
}
fn closes_block(line: &str) -> bool {
let trimmed = line.trim_start();
trimmed
.get(..6)
.is_some_and(|prefix| prefix.eq_ignore_ascii_case("#+end_"))
}
fn star_level(line: &str) -> usize {
line.chars().take_while(|&ch| ch == '*').count()
}
fn is_heading_at_most_2(line: &str) -> bool {
let level = star_level(line);
(level == 1 || level == 2) && line.get(level..).is_some_and(|rest| rest.starts_with(' '))
}
fn is_comment_heading(line: &str) -> bool {
star_level(line) == 3 && line.get(3..).is_some_and(|rest| rest.starts_with(' '))
}
fn author_label(author: &Author) -> String {
author
.display_name
.clone()
.or_else(|| author.email.clone())
.unwrap_or_else(|| "Unknown".to_owned())
}
fn one_line(text: &str) -> String {
text.split_whitespace().collect::<Vec<_>>().join(" ")
}
fn push_property(out: &mut String, key: &str, value: &str) {
out.push(':');
out.push_str(key);
out.push_str(": ");
out.push_str(value);
out.push('\n');
}
fn ensure_trailing_blank(out: &mut String) {
if !out.ends_with('\n') {
out.push('\n');
}
if !out.ends_with("\n\n") {
out.push('\n');
}
}
#[cfg(test)]
mod tests {
use super::{
CommentEntry, CommentState, PendingReply, clean_section, parse_entries, pending_replies,
render_comment, render_section,
};
use crate::google::drive::{Author, Comment, Reply};
fn comment(id: &str, content: &str) -> Comment {
Comment {
id: id.to_owned(),
author: Author {
display_name: Some("Alice".to_owned()),
email: Some("alice@example.com".to_owned()),
},
content: content.to_owned(),
created_time: Some("2026-06-01T12:00:00+00:00".to_owned()),
resolved: false,
anchor: None,
quoted_text: Some("the projected sentence".to_owned()),
replies: vec![Reply {
author: Author {
display_name: Some("Bob".to_owned()),
email: None,
},
content: "Agreed".to_owned(),
created_time: None,
}],
}
}
fn region_with(active: &str) -> String {
format!(
"* GDOC_METADATA :noexport:\n** Sync State\n#+begin_src emacs-lisp\n(gdoc-sync-state 1 (positions) (collaborators))\n#+end_src\n{active}"
)
}
#[test]
fn renders_a_todo_heading_with_drawer_quote_and_reply() {
let rendered = render_comment(&comment("C1", "Please clarify."), Some("sec-intro"));
assert!(rendered.starts_with("*** TODO Alice: Please clarify.\n"));
assert!(rendered.contains(":COMMENT_ID: C1\n"));
assert!(rendered.contains(":COMMENT_SECTION: sec-intro\n"));
assert!(rendered.contains(":COMMENT_EMAIL: alice@example.com\n"));
assert!(rendered.contains("#+begin_quote\nthe projected sentence\n#+end_quote\n"));
assert!(rendered.contains("Bob: Agreed\n"));
}
#[test]
fn parses_mixed_todo_and_done_with_sections() {
let active = "** Active Comments\n\
*** TODO Alice: open\n:PROPERTIES:\n:COMMENT_ID: C1\n:COMMENT_SECTION: sec-intro\n:END:\n\n\
*** DONE Bob: handled\n:PROPERTIES:\n:COMMENT_ID: C2\n:END:\n";
let entries = parse_entries(®ion_with(active));
assert_eq!(
entries,
vec![
CommentEntry {
id: "C1".to_owned(),
state: CommentState::Todo,
section: Some("sec-intro".to_owned()),
},
CommentEntry {
id: "C2".to_owned(),
state: CommentState::Done,
section: None,
},
]
);
}
#[test]
fn parse_skips_headings_without_a_comment_id() {
let active = "** Active Comments\n*** TODO operator's own note\nsome text\n";
assert!(parse_entries(®ion_with(active)).is_empty());
}
#[test]
fn quoted_heading_like_line_does_not_break_parsing() {
let active = "** Active Comments\n\
*** TODO Alice: see below\n:PROPERTIES:\n:COMMENT_ID: C1\n:END:\n\
#+begin_quote\n*** not a heading\n#+end_quote\n";
let entries = parse_entries(®ion_with(active));
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].id, "C1");
}
#[test]
fn merge_appends_new_and_preserves_existing_verbatim() {
let active = "** Active Comments\n\
*** TODO Alice: existing\n:PROPERTIES:\n:COMMENT_ID: C1\n:END:\n: operator note — clocking\n";
let region = region_with(active);
let new_comment = comment("C2", "fresh");
let merged = render_section(®ion, &[(&new_comment, Some("sec-two"))]);
assert!(merged.contains(":COMMENT_ID: C1\n:END:\n: operator note — clocking\n"));
assert!(merged.contains(":COMMENT_ID: C2\n"));
assert!(merged.contains(":COMMENT_SECTION: sec-two\n"));
}
#[test]
fn merge_is_idempotent_for_known_ids() {
let active =
"** Active Comments\n*** TODO Alice: existing\n:PROPERTIES:\n:COMMENT_ID: C1\n:END:\n";
let region = region_with(active);
let existing = comment("C1", "existing");
let merged = render_section(®ion, &[(&existing, Some("sec-intro"))]);
assert_eq!(merged.matches(":COMMENT_ID: C1\n").count(), 1);
}
#[test]
fn merge_creates_section_when_absent() {
let region = "* GDOC_METADATA :noexport:\n** Sync State\n#+begin_src emacs-lisp\n(gdoc-sync-state 1 (positions) (collaborators))\n#+end_src\n";
let new_comment = comment("C1", "first");
let merged = render_section(region, &[(&new_comment, None)]);
assert!(merged.starts_with("** Active Comments\n"));
assert!(merged.contains(":COMMENT_ID: C1\n"));
}
#[test]
fn clean_removes_only_done_subtrees() {
let active = "** Active Comments\n\
*** TODO Alice: keep\n:PROPERTIES:\n:COMMENT_ID: C1\n:END:\n\n\
*** DONE Bob: drop\n:PROPERTIES:\n:COMMENT_ID: C2\n:END:\n";
let cleaned = clean_section(®ion_with(active));
assert!(cleaned.contains(":COMMENT_ID: C1\n"));
assert!(!cleaned.contains(":COMMENT_ID: C2\n"));
assert!(!cleaned.contains("DONE"));
}
#[test]
fn no_section_yields_empty_parse() {
let region = "* GDOC_METADATA :noexport:\n** Sync State\n#+begin_src emacs-lisp\n(gdoc-sync-state 1 (positions) (collaborators))\n#+end_src\n";
assert!(parse_entries(region).is_empty());
}
#[test]
fn pending_replies_extracts_operator_authored_replies() {
let active = "** Active Comments\n\
*** TODO Alice: please clarify\n:PROPERTIES:\n:COMMENT_ID: C1\n:END:\n\
#+begin_quote\nthe quoted text\n#+end_quote\n\
**** REPLY\nClarified in the next paragraph.\n\n\
*** TODO Bob: typo\n:PROPERTIES:\n:COMMENT_ID: C2\n:END:\n\
**** REPLY\nFixed,\nthanks.\n";
let replies = pending_replies(®ion_with(active));
assert_eq!(
replies,
vec![
PendingReply {
comment_id: "C1".to_owned(),
content: "Clarified in the next paragraph.".to_owned(),
},
PendingReply {
comment_id: "C2".to_owned(),
content: "Fixed,\nthanks.".to_owned(),
},
]
);
}
#[test]
fn pending_replies_ignores_comments_without_a_reply_and_quoted_text() {
let active = "** Active Comments\n\
*** TODO Alice: see below\n:PROPERTIES:\n:COMMENT_ID: C1\n:END:\n\
#+begin_quote\n**** not a reply, just quoted\n#+end_quote\n";
assert!(pending_replies(®ion_with(active)).is_empty());
}
}