tftio-org-gdocs 0.1.1

Sync org-mode documents to Google Docs and pull reviewer comments back into org-mode
Documentation
//! P4a — Google Docs API operations over the `google_docs1` hub.
//!
//! Three calls back the push flow: create a blank doc, read its end index, and
//! apply a batch of [`Request`]s (built by [`crate::project`]). Every read/write
//! uses the first/legacy tab representation (`include_tabs_content(false)`, DI-6);
//! a document exposing multiple tabs is reported as [`Error::MultipleTabs`] rather
//! than silently mis-indexed.
//!
//! Hub errors are mapped to the crate's typed [`Error`] at the F6 seam
//! ([`crate::google::client::map_api_error`]); nothing here panics (EI-2).

use google_docs1::api::{
    BatchUpdateDocumentRequest, DeleteContentRangeRequest, Document, Range, Request, Scope,
};

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

/// A reference to a Google Doc: its id and human-openable URL.
///
/// Parsed from the foreign [`Document`] at the API boundary (EI-3) so callers
/// deal in this domain type, not the generated struct.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DocumentRef {
    /// The document id (the `{id}` in its URL).
    pub id: String,
    /// The browser URL for the document.
    pub url: String,
}

impl DocumentRef {
    /// Build a reference from a document id, deriving the canonical edit URL.
    #[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 {
    /// Create a blank document titled `title` and return its [`DocumentRef`].
    ///
    /// # Errors
    ///
    /// Returns [`Error::Google`] on API failure, or when the response omits a
    /// document id.
    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))
    }

    /// Fetch the document's end index (UTF-16 code units), operating on the
    /// first/legacy tab (DI-6).
    ///
    /// # Errors
    ///
    /// Returns [`Error::Google`] on API failure, or [`Error::MultipleTabs`] when
    /// the document exposes more than one tab.
    pub async fn document_end_index(&self, document_id: &str) -> Result<u32> {
        let (_, document) = self
            .docs
            .documents()
            .get(document_id)
            // DI-6: the legacy/first-tab representation; no `tabId` fan-out.
            .include_tabs_content(false)
            // The generated client defaults this read to the `documents.readonly`
            // scope, which our least-privilege token (D4) does not hold; request
            // the full `documents` scope we *do* hold so the cached token is reused
            // instead of triggering a fresh OAuth prompt.
            .add_scope(Scope::Document)
            .doit()
            .await
            .map_err(map_api_error)?;
        ensure_single_tab(&document)?;
        Ok(document_end_index(&document))
    }

    /// Apply `requests` to the document in a single `batchUpdate`.
    ///
    /// An empty request list is a no-op (the API rejects empty batches).
    ///
    /// # Errors
    ///
    /// Returns [`Error::Google`] on API failure.
    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(())
    }
}

/// Build a request that clears the existing document body for full-replace (DI-9).
///
/// Deletes the range `[1, end_index - 1)` — everything but the trailing newline
/// the API requires. Returns `None` when the body is already empty.
#[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()
    })
}

/// The document's end index: the largest structural-element end index in the
/// body, or 1 (start-of-body) for an empty document.
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)
}

/// Reject a document exposing more than one tab (DI-6).
///
/// Under `include_tabs_content(false)` the `tabs` field is normally empty (the
/// body is the first tab), so this is a best-effort guard that fires only when
/// the API still reports multiple tabs. Index math always uses the legacy body,
/// so a single tab is never mis-indexed regardless.
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};

    /// A client authenticated with a static token (`String: GetToken`), pointed
    /// at the mock 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)
            // The outgoing body carries our typed request, serialized to wire JSON.
            .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;
        // No mock registered: a network call would fail, proving none is made.
        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(_))));
    }
}