1use 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
43const METADATA_HEADING: &str = "* GDOC_METADATA :noexport:";
45
46#[derive(Debug, Clone)]
48pub struct PullOutcome {
49 pub document_id: String,
51 pub new_comment_ids: Vec<String>,
53 pub total_comments: usize,
55 pub new_content: String,
57}
58
59pub 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 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 let existing = SyncState::parse_block(machine)?;
83
84 let comments = client.list_comments(&document_id).await?;
86 let total_comments = comments.len();
87
88 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 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(§ions)
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
118fn 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_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, ®ion)
142}
143
144fn 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#[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
177fn 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 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 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 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 assert!(written.contains("(collab \"alice@example.com\" \"Alice\")"));
261 assert!(written.contains(&format!("#+GDOC_LAST_PULL: {NOW}\n")));
262 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 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 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}