Skip to main content

org_gdocs/
comments.rs

1//! Q1 — mapping a Google comment back to the org structural element it anchors to.
2//!
3//! Pull (Q2) needs to file each reviewer comment under the right section of the
4//! org file. Google gives us two hints, in descending order of reliability:
5//!
6//! 1. The comment **`anchor`** — an undocumented, brittle blob (D2; Google bug
7//!    292610078). When it happens to carry a recoverable start index, we look up
8//!    the position map ([`crate::project::PositionMap`]) for the element whose
9//!    projected start index is the largest at or below it, and report that
10//!    element's **containing section** (`:CUSTOM_ID:`).
11//! 2. The **`quotedFileContent`** — the text the reviewer selected. When the
12//!    anchor is unusable (empty, not JSON, or carrying no index we recognize), we
13//!    fall back to locating the section whose projected text contains the quote
14//!    (DI-5). This is the *primary* mechanism in practice: real kix anchors
15//!    frequently carry no plain index, so the quote fallback is what usually
16//!    resolves a comment.
17//!
18//! When neither hint resolves, the comment is reported as document-level. The
19//! output is always a [`Location`] — a `:CUSTOM_ID:` or document-level, **never a
20//! character offset** (DI-5).
21//!
22//! This module is **pure and total** (EI-4): the anchor parse is best-effort and
23//! never panics on a malformed blob (EI-2); it degrades to the fallback instead.
24
25use kb::ast::{Block, Document, ListItem, TableCell};
26use serde_json::Value;
27
28use crate::google::drive::Anchor;
29use crate::project::{DOCUMENT_PARENT, PositionMap, heading_id, inline_text};
30
31/// Where a reviewer comment anchors in the org file.
32///
33/// Per DI-5 this is a structural location, never a character offset: either the
34/// `:CUSTOM_ID:` of the containing heading, or document-level when nothing
35/// resolves.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub enum Location {
38    /// The comment resolves to the heading bearing this `:CUSTOM_ID:`.
39    Section(String),
40    /// The comment could not be tied to a section; it anchors at document level.
41    Document,
42}
43
44/// Resolves Google comments to org [`Location`]s for one document.
45///
46/// Built once per pull from the projected [`PositionMap`] and the parsed org
47/// [`Document`]; [`resolve`](Self::resolve) is then called per comment. The
48/// section texts (for the quoted-text fallback) are flattened and
49/// whitespace-normalized once at construction.
50pub struct Resolver<'a> {
51    positions: &'a PositionMap,
52    /// `(section custom-id, normalized projected text)` in document order. First
53    /// match wins, so an earlier section is preferred when a quote is ambiguous.
54    sections: Vec<(String, String)>,
55}
56
57impl<'a> Resolver<'a> {
58    /// Build a resolver from the projected position map and the parsed org body.
59    #[must_use]
60    pub fn new(positions: &'a PositionMap, document: &Document) -> Self {
61        let mut sections: Vec<(String, String)> = Vec::new();
62        collect_sections(&document.blocks, DOCUMENT_PARENT, &mut sections);
63        Self {
64            positions,
65            sections,
66        }
67    }
68
69    /// Resolve one comment's `anchor`/`quotedFileContent` to a [`Location`].
70    ///
71    /// Tries the anchor's recoverable start index first, then the quoted-text
72    /// fallback, then document-level — exactly the precedence DI-5 prescribes.
73    #[must_use]
74    pub fn resolve(&self, anchor: Option<&Anchor>, quoted_text: Option<&str>) -> Location {
75        if let Some(start) = anchor.and_then(anchor_start)
76            && let Some(section) = self.section_for_index(start)
77        {
78            return Location::Section(section);
79        }
80        if let Some(quote) = quoted_text
81            && let Some(section) = self.section_for_quote(quote)
82        {
83            return Location::Section(section);
84        }
85        Location::Document
86    }
87
88    /// The containing section of the position with the largest index at or below
89    /// `start`, or `None` when nothing precedes it or it is document-level.
90    fn section_for_index(&self, start: u32) -> Option<String> {
91        self.positions
92            .iter()
93            .filter(|(_, pos)| pos.index <= start)
94            .max_by_key(|(_, pos)| pos.index)
95            .map(|(id, _)| containing_section(id))
96            .filter(|section| *section != DOCUMENT_PARENT)
97            .map(str::to_owned)
98    }
99
100    /// The first section whose projected text contains `quote`, comparing on
101    /// whitespace-normalized text so wrapping differences do not defeat the match.
102    fn section_for_quote(&self, quote: &str) -> Option<String> {
103        let needle = normalize_ws(quote);
104        if needle.is_empty() {
105            return None;
106        }
107        self.sections
108            .iter()
109            .find(|(_, text)| text.contains(&needle))
110            .map(|(id, _)| id.clone())
111    }
112}
113
114/// The containing-section id of a position key: the part before the first `/`.
115///
116/// Heading keys are bare `:CUSTOM_ID:`s (no slash); non-heading keys are
117/// `<section>/<type>-<n>`. Either way the prefix is the heading the element lives
118/// under, which is the only id with a real drawer in the body for elisp to find.
119fn containing_section(id: &str) -> &str {
120    match id.split_once('/') {
121        Some((section, _)) => section,
122        None => id,
123    }
124}
125
126// ── Section text index (quoted-text fallback) ────────────────────────────────
127
128/// Walk the blocks under `parent`, accumulating each heading's projected text
129/// into `out` keyed by the heading's id. Pre-heading content (under
130/// [`DOCUMENT_PARENT`]) is dropped: a quote can never resolve to document level.
131fn collect_sections(blocks: &[Block], parent: &str, out: &mut Vec<(String, String)>) {
132    for block in blocks {
133        if let Block::Heading {
134            title, children, ..
135        } = block
136        {
137            let id = heading_id(title, children);
138            append_text(out, &id, title.as_str());
139            collect_sections(children, &id, out);
140        } else if parent != DOCUMENT_PARENT {
141            let text = block_text(block);
142            if !text.is_empty() {
143                append_text(out, parent, &text);
144            }
145        }
146    }
147}
148
149/// The visible projected text of a non-heading block (for quote matching).
150fn block_text(block: &Block) -> String {
151    match block {
152        Block::Paragraph { inlines } => normalize_ws(&inline_text(inlines)),
153        Block::SrcBlock { content, .. } | Block::ExampleBlock { content } => normalize_ws(content),
154        Block::QuoteBlock { children } => join_block_text(children),
155        Block::List { items, .. } => join_item_text(items),
156        Block::Table { rows } => join_cell_text(rows),
157        Block::Heading { .. }
158        | Block::HorizontalRule
159        | Block::PropertyDrawer { .. }
160        | Block::LogbookDrawer { .. }
161        | Block::Planning { .. }
162        | Block::Comment { .. }
163        | Block::Keyword { .. }
164        | Block::BlankLine => String::new(),
165    }
166}
167
168fn join_block_text(blocks: &[Block]) -> String {
169    join_nonempty(blocks.iter().map(block_text))
170}
171
172fn join_item_text(items: &[ListItem]) -> String {
173    join_nonempty(items.iter().map(|item| join_block_text(&item.content)))
174}
175
176fn join_cell_text(rows: &[Vec<TableCell>]) -> String {
177    join_nonempty(
178        rows.iter()
179            .flatten()
180            .map(|cell| normalize_ws(&inline_text(&cell.inlines))),
181    )
182}
183
184/// Space-join the non-empty members of `parts`.
185fn join_nonempty<I: Iterator<Item = String>>(parts: I) -> String {
186    let kept: Vec<String> = parts.filter(|part| !part.is_empty()).collect();
187    kept.join(" ")
188}
189
190/// Append `text` to the section entry for `id`, space-separated, creating the
191/// entry on first sight so document order of headings is preserved.
192fn append_text(out: &mut Vec<(String, String)>, id: &str, text: &str) {
193    let normalized = normalize_ws(text);
194    if normalized.is_empty() {
195        return;
196    }
197    if let Some(entry) = out.iter_mut().find(|(key, _)| key == id) {
198        entry.1.push(' ');
199        entry.1.push_str(&normalized);
200    } else {
201        out.push((id.to_owned(), normalized));
202    }
203}
204
205/// Collapse all runs of whitespace to a single space and trim the ends, so a
206/// quote that Google rewrapped still matches the projected prose.
207fn normalize_ws(text: &str) -> String {
208    text.split_whitespace().collect::<Vec<_>>().join(" ")
209}
210
211// ── Anchor index recovery (best-effort, never panics) ────────────────────────
212
213/// Best-effort extraction of a start index from a comment anchor blob.
214///
215/// The anchor is opaque JSON (D2); we parse it leniently and search for a map
216/// carrying both a recognizable start and end key. A blob that is empty, not
217/// JSON, or carries no such pair yields `None` so the caller falls back to the
218/// quoted text. Never panics (EI-2).
219fn anchor_start(anchor: &Anchor) -> Option<u32> {
220    let trimmed = anchor.0.trim();
221    if trimmed.is_empty() {
222        return None;
223    }
224    let value: Value = serde_json::from_str(trimmed).ok()?;
225    u32::try_from(find_start(&value)?).ok()
226}
227
228/// Recursively find the start index of the first JSON object that carries both a
229/// start key (`startIndex`/`start`/`s`) and an end key (`endIndex`/`end`/`e`).
230///
231/// Requiring both keys avoids matching an unrelated short key elsewhere in the
232/// blob (ported from the reference implementation's anchor heuristic).
233fn find_start(value: &Value) -> Option<i64> {
234    match value {
235        Value::Object(map) => {
236            let start = int_for_keys(map, &["startIndex", "start", "s"]);
237            let end = int_for_keys(map, &["endIndex", "end", "e"]);
238            if let (Some(start), Some(_)) = (start, end) {
239                return Some(start);
240            }
241            map.values().find_map(find_start)
242        }
243        Value::Array(items) => items.iter().find_map(find_start),
244        _ => None,
245    }
246}
247
248/// The integer value of the first present key in `keys`, if any is an integer.
249fn int_for_keys(map: &serde_json::Map<String, Value>, keys: &[&str]) -> Option<i64> {
250    keys.iter()
251        .find_map(|key| map.get(*key).and_then(Value::as_i64))
252}
253
254#[cfg(test)]
255mod tests {
256    use super::{Location, Resolver};
257    use crate::google::drive::Anchor;
258    use crate::project::{ElementKind, Position, PositionMap};
259    use kb::ast::{Block, Document, Inline, Title};
260
261    fn heading(title: &str, custom_id: &str, children: Vec<Block>) -> Block {
262        let mut all = vec![Block::PropertyDrawer {
263            entries: vec![("CUSTOM_ID".to_owned(), format!(" {custom_id}"))],
264        }];
265        all.extend(children);
266        Block::Heading {
267            level: 1,
268            title: Title(title.to_owned()),
269            tags: vec![],
270            children: all,
271        }
272    }
273
274    fn paragraph(text: &str) -> Block {
275        Block::Paragraph {
276            inlines: vec![Inline::Plain(text.to_owned())],
277        }
278    }
279
280    /// A two-section doc: `sec-intro` (heading + one paragraph) then `sec-body`.
281    fn sample_doc() -> Document {
282        Document {
283            blocks: vec![
284                heading(
285                    "Introduction",
286                    "sec-intro",
287                    vec![paragraph("The quick brown fox jumps over the lazy dog.")],
288                ),
289                heading(
290                    "Body",
291                    "sec-body",
292                    vec![paragraph("A wholly unrelated second paragraph.")],
293                ),
294            ],
295        }
296    }
297
298    /// Positions roughly matching `sample_doc`'s projection: the intro heading at
299    /// 1, its paragraph at 14, the body heading at 60, its paragraph at 66.
300    fn sample_positions() -> PositionMap {
301        let mut map = PositionMap::new();
302        let at = |index, kind| Position { index, kind };
303        map.insert("sec-intro".to_owned(), at(1, ElementKind::Heading));
304        map.insert(
305            "sec-intro/paragraph-1".to_owned(),
306            at(14, ElementKind::Paragraph),
307        );
308        map.insert("sec-body".to_owned(), at(60, ElementKind::Heading));
309        map.insert(
310            "sec-body/paragraph-1".to_owned(),
311            at(66, ElementKind::Paragraph),
312        );
313        map
314    }
315
316    #[test]
317    fn anchor_index_maps_to_containing_section() {
318        let positions = sample_positions();
319        let document = sample_doc();
320        let resolver = Resolver::new(&positions, &document);
321
322        // start=20 → largest position ≤ 20 is sec-intro/paragraph-1 @14 → sec-intro.
323        let anchor = Anchor(r#"{"startIndex":20,"endIndex":25}"#.to_owned());
324        assert_eq!(
325            resolver.resolve(Some(&anchor), None),
326            Location::Section("sec-intro".to_owned())
327        );
328    }
329
330    #[test]
331    fn anchor_index_in_later_section_resolves_there() {
332        let positions = sample_positions();
333        let document = sample_doc();
334        let resolver = Resolver::new(&positions, &document);
335
336        let anchor = Anchor(r#"{"a":[{"docs":{"startIndex":70,"endIndex":75}}]}"#.to_owned());
337        assert_eq!(
338            resolver.resolve(Some(&anchor), None),
339            Location::Section("sec-body".to_owned())
340        );
341    }
342
343    #[test]
344    fn garbage_anchor_falls_back_to_quoted_text() {
345        // DI-5: a deliberately-unusable anchor must recover via the quote.
346        let positions = sample_positions();
347        let document = sample_doc();
348        let resolver = Resolver::new(&positions, &document);
349
350        let anchor = Anchor("kix.totally-opaque-undecodable-blob".to_owned());
351        let location = resolver.resolve(Some(&anchor), Some("quick brown fox"));
352        assert_eq!(location, Location::Section("sec-intro".to_owned()));
353    }
354
355    #[test]
356    fn valid_json_anchor_without_indices_falls_back() {
357        let positions = sample_positions();
358        let document = sample_doc();
359        let resolver = Resolver::new(&positions, &document);
360
361        // Parses as JSON, but carries no start/end pair we recognize.
362        let anchor = Anchor(r#"{"type":"workspace.comment.anchor.v1","client":"docs"}"#.to_owned());
363        let location = resolver.resolve(Some(&anchor), Some("unrelated second paragraph"));
364        assert_eq!(location, Location::Section("sec-body".to_owned()));
365    }
366
367    #[test]
368    fn quote_matches_across_rewrapped_whitespace() {
369        let positions = sample_positions();
370        let document = sample_doc();
371        let resolver = Resolver::new(&positions, &document);
372
373        // Google may hand back the quote rewrapped with newlines/extra spaces.
374        let location = resolver.resolve(None, Some("quick   brown\n  fox jumps"));
375        assert_eq!(location, Location::Section("sec-intro".to_owned()));
376    }
377
378    #[test]
379    fn no_anchor_and_no_matching_quote_is_document_level() {
380        let positions = sample_positions();
381        let document = sample_doc();
382        let resolver = Resolver::new(&positions, &document);
383
384        assert_eq!(
385            resolver.resolve(None, Some("text that appears in no section")),
386            Location::Document
387        );
388        assert_eq!(resolver.resolve(None, None), Location::Document);
389    }
390
391    #[test]
392    fn empty_anchor_is_treated_as_absent() {
393        let positions = sample_positions();
394        let document = sample_doc();
395        let resolver = Resolver::new(&positions, &document);
396
397        let anchor = Anchor("   ".to_owned());
398        assert_eq!(
399            resolver.resolve(Some(&anchor), Some("lazy dog")),
400            Location::Section("sec-intro".to_owned())
401        );
402    }
403
404    #[test]
405    fn index_below_first_position_falls_back_to_quote() {
406        let mut positions = PositionMap::new();
407        positions.insert(
408            "sec-intro".to_owned(),
409            Position {
410                index: 100,
411                kind: ElementKind::Heading,
412            },
413        );
414        let document = sample_doc();
415        let resolver = Resolver::new(&positions, &document);
416
417        // start=5 precedes every position → no index hit → quote fallback.
418        let anchor = Anchor(r#"{"startIndex":5,"endIndex":9}"#.to_owned());
419        assert_eq!(
420            resolver.resolve(Some(&anchor), Some("lazy dog")),
421            Location::Section("sec-intro".to_owned())
422        );
423    }
424
425    #[test]
426    fn negative_start_index_is_rejected() {
427        let positions = sample_positions();
428        let document = sample_doc();
429        let resolver = Resolver::new(&positions, &document);
430
431        let anchor = Anchor(r#"{"startIndex":-3,"endIndex":-1}"#.to_owned());
432        // Unusable index → fall through to document level (no quote given).
433        assert_eq!(resolver.resolve(Some(&anchor), None), Location::Document);
434    }
435}