use anyhow::Result;
use serde::Serialize;
use crate::cli::drive::format::{write_items_jsonl, JsonlSerialize};
use crate::drive::docs::api::{DocsApi, SuggestionsViewMode};
use crate::drive::docs::structure::{flatten, flatten_content, DocElement};
use crate::drive::docs::types::ResolvedSegment;
#[derive(Debug, Clone)]
pub struct ReadOptions {
pub document_id: String,
pub tab: Option<String>,
pub suggestions: SuggestionsViewMode,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct SegmentContent {
pub segment_id: String,
pub elements: Vec<DocElement>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct TabContent {
#[serde(skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub named_ranges: std::collections::BTreeMap<String, super::types::NamedRanges>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tab_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
pub nesting_level: i64,
pub elements: Vec<DocElement>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub headers: Vec<SegmentContent>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub footers: Vec<SegmentContent>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub footnotes: Vec<SegmentContent>,
}
impl TabContent {
#[must_use]
pub fn segments_by_kind(&self) -> [(SegmentKind, &Vec<SegmentContent>); 3] {
[
(SegmentKind::Header, &self.headers),
(SegmentKind::Footer, &self.footers),
(SegmentKind::Footnote, &self.footnotes),
]
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ReadOutcome {
pub document_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub revision_id: Option<String>,
pub tabs: Vec<TabContent>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum SegmentKind {
Header,
Footer,
Footnote,
}
impl SegmentKind {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Header => "header",
Self::Footer => "footer",
Self::Footnote => "footnote",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub struct SegmentRef<'a> {
pub kind: SegmentKind,
pub segment_id: &'a str,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct FlatElement<'a> {
pub document_id: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
pub revision_id: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tab_id: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
pub segment: Option<SegmentRef<'a>>,
#[serde(flatten)]
pub element: &'a DocElement,
}
impl JsonlSerialize for ReadOutcome {
fn write_jsonl(&self, out: &mut dyn std::io::Write) -> Result<()> {
write_items_jsonl(self.flat_elements().iter(), out)
}
}
impl ReadOutcome {
#[must_use]
pub fn flat_elements(&self) -> Vec<FlatElement<'_>> {
let mut out = Vec::new();
for tab in &self.tabs {
for element in &tab.elements {
out.push(FlatElement {
document_id: &self.document_id,
revision_id: self.revision_id.as_deref(),
tab_id: tab.tab_id.as_deref(),
segment: None,
element,
});
}
for (kind, segments) in tab.segments_by_kind() {
for segment in segments {
for element in &segment.elements {
out.push(FlatElement {
document_id: &self.document_id,
revision_id: self.revision_id.as_deref(),
tab_id: tab.tab_id.as_deref(),
segment: Some(SegmentRef {
kind,
segment_id: &segment.segment_id,
}),
element,
});
}
}
}
}
out
}
}
fn segments_to_content(segments: &[ResolvedSegment<'_>]) -> Vec<SegmentContent> {
segments
.iter()
.map(|segment| SegmentContent {
segment_id: segment.segment_id.to_string(),
elements: flatten_content(segment.content),
})
.collect()
}
pub async fn read(api: &DocsApi<'_>, opts: &ReadOptions) -> Result<ReadOutcome> {
let document = api
.get_document(&opts.document_id, opts.suggestions)
.await?;
let resolved = document.resolved_tabs();
let mut tabs: Vec<TabContent> = resolved
.iter()
.map(|tab| TabContent {
named_ranges: tab
.named_ranges
.iter()
.map(|(name, ranges)| (name.clone(), ranges.clone()))
.collect(),
tab_id: tab.tab_id.map(ToString::to_string),
title: tab.title.map(ToString::to_string),
nesting_level: tab.nesting_level,
elements: tab.body.map(flatten).unwrap_or_default(),
headers: segments_to_content(&tab.headers()),
footers: segments_to_content(&tab.footers()),
footnotes: segments_to_content(&tab.footnotes()),
})
.collect();
if let Some(wanted) = &opts.tab {
let known: Vec<&str> = tabs.iter().filter_map(|t| t.tab_id.as_deref()).collect();
anyhow::ensure!(
known.contains(&wanted.as_str()),
"document '{}' has no tab '{wanted}'; it has {}",
opts.document_id,
if known.is_empty() {
"no tabs (it predates tabs, or was returned in the legacy single-body form)"
.to_string()
} else {
format!("tabs: {}", known.join(", "))
}
);
tabs.retain(|t| t.tab_id.as_deref() == Some(wanted.as_str()));
}
Ok(ReadOutcome {
document_id: opts.document_id.clone(),
title: document.title.clone(),
revision_id: document.revision_id.clone(),
tabs,
})
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
use crate::drive::client::DriveClient;
use crate::drive::docs::client::{DocsClient, DOCS_API_URL};
use crate::drive::docs::structure::ElementKind;
use crate::test_support::env::MapEnv;
use crate::utils::secret::Secret;
use wiremock::matchers::{method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn test_credentials() -> DriveCredentials {
DriveCredentials {
client_id: "client-1".to_string(),
client_secret: Secret::new("secret-1"),
refresh_token: Secret::new("refresh-1"),
scope: DriveGrantedScopes::READONLY,
}
}
async fn docs_client(server: &MockServer) -> DocsClient {
Mock::given(method("POST"))
.and(path("/token"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"access_token": "test-token",
"expires_in": 3600,
})))
.mount(server)
.await;
let mut drive = DriveClient::new(&server.uri(), &test_credentials()).unwrap();
crate::drive::client::test_support::replace_session(
&mut drive,
&test_credentials(),
&format!("{}/token", server.uri()),
);
let env = MapEnv::new().with(DOCS_API_URL, &server.uri());
DocsClient::from_drive_client_with(&env, &drive).unwrap()
}
fn opts(tab: Option<&str>) -> ReadOptions {
ReadOptions {
document_id: "d1".to_string(),
tab: tab.map(str::to_string),
suggestions: SuggestionsViewMode::default(),
}
}
fn paragraph(start: i64, end: i64, text: &str) -> serde_json::Value {
serde_json::json!({
"startIndex": start, "endIndex": end,
"paragraph": {
"elements": [{"textRun": {"content": text}}],
"paragraphStyle": {"namedStyleType": "NORMAL_TEXT"},
},
})
}
fn mount_document(body: serde_json::Value) -> Mock {
Mock::given(method("GET"))
.and(path("/v1/documents/d1"))
.respond_with(ResponseTemplate::new(200).set_body_json(body))
}
#[tokio::test]
async fn read_calls_documents_get_exactly_once() {
let server = MockServer::start().await;
let client = docs_client(&server).await;
mount_document(serde_json::json!({
"documentId": "d1", "title": "Doc", "revisionId": "rev-1",
"body": {"content": [paragraph(1, 10, "hello\n")]},
}))
.expect(1)
.mount(&server)
.await;
let outcome = read(&DocsApi::new(&client), &opts(None)).await.unwrap();
assert_eq!(outcome.tabs.len(), 1);
assert_eq!(outcome.tabs[0].elements[0].text, "hello");
}
#[tokio::test]
async fn read_carries_the_revision_id_through() {
let server = MockServer::start().await;
let client = docs_client(&server).await;
mount_document(serde_json::json!({
"documentId": "d1", "revisionId": "rev-abc",
"body": {"content": []},
}))
.mount(&server)
.await;
let outcome = read(&DocsApi::new(&client), &opts(None)).await.unwrap();
assert_eq!(outcome.revision_id.as_deref(), Some("rev-abc"));
}
#[tokio::test]
async fn read_without_edit_access_has_no_revision_id() {
let server = MockServer::start().await;
let client = docs_client(&server).await;
mount_document(serde_json::json!({"documentId": "d1", "body": {"content": []}}))
.mount(&server)
.await;
let outcome = read(&DocsApi::new(&client), &opts(None)).await.unwrap();
assert_eq!(outcome.revision_id, None);
}
#[tokio::test]
async fn read_of_a_tabbed_document_returns_every_tab_in_order() {
let server = MockServer::start().await;
let client = docs_client(&server).await;
mount_document(serde_json::json!({
"documentId": "d1", "revisionId": "rev-1",
"tabs": [
{
"tabProperties": {"tabId": "t.0", "title": "One", "nestingLevel": 0},
"documentTab": {"body": {"content": [paragraph(1, 5, "a\n")]}},
},
{
"tabProperties": {"tabId": "t.1", "title": "Two", "nestingLevel": 0},
"documentTab": {"body": {"content": [paragraph(1, 5, "b\n")]}},
},
],
}))
.mount(&server)
.await;
let outcome = read(&DocsApi::new(&client), &opts(None)).await.unwrap();
let ids: Vec<_> = outcome
.tabs
.iter()
.map(|t| t.tab_id.as_deref().unwrap())
.collect();
assert_eq!(ids, vec!["t.0", "t.1"]);
assert_eq!(outcome.tabs[1].elements[0].text, "b");
}
#[tokio::test]
async fn read_of_a_legacy_body_document_returns_one_anonymous_tab() {
let server = MockServer::start().await;
let client = docs_client(&server).await;
mount_document(serde_json::json!({
"documentId": "d1",
"body": {"content": [{"endIndex": 1, "sectionBreak": {}}]},
}))
.mount(&server)
.await;
let outcome = read(&DocsApi::new(&client), &opts(None)).await.unwrap();
assert_eq!(outcome.tabs.len(), 1);
assert_eq!(outcome.tabs[0].tab_id, None);
assert_eq!(outcome.tabs[0].elements[0].kind, ElementKind::SectionBreak);
}
#[tokio::test]
async fn read_filters_to_a_single_tab() {
let server = MockServer::start().await;
let client = docs_client(&server).await;
mount_document(serde_json::json!({
"documentId": "d1",
"tabs": [
{
"tabProperties": {"tabId": "t.0", "title": "One"},
"documentTab": {"body": {"content": [paragraph(1, 5, "a\n")]}},
},
{
"tabProperties": {"tabId": "t.1", "title": "Two"},
"documentTab": {"body": {"content": [paragraph(1, 5, "b\n")]}},
},
],
}))
.mount(&server)
.await;
let outcome = read(&DocsApi::new(&client), &opts(Some("t.1")))
.await
.unwrap();
assert_eq!(outcome.tabs.len(), 1);
assert_eq!(outcome.tabs[0].elements[0].text, "b");
}
#[tokio::test]
async fn read_with_an_unknown_tab_id_errors_and_names_the_real_ids() {
let server = MockServer::start().await;
let client = docs_client(&server).await;
mount_document(serde_json::json!({
"documentId": "d1",
"tabs": [
{"tabProperties": {"tabId": "t.0"}, "documentTab": {"body": {"content": []}}},
{"tabProperties": {"tabId": "t.1"}, "documentTab": {"body": {"content": []}}},
],
}))
.mount(&server)
.await;
let err = read(&DocsApi::new(&client), &opts(Some("nope")))
.await
.unwrap_err()
.to_string();
assert!(err.contains("no tab 'nope'"), "{err}");
assert!(err.contains("t.0"), "{err}");
assert!(err.contains("t.1"), "{err}");
}
#[tokio::test]
async fn read_with_a_tab_filter_on_a_legacy_document_explains_itself() {
let server = MockServer::start().await;
let client = docs_client(&server).await;
mount_document(serde_json::json!({"documentId": "d1", "body": {"content": []}}))
.mount(&server)
.await;
let err = read(&DocsApi::new(&client), &opts(Some("t.0")))
.await
.unwrap_err()
.to_string();
assert!(err.contains("no tabs"), "{err}");
}
#[tokio::test]
async fn read_surfaces_an_api_error_with_its_status_and_message() {
let server = MockServer::start().await;
let client = docs_client(&server).await;
Mock::given(method("GET"))
.and(path("/v1/documents/d1"))
.respond_with(ResponseTemplate::new(404).set_body_json(serde_json::json!({
"error": {"code": 404, "message": "Requested entity was not found.",
"status": "NOT_FOUND"},
})))
.mount(&server)
.await;
let err = read(&DocsApi::new(&client), &opts(None))
.await
.unwrap_err()
.to_string();
assert!(err.contains("Docs API request failed"), "{err}");
assert!(err.contains("404"), "{err}");
assert!(err.contains("Requested entity was not found."), "{err}");
}
#[tokio::test]
async fn read_honours_the_suggestions_view_mode() {
let server = MockServer::start().await;
let client = docs_client(&server).await;
Mock::given(method("GET"))
.and(path("/v1/documents/d1"))
.and(query_param(
"suggestionsViewMode",
"PREVIEW_SUGGESTIONS_ACCEPTED",
))
.and(query_param("includeTabsContent", "true"))
.respond_with(
ResponseTemplate::new(200).set_body_json(
serde_json::json!({"documentId": "d1", "body": {"content": []}}),
),
)
.expect(1)
.mount(&server)
.await;
let options = ReadOptions {
suggestions: SuggestionsViewMode::PreviewAccepted,
..opts(None)
};
read(&DocsApi::new(&client), &options).await.unwrap();
}
#[tokio::test]
async fn read_outcome_serialises_tabs_as_an_ordered_list() {
let server = MockServer::start().await;
let client = docs_client(&server).await;
mount_document(serde_json::json!({
"documentId": "d1", "title": "T", "revisionId": "rev-1",
"tabs": [
{"tabProperties": {"tabId": "t.0"}, "documentTab": {"body": {"content": []}}},
{"tabProperties": {"tabId": "t.1"}, "documentTab": {"body": {"content": []}}},
],
}))
.mount(&server)
.await;
let outcome = read(&DocsApi::new(&client), &opts(None)).await.unwrap();
let json = serde_json::to_value(&outcome).unwrap();
assert!(json["tabs"].is_array());
assert_eq!(json["tabs"][0]["tab_id"], "t.0");
assert_eq!(json["revision_id"], "rev-1");
}
#[tokio::test]
async fn read_outcome_omits_absent_optional_fields() {
let server = MockServer::start().await;
let client = docs_client(&server).await;
mount_document(serde_json::json!({"documentId": "d1", "body": {"content": []}}))
.mount(&server)
.await;
let outcome = read(&DocsApi::new(&client), &opts(None)).await.unwrap();
let json = serde_json::to_value(&outcome).unwrap();
assert!(json.get("title").is_none());
assert!(json.get("revision_id").is_none());
assert!(json["tabs"][0].get("tab_id").is_none());
}
async fn two_tab_outcome(server: &MockServer) -> ReadOutcome {
let client = docs_client(server).await;
mount_document(serde_json::json!({
"documentId": "d1",
"revisionId": "rev-1",
"tabs": [
{
"tabProperties": {"tabId": "t.0"},
"documentTab": {"body": {"content": [
paragraph(1, 5, "a\n"), paragraph(5, 9, "b\n")]}},
},
{
"tabProperties": {"tabId": "t.1"},
"documentTab": {"body": {"content": [paragraph(1, 5, "c\n")]}},
},
],
}))
.mount(server)
.await;
read(&DocsApi::new(&client), &opts(None)).await.unwrap()
}
fn render_jsonl(outcome: &ReadOutcome) -> String {
let mut buf = Vec::new();
outcome.write_jsonl(&mut buf).unwrap();
String::from_utf8(buf).unwrap()
}
#[tokio::test]
async fn jsonl_emits_one_line_per_element_across_every_tab() {
let server = MockServer::start().await;
let outcome = two_tab_outcome(&server).await;
let rendered = render_jsonl(&outcome);
let lines: Vec<&str> = rendered.lines().collect();
assert_eq!(lines.len(), 3);
let texts: Vec<String> = lines
.iter()
.map(|line| {
serde_json::from_str::<serde_json::Value>(line).unwrap()["text"].to_string()
})
.collect();
assert_eq!(texts, vec!["\"a\"", "\"b\"", "\"c\""]);
}
#[tokio::test]
async fn jsonl_denormalises_document_and_tab_identity_onto_every_line() {
let server = MockServer::start().await;
let outcome = two_tab_outcome(&server).await;
let rendered = render_jsonl(&outcome);
let lines: Vec<serde_json::Value> = rendered
.lines()
.map(|line| serde_json::from_str(line).unwrap())
.collect();
for line in &lines {
assert_eq!(line["document_id"], "d1");
assert_eq!(line["revision_id"], "rev-1");
}
assert_eq!(lines[0]["tab_id"], "t.0");
assert_eq!(lines[1]["tab_id"], "t.0");
assert_eq!(lines[2]["tab_id"], "t.1");
}
#[tokio::test]
async fn jsonl_flattens_element_fields_to_the_top_level() {
let server = MockServer::start().await;
let outcome = two_tab_outcome(&server).await;
let rendered = render_jsonl(&outcome);
let first: serde_json::Value =
serde_json::from_str(rendered.lines().next().unwrap()).unwrap();
assert_eq!(first["text"], "a");
assert_eq!(first["kind"], "paragraph");
assert_eq!(first["start_index"], 1);
assert_eq!(first["end_index"], 5);
assert!(first.get("element").is_none());
}
#[tokio::test]
async fn jsonl_omits_revision_and_tab_when_absent() {
let server = MockServer::start().await;
let client = docs_client(&server).await;
mount_document(serde_json::json!({
"documentId": "d1",
"body": {"content": [paragraph(1, 5, "a\n")]},
}))
.mount(&server)
.await;
let outcome = read(&DocsApi::new(&client), &opts(None)).await.unwrap();
let rendered = render_jsonl(&outcome);
let line: serde_json::Value =
serde_json::from_str(rendered.lines().next().unwrap()).unwrap();
assert_eq!(line["document_id"], "d1");
assert!(line.get("revision_id").is_none());
assert!(line.get("tab_id").is_none());
}
#[tokio::test]
async fn jsonl_emits_nothing_for_a_document_with_no_elements() {
let server = MockServer::start().await;
let client = docs_client(&server).await;
mount_document(serde_json::json!({"documentId": "d1", "body": {"content": []}}))
.mount(&server)
.await;
let outcome = read(&DocsApi::new(&client), &opts(None)).await.unwrap();
assert_eq!(render_jsonl(&outcome), "");
}
#[tokio::test]
async fn read_populates_headers_footers_and_footnotes() {
let server = MockServer::start().await;
let client = docs_client(&server).await;
mount_document(serde_json::json!({
"documentId": "d1",
"body": {"content": [paragraph(1, 5, "body\n")]},
"headers": {"h1": {"content": [paragraph(0, 8, "Header\n")]}},
"footers": {"f1": {"content": [paragraph(0, 8, "Footer\n")]}},
"footnotes": {"n1": {"content": [paragraph(0, 5, "Note\n")]}},
}))
.mount(&server)
.await;
let outcome = read(&DocsApi::new(&client), &opts(None)).await.unwrap();
let tab = &outcome.tabs[0];
assert_eq!(tab.headers.len(), 1);
assert_eq!(tab.headers[0].segment_id, "h1");
assert_eq!(tab.headers[0].elements[0].text, "Header");
assert_eq!(tab.footers[0].elements[0].text, "Footer");
assert_eq!(tab.footnotes[0].elements[0].text, "Note");
}
#[tokio::test]
async fn a_document_with_no_segments_serialises_without_the_new_fields() {
let server = MockServer::start().await;
let client = docs_client(&server).await;
mount_document(serde_json::json!({
"documentId": "d1",
"body": {"content": [paragraph(1, 5, "a\n")]},
}))
.mount(&server)
.await;
let outcome = read(&DocsApi::new(&client), &opts(None)).await.unwrap();
let json = serde_json::to_value(&outcome).unwrap();
let tab = &json["tabs"][0];
assert!(tab.get("headers").is_none());
assert!(tab.get("footers").is_none());
assert!(tab.get("footnotes").is_none());
}
#[tokio::test]
async fn jsonl_carries_segment_identity_for_segment_elements() {
let server = MockServer::start().await;
let client = docs_client(&server).await;
mount_document(serde_json::json!({
"documentId": "d1",
"body": {"content": [paragraph(1, 5, "body\n")]},
"footnotes": {"n1": {"content": [paragraph(0, 5, "Note\n")]}},
}))
.mount(&server)
.await;
let outcome = read(&DocsApi::new(&client), &opts(None)).await.unwrap();
let rendered = render_jsonl(&outcome);
let lines: Vec<serde_json::Value> = rendered
.lines()
.map(|line| serde_json::from_str(line).unwrap())
.collect();
assert_eq!(lines.len(), 2);
assert!(lines[0].get("segment").is_none());
assert_eq!(lines[1]["segment"]["kind"], "footnote");
assert_eq!(lines[1]["segment"]["segment_id"], "n1");
assert_eq!(lines[1]["text"], "Note");
}
}