use google_drive3::api::{Comment as ApiComment, Reply as ApiReply, Scope, User};
use crate::error::Result;
use crate::google::client::{GoogleClient, map_api_error};
const COMMENT_FIELDS: &str = "nextPageToken,comments(id,anchor,author(displayName,emailAddress),content,createdTime,resolved,quotedFileContent(value),replies(author(displayName,emailAddress),content,createdTime))";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Anchor(pub String);
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Author {
pub display_name: Option<String>,
pub email: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Reply {
pub author: Author,
pub content: String,
pub created_time: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Comment {
pub id: String,
pub author: Author,
pub content: String,
pub created_time: Option<String>,
pub resolved: bool,
pub anchor: Option<Anchor>,
pub quoted_text: Option<String>,
pub replies: Vec<Reply>,
}
impl GoogleClient {
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)
.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),
}
}
}
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)
.add_scope(Scope::File)
.param("fields", "id,resolved")
.doit()
.await
.map_err(map_api_error)?;
Ok(())
}
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
}
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)
.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()))
}
}
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(),
})
}
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()),
}
}
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;
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());
}
}