Skip to main content

org_gdocs/google/
docs.rs

1//! P4a — Google Docs API operations over the `google_docs1` hub.
2//!
3//! Three calls back the push flow: create a blank doc, read its end index, and
4//! apply a batch of [`Request`]s (built by [`crate::project`]). Every read/write
5//! uses the first/legacy tab representation (`include_tabs_content(false)`, DI-6);
6//! a document exposing multiple tabs is reported as [`Error::MultipleTabs`] rather
7//! than silently mis-indexed.
8//!
9//! Hub errors are mapped to the crate's typed [`Error`] at the F6 seam
10//! ([`crate::google::client::map_api_error`]); nothing here panics (EI-2).
11
12use 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/// A reference to a Google Doc: its id and human-openable URL.
20///
21/// Parsed from the foreign [`Document`] at the API boundary (EI-3) so callers
22/// deal in this domain type, not the generated struct.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct DocumentRef {
25    /// The document id (the `{id}` in its URL).
26    pub id: String,
27    /// The browser URL for the document.
28    pub url: String,
29}
30
31impl DocumentRef {
32    /// Build a reference from a document id, deriving the canonical edit URL.
33    #[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    /// Create a blank document titled `title` and return its [`DocumentRef`].
42    ///
43    /// # Errors
44    ///
45    /// Returns [`Error::Google`] on API failure, or when the response omits a
46    /// document id.
47    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    /// Fetch the document's end index (UTF-16 code units), operating on the
66    /// first/legacy tab (DI-6).
67    ///
68    /// # Errors
69    ///
70    /// Returns [`Error::Google`] on API failure, or [`Error::MultipleTabs`] when
71    /// the document exposes more than one tab.
72    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            // DI-6: the legacy/first-tab representation; no `tabId` fan-out.
78            .include_tabs_content(false)
79            // The generated client defaults this read to the `documents.readonly`
80            // scope, which our least-privilege token (D4) does not hold; request
81            // the full `documents` scope we *do* hold so the cached token is reused
82            // instead of triggering a fresh OAuth prompt.
83            .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    /// Apply `requests` to the document in a single `batchUpdate`.
92    ///
93    /// An empty request list is a no-op (the API rejects empty batches).
94    ///
95    /// # Errors
96    ///
97    /// Returns [`Error::Google`] on API failure.
98    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/// Build a request that clears the existing document body for full-replace (DI-9).
117///
118/// Deletes the range `[1, end_index - 1)` — everything but the trailing newline
119/// the API requires. Returns `None` when the body is already empty.
120#[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
139/// The document's end index: the largest structural-element end index in the
140/// body, or 1 (start-of-body) for an empty document.
141fn 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
156/// Reject a document exposing more than one tab (DI-6).
157///
158/// Under `include_tabs_content(false)` the `tabs` field is normally empty (the
159/// body is the first tab), so this is a best-effort guard that fires only when
160/// the API still reports multiple tabs. Index math always uses the legacy body,
161/// so a single tab is never mis-indexed regardless.
162fn 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    /// A client authenticated with a static token (`String: GetToken`), pointed
178    /// at the mock server.
179    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            // The outgoing body carries our typed request, serialized to wire JSON.
271            .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        // No mock registered: a network call would fail, proving none is made.
293        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}