Skip to main content

org_gdocs/
pull.rs

1//! Q2 — the `pull` orchestration: fetch reviewer comments from the linked Google
2//! Doc and merge them into the org file as structural-anchored `TODO` headings.
3//!
4//! This is the imperative shell (EI-4). It sequences the effects (list comments
5//! over the network, then rewrite the file content) and delegates every decision
6//! to the pure cores — anchor→section mapping ([`crate::comments`]), comment
7//! rendering/merge ([`crate::comments_meta`]), sync state ([`crate::syncstate`]),
8//! region writeback ([`crate::orgfile`]).
9//!
10//! Guarantees:
11//!
12//! - **Merge-by-id (A3).** Existing comment headings are preserved byte-for-byte
13//!   and never reordered; only ids not already present are appended. A pull that
14//!   finds no new comments is a no-op on the file (proven by a test).
15//! - **DI-2 — the body is sacrosanct.** Pull reuses the verbatim-body writeback;
16//!   the only body change is the idempotent, already-present `:CUSTOM_ID:`
17//!   insertion (so every section a comment references has a real drawer for elisp
18//!   to jump to).
19//! - **DI-3 — stateless.** Everything is read from and written to the file content
20//!   (plus the live comment list); no sidecar.
21//! - **DI-4 — pull never creates or anchors a comment in Google;** it only lists.
22//!
23//! Pull does **not** re-project the body: the position map describes the *pushed*
24//! doc (what the Google anchors point into), so it is preserved from `** Sync
25//! State` rather than regenerated. Only the collaborator cache and
26//! `#+GDOC_LAST_PULL:` change.
27
28use std::collections::HashSet;
29
30use kb::parser::parse_document;
31
32use crate::comments::{Location, Resolver};
33use crate::comments_meta;
34use crate::custom_id::ensure_section_ids;
35use crate::envelope;
36use crate::error::{Error, Result};
37use crate::google::client::GoogleClient;
38use crate::google::drive::Comment;
39use crate::orgfile;
40use crate::sexp::Sexp;
41use crate::syncstate::{Collaborator, SyncState};
42
43/// The tool-owned metadata heading that opens the machine region.
44const METADATA_HEADING: &str = "* GDOC_METADATA :noexport:";
45
46/// The result of a pull: what was merged and the new file content to persist.
47#[derive(Debug, Clone)]
48pub struct PullOutcome {
49    /// The linked document the comments were pulled from.
50    pub document_id: String,
51    /// Ids of comments newly inserted as `TODO` headings this pull.
52    pub new_comment_ids: Vec<String>,
53    /// Total comments Google returned for the document.
54    pub total_comments: usize,
55    /// The new file content to write back (body preserved, machine region rebuilt).
56    pub new_content: String,
57}
58
59/// Fetch the linked doc's comments and merge new ones into `content`, returning
60/// the rewritten file.
61///
62/// `now` is the caller-supplied pull timestamp (clock at the edge, EI-4).
63///
64/// # Errors
65///
66/// Returns [`Error::NotLinked`] when the file has no `#+GDOC_ID:`, [`Error::Google`]
67/// on any comment-list failure, [`Error::OrgParse`] on org-parse failure, or
68/// [`Error::Sexp`] when the existing `** Sync State` block is malformed.
69pub async fn pull(client: &GoogleClient, content: &str, now: &str) -> Result<PullOutcome> {
70    let document_id = orgfile::read_keyword(content, "GDOC_ID").ok_or(Error::NotLinked)?;
71
72    let (body, machine) = orgfile::split(content);
73    let machine = machine.unwrap_or("");
74
75    // Idempotent body mutation: guarantee every heading a comment may reference has
76    // a `:CUSTOM_ID:` drawer (DI-2). After a push this is already true — a no-op.
77    let body_with_ids = ensure_section_ids(body);
78    let document =
79        parse_document(&body_with_ids).map_err(|err| Error::OrgParse(err.to_string()))?;
80
81    // The position map describes the pushed doc; preserve it (pull does not push).
82    let existing = SyncState::parse_block(machine)?;
83
84    // List the live comments (DI-4: read only).
85    let comments = client.list_comments(&document_id).await?;
86    let total_comments = comments.len();
87
88    // Map each comment to its section once, scoping the resolver's borrow of the
89    // preserved positions so they can be moved into the rebuilt sync state below.
90    let sections: Vec<Option<String>> = {
91        let resolver = Resolver::new(&existing.positions, &document);
92        comments.iter().map(|c| section_id(&resolver, c)).collect()
93    };
94
95    // New = unresolved in Google and not already filed under a known id (A3).
96    let known: HashSet<String> = comments_meta::parse_entries(machine)
97        .into_iter()
98        .map(|entry| entry.id)
99        .collect();
100    let fresh: Vec<(&Comment, Option<&str>)> = comments
101        .iter()
102        .zip(&sections)
103        .filter(|(comment, _)| !comment.resolved && !known.contains(&comment.id))
104        .map(|(comment, section)| (comment, section.as_deref()))
105        .collect();
106    let new_comment_ids: Vec<String> = fresh.iter().map(|(c, _)| c.id.clone()).collect();
107
108    let new_content = write_back(&body_with_ids, machine, existing, &comments, &fresh, now);
109
110    Ok(PullOutcome {
111        document_id,
112        new_comment_ids,
113        total_comments,
114        new_content,
115    })
116}
117
118/// Reassemble the file: body (with `CUSTOM_ID`s) + refreshed `#+GDOC_LAST_PULL:`
119/// keyword + a regenerated machine region (positions preserved, collaborator
120/// cache extended, new comments merged into Active Comments verbatim-safe).
121fn write_back(
122    body_with_ids: &str,
123    machine: &str,
124    existing: SyncState,
125    all_comments: &[Comment],
126    fresh: &[(&Comment, Option<&str>)],
127    now: &str,
128) -> String {
129    let sync = SyncState {
130        positions: existing.positions,
131        collaborators: merge_collaborators(existing.collaborators, all_comments),
132        // Posted-reply bookkeeping and the projection fingerprint are push's
133        // concern; carry them through untouched.
134        posted_replies: existing.posted_replies,
135        projection_hash: existing.projection_hash,
136    };
137    let active = comments_meta::render_section(machine, fresh);
138    let region = format!("{METADATA_HEADING}\n{}{active}", sync.render_block());
139
140    let with_keyword = orgfile::upsert_keyword(body_with_ids, "GDOC_LAST_PULL", now);
141    orgfile::replace_metadata(&with_keyword, &region)
142}
143
144/// The `:CUSTOM_ID:` a comment resolves to, or `None` for document level.
145fn section_id(resolver: &Resolver, comment: &Comment) -> Option<String> {
146    match resolver.resolve(comment.anchor.as_ref(), comment.quoted_text.as_deref()) {
147        Location::Section(id) => Some(id),
148        Location::Document => None,
149    }
150}
151
152/// Build the success envelope for a completed pull (A5).
153#[must_use]
154pub fn envelope(outcome: &PullOutcome) -> Sexp {
155    let new_ids = outcome
156        .new_comment_ids
157        .iter()
158        .map(|id| Sexp::string(id.clone()))
159        .collect();
160    envelope::ok(
161        "pull",
162        vec![
163            ("document-id", Sexp::string(outcome.document_id.clone())),
164            (
165                "new",
166                Sexp::int(i64::try_from(outcome.new_comment_ids.len()).unwrap_or(i64::MAX)),
167            ),
168            (
169                "total",
170                Sexp::int(i64::try_from(outcome.total_comments).unwrap_or(i64::MAX)),
171            ),
172            ("new-ids", Sexp::list(new_ids)),
173        ],
174    )
175}
176
177/// Merge reviewer authors seen on `comments` into the existing collaborator cache,
178/// appending only emails not already present (so the merge is idempotent).
179fn merge_collaborators(existing: Vec<Collaborator>, comments: &[Comment]) -> Vec<Collaborator> {
180    let mut seen: HashSet<String> = existing.iter().map(|c| c.email.clone()).collect();
181    let mut merged = existing;
182    for author in comments
183        .iter()
184        .flat_map(|c| std::iter::once(&c.author).chain(c.replies.iter().map(|r| &r.author)))
185    {
186        if let (Some(email), Some(name)) = (&author.email, &author.display_name)
187            && seen.insert(email.clone())
188        {
189            merged.push(Collaborator {
190                email: email.clone(),
191                display_name: name.clone(),
192            });
193        }
194    }
195    merged
196}
197
198#[cfg(test)]
199mod tests {
200    use super::pull;
201    use crate::error::Error;
202    use crate::google::client::GoogleClient;
203    use crate::orgfile;
204    use mockito::{Matcher, Server};
205
206    const NOW: &str = "2026-06-11T00:00:00+00:00";
207
208    /// A linked file with one section and a populated `** Sync State`, ready to
209    /// receive pulled comments.
210    const LINKED: &str = "#+GDOC_ID: DOC1\n#+TITLE: Spec\n\
211        * Introduction\n:PROPERTIES:\n:CUSTOM_ID: sec-intro\n:END:\nThe quick brown fox.\n\n\
212        * GDOC_METADATA :noexport:\n** Sync State\n#+begin_src emacs-lisp\n\
213        (gdoc-sync-state 1 (positions (pos \"sec-intro\" 1 heading)) (collaborators))\n\
214        #+end_src\n** Active Comments\n";
215
216    fn test_client(server: &Server) -> GoogleClient {
217        let mut client = GoogleClient::new("test-token".to_owned()).expect("client builds");
218        client.set_base_url(&server.url());
219        client
220    }
221
222    async fn mock_comments(server: &mut Server, body: &str) -> mockito::Mock {
223        server
224            .mock("GET", "/files/DOC1/comments")
225            .match_query(Matcher::Any)
226            .with_status(200)
227            .with_header("content-type", "application/json")
228            .with_body(body)
229            .create_async()
230            .await
231    }
232
233    /// One unresolved comment quoting text from the intro section.
234    const ONE_COMMENT: &str = r#"{"comments":[
235        {"id":"C1",
236         "author":{"displayName":"Alice","emailAddress":"alice@example.com"},
237         "content":"Please clarify.","createdTime":"2026-06-01T12:00:00Z","resolved":false,
238         "quotedFileContent":{"value":"quick brown fox"}}
239    ]}"#;
240
241    #[tokio::test]
242    async fn pull_merges_new_comment_under_resolved_section() {
243        let mut server = Server::new_async().await;
244        let mock = mock_comments(&mut server, ONE_COMMENT).await;
245
246        let outcome = pull(&test_client(&server), LINKED, NOW)
247            .await
248            .expect("pull succeeds");
249
250        assert_eq!(outcome.document_id, "DOC1");
251        assert_eq!(outcome.new_comment_ids, vec!["C1".to_owned()]);
252        assert_eq!(outcome.total_comments, 1);
253
254        let written = &outcome.new_content;
255        // The quote resolved to sec-intro via the fallback.
256        assert!(written.contains(":COMMENT_ID: C1\n"));
257        assert!(written.contains(":COMMENT_SECTION: sec-intro\n"));
258        assert!(written.contains("*** TODO Alice: Please clarify.\n"));
259        // The reviewer was cached, and the pull timestamp recorded.
260        assert!(written.contains("(collab \"alice@example.com\" \"Alice\")"));
261        assert!(written.contains(&format!("#+GDOC_LAST_PULL: {NOW}\n")));
262        // Positions are preserved from the pushed doc, not regenerated.
263        assert!(written.contains("(pos \"sec-intro\" 1 heading)"));
264        mock.assert_async().await;
265    }
266
267    #[tokio::test]
268    async fn pull_preserves_body_prose_byte_for_byte() {
269        let mut server = Server::new_async().await;
270        let _mock = mock_comments(&mut server, ONE_COMMENT).await;
271
272        let outcome = pull(&test_client(&server), LINKED, NOW)
273            .await
274            .expect("pull succeeds");
275
276        // The body equals the original body + the one refreshed keyword; nothing in
277        // the prose region is reflowed or canonicalized (DI-2).
278        let expected = {
279            let body = orgfile::body(LINKED);
280            orgfile::upsert_keyword(body, "GDOC_LAST_PULL", NOW)
281        };
282        let pulled_body = orgfile::body(&outcome.new_content);
283        assert_eq!(
284            pulled_body.trim_end_matches('\n'),
285            expected.trim_end_matches('\n')
286        );
287    }
288
289    #[tokio::test]
290    async fn re_pull_with_no_new_comments_is_a_noop() {
291        let mut server = Server::new_async().await;
292        let _mock = mock_comments(&mut server, ONE_COMMENT).await;
293        let client = test_client(&server);
294
295        let once = pull(&client, LINKED, NOW).await.expect("first pull");
296        let twice = pull(&client, &once.new_content, NOW)
297            .await
298            .expect("second pull");
299
300        // Merge-by-id (A3): the second pull finds C1 already filed and changes
301        // nothing — byte-identical content.
302        assert!(twice.new_comment_ids.is_empty());
303        assert_eq!(twice.new_content, once.new_content);
304    }
305
306    #[tokio::test]
307    async fn pull_skips_comments_already_resolved_in_google() {
308        let mut server = Server::new_async().await;
309        let resolved = r#"{"comments":[
310            {"id":"CR","author":{"displayName":"Alice","emailAddress":"alice@example.com"},
311             "content":"done already","resolved":true,
312             "quotedFileContent":{"value":"quick brown fox"}}
313        ]}"#;
314        let _mock = mock_comments(&mut server, resolved).await;
315
316        let outcome = pull(&test_client(&server), LINKED, NOW)
317            .await
318            .expect("pull succeeds");
319
320        assert!(outcome.new_comment_ids.is_empty());
321        assert!(!outcome.new_content.contains(":COMMENT_ID: CR\n"));
322    }
323
324    #[tokio::test]
325    async fn pull_without_a_linked_doc_is_not_linked_error() {
326        let server = Server::new_async().await;
327        let content = "* Intro\nNo GDOC_ID here.\n";
328        let result = pull(&test_client(&server), content, NOW).await;
329        assert!(matches!(result, Err(Error::NotLinked)));
330    }
331}