tftio-org-gdocs 0.1.1

Sync org-mode documents to Google Docs and pull reviewer comments back into org-mode
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
//! P7 — the `push` orchestration: publish the org body's projection to its linked
//! Google Doc and write machine state back into the file.
//!
//! This is the imperative shell (EI-4). It delegates every decision to the pure
//! cores — `CUSTOM_ID`s ([`crate::custom_id`]), projection ([`crate::project`]),
//! sync state ([`crate::syncstate`]), comments ([`crate::comments_meta`]),
//! region writeback ([`crate::orgfile`]) — and only sequences the effects
//! (network, then file content) between them.
//!
//! Guarantees:
//!
//! - **DI-2** — the body is reproduced byte-for-byte except the sanctioned
//!   `:CUSTOM_ID:` insertions (and the tool-owned `#+GDOC_*` header keywords).
//! - **DI-9 (refined)** — when the body *does* change, the push fully replaces it
//!   (clear then re-insert, never diffed). But a push whose projection fingerprint
//!   ([`crate::project::Projection::fingerprint`]) matches the one recorded in
//!   `** Sync State` **skips the body write entirely** — a full-replace deletes the
//!   text existing Google comments anchor to, orphaning them ("original content
//!   deleted"), so re-pushing unchanged content (e.g. just to post a reply or
//!   resolve a comment) must not touch the body. Operator-approved relaxation.
//! - **DI-3** — all state is read from and written to the file content; no sidecar.
//!
//! IO (reading the file, writing it back, the clock) stays at the binary edge;
//! [`push`] takes the file content and the timestamp and returns the new content.

use kb::parser::parse_document;

use crate::comments_meta::{self, CommentState};
use crate::custom_id::ensure_section_ids;
use crate::envelope;
use crate::error::{Error, Result};
use crate::google::client::GoogleClient;
use crate::google::docs::{self, DocumentRef};
use crate::orgfile;
use crate::project;
use crate::sexp::Sexp;
use crate::syncstate::{PostedReply, SyncState};

/// The tool-owned metadata heading that opens the machine region.
const METADATA_HEADING: &str = "* GDOC_METADATA :noexport:";

/// The result of a push: the document reference, what changed, and the new file
/// content the caller must persist.
#[derive(Debug, Clone)]
pub struct PushOutcome {
    /// The (possibly newly created) target document.
    pub document: DocumentRef,
    /// Whether the document was created by this push.
    pub created: bool,
    /// Number of anchored elements projected (position-map size).
    pub element_count: usize,
    /// Comment ids that were resolved in Google this push.
    pub resolved: Vec<String>,
    /// Number of operator-authored replies posted to Google this push.
    pub replied: usize,
    /// Whether the document body was (re)written this push. `false` when the
    /// projection was unchanged since the last push, so the full-replace was
    /// skipped to preserve existing comment anchors.
    pub body_updated: bool,
    /// The new file content to write back (body preserved, machine region rebuilt).
    pub new_content: String,
}

/// Publish `content`'s projection to its linked doc, returning the rewritten file.
///
/// `default_title` is used to title a newly created doc when the file has no
/// `#+TITLE:`. `now` is the caller-supplied push timestamp (clock at the edge).
///
/// # Errors
///
/// Returns a [`crate::error::Error`] on org-parse failure, any Google API failure,
/// or a malformed existing machine region.
pub async fn push(
    client: &GoogleClient,
    content: &str,
    default_title: &str,
    now: &str,
) -> Result<PushOutcome> {
    let (body, machine) = orgfile::split(content);
    let machine = machine.unwrap_or("");

    // 1. Sanctioned body mutation: ensure every heading has a CUSTOM_ID (P2).
    let body_with_ids = ensure_section_ids(body);

    // 2. Parse the body read-only (P1/kb) and project it (P3).
    let document =
        parse_document(&body_with_ids).map_err(|err| Error::OrgParse(err.to_string()))?;
    let projection = project::project(&document);
    let element_count = projection.positions.len();
    let fingerprint = projection.fingerprint();
    let existing = SyncState::parse_block(machine)?;

    // 3. Create-or-get the doc.
    let (document_ref, created) = if let Some(id) = orgfile::read_keyword(content, "GDOC_ID") {
        (DocumentRef::from_id(id), false)
    } else {
        let title =
            orgfile::read_keyword(content, "TITLE").unwrap_or_else(|| default_title.to_owned());
        (client.create_document(&title).await?, true)
    };

    // 4. Full-replace the body (P4a, DI-9) ONLY for a new doc or when the
    //    projection changed since the last push. Skipping an unchanged re-push
    //    avoids deleting the text that existing Google comments anchor to — a
    //    full-replace would orphan every anchored comment as "original content
    //    deleted" (operator-approved DI-9 relaxation: full-replace on change,
    //    no-op on no change).
    let body_updated = created || existing.projection_hash.as_deref() != Some(fingerprint.as_str());
    if body_updated {
        let batch =
            full_replace_batch(client, &document_ref.id, created, projection.requests).await?;
        client.batch_update(&document_ref.id, batch).await?;
    }

    // 5. Resolve comments the operator marked DONE (P6 read + P4b update, DI-4).
    let resolved = resolve_done(client, &document_ref.id, machine).await;

    // 6. Post operator-authored replies not already posted (P6 read + Drive
    //    replies.create), tracking posted state in the sync state for idempotence.
    let (mut state, replied) = post_replies(client, &document_ref.id, machine, existing).await;

    // 7. Write machine state back, body preserved (P5/P6/P1). The position map and
    //    projection fingerprint are this push's; collaborators and posted replies
    //    carry over.
    state.positions = projection.positions;
    state.projection_hash = Some(fingerprint);
    let new_content = write_back(&body_with_ids, machine, &document_ref, &state, now);

    Ok(PushOutcome {
        document: document_ref,
        created,
        element_count,
        resolved,
        replied,
        body_updated,
        new_content,
    })
}

/// Post every operator-authored reply not already recorded as posted, isolating
/// failures (a failed post is simply retried next push). Returns the updated sync
/// state (collaborators preserved, newly-posted replies appended) and the count
/// posted this run.
async fn post_replies(
    client: &GoogleClient,
    document_id: &str,
    machine: &str,
    mut state: SyncState,
) -> (SyncState, usize) {
    let mut replied = 0;
    for reply in comments_meta::pending_replies(machine) {
        if state.reply_posted(&reply.comment_id, &reply.content) {
            continue;
        }
        if client
            .create_reply(document_id, &reply.comment_id, &reply.content)
            .await
            .is_ok()
        {
            state.posted_replies.push(PostedReply {
                comment_id: reply.comment_id,
                content: reply.content,
            });
            replied += 1;
        }
    }
    (state, replied)
}

/// Build the batch: for an existing doc, clear the body first (DI-9); then the
/// projection requests. Fetching the end index also enforces DI-6 (single tab).
async fn full_replace_batch(
    client: &GoogleClient,
    document_id: &str,
    created: bool,
    requests: Vec<google_docs1::api::Request>,
) -> Result<Vec<google_docs1::api::Request>> {
    let mut batch = Vec::with_capacity(requests.len() + 1);
    if !created {
        let end_index = client.document_end_index(document_id).await?;
        if let Some(delete) = docs::delete_body_request(end_index) {
            batch.push(delete);
        }
    }
    batch.extend(requests);
    Ok(batch)
}

/// Resolve every comment whose heading is `DONE`, isolating failures; return the
/// ids that were resolved.
async fn resolve_done(client: &GoogleClient, document_id: &str, machine: &str) -> Vec<String> {
    let done: Vec<String> = comments_meta::parse_entries(machine)
        .into_iter()
        .filter(|entry| entry.state == CommentState::Done)
        .map(|entry| entry.id)
        .collect();
    client
        .resolve_comments(document_id, &done)
        .await
        .into_iter()
        .filter_map(|(id, outcome)| outcome.is_ok().then_some(id))
        .collect()
}

/// Reassemble the file: body (with `CUSTOM_ID`s) + tool keywords + a regenerated
/// machine region (the given Sync State, existing Active Comments preserved
/// verbatim).
fn write_back(
    body_with_ids: &str,
    machine: &str,
    document: &DocumentRef,
    sync: &SyncState,
    now: &str,
) -> String {
    // Active Comments: preserve existing subtree verbatim (no new comments on push).
    let active = comments_meta::render_section(machine, &[]);
    let region = format!("{METADATA_HEADING}\n{}{active}", sync.render_block());

    let with_keywords = write_keywords(body_with_ids, document, now);
    orgfile::replace_metadata(&with_keywords, &region)
}

/// Upsert the tool-owned header keywords into the body.
fn write_keywords(body: &str, document: &DocumentRef, now: &str) -> String {
    let with_id = orgfile::upsert_keyword(body, "GDOC_ID", &document.id);
    let with_url = orgfile::upsert_keyword(&with_id, "GDOC_URL", &document.url);
    orgfile::upsert_keyword(&with_url, "GDOC_LAST_PUSH", now)
}

/// Build the success envelope for a completed push (A5).
#[must_use]
pub fn envelope(outcome: &PushOutcome) -> Sexp {
    let resolved = outcome
        .resolved
        .iter()
        .map(|id| Sexp::string(id.clone()))
        .collect();
    envelope::ok(
        "push",
        vec![
            ("document-id", Sexp::string(outcome.document.id.clone())),
            ("document-url", Sexp::string(outcome.document.url.clone())),
            ("created", Sexp::symbol(envelope::flag(outcome.created))),
            (
                "elements",
                Sexp::int(i64::try_from(outcome.element_count).unwrap_or(i64::MAX)),
            ),
            ("resolved", Sexp::list(resolved)),
            (
                "replied",
                Sexp::int(i64::try_from(outcome.replied).unwrap_or(i64::MAX)),
            ),
            (
                "body-updated",
                Sexp::symbol(envelope::flag(outcome.body_updated)),
            ),
        ],
    )
}

#[cfg(test)]
mod tests {
    use super::push;
    use crate::custom_id::ensure_section_ids;
    use crate::google::client::GoogleClient;
    use crate::orgfile;
    use mockito::{Matcher, Server};

    const NOW: &str = "2026-06-10T00:00:00+00:00";

    fn test_client(server: &Server) -> GoogleClient {
        let mut client = GoogleClient::new("test-token".to_owned()).expect("client builds");
        client.set_base_url(&server.url());
        client
    }

    async fn mock_create(server: &mut Server, id: &str) -> mockito::Mock {
        server
            .mock("POST", "/v1/documents")
            .match_query(Matcher::Any)
            .with_status(200)
            .with_header("content-type", "application/json")
            .with_body(format!(r#"{{"documentId":"{id}"}}"#))
            .create_async()
            .await
    }

    async fn mock_batch(server: &mut Server, id: &str) -> mockito::Mock {
        server
            .mock("POST", format!("/v1/documents/{id}:batchUpdate").as_str())
            .match_query(Matcher::Any)
            .with_status(200)
            .with_header("content-type", "application/json")
            .with_body(format!(r#"{{"documentId":"{id}"}}"#))
            .create_async()
            .await
    }

    #[tokio::test]
    async fn push_creates_doc_and_rebuilds_machine_region() {
        let mut server = Server::new_async().await;
        let create = mock_create(&mut server, "DOC1").await;
        let batch = mock_batch(&mut server, "DOC1").await;

        let content = "#+TITLE: Spec\n* Intro\nHello world.\n";
        let outcome = push(&test_client(&server), content, "fallback", NOW)
            .await
            .expect("push succeeds");

        assert!(outcome.created);
        assert_eq!(outcome.element_count, 2); // heading + paragraph
        let written = &outcome.new_content;
        assert!(written.contains("Hello world.\n"));
        assert!(written.contains(":CUSTOM_ID: sec-intro\n"));
        assert!(written.contains("#+GDOC_ID: DOC1\n"));
        assert!(written.contains("#+GDOC_URL: https://docs.google.com/document/d/DOC1/edit\n"));
        assert!(written.contains(&format!("#+GDOC_LAST_PUSH: {NOW}\n")));
        assert!(written.contains("* GDOC_METADATA :noexport:\n** Sync State\n"));
        assert!(written.contains("(pos \"sec-intro\" 1 heading)"));
        assert!(written.contains("** Active Comments\n"));

        create.assert_async().await;
        batch.assert_async().await;
    }

    #[tokio::test]
    async fn push_preserves_body_prose_byte_for_byte() {
        let mut server = Server::new_async().await;
        let _create = mock_create(&mut server, "DOC1").await;
        let _batch = mock_batch(&mut server, "DOC1").await;

        let content = "* Intro\nHello world.\n\n* Details\nMore text.\n";
        let outcome = push(&test_client(&server), content, "fallback", NOW)
            .await
            .expect("push succeeds");

        // The body equals the original + sanctioned CUSTOM_IDs + tool keywords, and
        // nothing else: no reflow, no kb canonicalization (DI-2).
        let expected = {
            let body = ensure_section_ids(content);
            let body = orgfile::upsert_keyword(&body, "GDOC_ID", "DOC1");
            let body = orgfile::upsert_keyword(
                &body,
                "GDOC_URL",
                "https://docs.google.com/document/d/DOC1/edit",
            );
            orgfile::upsert_keyword(&body, "GDOC_LAST_PUSH", NOW)
        };
        let pushed_body = orgfile::body(&outcome.new_content);
        assert_eq!(
            pushed_body.trim_end_matches('\n'),
            expected.trim_end_matches('\n')
        );
    }

    #[tokio::test]
    async fn push_existing_doc_full_replaces_without_create() {
        let mut server = Server::new_async().await;
        // GET end index (existing doc) — also enforces DI-6.
        let get = server
            .mock("GET", "/v1/documents/EXISTING")
            .match_query(Matcher::Any)
            .with_status(200)
            .with_header("content-type", "application/json")
            .with_body(r#"{"body":{"content":[{"endIndex":50}]}}"#)
            .create_async()
            .await;
        // The batch must clear the body first (DI-9 full-replace).
        let batch = server
            .mock("POST", "/v1/documents/EXISTING:batchUpdate")
            .match_query(Matcher::Any)
            .match_body(Matcher::Regex("deleteContentRange".to_owned()))
            .with_status(200)
            .with_header("content-type", "application/json")
            .with_body(r#"{"documentId":"EXISTING"}"#)
            .create_async()
            .await;

        let content = "#+GDOC_ID: EXISTING\n#+TITLE: Spec\n* Intro\nHi.\n";
        let outcome = push(&test_client(&server), content, "fallback", NOW)
            .await
            .expect("push succeeds");

        assert!(!outcome.created);
        // No create mock registered: a create call would 404 and fail the push.
        get.assert_async().await;
        batch.assert_async().await;
    }

    #[tokio::test]
    async fn push_resolves_done_comments() {
        let mut server = Server::new_async().await;
        let _create = mock_create(&mut server, "DOC1").await;
        let _batch = mock_batch(&mut server, "DOC1").await;
        let resolve = server
            .mock("PATCH", "/files/DOC1/comments/CDONE")
            .match_query(Matcher::Any)
            .match_body(Matcher::PartialJsonString(
                r#"{"resolved":true}"#.to_owned(),
            ))
            .with_status(200)
            .with_header("content-type", "application/json")
            .with_body(r#"{"id":"CDONE","resolved":true}"#)
            .create_async()
            .await;

        let content = "#+GDOC_ID: DOC1\n* Intro\nHi.\n\n\
            * GDOC_METADATA :noexport:\n** Sync State\n#+begin_src emacs-lisp\n(gdoc-sync-state 1 (positions) (collaborators))\n#+end_src\n\
            ** Active Comments\n*** DONE Alice: fixed\n:PROPERTIES:\n:COMMENT_ID: CDONE\n:END:\n";
        // Existing doc id present, so create is not called; GET end index is needed.
        let _get = server
            .mock("GET", "/v1/documents/DOC1")
            .match_query(Matcher::Any)
            .with_status(200)
            .with_header("content-type", "application/json")
            .with_body(r#"{"body":{"content":[{"endIndex":10}]}}"#)
            .create_async()
            .await;

        let outcome = push(&test_client(&server), content, "fallback", NOW)
            .await
            .expect("push succeeds");

        assert_eq!(outcome.resolved, vec!["CDONE".to_owned()]);
        // The DONE comment heading is preserved (push resolves in Google; `clean`
        // removes it later).
        assert!(outcome.new_content.contains(":COMMENT_ID: CDONE\n"));
        resolve.assert_async().await;
    }

    #[tokio::test]
    async fn unchanged_reprojection_skips_the_full_replace() {
        let mut server = Server::new_async().await;
        // Both are expected exactly once — the second push must NOT touch the body.
        let get = server
            .mock("GET", "/v1/documents/DOC1")
            .match_query(Matcher::Any)
            .with_status(200)
            .with_header("content-type", "application/json")
            .with_body(r#"{"body":{"content":[{"endIndex":20}]}}"#)
            .expect(1)
            .create_async()
            .await;
        let batch = server
            .mock("POST", "/v1/documents/DOC1:batchUpdate")
            .match_query(Matcher::Any)
            .with_status(200)
            .with_header("content-type", "application/json")
            .with_body(r#"{"documentId":"DOC1"}"#)
            .expect(1)
            .create_async()
            .await;

        // Existing doc, no stored fingerprint yet → first push full-replaces.
        let content = "#+GDOC_ID: DOC1\n* Intro\nHello world.\n";
        let first = push(&test_client(&server), content, "fallback", NOW)
            .await
            .expect("first push succeeds");
        assert!(first.body_updated);
        assert!(first.new_content.contains("(projection-hash "));

        // Re-push the rewritten file unchanged: the fingerprint matches, so the
        // body write (GET + batchUpdate) is skipped and comment anchors survive.
        let second = push(&test_client(&server), &first.new_content, "fallback", NOW)
            .await
            .expect("second push succeeds");
        assert!(!second.body_updated);

        get.assert_async().await; // exactly once — first push only
        batch.assert_async().await;
    }

    #[tokio::test]
    async fn push_posts_pending_replies_then_records_them_for_idempotence() {
        let mut server = Server::new_async().await;
        let _create = mock_create(&mut server, "DOC1").await;
        let _batch = mock_batch(&mut server, "DOC1").await;
        let _get = server
            .mock("GET", "/v1/documents/DOC1")
            .match_query(Matcher::Any)
            .with_status(200)
            .with_header("content-type", "application/json")
            .with_body(r#"{"body":{"content":[{"endIndex":10}]}}"#)
            .create_async()
            .await;
        // The reply post is expected exactly once across two pushes (idempotent).
        let reply = server
            .mock("POST", "/files/DOC1/comments/C1/replies")
            .match_query(Matcher::Any)
            .match_body(Matcher::PartialJsonString(
                r#"{"content":"Clarified."}"#.to_owned(),
            ))
            .with_status(200)
            .with_header("content-type", "application/json")
            .with_body(r#"{"id":"R1"}"#)
            .expect(1)
            .create_async()
            .await;

        let content = "#+GDOC_ID: DOC1\n* Intro\nHi.\n\n\
            * GDOC_METADATA :noexport:\n** Sync State\n#+begin_src emacs-lisp\n(gdoc-sync-state 1 (positions) (collaborators))\n#+end_src\n\
            ** Active Comments\n*** TODO Alice: please clarify\n:PROPERTIES:\n:COMMENT_ID: C1\n:END:\n**** REPLY\nClarified.\n";

        let first = push(&test_client(&server), content, "fallback", NOW)
            .await
            .expect("first push succeeds");
        assert_eq!(first.replied, 1);
        // The posted reply is recorded in the regenerated sync state.
        assert!(
            first
                .new_content
                .contains("(posted-replies (reply \"C1\" \"Clarified.\")")
        );

        // A second push over the rewritten file must NOT re-post (mock expects 1).
        let second = push(&test_client(&server), &first.new_content, "fallback", NOW)
            .await
            .expect("second push succeeds");
        assert_eq!(second.replied, 0);
        reply.assert_async().await;
    }
}