use google_docs1::api::{
BatchUpdateDocumentRequest, DeleteContentRangeRequest, Document, Range, Request, Scope,
};
use crate::error::{Error, Result};
use crate::google::client::{GoogleClient, map_api_error};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DocumentRef {
pub id: String,
pub url: String,
}
impl DocumentRef {
#[must_use]
pub fn from_id(id: String) -> Self {
let url = format!("https://docs.google.com/document/d/{id}/edit");
Self { id, url }
}
}
impl GoogleClient {
pub async fn create_document(&self, title: &str) -> Result<DocumentRef> {
let request = Document {
title: Some(title.to_owned()),
..Document::default()
};
let (_, created) = self
.docs
.documents()
.create(request)
.doit()
.await
.map_err(map_api_error)?;
let id = created
.document_id
.ok_or_else(|| Error::Google("create returned no document id".to_owned()))?;
Ok(DocumentRef::from_id(id))
}
pub async fn document_end_index(&self, document_id: &str) -> Result<u32> {
let (_, document) = self
.docs
.documents()
.get(document_id)
.include_tabs_content(false)
.add_scope(Scope::Document)
.doit()
.await
.map_err(map_api_error)?;
ensure_single_tab(&document)?;
Ok(document_end_index(&document))
}
pub async fn batch_update(&self, document_id: &str, requests: Vec<Request>) -> Result<()> {
if requests.is_empty() {
return Ok(());
}
let request = BatchUpdateDocumentRequest {
requests: Some(requests),
..BatchUpdateDocumentRequest::default()
};
self.docs
.documents()
.batch_update(request, document_id)
.doit()
.await
.map_err(map_api_error)?;
Ok(())
}
}
#[must_use]
pub fn delete_body_request(end_index: u32) -> Option<Request> {
let delete_end = end_index.saturating_sub(1);
if delete_end <= 1 {
return None;
}
Some(Request {
delete_content_range: Some(DeleteContentRangeRequest {
range: Some(Range {
start_index: Some(1),
end_index: i32::try_from(delete_end).ok(),
segment_id: None,
tab_id: None,
}),
}),
..Request::default()
})
}
fn document_end_index(document: &Document) -> u32 {
let max = document
.body
.as_ref()
.and_then(|body| body.content.as_ref())
.map_or(1, |content| {
content
.iter()
.filter_map(|element| element.end_index)
.max()
.unwrap_or(1)
});
u32::try_from(max).unwrap_or(1)
}
fn ensure_single_tab(document: &Document) -> Result<()> {
match &document.tabs {
Some(tabs) if tabs.len() > 1 => Err(Error::MultipleTabs),
_ => Ok(()),
}
}
#[cfg(test)]
mod tests {
use super::DocumentRef;
use crate::error::Error;
use crate::google::client::GoogleClient;
use google_docs1::api::{InsertTextRequest, Location, Request};
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
}
fn insert_text(text: &str) -> Request {
Request {
insert_text: Some(InsertTextRequest {
location: Some(Location {
index: Some(1),
segment_id: None,
tab_id: None,
}),
text: Some(text.to_owned()),
end_of_segment_location: None,
}),
..Request::default()
}
}
#[tokio::test]
async fn create_document_returns_ref() {
let mut server = Server::new_async().await;
let mock = server
.mock("POST", "/v1/documents")
.match_query(Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"documentId":"DOC123","title":"Spec"}"#)
.create_async()
.await;
let doc = test_client(&server)
.create_document("Spec")
.await
.expect("create succeeds");
assert_eq!(
doc,
DocumentRef {
id: "DOC123".to_owned(),
url: "https://docs.google.com/document/d/DOC123/edit".to_owned(),
}
);
mock.assert_async().await;
}
#[tokio::test]
async fn document_end_index_takes_max_end_index() {
let mut server = Server::new_async().await;
let mock = server
.mock("GET", "/v1/documents/DOC123")
.match_query(Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"body":{"content":[{"endIndex":5},{"endIndex":42}]}}"#)
.create_async()
.await;
let end = test_client(&server)
.document_end_index("DOC123")
.await
.expect("get succeeds");
assert_eq!(end, 42);
mock.assert_async().await;
}
#[tokio::test]
async fn multi_tab_document_is_rejected() {
let mut server = Server::new_async().await;
server
.mock("GET", "/v1/documents/DOC123")
.match_query(Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"tabs":[{"tabProperties":{}},{"tabProperties":{}}]}"#)
.create_async()
.await;
let result = test_client(&server).document_end_index("DOC123").await;
assert!(matches!(result, Err(Error::MultipleTabs)));
}
#[tokio::test]
async fn batch_update_sends_typed_requests() {
let mut server = Server::new_async().await;
let mock = server
.mock("POST", "/v1/documents/DOC123:batchUpdate")
.match_query(Matcher::Any)
.match_body(Matcher::AllOf(vec![
Matcher::Regex("insertText".to_owned()),
Matcher::Regex("Hello".to_owned()),
]))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"documentId":"DOC123","replies":[{}]}"#)
.create_async()
.await;
test_client(&server)
.batch_update("DOC123", vec![insert_text("Hello")])
.await
.expect("batchUpdate succeeds");
mock.assert_async().await;
}
#[tokio::test]
async fn batch_update_with_no_requests_is_a_noop() {
let server = Server::new_async().await;
test_client(&server)
.batch_update("DOC123", vec![])
.await
.expect("empty batch is a no-op");
}
#[tokio::test]
async fn api_failure_maps_to_typed_error() {
let mut server = Server::new_async().await;
server
.mock("GET", "/v1/documents/DOC123")
.match_query(Matcher::Any)
.with_status(403)
.with_header("content-type", "application/json")
.with_body(r#"{"error":{"code":403,"message":"forbidden"}}"#)
.create_async()
.await;
let result = test_client(&server).document_end_index("DOC123").await;
assert!(matches!(result, Err(Error::Google(_))));
}
}