1use crate::google::drive::{Author, Comment};
25
26const SECTION_HEADING: &str = "** Active Comments";
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum CommentState {
32 Todo,
34 Done,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct PendingReply {
46 pub comment_id: String,
48 pub content: String,
50}
51
52#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct CommentEntry {
55 pub id: String,
57 pub state: CommentState,
59 pub section: Option<String>,
62}
63
64#[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#[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#[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#[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#[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
194fn 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 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
222fn 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
235fn is_drawer_line(line: &str) -> bool {
238 line.trim()
239 .strip_prefix(':')
240 .is_some_and(|rest| rest.contains(':'))
241}
242
243fn 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
253fn 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
264fn known_ids(region: &str) -> Vec<String> {
268 parse_entries(region)
269 .into_iter()
270 .map(|entry| entry.id)
271 .collect()
272}
273
274fn 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
282fn 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
291fn 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
300fn 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
312fn section_body(region: &str) -> Option<&str> {
317 let (start, end) = section_bounds(region)?;
318 region.get(start..end)
319}
320
321fn 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
346fn 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
374fn 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
397fn star_level(line: &str) -> usize {
399 line.chars().take_while(|&ch| ch == '*').count()
400}
401
402fn 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
408fn is_comment_heading(line: &str) -> bool {
410 star_level(line) == 3 && line.get(3..).is_some_and(|rest| rest.starts_with(' '))
411}
412
413fn 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
424fn one_line(text: &str) -> String {
427 text.split_whitespace().collect::<Vec<_>>().join(" ")
428}
429
430fn 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
439fn 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 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(®ion_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(®ion_with(active)).is_empty());
526 }
527
528 #[test]
529 fn quoted_heading_like_line_does_not_break_parsing() {
530 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(®ion_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 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(®ion, &[(&new_comment, Some("sec-two"))]);
549
550 assert!(merged.contains(":COMMENT_ID: C1\n:END:\n: operator note — clocking\n"));
552 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(®ion, &[(&existing, Some("sec-intro"))]);
564 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(®ion_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(®ion_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 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(®ion_with(active)).is_empty());
626 }
627}