use anyhow::{Context, Result};
use clap::{Parser, ValueEnum};
use crate::cli::drive::format::{output_as, sanitize_for_terminal, OutputFormat};
use crate::drive::client::DriveClient;
use crate::drive::docs::api::{DocsApi, SuggestionsViewMode};
use crate::drive::docs::client::DocsClient;
use crate::drive::docs::read::{read, ReadOptions, ReadOutcome, SegmentContent};
use crate::drive::docs::structure::DocElement;
use crate::drive::docs::target;
use crate::drive::files_api::FilesApi;
#[derive(Parser)]
pub struct ReadCommand {
pub document_id: String,
#[arg(long)]
pub tab: Option<String>,
#[arg(long, value_enum, default_value_t = SuggestionsViewArg::Default)]
pub suggestions_view: SuggestionsViewArg,
#[arg(short = 'o', long, value_enum, default_value_t = OutputFormat::Table)]
pub output: OutputFormat,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum SuggestionsViewArg {
Default,
Inline,
Accepted,
Without,
}
impl From<SuggestionsViewArg> for SuggestionsViewMode {
fn from(arg: SuggestionsViewArg) -> Self {
match arg {
SuggestionsViewArg::Default => Self::DefaultForCurrentAccess,
SuggestionsViewArg::Inline => Self::Inline,
SuggestionsViewArg::Accepted => Self::PreviewAccepted,
SuggestionsViewArg::Without => Self::PreviewWithoutSuggestions,
}
}
}
impl ReadCommand {
pub async fn execute(self, client: &DriveClient) -> Result<()> {
let docs = DocsClient::from_drive_client(client)?;
let opts = ReadOptions {
document_id: self.document_id,
tab: self.tab,
suggestions: self.suggestions_view.into(),
};
run_read(client, &docs, &opts, &self.output).await
}
}
async fn run_read(
drive: &DriveClient,
docs: &DocsClient,
opts: &ReadOptions,
output: &OutputFormat,
) -> Result<()> {
let outcome = match read(&DocsApi::new(docs), opts).await {
Ok(outcome) => outcome,
Err(err) => {
return Err(target::explain_failure(
&FilesApi::new(drive),
&opts.document_id,
"read",
err,
)
.await)
}
};
if output_as(&outcome, output)? {
return Ok(());
}
let stdout = std::io::stdout();
let mut handle = stdout.lock();
render_read_table(&outcome, &mut handle)
}
fn render_read_table(outcome: &ReadOutcome, out: &mut dyn std::io::Write) -> Result<()> {
let ctx = "Failed to write docs read output";
let multi_tab = outcome.tabs.len() > 1;
for (position, tab) in outcome.tabs.iter().enumerate() {
if multi_tab {
if position > 0 {
writeln!(out).context(ctx)?;
}
let id = tab.tab_id.as_deref().unwrap_or("(no id)");
let title = tab.title.as_deref().unwrap_or("");
writeln!(
out,
"# {} {}",
sanitize_for_terminal(id),
sanitize_for_terminal(title)
)
.context(ctx)?;
}
render_elements(&tab.elements, out)?;
for (kind, segments) in tab.segments_by_kind() {
render_segments(kind.as_str(), segments, out)?;
}
}
Ok(())
}
fn render_segments(
label: &str,
segments: &[SegmentContent],
out: &mut dyn std::io::Write,
) -> Result<()> {
let ctx = "Failed to write docs read output";
for segment in segments {
writeln!(
out,
"## {label} {}",
sanitize_for_terminal(&segment.segment_id)
)
.context(ctx)?;
render_elements(&segment.elements, out)?;
}
Ok(())
}
fn render_elements(elements: &[DocElement], out: &mut dyn std::io::Write) -> Result<()> {
let ctx = "Failed to write docs read output";
if elements.is_empty() {
writeln!(out, "(empty)").context(ctx)?;
return Ok(());
}
let start_w = elements
.iter()
.map(|e| e.start_index.to_string().len())
.max()
.unwrap_or(5)
.max(5);
let end_w = elements
.iter()
.map(|e| e.end_index.to_string().len())
.max()
.unwrap_or(3)
.max(3);
let kind_w = elements
.iter()
.map(|e| e.kind.as_str().len() + e.depth() * 2)
.max()
.unwrap_or(4)
.max(4);
let style_w = elements
.iter()
.filter_map(|e| e.style.as_ref().map(String::len))
.max()
.unwrap_or(5)
.max(5);
writeln!(
out,
"{:>start_w$} {:>end_w$} {:<kind_w$} {:<style_w$} TEXT",
"START", "END", "KIND", "STYLE"
)
.context(ctx)?;
for element in elements {
let kind = format!("{}{}", " ".repeat(element.depth()), element.kind.as_str());
let text = match (element.rows, element.columns) {
(Some(rows), Some(cols)) => format!("{rows}x{cols}"),
_ => sanitize_for_terminal(&element.text),
};
writeln!(
out,
"{:>start_w$} {:>end_w$} {:<kind_w$} {:<style_w$} {}",
element.start_index,
element.end_index,
kind,
element.style.as_deref().unwrap_or(""),
text
)
.context(ctx)?;
}
Ok(())
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use crate::drive::docs::read::TabContent;
use crate::drive::docs::structure::{DocElement, ElementKind};
fn element(
start: i64,
end: i64,
kind: ElementKind,
path: Vec<usize>,
style: Option<&str>,
text: &str,
) -> DocElement {
DocElement {
start_index: start,
end_index: end,
kind,
path,
style: style.map(str::to_string),
list_id: None,
rows: None,
columns: None,
text: text.to_string(),
}
}
fn outcome(tabs: Vec<TabContent>) -> ReadOutcome {
ReadOutcome {
document_id: "d1".to_string(),
title: Some("Doc".to_string()),
revision_id: Some("rev-1".to_string()),
tabs,
}
}
fn tab(id: Option<&str>, title: Option<&str>, elements: Vec<DocElement>) -> TabContent {
TabContent {
named_ranges: std::collections::BTreeMap::default(),
tab_id: id.map(str::to_string),
title: title.map(str::to_string),
nesting_level: 0,
elements,
headers: Vec::new(),
footers: Vec::new(),
footnotes: Vec::new(),
}
}
fn segment(id: &str, elements: Vec<DocElement>) -> SegmentContent {
SegmentContent {
segment_id: id.to_string(),
elements,
}
}
fn render(outcome: &ReadOutcome) -> String {
let mut buf = Vec::new();
render_read_table(outcome, &mut buf).unwrap();
String::from_utf8(buf).unwrap()
}
#[test]
fn render_emits_one_line_per_element_with_its_index_range() {
let text = render(&outcome(vec![tab(
None,
None,
vec![
element(
1,
10,
ElementKind::Paragraph,
vec![0],
Some("HEADING_1"),
"Overview",
),
element(
10,
20,
ElementKind::Paragraph,
vec![1],
Some("NORMAL_TEXT"),
"Body",
),
],
)]));
assert!(text.contains("START"), "{text}");
assert!(text.contains("Overview"), "{text}");
assert!(text.contains("HEADING_1"), "{text}");
assert_eq!(text.lines().count(), 3, "{text}");
}
#[test]
fn render_indents_elements_nested_in_a_table() {
let text = render(&outcome(vec![tab(
None,
None,
vec![
element(1, 40, ElementKind::Table, vec![0], None, ""),
element(
3,
10,
ElementKind::Paragraph,
vec![0, 0, 0, 0],
None,
"Name",
),
],
)]));
let nested = text.lines().find(|l| l.contains("Name")).unwrap();
assert!(nested.contains(" paragraph"), "{nested}");
}
#[test]
fn render_shows_table_dimensions_on_the_container_row() {
let mut table = element(1, 40, ElementKind::Table, vec![0], None, "");
table.rows = Some(3);
table.columns = Some(2);
let text = render(&outcome(vec![tab(None, None, vec![table])]));
assert!(text.contains("3x2"), "{text}");
}
#[test]
fn render_sanitizes_element_text() {
let text = render(&outcome(vec![tab(
None,
None,
vec![element(
1,
10,
ElementKind::Paragraph,
vec![0],
None,
"before\u{1b}[31mred\nnext",
)],
)]));
assert!(!text.contains('\u{1b}'), "{text}");
assert_eq!(text.lines().count(), 2, "{text}");
}
#[test]
fn multiple_tabs_get_a_commented_header_and_a_blank_separator() {
let text = render(&outcome(vec![
tab(
Some("t.0"),
Some("One"),
vec![element(1, 5, ElementKind::Paragraph, vec![0], None, "a")],
),
tab(
Some("t.1"),
Some("Two"),
vec![element(1, 5, ElementKind::Paragraph, vec![0], None, "b")],
),
]));
assert!(text.contains("# t.0 One"), "{text}");
assert!(text.contains("# t.1 Two"), "{text}");
assert!(text.contains("\n\n# t.1"), "{text}");
}
#[test]
fn a_single_tab_renders_without_a_header_line() {
let text = render(&outcome(vec![tab(
Some("t.0"),
Some("One"),
vec![element(1, 5, ElementKind::Paragraph, vec![0], None, "a")],
)]));
assert!(!text.contains("# t.0"), "{text}");
}
#[test]
fn an_empty_tab_says_so_rather_than_printing_a_bare_header() {
let text = render(&outcome(vec![tab(None, None, vec![])]));
assert!(text.contains("(empty)"), "{text}");
}
#[test]
fn a_tab_with_no_segments_renders_no_segment_blocks() {
let text = render(&outcome(vec![tab(
None,
None,
vec![element(1, 5, ElementKind::Paragraph, vec![0], None, "a")],
)]));
assert!(!text.contains("## header"), "{text}");
assert!(!text.contains("## footer"), "{text}");
assert!(!text.contains("## footnote"), "{text}");
}
#[test]
fn headers_footers_and_footnotes_render_as_labeled_blocks_after_the_body() {
let mut content = tab(
None,
None,
vec![element(1, 5, ElementKind::Paragraph, vec![0], None, "body")],
);
content.headers = vec![segment(
"h1",
vec![element(
0,
8,
ElementKind::Paragraph,
vec![0],
None,
"Header",
)],
)];
content.footers = vec![segment(
"f1",
vec![element(
0,
8,
ElementKind::Paragraph,
vec![0],
None,
"Footer",
)],
)];
content.footnotes = vec![segment(
"n1",
vec![element(0, 5, ElementKind::Paragraph, vec![0], None, "Note")],
)];
let text = render(&outcome(vec![content]));
assert!(text.contains("## header h1"), "{text}");
assert!(text.contains("## footer f1"), "{text}");
assert!(text.contains("## footnote n1"), "{text}");
let body_pos = text.find("body").unwrap();
let header_pos = text.find("## header").unwrap();
assert!(body_pos < header_pos, "{text}");
}
#[test]
fn suggestions_view_arg_maps_onto_the_engine_option() {
for (arg, mode) in [
(
SuggestionsViewArg::Default,
SuggestionsViewMode::DefaultForCurrentAccess,
),
(SuggestionsViewArg::Inline, SuggestionsViewMode::Inline),
(
SuggestionsViewArg::Accepted,
SuggestionsViewMode::PreviewAccepted,
),
(
SuggestionsViewArg::Without,
SuggestionsViewMode::PreviewWithoutSuggestions,
),
] {
assert_eq!(SuggestionsViewMode::from(arg), mode, "{arg:?}");
}
}
}