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};
const METADATA_HEADING: &str = "* GDOC_METADATA :noexport:";
#[derive(Debug, Clone)]
pub struct PushOutcome {
pub document: DocumentRef,
pub created: bool,
pub element_count: usize,
pub resolved: Vec<String>,
pub replied: usize,
pub body_updated: bool,
pub new_content: String,
}
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("");
let body_with_ids = ensure_section_ids(body);
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)?;
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)
};
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?;
}
let resolved = resolve_done(client, &document_ref.id, machine).await;
let (mut state, replied) = post_replies(client, &document_ref.id, machine, existing).await;
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,
})
}
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)
}
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)
}
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()
}
fn write_back(
body_with_ids: &str,
machine: &str,
document: &DocumentRef,
sync: &SyncState,
now: &str,
) -> String {
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, ®ion)
}
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)
}
#[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); 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");
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;
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;
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);
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";
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()]);
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;
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;
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 "));
let second = push(&test_client(&server), &first.new_content, "fallback", NOW)
.await
.expect("second push succeeds");
assert!(!second.body_updated);
get.assert_async().await; 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;
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);
assert!(
first
.new_content
.contains("(posted-replies (reply \"C1\" \"Clarified.\")")
);
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;
}
}