use std::collections::HashSet;
use kb::parser::parse_document;
use crate::comments::{Location, Resolver};
use crate::comments_meta;
use crate::custom_id::ensure_section_ids;
use crate::envelope;
use crate::error::{Error, Result};
use crate::google::client::GoogleClient;
use crate::google::drive::Comment;
use crate::orgfile;
use crate::sexp::Sexp;
use crate::syncstate::{Collaborator, SyncState};
const METADATA_HEADING: &str = "* GDOC_METADATA :noexport:";
#[derive(Debug, Clone)]
pub struct PullOutcome {
pub document_id: String,
pub new_comment_ids: Vec<String>,
pub total_comments: usize,
pub new_content: String,
}
pub async fn pull(client: &GoogleClient, content: &str, now: &str) -> Result<PullOutcome> {
let document_id = orgfile::read_keyword(content, "GDOC_ID").ok_or(Error::NotLinked)?;
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 existing = SyncState::parse_block(machine)?;
let comments = client.list_comments(&document_id).await?;
let total_comments = comments.len();
let sections: Vec<Option<String>> = {
let resolver = Resolver::new(&existing.positions, &document);
comments.iter().map(|c| section_id(&resolver, c)).collect()
};
let known: HashSet<String> = comments_meta::parse_entries(machine)
.into_iter()
.map(|entry| entry.id)
.collect();
let fresh: Vec<(&Comment, Option<&str>)> = comments
.iter()
.zip(§ions)
.filter(|(comment, _)| !comment.resolved && !known.contains(&comment.id))
.map(|(comment, section)| (comment, section.as_deref()))
.collect();
let new_comment_ids: Vec<String> = fresh.iter().map(|(c, _)| c.id.clone()).collect();
let new_content = write_back(&body_with_ids, machine, existing, &comments, &fresh, now);
Ok(PullOutcome {
document_id,
new_comment_ids,
total_comments,
new_content,
})
}
fn write_back(
body_with_ids: &str,
machine: &str,
existing: SyncState,
all_comments: &[Comment],
fresh: &[(&Comment, Option<&str>)],
now: &str,
) -> String {
let sync = SyncState {
positions: existing.positions,
collaborators: merge_collaborators(existing.collaborators, all_comments),
posted_replies: existing.posted_replies,
projection_hash: existing.projection_hash,
};
let active = comments_meta::render_section(machine, fresh);
let region = format!("{METADATA_HEADING}\n{}{active}", sync.render_block());
let with_keyword = orgfile::upsert_keyword(body_with_ids, "GDOC_LAST_PULL", now);
orgfile::replace_metadata(&with_keyword, ®ion)
}
fn section_id(resolver: &Resolver, comment: &Comment) -> Option<String> {
match resolver.resolve(comment.anchor.as_ref(), comment.quoted_text.as_deref()) {
Location::Section(id) => Some(id),
Location::Document => None,
}
}
#[must_use]
pub fn envelope(outcome: &PullOutcome) -> Sexp {
let new_ids = outcome
.new_comment_ids
.iter()
.map(|id| Sexp::string(id.clone()))
.collect();
envelope::ok(
"pull",
vec![
("document-id", Sexp::string(outcome.document_id.clone())),
(
"new",
Sexp::int(i64::try_from(outcome.new_comment_ids.len()).unwrap_or(i64::MAX)),
),
(
"total",
Sexp::int(i64::try_from(outcome.total_comments).unwrap_or(i64::MAX)),
),
("new-ids", Sexp::list(new_ids)),
],
)
}
fn merge_collaborators(existing: Vec<Collaborator>, comments: &[Comment]) -> Vec<Collaborator> {
let mut seen: HashSet<String> = existing.iter().map(|c| c.email.clone()).collect();
let mut merged = existing;
for author in comments
.iter()
.flat_map(|c| std::iter::once(&c.author).chain(c.replies.iter().map(|r| &r.author)))
{
if let (Some(email), Some(name)) = (&author.email, &author.display_name)
&& seen.insert(email.clone())
{
merged.push(Collaborator {
email: email.clone(),
display_name: name.clone(),
});
}
}
merged
}
#[cfg(test)]
mod tests {
use super::pull;
use crate::error::Error;
use crate::google::client::GoogleClient;
use crate::orgfile;
use mockito::{Matcher, Server};
const NOW: &str = "2026-06-11T00:00:00+00:00";
const LINKED: &str = "#+GDOC_ID: DOC1\n#+TITLE: Spec\n\
* Introduction\n:PROPERTIES:\n:CUSTOM_ID: sec-intro\n:END:\nThe quick brown fox.\n\n\
* GDOC_METADATA :noexport:\n** Sync State\n#+begin_src emacs-lisp\n\
(gdoc-sync-state 1 (positions (pos \"sec-intro\" 1 heading)) (collaborators))\n\
#+end_src\n** Active Comments\n";
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_comments(server: &mut Server, body: &str) -> mockito::Mock {
server
.mock("GET", "/files/DOC1/comments")
.match_query(Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(body)
.create_async()
.await
}
const ONE_COMMENT: &str = r#"{"comments":[
{"id":"C1",
"author":{"displayName":"Alice","emailAddress":"alice@example.com"},
"content":"Please clarify.","createdTime":"2026-06-01T12:00:00Z","resolved":false,
"quotedFileContent":{"value":"quick brown fox"}}
]}"#;
#[tokio::test]
async fn pull_merges_new_comment_under_resolved_section() {
let mut server = Server::new_async().await;
let mock = mock_comments(&mut server, ONE_COMMENT).await;
let outcome = pull(&test_client(&server), LINKED, NOW)
.await
.expect("pull succeeds");
assert_eq!(outcome.document_id, "DOC1");
assert_eq!(outcome.new_comment_ids, vec!["C1".to_owned()]);
assert_eq!(outcome.total_comments, 1);
let written = &outcome.new_content;
assert!(written.contains(":COMMENT_ID: C1\n"));
assert!(written.contains(":COMMENT_SECTION: sec-intro\n"));
assert!(written.contains("*** TODO Alice: Please clarify.\n"));
assert!(written.contains("(collab \"alice@example.com\" \"Alice\")"));
assert!(written.contains(&format!("#+GDOC_LAST_PULL: {NOW}\n")));
assert!(written.contains("(pos \"sec-intro\" 1 heading)"));
mock.assert_async().await;
}
#[tokio::test]
async fn pull_preserves_body_prose_byte_for_byte() {
let mut server = Server::new_async().await;
let _mock = mock_comments(&mut server, ONE_COMMENT).await;
let outcome = pull(&test_client(&server), LINKED, NOW)
.await
.expect("pull succeeds");
let expected = {
let body = orgfile::body(LINKED);
orgfile::upsert_keyword(body, "GDOC_LAST_PULL", NOW)
};
let pulled_body = orgfile::body(&outcome.new_content);
assert_eq!(
pulled_body.trim_end_matches('\n'),
expected.trim_end_matches('\n')
);
}
#[tokio::test]
async fn re_pull_with_no_new_comments_is_a_noop() {
let mut server = Server::new_async().await;
let _mock = mock_comments(&mut server, ONE_COMMENT).await;
let client = test_client(&server);
let once = pull(&client, LINKED, NOW).await.expect("first pull");
let twice = pull(&client, &once.new_content, NOW)
.await
.expect("second pull");
assert!(twice.new_comment_ids.is_empty());
assert_eq!(twice.new_content, once.new_content);
}
#[tokio::test]
async fn pull_skips_comments_already_resolved_in_google() {
let mut server = Server::new_async().await;
let resolved = r#"{"comments":[
{"id":"CR","author":{"displayName":"Alice","emailAddress":"alice@example.com"},
"content":"done already","resolved":true,
"quotedFileContent":{"value":"quick brown fox"}}
]}"#;
let _mock = mock_comments(&mut server, resolved).await;
let outcome = pull(&test_client(&server), LINKED, NOW)
.await
.expect("pull succeeds");
assert!(outcome.new_comment_ids.is_empty());
assert!(!outcome.new_content.contains(":COMMENT_ID: CR\n"));
}
#[tokio::test]
async fn pull_without_a_linked_doc_is_not_linked_error() {
let server = Server::new_async().await;
let content = "* Intro\nNo GDOC_ID here.\n";
let result = pull(&test_client(&server), content, NOW).await;
assert!(matches!(result, Err(Error::NotLinked)));
}
}