Skip to main content

org_gdocs/google/
drive.rs

1//! P4b — Drive comment operations over the `google_drive3` hub.
2//!
3//! Two calls back the pull/resolve flow: list a document's comments and resolve a
4//! comment via `comments.update {resolved:true}`. Per DI-4 this module **never**
5//! creates a comment, and batch resolution isolates failures so one bad call never
6//! aborts the rest.
7//!
8//! The generated `Comment`/`Reply`/`User` types are mapped into this crate's own
9//! domain models at the boundary (EI-3), so the pull logic (Q1) depends on
10//! [`Comment`] here, not the foreign struct. The `anchor` is carried as an opaque
11//! [`Anchor`] — never assumed well-formed (DI-5); Q1 parses it best-effort.
12
13use 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
18/// Partial-response field mask requesting exactly the comment data we consume.
19const COMMENT_FIELDS: &str = "nextPageToken,comments(id,anchor,author(displayName,emailAddress),content,createdTime,resolved,quotedFileContent(value),replies(author(displayName,emailAddress),content,createdTime))";
20
21/// An opaque comment anchor blob as returned by Google.
22///
23/// The Docs comment `anchor` is an undocumented, brittle structure (D2); this
24/// layer treats it as opaque text and never assumes it parses (DI-5). Q1 decodes
25/// it best-effort, falling back to `quoted_text`.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct Anchor(pub String);
28
29/// A comment or reply author.
30#[derive(Debug, Clone, PartialEq, Eq, Default)]
31pub struct Author {
32    /// The author's display name, if Google reported one.
33    pub display_name: Option<String>,
34    /// The author's email address, if Google reported one.
35    pub email: Option<String>,
36}
37
38/// A reply to a comment.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct Reply {
41    /// Who wrote the reply.
42    pub author: Author,
43    /// The reply text.
44    pub content: String,
45    /// When it was created (RFC 3339), if known.
46    pub created_time: Option<String>,
47}
48
49/// A reviewer comment on the document, mapped from the Drive API.
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct Comment {
52    /// The Drive comment id (stable key; merge anchor in the org file).
53    pub id: String,
54    /// Who wrote the comment.
55    pub author: Author,
56    /// The comment text.
57    pub content: String,
58    /// When it was created (RFC 3339), if known.
59    pub created_time: Option<String>,
60    /// Whether Google considers the comment resolved.
61    pub resolved: bool,
62    /// The opaque anchor blob, if present (DI-5).
63    pub anchor: Option<Anchor>,
64    /// The quoted document text the comment refers to, used as the anchoring
65    /// fallback (D2) when the anchor is unusable.
66    pub quoted_text: Option<String>,
67    /// Replies, in Google's order.
68    pub replies: Vec<Reply>,
69}
70
71impl GoogleClient {
72    /// List every comment on the document, following pagination, mapped to the
73    /// domain [`Comment`] model. Comments lacking an id are dropped (they cannot
74    /// be merged or resolved).
75    ///
76    /// # Errors
77    ///
78    /// Returns [`crate::error::Error::Google`] on API failure.
79    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                // The generated client defaults this to `drive.meet.readonly`,
88                // which our least-privilege token (D4) does not hold; request the
89                // `drive.file` scope we do hold so the cached token is reused.
90                .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    /// Resolve a single comment via `comments.update {resolved:true}` (DI-4).
107    ///
108    /// # Errors
109    ///
110    /// Returns [`crate::error::Error::Google`] on API failure.
111    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            // As in `list_comments`: override the generated default scope with the
120            // `drive.file` scope our token holds (DI-4 resolve path).
121            .add_scope(Scope::File)
122            .param("fields", "id,resolved")
123            .doit()
124            .await
125            .map_err(map_api_error)?;
126        Ok(())
127    }
128
129    /// Resolve many comments, isolating failures so one failed call never aborts
130    /// the rest (DI-4). Returns each comment id paired with its own outcome.
131    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    /// Post a reply to an existing comment via `replies.create`, returning the new
145    /// reply's id.
146    ///
147    /// Unlike anchored top-level comments (which the API cannot create with a
148    /// usable anchor — D1, hence DI-4's "never create comments"), a *reply* carries
149    /// no anchor: it inherits its parent comment's. Creating replies is therefore
150    /// both supported by the API and outside DI-4's prohibition.
151    ///
152    /// # Errors
153    ///
154    /// Returns [`crate::error::Error::Google`] on API failure, or when the response
155    /// omits a reply id.
156    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            // As elsewhere: override the generated default scope with the
171            // `drive.file` scope our token holds.
172            .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
183/// Map a foreign [`ApiComment`] into the domain [`Comment`], or `None` when it has
184/// no id.
185fn 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
204/// Map a foreign [`ApiReply`] into the domain [`Reply`].
205fn 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
213/// Map a foreign [`User`] into the domain [`Author`].
214fn 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        // Registered first with `Any`: mockito serves it once (until its hit count
297        // is satisfied), then falls through to the specific page-two mock below.
298        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}