tftio-org-gdocs 0.1.3

Sync org-mode documents to Google Docs and pull reviewer comments back into org-mode
Documentation
//! P4b — Drive comment operations over the `google_drive3` hub.
//!
//! Two calls back the pull/resolve flow: list a document's comments and resolve a
//! comment via `comments.update {resolved:true}`. Per DI-4 this module **never**
//! creates a comment, and batch resolution isolates failures so one bad call never
//! aborts the rest.
//!
//! The generated `Comment`/`Reply`/`User` types are mapped into this crate's own
//! domain models at the boundary (EI-3), so the pull logic (Q1) depends on
//! [`Comment`] here, not the foreign struct. The `anchor` is carried as an opaque
//! [`Anchor`] — never assumed well-formed (DI-5); Q1 parses it best-effort.

use google_drive3::api::{Comment as ApiComment, Reply as ApiReply, Scope, User};

use crate::error::Result;
use crate::google::client::{GoogleClient, map_api_error};

/// Partial-response field mask requesting exactly the comment data we consume.
const COMMENT_FIELDS: &str = "nextPageToken,comments(id,anchor,author(displayName,emailAddress),content,createdTime,resolved,quotedFileContent(value),replies(author(displayName,emailAddress),content,createdTime))";

/// An opaque comment anchor blob as returned by Google.
///
/// The Docs comment `anchor` is an undocumented, brittle structure (D2); this
/// layer treats it as opaque text and never assumes it parses (DI-5). Q1 decodes
/// it best-effort, falling back to `quoted_text`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Anchor(pub String);

/// A comment or reply author.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Author {
    /// The author's display name, if Google reported one.
    pub display_name: Option<String>,
    /// The author's email address, if Google reported one.
    pub email: Option<String>,
}

/// A reply to a comment.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Reply {
    /// Who wrote the reply.
    pub author: Author,
    /// The reply text.
    pub content: String,
    /// When it was created (RFC 3339), if known.
    pub created_time: Option<String>,
}

/// A reviewer comment on the document, mapped from the Drive API.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Comment {
    /// The Drive comment id (stable key; merge anchor in the org file).
    pub id: String,
    /// Who wrote the comment.
    pub author: Author,
    /// The comment text.
    pub content: String,
    /// When it was created (RFC 3339), if known.
    pub created_time: Option<String>,
    /// Whether Google considers the comment resolved.
    pub resolved: bool,
    /// The opaque anchor blob, if present (DI-5).
    pub anchor: Option<Anchor>,
    /// The quoted document text the comment refers to, used as the anchoring
    /// fallback (D2) when the anchor is unusable.
    pub quoted_text: Option<String>,
    /// Replies, in Google's order.
    pub replies: Vec<Reply>,
}

impl GoogleClient {
    /// List every comment on the document, following pagination, mapped to the
    /// domain [`Comment`] model. Comments lacking an id are dropped (they cannot
    /// be merged or resolved).
    ///
    /// # Errors
    ///
    /// Returns [`crate::error::Error::Google`] on API failure.
    pub async fn list_comments(&self, file_id: &str) -> Result<Vec<Comment>> {
        let mut comments = Vec::new();
        let mut page_token: Option<String> = None;
        loop {
            let mut call = self
                .drive
                .comments()
                .list(file_id)
                // The generated client defaults this to `drive.meet.readonly`,
                // which our least-privilege token (D4) does not hold; request the
                // `drive.file` scope we do hold so the cached token is reused.
                .add_scope(Scope::File)
                .param("fields", COMMENT_FIELDS);
            if let Some(token) = &page_token {
                call = call.page_token(token);
            }
            let (_, list) = call.doit().await.map_err(map_api_error)?;
            if let Some(batch) = list.comments {
                comments.extend(batch.into_iter().filter_map(map_comment));
            }
            match list.next_page_token {
                Some(token) => page_token = Some(token),
                None => return Ok(comments),
            }
        }
    }

    /// Resolve a single comment via `comments.update {resolved:true}` (DI-4).
    ///
    /// # Errors
    ///
    /// Returns [`crate::error::Error::Google`] on API failure.
    pub async fn resolve_comment(&self, file_id: &str, comment_id: &str) -> Result<()> {
        let request = ApiComment {
            resolved: Some(true),
            ..ApiComment::default()
        };
        self.drive
            .comments()
            .update(request, file_id, comment_id)
            // As in `list_comments`: override the generated default scope with the
            // `drive.file` scope our token holds (DI-4 resolve path).
            .add_scope(Scope::File)
            .param("fields", "id,resolved")
            .doit()
            .await
            .map_err(map_api_error)?;
        Ok(())
    }

    /// Resolve many comments, isolating failures so one failed call never aborts
    /// the rest (DI-4). Returns each comment id paired with its own outcome.
    pub async fn resolve_comments(
        &self,
        file_id: &str,
        comment_ids: &[String],
    ) -> Vec<(String, Result<()>)> {
        let mut outcomes = Vec::with_capacity(comment_ids.len());
        for comment_id in comment_ids {
            let outcome = self.resolve_comment(file_id, comment_id).await;
            outcomes.push((comment_id.clone(), outcome));
        }
        outcomes
    }

    /// Post a reply to an existing comment via `replies.create`, returning the new
    /// reply's id.
    ///
    /// Unlike anchored top-level comments (which the API cannot create with a
    /// usable anchor — D1, hence DI-4's "never create comments"), a *reply* carries
    /// no anchor: it inherits its parent comment's. Creating replies is therefore
    /// both supported by the API and outside DI-4's prohibition.
    ///
    /// # Errors
    ///
    /// Returns [`crate::error::Error::Google`] on API failure, or when the response
    /// omits a reply id.
    pub async fn create_reply(
        &self,
        file_id: &str,
        comment_id: &str,
        content: &str,
    ) -> Result<String> {
        let request = ApiReply {
            content: Some(content.to_owned()),
            ..ApiReply::default()
        };
        let (_, reply) = self
            .drive
            .replies()
            .create(request, file_id, comment_id)
            // As elsewhere: override the generated default scope with the
            // `drive.file` scope our token holds.
            .add_scope(Scope::File)
            .param("fields", "id")
            .doit()
            .await
            .map_err(map_api_error)?;
        reply
            .id
            .ok_or_else(|| crate::error::Error::Google("reply create returned no id".to_owned()))
    }
}

/// Map a foreign [`ApiComment`] into the domain [`Comment`], or `None` when it has
/// no id.
fn map_comment(api: ApiComment) -> Option<Comment> {
    let id = api.id?;
    Some(Comment {
        id,
        author: map_author(api.author),
        content: api.content.unwrap_or_default(),
        created_time: api.created_time.map(|time| time.to_rfc3339()),
        resolved: api.resolved.unwrap_or(false),
        anchor: api.anchor.map(Anchor),
        quoted_text: api.quoted_file_content.and_then(|quoted| quoted.value),
        replies: api
            .replies
            .unwrap_or_default()
            .into_iter()
            .map(map_reply)
            .collect(),
    })
}

/// Map a foreign [`ApiReply`] into the domain [`Reply`].
fn map_reply(api: ApiReply) -> Reply {
    Reply {
        author: map_author(api.author),
        content: api.content.unwrap_or_default(),
        created_time: api.created_time.map(|time| time.to_rfc3339()),
    }
}

/// Map a foreign [`User`] into the domain [`Author`].
fn map_author(user: Option<User>) -> Author {
    user.map_or_else(Author::default, |user| Author {
        display_name: user.display_name,
        email: user.email_address,
    })
}

#[cfg(test)]
mod tests {
    use super::{Anchor, Author, Comment, Reply};
    use crate::google::client::GoogleClient;
    use mockito::{Matcher, Server};

    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
    }

    const RICH_PAYLOAD: &str = r#"{
      "comments": [
        {
          "id": "C1",
          "anchor": "kix.anchor-blob",
          "author": {"displayName": "Alice", "emailAddress": "alice@example.com"},
          "content": "Please clarify this.",
          "createdTime": "2026-06-01T12:00:00Z",
          "resolved": false,
          "quotedFileContent": {"value": "the projected sentence"},
          "replies": [
            {"author": {"displayName": "Bob", "emailAddress": "bob@example.com"}, "content": "Agreed", "createdTime": "2026-06-02T09:30:00Z"}
          ]
        }
      ]
    }"#;

    #[tokio::test]
    async fn list_comments_maps_a_realistic_payload() {
        let mut server = Server::new_async().await;
        let mock = server
            .mock("GET", "/files/DOC123/comments")
            .match_query(Matcher::Any)
            .with_status(200)
            .with_header("content-type", "application/json")
            .with_body(RICH_PAYLOAD)
            .create_async()
            .await;

        let comments = test_client(&server)
            .list_comments("DOC123")
            .await
            .expect("list succeeds");

        assert_eq!(
            comments,
            vec![Comment {
                id: "C1".to_owned(),
                author: Author {
                    display_name: Some("Alice".to_owned()),
                    email: Some("alice@example.com".to_owned()),
                },
                content: "Please clarify this.".to_owned(),
                created_time: Some("2026-06-01T12:00:00+00:00".to_owned()),
                resolved: false,
                anchor: Some(Anchor("kix.anchor-blob".to_owned())),
                quoted_text: Some("the projected sentence".to_owned()),
                replies: vec![Reply {
                    author: Author {
                        display_name: Some("Bob".to_owned()),
                        email: Some("bob@example.com".to_owned()),
                    },
                    content: "Agreed".to_owned(),
                    created_time: Some("2026-06-02T09:30:00+00:00".to_owned()),
                }],
            }]
        );
        mock.assert_async().await;
    }

    #[tokio::test]
    async fn list_comments_follows_pagination_and_drops_idless() {
        let mut server = Server::new_async().await;
        // Registered first with `Any`: mockito serves it once (until its hit count
        // is satisfied), then falls through to the specific page-two mock below.
        let page_one = server
            .mock("GET", "/files/DOC123/comments")
            .match_query(Matcher::Any)
            .with_status(200)
            .with_header("content-type", "application/json")
            .with_body(r#"{"nextPageToken":"PAGE2","comments":[{"id":"C1","content":"first"},{"content":"no id, dropped"}]}"#)
            .create_async()
            .await;
        let page_two = server
            .mock("GET", "/files/DOC123/comments")
            .match_query(Matcher::UrlEncoded("pageToken".into(), "PAGE2".into()))
            .with_status(200)
            .with_header("content-type", "application/json")
            .with_body(r#"{"comments":[{"id":"C2","content":"second"}]}"#)
            .create_async()
            .await;

        let comments = test_client(&server)
            .list_comments("DOC123")
            .await
            .expect("list succeeds");

        let ids: Vec<&str> = comments.iter().map(|comment| comment.id.as_str()).collect();
        assert_eq!(ids, vec!["C1", "C2"]);
        page_one.assert_async().await;
        page_two.assert_async().await;
    }

    #[tokio::test]
    async fn resolve_comment_patches_resolved_true() {
        let mut server = Server::new_async().await;
        let mock = server
            .mock("PATCH", "/files/DOC123/comments/C1")
            .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":"C1","resolved":true}"#)
            .create_async()
            .await;

        test_client(&server)
            .resolve_comment("DOC123", "C1")
            .await
            .expect("resolve succeeds");
        mock.assert_async().await;
    }

    #[tokio::test]
    async fn create_reply_posts_content_and_returns_id() {
        let mut server = Server::new_async().await;
        let mock = server
            .mock("POST", "/files/DOC123/comments/C1/replies")
            .match_query(Matcher::Any)
            .match_body(Matcher::PartialJsonString(
                r#"{"content":"Fixed, thanks."}"#.to_owned(),
            ))
            .with_status(200)
            .with_header("content-type", "application/json")
            .with_body(r#"{"id":"R1"}"#)
            .create_async()
            .await;

        let id = test_client(&server)
            .create_reply("DOC123", "C1", "Fixed, thanks.")
            .await
            .expect("reply posts");
        assert_eq!(id, "R1");
        mock.assert_async().await;
    }

    #[tokio::test]
    async fn resolve_comments_isolates_failures() {
        let mut server = Server::new_async().await;
        server
            .mock("PATCH", "/files/DOC123/comments/OK")
            .match_query(Matcher::Any)
            .with_status(200)
            .with_header("content-type", "application/json")
            .with_body(r#"{"id":"OK","resolved":true}"#)
            .create_async()
            .await;
        server
            .mock("PATCH", "/files/DOC123/comments/BAD")
            .match_query(Matcher::Any)
            .with_status(404)
            .with_header("content-type", "application/json")
            .with_body(r#"{"error":{"code":404,"message":"not found"}}"#)
            .create_async()
            .await;

        let outcomes = test_client(&server)
            .resolve_comments("DOC123", &["OK".to_owned(), "BAD".to_owned()])
            .await;

        assert_eq!(outcomes.len(), 2);
        assert_eq!(outcomes[0].0, "OK");
        assert!(outcomes[0].1.is_ok());
        assert_eq!(outcomes[1].0, "BAD");
        assert!(outcomes[1].1.is_err());
    }
}