Skip to main content

org_gdocs/
push.rs

1//! P7 — the `push` orchestration: publish the org body's projection to its linked
2//! Google Doc and write machine state back into the file.
3//!
4//! This is the imperative shell (EI-4). It delegates every decision to the pure
5//! cores — `CUSTOM_ID`s ([`crate::custom_id`]), projection ([`crate::project`]),
6//! sync state ([`crate::syncstate`]), comments ([`crate::comments_meta`]),
7//! region writeback ([`crate::orgfile`]) — and only sequences the effects
8//! (network, then file content) between them.
9//!
10//! Guarantees:
11//!
12//! - **DI-2** — the body is reproduced byte-for-byte except the sanctioned
13//!   `:CUSTOM_ID:` insertions (and the tool-owned `#+GDOC_*` header keywords).
14//! - **DI-9 (refined)** — when the body *does* change, the push fully replaces it
15//!   (clear then re-insert, never diffed). But a push whose projection fingerprint
16//!   ([`crate::project::Projection::fingerprint`]) matches the one recorded in
17//!   `** Sync State` **skips the body write entirely** — a full-replace deletes the
18//!   text existing Google comments anchor to, orphaning them ("original content
19//!   deleted"), so re-pushing unchanged content (e.g. just to post a reply or
20//!   resolve a comment) must not touch the body. Operator-approved relaxation.
21//! - **DI-3** — all state is read from and written to the file content; no sidecar.
22//!
23//! IO (reading the file, writing it back, the clock) stays at the binary edge;
24//! [`push`] takes the file content and the timestamp and returns the new content.
25
26use kb::parser::parse_document;
27
28use crate::comments_meta::{self, CommentState};
29use crate::custom_id::ensure_section_ids;
30use crate::envelope;
31use crate::error::{Error, Result};
32use crate::google::client::GoogleClient;
33use crate::google::docs::{self, DocumentRef};
34use crate::orgfile;
35use crate::project;
36use crate::sexp::Sexp;
37use crate::syncstate::{PostedReply, SyncState};
38
39/// The tool-owned metadata heading that opens the machine region.
40const METADATA_HEADING: &str = "* GDOC_METADATA :noexport:";
41
42/// The result of a push: the document reference, what changed, and the new file
43/// content the caller must persist.
44#[derive(Debug, Clone)]
45pub struct PushOutcome {
46    /// The (possibly newly created) target document.
47    pub document: DocumentRef,
48    /// Whether the document was created by this push.
49    pub created: bool,
50    /// Number of anchored elements projected (position-map size).
51    pub element_count: usize,
52    /// Comment ids that were resolved in Google this push.
53    pub resolved: Vec<String>,
54    /// Number of operator-authored replies posted to Google this push.
55    pub replied: usize,
56    /// Whether the document body was (re)written this push. `false` when the
57    /// projection was unchanged since the last push, so the full-replace was
58    /// skipped to preserve existing comment anchors.
59    pub body_updated: bool,
60    /// The new file content to write back (body preserved, machine region rebuilt).
61    pub new_content: String,
62}
63
64/// Publish `content`'s projection to its linked doc, returning the rewritten file.
65///
66/// `default_title` is used to title a newly created doc when the file has no
67/// `#+TITLE:`. `now` is the caller-supplied push timestamp (clock at the edge).
68///
69/// # Errors
70///
71/// Returns a [`crate::error::Error`] on org-parse failure, any Google API failure,
72/// or a malformed existing machine region.
73pub async fn push(
74    client: &GoogleClient,
75    content: &str,
76    default_title: &str,
77    now: &str,
78) -> Result<PushOutcome> {
79    let (body, machine) = orgfile::split(content);
80    let machine = machine.unwrap_or("");
81
82    // 1. Sanctioned body mutation: ensure every heading has a CUSTOM_ID (P2).
83    let body_with_ids = ensure_section_ids(body);
84
85    // 2. Parse the body read-only (P1/kb) and project it (P3).
86    let document =
87        parse_document(&body_with_ids).map_err(|err| Error::OrgParse(err.to_string()))?;
88    let projection = project::project(&document);
89    let element_count = projection.positions.len();
90    let fingerprint = projection.fingerprint();
91    let existing = SyncState::parse_block(machine)?;
92
93    // 3. Create-or-get the doc.
94    let (document_ref, created) = if let Some(id) = orgfile::read_keyword(content, "GDOC_ID") {
95        (DocumentRef::from_id(id), false)
96    } else {
97        let title =
98            orgfile::read_keyword(content, "TITLE").unwrap_or_else(|| default_title.to_owned());
99        (client.create_document(&title).await?, true)
100    };
101
102    // 4. Full-replace the body (P4a, DI-9) ONLY for a new doc or when the
103    //    projection changed since the last push. Skipping an unchanged re-push
104    //    avoids deleting the text that existing Google comments anchor to — a
105    //    full-replace would orphan every anchored comment as "original content
106    //    deleted" (operator-approved DI-9 relaxation: full-replace on change,
107    //    no-op on no change).
108    let body_updated = created || existing.projection_hash.as_deref() != Some(fingerprint.as_str());
109    if body_updated {
110        let batch =
111            full_replace_batch(client, &document_ref.id, created, projection.requests).await?;
112        client.batch_update(&document_ref.id, batch).await?;
113    }
114
115    // 5. Resolve comments the operator marked DONE (P6 read + P4b update, DI-4).
116    let resolved = resolve_done(client, &document_ref.id, machine).await;
117
118    // 6. Post operator-authored replies not already posted (P6 read + Drive
119    //    replies.create), tracking posted state in the sync state for idempotence.
120    let (mut state, replied) = post_replies(client, &document_ref.id, machine, existing).await;
121
122    // 7. Write machine state back, body preserved (P5/P6/P1). The position map and
123    //    projection fingerprint are this push's; collaborators and posted replies
124    //    carry over.
125    state.positions = projection.positions;
126    state.projection_hash = Some(fingerprint);
127    let new_content = write_back(&body_with_ids, machine, &document_ref, &state, now);
128
129    Ok(PushOutcome {
130        document: document_ref,
131        created,
132        element_count,
133        resolved,
134        replied,
135        body_updated,
136        new_content,
137    })
138}
139
140/// Post every operator-authored reply not already recorded as posted, isolating
141/// failures (a failed post is simply retried next push). Returns the updated sync
142/// state (collaborators preserved, newly-posted replies appended) and the count
143/// posted this run.
144async fn post_replies(
145    client: &GoogleClient,
146    document_id: &str,
147    machine: &str,
148    mut state: SyncState,
149) -> (SyncState, usize) {
150    let mut replied = 0;
151    for reply in comments_meta::pending_replies(machine) {
152        if state.reply_posted(&reply.comment_id, &reply.content) {
153            continue;
154        }
155        if client
156            .create_reply(document_id, &reply.comment_id, &reply.content)
157            .await
158            .is_ok()
159        {
160            state.posted_replies.push(PostedReply {
161                comment_id: reply.comment_id,
162                content: reply.content,
163            });
164            replied += 1;
165        }
166    }
167    (state, replied)
168}
169
170/// Build the batch: for an existing doc, clear the body first (DI-9); then the
171/// projection requests. Fetching the end index also enforces DI-6 (single tab).
172async fn full_replace_batch(
173    client: &GoogleClient,
174    document_id: &str,
175    created: bool,
176    requests: Vec<google_docs1::api::Request>,
177) -> Result<Vec<google_docs1::api::Request>> {
178    let mut batch = Vec::with_capacity(requests.len() + 1);
179    if !created {
180        let end_index = client.document_end_index(document_id).await?;
181        if let Some(delete) = docs::delete_body_request(end_index) {
182            batch.push(delete);
183        }
184    }
185    batch.extend(requests);
186    Ok(batch)
187}
188
189/// Resolve every comment whose heading is `DONE`, isolating failures; return the
190/// ids that were resolved.
191async fn resolve_done(client: &GoogleClient, document_id: &str, machine: &str) -> Vec<String> {
192    let done: Vec<String> = comments_meta::parse_entries(machine)
193        .into_iter()
194        .filter(|entry| entry.state == CommentState::Done)
195        .map(|entry| entry.id)
196        .collect();
197    client
198        .resolve_comments(document_id, &done)
199        .await
200        .into_iter()
201        .filter_map(|(id, outcome)| outcome.is_ok().then_some(id))
202        .collect()
203}
204
205/// Reassemble the file: body (with `CUSTOM_ID`s) + tool keywords + a regenerated
206/// machine region (the given Sync State, existing Active Comments preserved
207/// verbatim).
208fn write_back(
209    body_with_ids: &str,
210    machine: &str,
211    document: &DocumentRef,
212    sync: &SyncState,
213    now: &str,
214) -> String {
215    // Active Comments: preserve existing subtree verbatim (no new comments on push).
216    let active = comments_meta::render_section(machine, &[]);
217    let region = format!("{METADATA_HEADING}\n{}{active}", sync.render_block());
218
219    let with_keywords = write_keywords(body_with_ids, document, now);
220    orgfile::replace_metadata(&with_keywords, &region)
221}
222
223/// Upsert the tool-owned header keywords into the body.
224fn write_keywords(body: &str, document: &DocumentRef, now: &str) -> String {
225    let with_id = orgfile::upsert_keyword(body, "GDOC_ID", &document.id);
226    let with_url = orgfile::upsert_keyword(&with_id, "GDOC_URL", &document.url);
227    orgfile::upsert_keyword(&with_url, "GDOC_LAST_PUSH", now)
228}
229
230/// Build the success envelope for a completed push (A5).
231#[must_use]
232pub fn envelope(outcome: &PushOutcome) -> Sexp {
233    let resolved = outcome
234        .resolved
235        .iter()
236        .map(|id| Sexp::string(id.clone()))
237        .collect();
238    envelope::ok(
239        "push",
240        vec![
241            ("document-id", Sexp::string(outcome.document.id.clone())),
242            ("document-url", Sexp::string(outcome.document.url.clone())),
243            ("created", Sexp::symbol(envelope::flag(outcome.created))),
244            (
245                "elements",
246                Sexp::int(i64::try_from(outcome.element_count).unwrap_or(i64::MAX)),
247            ),
248            ("resolved", Sexp::list(resolved)),
249            (
250                "replied",
251                Sexp::int(i64::try_from(outcome.replied).unwrap_or(i64::MAX)),
252            ),
253            (
254                "body-updated",
255                Sexp::symbol(envelope::flag(outcome.body_updated)),
256            ),
257        ],
258    )
259}
260
261#[cfg(test)]
262mod tests {
263    use super::push;
264    use crate::custom_id::ensure_section_ids;
265    use crate::google::client::GoogleClient;
266    use crate::orgfile;
267    use mockito::{Matcher, Server};
268
269    const NOW: &str = "2026-06-10T00:00:00+00:00";
270
271    fn test_client(server: &Server) -> GoogleClient {
272        let mut client = GoogleClient::new("test-token".to_owned()).expect("client builds");
273        client.set_base_url(&server.url());
274        client
275    }
276
277    async fn mock_create(server: &mut Server, id: &str) -> mockito::Mock {
278        server
279            .mock("POST", "/v1/documents")
280            .match_query(Matcher::Any)
281            .with_status(200)
282            .with_header("content-type", "application/json")
283            .with_body(format!(r#"{{"documentId":"{id}"}}"#))
284            .create_async()
285            .await
286    }
287
288    async fn mock_batch(server: &mut Server, id: &str) -> mockito::Mock {
289        server
290            .mock("POST", format!("/v1/documents/{id}:batchUpdate").as_str())
291            .match_query(Matcher::Any)
292            .with_status(200)
293            .with_header("content-type", "application/json")
294            .with_body(format!(r#"{{"documentId":"{id}"}}"#))
295            .create_async()
296            .await
297    }
298
299    #[tokio::test]
300    async fn push_creates_doc_and_rebuilds_machine_region() {
301        let mut server = Server::new_async().await;
302        let create = mock_create(&mut server, "DOC1").await;
303        let batch = mock_batch(&mut server, "DOC1").await;
304
305        let content = "#+TITLE: Spec\n* Intro\nHello world.\n";
306        let outcome = push(&test_client(&server), content, "fallback", NOW)
307            .await
308            .expect("push succeeds");
309
310        assert!(outcome.created);
311        assert_eq!(outcome.element_count, 2); // heading + paragraph
312        let written = &outcome.new_content;
313        assert!(written.contains("Hello world.\n"));
314        assert!(written.contains(":CUSTOM_ID: sec-intro\n"));
315        assert!(written.contains("#+GDOC_ID: DOC1\n"));
316        assert!(written.contains("#+GDOC_URL: https://docs.google.com/document/d/DOC1/edit\n"));
317        assert!(written.contains(&format!("#+GDOC_LAST_PUSH: {NOW}\n")));
318        assert!(written.contains("* GDOC_METADATA :noexport:\n** Sync State\n"));
319        assert!(written.contains("(pos \"sec-intro\" 1 heading)"));
320        assert!(written.contains("** Active Comments\n"));
321
322        create.assert_async().await;
323        batch.assert_async().await;
324    }
325
326    #[tokio::test]
327    async fn push_preserves_body_prose_byte_for_byte() {
328        let mut server = Server::new_async().await;
329        let _create = mock_create(&mut server, "DOC1").await;
330        let _batch = mock_batch(&mut server, "DOC1").await;
331
332        let content = "* Intro\nHello world.\n\n* Details\nMore text.\n";
333        let outcome = push(&test_client(&server), content, "fallback", NOW)
334            .await
335            .expect("push succeeds");
336
337        // The body equals the original + sanctioned CUSTOM_IDs + tool keywords, and
338        // nothing else: no reflow, no kb canonicalization (DI-2).
339        let expected = {
340            let body = ensure_section_ids(content);
341            let body = orgfile::upsert_keyword(&body, "GDOC_ID", "DOC1");
342            let body = orgfile::upsert_keyword(
343                &body,
344                "GDOC_URL",
345                "https://docs.google.com/document/d/DOC1/edit",
346            );
347            orgfile::upsert_keyword(&body, "GDOC_LAST_PUSH", NOW)
348        };
349        let pushed_body = orgfile::body(&outcome.new_content);
350        assert_eq!(
351            pushed_body.trim_end_matches('\n'),
352            expected.trim_end_matches('\n')
353        );
354    }
355
356    #[tokio::test]
357    async fn push_existing_doc_full_replaces_without_create() {
358        let mut server = Server::new_async().await;
359        // GET end index (existing doc) — also enforces DI-6.
360        let get = server
361            .mock("GET", "/v1/documents/EXISTING")
362            .match_query(Matcher::Any)
363            .with_status(200)
364            .with_header("content-type", "application/json")
365            .with_body(r#"{"body":{"content":[{"endIndex":50}]}}"#)
366            .create_async()
367            .await;
368        // The batch must clear the body first (DI-9 full-replace).
369        let batch = server
370            .mock("POST", "/v1/documents/EXISTING:batchUpdate")
371            .match_query(Matcher::Any)
372            .match_body(Matcher::Regex("deleteContentRange".to_owned()))
373            .with_status(200)
374            .with_header("content-type", "application/json")
375            .with_body(r#"{"documentId":"EXISTING"}"#)
376            .create_async()
377            .await;
378
379        let content = "#+GDOC_ID: EXISTING\n#+TITLE: Spec\n* Intro\nHi.\n";
380        let outcome = push(&test_client(&server), content, "fallback", NOW)
381            .await
382            .expect("push succeeds");
383
384        assert!(!outcome.created);
385        // No create mock registered: a create call would 404 and fail the push.
386        get.assert_async().await;
387        batch.assert_async().await;
388    }
389
390    #[tokio::test]
391    async fn push_resolves_done_comments() {
392        let mut server = Server::new_async().await;
393        let _create = mock_create(&mut server, "DOC1").await;
394        let _batch = mock_batch(&mut server, "DOC1").await;
395        let resolve = server
396            .mock("PATCH", "/files/DOC1/comments/CDONE")
397            .match_query(Matcher::Any)
398            .match_body(Matcher::PartialJsonString(
399                r#"{"resolved":true}"#.to_owned(),
400            ))
401            .with_status(200)
402            .with_header("content-type", "application/json")
403            .with_body(r#"{"id":"CDONE","resolved":true}"#)
404            .create_async()
405            .await;
406
407        let content = "#+GDOC_ID: DOC1\n* Intro\nHi.\n\n\
408            * GDOC_METADATA :noexport:\n** Sync State\n#+begin_src emacs-lisp\n(gdoc-sync-state 1 (positions) (collaborators))\n#+end_src\n\
409            ** Active Comments\n*** DONE Alice: fixed\n:PROPERTIES:\n:COMMENT_ID: CDONE\n:END:\n";
410        // Existing doc id present, so create is not called; GET end index is needed.
411        let _get = server
412            .mock("GET", "/v1/documents/DOC1")
413            .match_query(Matcher::Any)
414            .with_status(200)
415            .with_header("content-type", "application/json")
416            .with_body(r#"{"body":{"content":[{"endIndex":10}]}}"#)
417            .create_async()
418            .await;
419
420        let outcome = push(&test_client(&server), content, "fallback", NOW)
421            .await
422            .expect("push succeeds");
423
424        assert_eq!(outcome.resolved, vec!["CDONE".to_owned()]);
425        // The DONE comment heading is preserved (push resolves in Google; `clean`
426        // removes it later).
427        assert!(outcome.new_content.contains(":COMMENT_ID: CDONE\n"));
428        resolve.assert_async().await;
429    }
430
431    #[tokio::test]
432    async fn unchanged_reprojection_skips_the_full_replace() {
433        let mut server = Server::new_async().await;
434        // Both are expected exactly once — the second push must NOT touch the body.
435        let get = server
436            .mock("GET", "/v1/documents/DOC1")
437            .match_query(Matcher::Any)
438            .with_status(200)
439            .with_header("content-type", "application/json")
440            .with_body(r#"{"body":{"content":[{"endIndex":20}]}}"#)
441            .expect(1)
442            .create_async()
443            .await;
444        let batch = server
445            .mock("POST", "/v1/documents/DOC1:batchUpdate")
446            .match_query(Matcher::Any)
447            .with_status(200)
448            .with_header("content-type", "application/json")
449            .with_body(r#"{"documentId":"DOC1"}"#)
450            .expect(1)
451            .create_async()
452            .await;
453
454        // Existing doc, no stored fingerprint yet → first push full-replaces.
455        let content = "#+GDOC_ID: DOC1\n* Intro\nHello world.\n";
456        let first = push(&test_client(&server), content, "fallback", NOW)
457            .await
458            .expect("first push succeeds");
459        assert!(first.body_updated);
460        assert!(first.new_content.contains("(projection-hash "));
461
462        // Re-push the rewritten file unchanged: the fingerprint matches, so the
463        // body write (GET + batchUpdate) is skipped and comment anchors survive.
464        let second = push(&test_client(&server), &first.new_content, "fallback", NOW)
465            .await
466            .expect("second push succeeds");
467        assert!(!second.body_updated);
468
469        get.assert_async().await; // exactly once — first push only
470        batch.assert_async().await;
471    }
472
473    #[tokio::test]
474    async fn push_posts_pending_replies_then_records_them_for_idempotence() {
475        let mut server = Server::new_async().await;
476        let _create = mock_create(&mut server, "DOC1").await;
477        let _batch = mock_batch(&mut server, "DOC1").await;
478        let _get = server
479            .mock("GET", "/v1/documents/DOC1")
480            .match_query(Matcher::Any)
481            .with_status(200)
482            .with_header("content-type", "application/json")
483            .with_body(r#"{"body":{"content":[{"endIndex":10}]}}"#)
484            .create_async()
485            .await;
486        // The reply post is expected exactly once across two pushes (idempotent).
487        let reply = server
488            .mock("POST", "/files/DOC1/comments/C1/replies")
489            .match_query(Matcher::Any)
490            .match_body(Matcher::PartialJsonString(
491                r#"{"content":"Clarified."}"#.to_owned(),
492            ))
493            .with_status(200)
494            .with_header("content-type", "application/json")
495            .with_body(r#"{"id":"R1"}"#)
496            .expect(1)
497            .create_async()
498            .await;
499
500        let content = "#+GDOC_ID: DOC1\n* Intro\nHi.\n\n\
501            * GDOC_METADATA :noexport:\n** Sync State\n#+begin_src emacs-lisp\n(gdoc-sync-state 1 (positions) (collaborators))\n#+end_src\n\
502            ** Active Comments\n*** TODO Alice: please clarify\n:PROPERTIES:\n:COMMENT_ID: C1\n:END:\n**** REPLY\nClarified.\n";
503
504        let first = push(&test_client(&server), content, "fallback", NOW)
505            .await
506            .expect("first push succeeds");
507        assert_eq!(first.replied, 1);
508        // The posted reply is recorded in the regenerated sync state.
509        assert!(
510            first
511                .new_content
512                .contains("(posted-replies (reply \"C1\" \"Clarified.\")")
513        );
514
515        // A second push over the rewritten file must NOT re-post (mock expects 1).
516        let second = push(&test_client(&server), &first.new_content, "fallback", NOW)
517            .await
518            .expect("second push succeeds");
519        assert_eq!(second.replied, 0);
520        reply.assert_async().await;
521    }
522}