1use google_docs1::api::{
13 BatchUpdateDocumentRequest, DeleteContentRangeRequest, Document, Range, Request, Scope,
14};
15
16use crate::error::{Error, Result};
17use crate::google::client::{GoogleClient, map_api_error};
18
19#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct DocumentRef {
25 pub id: String,
27 pub url: String,
29}
30
31impl DocumentRef {
32 #[must_use]
34 pub fn from_id(id: String) -> Self {
35 let url = format!("https://docs.google.com/document/d/{id}/edit");
36 Self { id, url }
37 }
38}
39
40impl GoogleClient {
41 pub async fn create_document(&self, title: &str) -> Result<DocumentRef> {
48 let request = Document {
49 title: Some(title.to_owned()),
50 ..Document::default()
51 };
52 let (_, created) = self
53 .docs
54 .documents()
55 .create(request)
56 .doit()
57 .await
58 .map_err(map_api_error)?;
59 let id = created
60 .document_id
61 .ok_or_else(|| Error::Google("create returned no document id".to_owned()))?;
62 Ok(DocumentRef::from_id(id))
63 }
64
65 pub async fn document_end_index(&self, document_id: &str) -> Result<u32> {
73 let (_, document) = self
74 .docs
75 .documents()
76 .get(document_id)
77 .include_tabs_content(false)
79 .add_scope(Scope::Document)
84 .doit()
85 .await
86 .map_err(map_api_error)?;
87 ensure_single_tab(&document)?;
88 Ok(document_end_index(&document))
89 }
90
91 pub async fn batch_update(&self, document_id: &str, requests: Vec<Request>) -> Result<()> {
99 if requests.is_empty() {
100 return Ok(());
101 }
102 let request = BatchUpdateDocumentRequest {
103 requests: Some(requests),
104 ..BatchUpdateDocumentRequest::default()
105 };
106 self.docs
107 .documents()
108 .batch_update(request, document_id)
109 .doit()
110 .await
111 .map_err(map_api_error)?;
112 Ok(())
113 }
114}
115
116#[must_use]
121pub fn delete_body_request(end_index: u32) -> Option<Request> {
122 let delete_end = end_index.saturating_sub(1);
123 if delete_end <= 1 {
124 return None;
125 }
126 Some(Request {
127 delete_content_range: Some(DeleteContentRangeRequest {
128 range: Some(Range {
129 start_index: Some(1),
130 end_index: i32::try_from(delete_end).ok(),
131 segment_id: None,
132 tab_id: None,
133 }),
134 }),
135 ..Request::default()
136 })
137}
138
139fn document_end_index(document: &Document) -> u32 {
142 let max = document
143 .body
144 .as_ref()
145 .and_then(|body| body.content.as_ref())
146 .map_or(1, |content| {
147 content
148 .iter()
149 .filter_map(|element| element.end_index)
150 .max()
151 .unwrap_or(1)
152 });
153 u32::try_from(max).unwrap_or(1)
154}
155
156fn ensure_single_tab(document: &Document) -> Result<()> {
163 match &document.tabs {
164 Some(tabs) if tabs.len() > 1 => Err(Error::MultipleTabs),
165 _ => Ok(()),
166 }
167}
168
169#[cfg(test)]
170mod tests {
171 use super::DocumentRef;
172 use crate::error::Error;
173 use crate::google::client::GoogleClient;
174 use google_docs1::api::{InsertTextRequest, Location, Request};
175 use mockito::{Matcher, Server};
176
177 fn test_client(server: &Server) -> GoogleClient {
180 let mut client = GoogleClient::new("test-token".to_owned()).expect("client builds");
181 client.set_base_url(&server.url());
182 client
183 }
184
185 fn insert_text(text: &str) -> Request {
186 Request {
187 insert_text: Some(InsertTextRequest {
188 location: Some(Location {
189 index: Some(1),
190 segment_id: None,
191 tab_id: None,
192 }),
193 text: Some(text.to_owned()),
194 end_of_segment_location: None,
195 }),
196 ..Request::default()
197 }
198 }
199
200 #[tokio::test]
201 async fn create_document_returns_ref() {
202 let mut server = Server::new_async().await;
203 let mock = server
204 .mock("POST", "/v1/documents")
205 .match_query(Matcher::Any)
206 .with_status(200)
207 .with_header("content-type", "application/json")
208 .with_body(r#"{"documentId":"DOC123","title":"Spec"}"#)
209 .create_async()
210 .await;
211
212 let doc = test_client(&server)
213 .create_document("Spec")
214 .await
215 .expect("create succeeds");
216
217 assert_eq!(
218 doc,
219 DocumentRef {
220 id: "DOC123".to_owned(),
221 url: "https://docs.google.com/document/d/DOC123/edit".to_owned(),
222 }
223 );
224 mock.assert_async().await;
225 }
226
227 #[tokio::test]
228 async fn document_end_index_takes_max_end_index() {
229 let mut server = Server::new_async().await;
230 let mock = server
231 .mock("GET", "/v1/documents/DOC123")
232 .match_query(Matcher::Any)
233 .with_status(200)
234 .with_header("content-type", "application/json")
235 .with_body(r#"{"body":{"content":[{"endIndex":5},{"endIndex":42}]}}"#)
236 .create_async()
237 .await;
238
239 let end = test_client(&server)
240 .document_end_index("DOC123")
241 .await
242 .expect("get succeeds");
243
244 assert_eq!(end, 42);
245 mock.assert_async().await;
246 }
247
248 #[tokio::test]
249 async fn multi_tab_document_is_rejected() {
250 let mut server = Server::new_async().await;
251 server
252 .mock("GET", "/v1/documents/DOC123")
253 .match_query(Matcher::Any)
254 .with_status(200)
255 .with_header("content-type", "application/json")
256 .with_body(r#"{"tabs":[{"tabProperties":{}},{"tabProperties":{}}]}"#)
257 .create_async()
258 .await;
259
260 let result = test_client(&server).document_end_index("DOC123").await;
261 assert!(matches!(result, Err(Error::MultipleTabs)));
262 }
263
264 #[tokio::test]
265 async fn batch_update_sends_typed_requests() {
266 let mut server = Server::new_async().await;
267 let mock = server
268 .mock("POST", "/v1/documents/DOC123:batchUpdate")
269 .match_query(Matcher::Any)
270 .match_body(Matcher::AllOf(vec![
272 Matcher::Regex("insertText".to_owned()),
273 Matcher::Regex("Hello".to_owned()),
274 ]))
275 .with_status(200)
276 .with_header("content-type", "application/json")
277 .with_body(r#"{"documentId":"DOC123","replies":[{}]}"#)
278 .create_async()
279 .await;
280
281 test_client(&server)
282 .batch_update("DOC123", vec![insert_text("Hello")])
283 .await
284 .expect("batchUpdate succeeds");
285
286 mock.assert_async().await;
287 }
288
289 #[tokio::test]
290 async fn batch_update_with_no_requests_is_a_noop() {
291 let server = Server::new_async().await;
292 test_client(&server)
294 .batch_update("DOC123", vec![])
295 .await
296 .expect("empty batch is a no-op");
297 }
298
299 #[tokio::test]
300 async fn api_failure_maps_to_typed_error() {
301 let mut server = Server::new_async().await;
302 server
303 .mock("GET", "/v1/documents/DOC123")
304 .match_query(Matcher::Any)
305 .with_status(403)
306 .with_header("content-type", "application/json")
307 .with_body(r#"{"error":{"code":403,"message":"forbidden"}}"#)
308 .create_async()
309 .await;
310
311 let result = test_client(&server).document_end_index("DOC123").await;
312 assert!(matches!(result, Err(Error::Google(_))));
313 }
314}