use anyhow::{Context, Result};
use clap::Parser;
use serde::Serialize;
use crate::cli::drive::format::{
output_as, sanitize_for_terminal, write_scalar_jsonl, JsonlSerialize, OutputFormat,
};
use crate::drive::client::DriveClient;
use crate::drive::docs::api::DocsApi;
use crate::drive::docs::client::DocsClient;
use crate::drive::docs::structure::{outline, TabOutline};
use crate::drive::docs::target;
use crate::drive::docs::types::Document;
use crate::drive::files_api::FilesApi;
#[derive(Parser)]
pub struct InfoCommand {
pub document_id: String,
#[arg(short = 'o', long, value_enum, default_value_t = OutputFormat::Table)]
pub output: OutputFormat,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct InfoOutcome {
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 named_ranges: Vec<NamedRangeSummary>,
pub tabs: Vec<TabOutline>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct NamedRangeSummary {
pub name: String,
pub ranges: usize,
}
impl JsonlSerialize for InfoOutcome {
fn write_jsonl(&self, out: &mut dyn std::io::Write) -> Result<()> {
write_scalar_jsonl(self, out)
}
}
impl InfoOutcome {
#[must_use]
pub fn of(document_id: &str, document: &Document) -> Self {
let mut named_ranges: Vec<NamedRangeSummary> = document
.named_ranges
.iter()
.map(|(name, group)| NamedRangeSummary {
name: name.clone(),
ranges: group.named_ranges.len(),
})
.collect();
named_ranges.sort_by(|a, b| a.name.cmp(&b.name));
Self {
document_id: document_id.to_string(),
title: document.title.clone(),
revision_id: document.revision_id.clone(),
named_ranges,
tabs: document.resolved_tabs().iter().map(outline).collect(),
}
}
}
impl InfoCommand {
pub async fn execute(self, client: &DriveClient) -> Result<()> {
let docs = DocsClient::from_drive_client(client)?;
run_info(client, &docs, &self.document_id, &self.output).await
}
}
async fn run_info(
drive: &DriveClient,
docs: &DocsClient,
document_id: &str,
output: &OutputFormat,
) -> Result<()> {
let document = match DocsApi::new(docs)
.get_document(
document_id,
crate::drive::docs::api::SuggestionsViewMode::default(),
)
.await
{
Ok(document) => document,
Err(err) => {
return Err(
target::explain_failure(&FilesApi::new(drive), document_id, "info", err).await,
)
}
};
let outcome = InfoOutcome::of(document_id, &document);
if output_as(&outcome, output)? {
return Ok(());
}
let stdout = std::io::stdout();
let mut handle = stdout.lock();
render_info_table(&outcome, &mut handle)
}
fn render_info_table(outcome: &InfoOutcome, out: &mut dyn std::io::Write) -> Result<()> {
let ctx = "Failed to write docs info";
writeln!(out, "Id: {}", sanitize_for_terminal(&outcome.document_id)).context(ctx)?;
writeln!(
out,
"Title: {}",
sanitize_for_terminal(outcome.title.as_deref().unwrap_or(""))
)
.context(ctx)?;
match &outcome.revision_id {
Some(revision) => {
writeln!(out, "Revision: {}", sanitize_for_terminal(revision)).context(ctx)?;
}
None => writeln!(out, "Revision: (none — read-only access)").context(ctx)?,
}
writeln!(out, "Tabs: {}", outcome.tabs.len()).context(ctx)?;
if !outcome.named_ranges.is_empty() {
writeln!(out, "Named ranges: {}", outcome.named_ranges.len()).context(ctx)?;
for range in &outcome.named_ranges {
writeln!(
out,
" {} ({} range(s))",
sanitize_for_terminal(&range.name),
range.ranges
)
.context(ctx)?;
}
}
for tab in &outcome.tabs {
writeln!(out).context(ctx)?;
let label = match (&tab.tab_id, &tab.title) {
(Some(id), Some(title)) => format!(
"Tab: {} \"{}\"",
sanitize_for_terminal(id),
sanitize_for_terminal(title)
),
(Some(id), None) => format!("Tab: {}", sanitize_for_terminal(id)),
_ => "Tab: (single body)".to_string(),
};
let mut counts = vec![
count(tab.characters, "char"),
count(tab.paragraphs, "paragraph"),
];
if tab.tables > 0 {
counts.push(count(tab.tables, "table"));
}
if tab.section_breaks > 0 {
counts.push(count(tab.section_breaks, "section break"));
}
if tab.tables_of_contents > 0 {
counts.push(count(tab.tables_of_contents, "TOC"));
}
writeln!(out, "{label} — {}", counts.join(", ")).context(ctx)?;
for heading in &tab.headings {
writeln!(
out,
" {:<10} [{}..{}) {}",
heading.style,
heading.start_index,
heading.end_index,
sanitize_for_terminal(&heading.text)
)
.context(ctx)?;
}
}
Ok(())
}
fn count(n: impl std::fmt::Display, noun: &str) -> String {
let n = n.to_string();
let suffix = if n == "1" { "" } else { "s" };
format!("{n} {noun}{suffix}")
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
fn document(json: serde_json::Value) -> Document {
serde_json::from_value(json).unwrap()
}
fn paragraph(start: i64, end: i64, text: &str, style: &str) -> serde_json::Value {
serde_json::json!({
"startIndex": start, "endIndex": end,
"paragraph": {
"elements": [{"textRun": {"content": text}}],
"paragraphStyle": {"namedStyleType": style},
},
})
}
fn render(outcome: &InfoOutcome) -> String {
let mut buf = Vec::new();
render_info_table(outcome, &mut buf).unwrap();
String::from_utf8(buf).unwrap()
}
#[test]
fn reports_id_title_and_revision() {
let doc = document(serde_json::json!({
"documentId": "d1", "title": "Design Doc", "revisionId": "rev-abc",
"body": {"content": []},
}));
let text = render(&InfoOutcome::of("d1", &doc));
assert!(text.contains("Id: d1"), "{text}");
assert!(text.contains("Title: Design Doc"), "{text}");
assert!(text.contains("Revision: rev-abc"), "{text}");
}
#[test]
fn a_missing_revision_id_explains_itself() {
let doc = document(serde_json::json!({"documentId": "d1", "body": {"content": []}}));
let text = render(&InfoOutcome::of("d1", &doc));
assert!(
text.contains("Revision: (none — read-only access)"),
"{text}"
);
}
#[test]
fn lists_every_tab_with_its_counts() {
let doc = document(serde_json::json!({
"documentId": "d1", "title": "T",
"tabs": [
{
"tabProperties": {"tabId": "t.0", "title": "Overview"},
"documentTab": {"body": {"content": [
paragraph(1, 10, "Overview\n", "HEADING_1"),
paragraph(10, 30, "prose\n", "NORMAL_TEXT"),
]}},
},
{
"tabProperties": {"tabId": "t.1", "title": "Appendix"},
"documentTab": {"body": {"content": [paragraph(1, 12, "x\n", "NORMAL_TEXT")]}},
},
],
}));
let text = render(&InfoOutcome::of("d1", &doc));
assert!(text.contains("Tabs: 2"), "{text}");
assert!(text.contains("Tab: t.0 \"Overview\""), "{text}");
assert!(text.contains("2 paragraphs"), "{text}");
assert!(text.contains("30 chars"), "{text}");
assert!(text.contains("Tab: t.1 \"Appendix\""), "{text}");
}
#[test]
fn tab_counts_are_singular_only_for_exactly_one() {
assert_eq!(count(1_usize, "section break"), "1 section break");
assert_eq!(count(0_usize, "table"), "0 tables");
assert_eq!(count(2_i64, "char"), "2 chars");
let doc = document(serde_json::json!({
"documentId": "d1",
"body": {"content": [paragraph(1, 2, "\n", "NORMAL_TEXT")]},
}));
let text = render(&InfoOutcome::of("d1", &doc));
assert!(
text.contains("1 paragraph,") || text.ends_with("1 paragraph\n"),
"{text}"
);
assert!(!text.contains("1 paragraphs"), "{text}");
}
#[test]
fn lists_headings_with_their_index_ranges() {
let doc = document(serde_json::json!({
"documentId": "d1",
"body": {"content": [
paragraph(1, 10, "Overview\n", "HEADING_1"),
paragraph(10, 20, "prose\n", "NORMAL_TEXT"),
paragraph(20, 30, "Goals\n", "HEADING_2"),
]},
}));
let text = render(&InfoOutcome::of("d1", &doc));
assert!(text.contains("HEADING_1 [1..10) Overview"), "{text}");
assert!(text.contains("HEADING_2 [20..30) Goals"), "{text}");
assert!(!text.contains("prose"), "{text}");
}
#[test]
fn lists_named_ranges_sorted_by_name() {
let doc = document(serde_json::json!({
"documentId": "d1", "body": {"content": []},
"namedRanges": {
"zeta": {"name": "zeta", "namedRanges": [{"name": "zeta", "ranges": []}]},
"alpha": {"name": "alpha", "namedRanges": [
{"name": "alpha", "ranges": []}, {"name": "alpha", "ranges": []}]},
},
}));
let outcome = InfoOutcome::of("d1", &doc);
assert_eq!(
outcome
.named_ranges
.iter()
.map(|r| r.name.as_str())
.collect::<Vec<_>>(),
vec!["alpha", "zeta"]
);
let text = render(&outcome);
assert!(text.contains("Named ranges: 2"), "{text}");
assert!(text.contains("alpha (2 range(s))"), "{text}");
}
#[test]
fn omits_the_named_ranges_block_when_there_are_none() {
let doc = document(serde_json::json!({"documentId": "d1", "body": {"content": []}}));
assert!(!render(&InfoOutcome::of("d1", &doc)).contains("Named ranges"));
}
#[test]
fn strips_control_bytes_from_server_strings() {
let doc = document(serde_json::json!({
"documentId": "d1", "title": "T\u{1b}[31mitle",
"body": {"content": [paragraph(1, 10, "He\nad\n", "HEADING_1")]},
}));
let text = render(&InfoOutcome::of("d1", &doc));
assert!(!text.contains('\u{1b}'), "{text}");
assert!(text.contains("Title: T[31mitle"), "{text}");
}
#[test]
fn handles_a_document_with_no_body_and_no_tabs() {
let doc = document(serde_json::json!({"documentId": "d1", "title": "Empty"}));
let text = render(&InfoOutcome::of("d1", &doc));
assert!(text.contains("Tabs: 0"), "{text}");
}
#[test]
fn serialises_with_absent_optionals_omitted() {
let doc = document(serde_json::json!({"documentId": "d1", "body": {"content": []}}));
let json = serde_json::to_value(InfoOutcome::of("d1", &doc)).unwrap();
assert!(json.get("title").is_none());
assert!(json.get("revision_id").is_none());
assert!(json["tabs"].is_array());
}
}