1use google_drive3::api::{Comment as ApiComment, Reply as ApiReply, Scope, User};
14
15use crate::error::Result;
16use crate::google::client::{GoogleClient, map_api_error};
17
18const COMMENT_FIELDS: &str = "nextPageToken,comments(id,anchor,author(displayName,emailAddress),content,createdTime,resolved,quotedFileContent(value),replies(author(displayName,emailAddress),content,createdTime))";
20
21#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct Anchor(pub String);
28
29#[derive(Debug, Clone, PartialEq, Eq, Default)]
31pub struct Author {
32 pub display_name: Option<String>,
34 pub email: Option<String>,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct Reply {
41 pub author: Author,
43 pub content: String,
45 pub created_time: Option<String>,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct Comment {
52 pub id: String,
54 pub author: Author,
56 pub content: String,
58 pub created_time: Option<String>,
60 pub resolved: bool,
62 pub anchor: Option<Anchor>,
64 pub quoted_text: Option<String>,
67 pub replies: Vec<Reply>,
69}
70
71impl GoogleClient {
72 pub async fn list_comments(&self, file_id: &str) -> Result<Vec<Comment>> {
80 let mut comments = Vec::new();
81 let mut page_token: Option<String> = None;
82 loop {
83 let mut call = self
84 .drive
85 .comments()
86 .list(file_id)
87 .add_scope(Scope::File)
91 .param("fields", COMMENT_FIELDS);
92 if let Some(token) = &page_token {
93 call = call.page_token(token);
94 }
95 let (_, list) = call.doit().await.map_err(map_api_error)?;
96 if let Some(batch) = list.comments {
97 comments.extend(batch.into_iter().filter_map(map_comment));
98 }
99 match list.next_page_token {
100 Some(token) => page_token = Some(token),
101 None => return Ok(comments),
102 }
103 }
104 }
105
106 pub async fn resolve_comment(&self, file_id: &str, comment_id: &str) -> Result<()> {
112 let request = ApiComment {
113 resolved: Some(true),
114 ..ApiComment::default()
115 };
116 self.drive
117 .comments()
118 .update(request, file_id, comment_id)
119 .add_scope(Scope::File)
122 .param("fields", "id,resolved")
123 .doit()
124 .await
125 .map_err(map_api_error)?;
126 Ok(())
127 }
128
129 pub async fn resolve_comments(
132 &self,
133 file_id: &str,
134 comment_ids: &[String],
135 ) -> Vec<(String, Result<()>)> {
136 let mut outcomes = Vec::with_capacity(comment_ids.len());
137 for comment_id in comment_ids {
138 let outcome = self.resolve_comment(file_id, comment_id).await;
139 outcomes.push((comment_id.clone(), outcome));
140 }
141 outcomes
142 }
143
144 pub async fn create_reply(
157 &self,
158 file_id: &str,
159 comment_id: &str,
160 content: &str,
161 ) -> Result<String> {
162 let request = ApiReply {
163 content: Some(content.to_owned()),
164 ..ApiReply::default()
165 };
166 let (_, reply) = self
167 .drive
168 .replies()
169 .create(request, file_id, comment_id)
170 .add_scope(Scope::File)
173 .param("fields", "id")
174 .doit()
175 .await
176 .map_err(map_api_error)?;
177 reply
178 .id
179 .ok_or_else(|| crate::error::Error::Google("reply create returned no id".to_owned()))
180 }
181}
182
183fn map_comment(api: ApiComment) -> Option<Comment> {
186 let id = api.id?;
187 Some(Comment {
188 id,
189 author: map_author(api.author),
190 content: api.content.unwrap_or_default(),
191 created_time: api.created_time.map(|time| time.to_rfc3339()),
192 resolved: api.resolved.unwrap_or(false),
193 anchor: api.anchor.map(Anchor),
194 quoted_text: api.quoted_file_content.and_then(|quoted| quoted.value),
195 replies: api
196 .replies
197 .unwrap_or_default()
198 .into_iter()
199 .map(map_reply)
200 .collect(),
201 })
202}
203
204fn map_reply(api: ApiReply) -> Reply {
206 Reply {
207 author: map_author(api.author),
208 content: api.content.unwrap_or_default(),
209 created_time: api.created_time.map(|time| time.to_rfc3339()),
210 }
211}
212
213fn map_author(user: Option<User>) -> Author {
215 user.map_or_else(Author::default, |user| Author {
216 display_name: user.display_name,
217 email: user.email_address,
218 })
219}
220
221#[cfg(test)]
222mod tests {
223 use super::{Anchor, Author, Comment, Reply};
224 use crate::google::client::GoogleClient;
225 use mockito::{Matcher, Server};
226
227 fn test_client(server: &Server) -> GoogleClient {
228 let mut client = GoogleClient::new("test-token".to_owned()).expect("client builds");
229 client.set_base_url(&server.url());
230 client
231 }
232
233 const RICH_PAYLOAD: &str = r#"{
234 "comments": [
235 {
236 "id": "C1",
237 "anchor": "kix.anchor-blob",
238 "author": {"displayName": "Alice", "emailAddress": "alice@example.com"},
239 "content": "Please clarify this.",
240 "createdTime": "2026-06-01T12:00:00Z",
241 "resolved": false,
242 "quotedFileContent": {"value": "the projected sentence"},
243 "replies": [
244 {"author": {"displayName": "Bob", "emailAddress": "bob@example.com"}, "content": "Agreed", "createdTime": "2026-06-02T09:30:00Z"}
245 ]
246 }
247 ]
248 }"#;
249
250 #[tokio::test]
251 async fn list_comments_maps_a_realistic_payload() {
252 let mut server = Server::new_async().await;
253 let mock = server
254 .mock("GET", "/files/DOC123/comments")
255 .match_query(Matcher::Any)
256 .with_status(200)
257 .with_header("content-type", "application/json")
258 .with_body(RICH_PAYLOAD)
259 .create_async()
260 .await;
261
262 let comments = test_client(&server)
263 .list_comments("DOC123")
264 .await
265 .expect("list succeeds");
266
267 assert_eq!(
268 comments,
269 vec![Comment {
270 id: "C1".to_owned(),
271 author: Author {
272 display_name: Some("Alice".to_owned()),
273 email: Some("alice@example.com".to_owned()),
274 },
275 content: "Please clarify this.".to_owned(),
276 created_time: Some("2026-06-01T12:00:00+00:00".to_owned()),
277 resolved: false,
278 anchor: Some(Anchor("kix.anchor-blob".to_owned())),
279 quoted_text: Some("the projected sentence".to_owned()),
280 replies: vec![Reply {
281 author: Author {
282 display_name: Some("Bob".to_owned()),
283 email: Some("bob@example.com".to_owned()),
284 },
285 content: "Agreed".to_owned(),
286 created_time: Some("2026-06-02T09:30:00+00:00".to_owned()),
287 }],
288 }]
289 );
290 mock.assert_async().await;
291 }
292
293 #[tokio::test]
294 async fn list_comments_follows_pagination_and_drops_idless() {
295 let mut server = Server::new_async().await;
296 let page_one = server
299 .mock("GET", "/files/DOC123/comments")
300 .match_query(Matcher::Any)
301 .with_status(200)
302 .with_header("content-type", "application/json")
303 .with_body(r#"{"nextPageToken":"PAGE2","comments":[{"id":"C1","content":"first"},{"content":"no id, dropped"}]}"#)
304 .create_async()
305 .await;
306 let page_two = server
307 .mock("GET", "/files/DOC123/comments")
308 .match_query(Matcher::UrlEncoded("pageToken".into(), "PAGE2".into()))
309 .with_status(200)
310 .with_header("content-type", "application/json")
311 .with_body(r#"{"comments":[{"id":"C2","content":"second"}]}"#)
312 .create_async()
313 .await;
314
315 let comments = test_client(&server)
316 .list_comments("DOC123")
317 .await
318 .expect("list succeeds");
319
320 let ids: Vec<&str> = comments.iter().map(|comment| comment.id.as_str()).collect();
321 assert_eq!(ids, vec!["C1", "C2"]);
322 page_one.assert_async().await;
323 page_two.assert_async().await;
324 }
325
326 #[tokio::test]
327 async fn resolve_comment_patches_resolved_true() {
328 let mut server = Server::new_async().await;
329 let mock = server
330 .mock("PATCH", "/files/DOC123/comments/C1")
331 .match_query(Matcher::Any)
332 .match_body(Matcher::PartialJsonString(
333 r#"{"resolved":true}"#.to_owned(),
334 ))
335 .with_status(200)
336 .with_header("content-type", "application/json")
337 .with_body(r#"{"id":"C1","resolved":true}"#)
338 .create_async()
339 .await;
340
341 test_client(&server)
342 .resolve_comment("DOC123", "C1")
343 .await
344 .expect("resolve succeeds");
345 mock.assert_async().await;
346 }
347
348 #[tokio::test]
349 async fn create_reply_posts_content_and_returns_id() {
350 let mut server = Server::new_async().await;
351 let mock = server
352 .mock("POST", "/files/DOC123/comments/C1/replies")
353 .match_query(Matcher::Any)
354 .match_body(Matcher::PartialJsonString(
355 r#"{"content":"Fixed, thanks."}"#.to_owned(),
356 ))
357 .with_status(200)
358 .with_header("content-type", "application/json")
359 .with_body(r#"{"id":"R1"}"#)
360 .create_async()
361 .await;
362
363 let id = test_client(&server)
364 .create_reply("DOC123", "C1", "Fixed, thanks.")
365 .await
366 .expect("reply posts");
367 assert_eq!(id, "R1");
368 mock.assert_async().await;
369 }
370
371 #[tokio::test]
372 async fn resolve_comments_isolates_failures() {
373 let mut server = Server::new_async().await;
374 server
375 .mock("PATCH", "/files/DOC123/comments/OK")
376 .match_query(Matcher::Any)
377 .with_status(200)
378 .with_header("content-type", "application/json")
379 .with_body(r#"{"id":"OK","resolved":true}"#)
380 .create_async()
381 .await;
382 server
383 .mock("PATCH", "/files/DOC123/comments/BAD")
384 .match_query(Matcher::Any)
385 .with_status(404)
386 .with_header("content-type", "application/json")
387 .with_body(r#"{"error":{"code":404,"message":"not found"}}"#)
388 .create_async()
389 .await;
390
391 let outcomes = test_client(&server)
392 .resolve_comments("DOC123", &["OK".to_owned(), "BAD".to_owned()])
393 .await;
394
395 assert_eq!(outcomes.len(), 2);
396 assert_eq!(outcomes[0].0, "OK");
397 assert!(outcomes[0].1.is_ok());
398 assert_eq!(outcomes[1].0, "BAD");
399 assert!(outcomes[1].1.is_err());
400 }
401}