use anyhow::Result;
use clap::Parser;
use crate::cli::drive::format::{output_as, OutputFormat};
use crate::cli::drive::helpers;
use crate::cli::drive::helpers::StructureWriteArgs;
use crate::cli::format::sanitize_for_terminal;
use crate::drive::client::DriveClient;
use crate::drive::sheets::client::SheetsClient;
use crate::drive::sheets::structure::{
describe_lines, structure, IterativeCalculationToggle, StructureOptions, StructureVerb,
};
#[derive(Parser)]
pub struct SpreadsheetIdArg {
pub spreadsheet_id: String,
}
#[derive(Parser)]
pub struct AddSheetCommand {
#[command(flatten)]
pub target: SpreadsheetIdArg,
#[arg(long, value_name = "TITLE")]
pub title: String,
#[arg(long, value_name = "N")]
pub index: Option<i64>,
#[arg(long, value_name = "N")]
pub rows: Option<i64>,
#[arg(long, value_name = "N")]
pub columns: Option<i64>,
#[command(flatten)]
pub write: StructureWriteArgs,
}
#[derive(Parser)]
pub struct RenameSheetCommand {
#[command(flatten)]
pub target: SpreadsheetIdArg,
#[arg(long, value_name = "NAME")]
pub sheet: String,
#[arg(long, value_name = "TITLE")]
pub title: String,
#[command(flatten)]
pub write: StructureWriteArgs,
}
#[derive(Parser)]
pub struct InsertRowsCommand {
#[command(flatten)]
pub target: SpreadsheetIdArg,
#[arg(long, value_name = "NAME")]
pub sheet: String,
#[arg(long, value_name = "ROW")]
pub at: i64,
#[arg(long, value_name = "N", default_value_t = 1)]
pub count: i64,
#[command(flatten)]
pub write: StructureWriteArgs,
}
#[derive(Parser)]
pub struct InsertColumnsCommand {
#[command(flatten)]
pub target: SpreadsheetIdArg,
#[arg(long, value_name = "NAME")]
pub sheet: String,
#[arg(long, value_name = "COLUMN")]
pub at: i64,
#[arg(long, value_name = "N", default_value_t = 1)]
pub count: i64,
#[command(flatten)]
pub write: StructureWriteArgs,
}
#[derive(Parser)]
pub struct MoveRowsCommand {
#[command(flatten)]
pub target: SpreadsheetIdArg,
#[arg(long, value_name = "NAME")]
pub sheet: String,
#[arg(long, value_name = "ROW")]
pub at: i64,
#[arg(long, value_name = "N", default_value_t = 1)]
pub count: i64,
#[arg(long, value_name = "ROW")]
pub before: i64,
#[command(flatten)]
pub write: StructureWriteArgs,
}
#[derive(Parser)]
pub struct MoveColumnsCommand {
#[command(flatten)]
pub target: SpreadsheetIdArg,
#[arg(long, value_name = "NAME")]
pub sheet: String,
#[arg(long, value_name = "COLUMN")]
pub at: i64,
#[arg(long, value_name = "N", default_value_t = 1)]
pub count: i64,
#[arg(long, value_name = "COLUMN")]
pub before: i64,
#[command(flatten)]
pub write: StructureWriteArgs,
}
#[derive(Parser)]
pub struct DeleteSheetCommand {
#[command(flatten)]
pub target: SpreadsheetIdArg,
#[arg(long, value_name = "NAME")]
pub sheet: String,
#[command(flatten)]
pub write: StructureWriteArgs,
}
#[derive(Parser)]
pub struct DeleteRowsCommand {
#[command(flatten)]
pub target: SpreadsheetIdArg,
#[arg(long, value_name = "NAME")]
pub sheet: String,
#[arg(long, value_name = "ROW")]
pub at: i64,
#[arg(long, value_name = "N", default_value_t = 1)]
pub count: i64,
#[command(flatten)]
pub write: StructureWriteArgs,
}
#[derive(Parser)]
pub struct DeleteColumnsCommand {
#[command(flatten)]
pub target: SpreadsheetIdArg,
#[arg(long, value_name = "NAME")]
pub sheet: String,
#[arg(long, value_name = "COLUMN")]
pub at: i64,
#[arg(long, value_name = "N", default_value_t = 1)]
pub count: i64,
#[command(flatten)]
pub write: StructureWriteArgs,
}
#[derive(Debug, Clone, Copy, clap::ValueEnum)]
pub enum ShiftArg {
Rows,
Columns,
}
impl From<ShiftArg> for crate::drive::sheets::types::ShiftDimension {
fn from(arg: ShiftArg) -> Self {
match arg {
ShiftArg::Rows => Self::Rows,
ShiftArg::Columns => Self::Columns,
}
}
}
#[derive(Parser)]
pub struct GridRangeArgs {
#[arg(long, value_name = "ROW")]
pub start_row: i64,
#[arg(long, value_name = "ROW")]
pub end_row: i64,
#[arg(long, value_name = "COLUMN")]
pub start_column: i64,
#[arg(long, value_name = "COLUMN")]
pub end_column: i64,
#[arg(long, value_enum)]
pub shift: ShiftArg,
}
#[derive(Parser)]
pub struct InsertRangeCommand {
#[command(flatten)]
pub target: SpreadsheetIdArg,
#[arg(long, value_name = "NAME")]
pub sheet: String,
#[command(flatten)]
pub range: GridRangeArgs,
#[command(flatten)]
pub write: StructureWriteArgs,
}
#[derive(Parser)]
pub struct DeleteRangeCommand {
#[command(flatten)]
pub target: SpreadsheetIdArg,
#[arg(long, value_name = "NAME")]
pub sheet: String,
#[command(flatten)]
pub range: GridRangeArgs,
#[command(flatten)]
pub write: StructureWriteArgs,
}
#[derive(Parser)]
pub struct DuplicateSheetCommand {
#[command(flatten)]
pub target: SpreadsheetIdArg,
#[arg(long, value_name = "NAME")]
pub sheet: String,
#[arg(long, value_name = "TITLE")]
pub title: Option<String>,
#[arg(long, value_name = "N")]
pub index: Option<i64>,
#[command(flatten)]
pub write: StructureWriteArgs,
}
#[derive(Parser)]
pub struct ReorderSheetCommand {
#[command(flatten)]
pub target: SpreadsheetIdArg,
#[arg(long, value_name = "NAME")]
pub sheet: String,
#[arg(long, value_name = "N")]
pub index: i64,
#[command(flatten)]
pub write: StructureWriteArgs,
}
#[derive(Parser)]
pub struct HideSheetCommand {
#[command(flatten)]
pub target: SpreadsheetIdArg,
#[arg(long, value_name = "NAME")]
pub sheet: String,
#[command(flatten)]
pub write: StructureWriteArgs,
}
#[derive(Parser)]
pub struct ShowSheetCommand {
#[command(flatten)]
pub target: SpreadsheetIdArg,
#[arg(long, value_name = "NAME")]
pub sheet: String,
#[command(flatten)]
pub write: StructureWriteArgs,
}
#[derive(Parser)]
#[command(group(clap::ArgGroup::new("change")
.args(["freeze_rows", "freeze_columns", "tab_color", "clear_tab_color", "right_to_left", "hide_gridlines"])
.multiple(true)
.required(true)))]
pub struct UpdateSheetPropertiesCommand {
#[command(flatten)]
pub target: SpreadsheetIdArg,
#[arg(long, value_name = "NAME")]
pub sheet: String,
#[arg(long, value_name = "N")]
pub freeze_rows: Option<i64>,
#[arg(long, value_name = "N")]
pub freeze_columns: Option<i64>,
#[arg(long, value_name = "HEX", conflicts_with = "clear_tab_color")]
pub tab_color: Option<String>,
#[arg(long, conflicts_with = "tab_color")]
pub clear_tab_color: bool,
#[arg(long, value_name = "BOOL")]
pub right_to_left: Option<bool>,
#[arg(long, value_name = "BOOL")]
pub hide_gridlines: Option<bool>,
#[command(flatten)]
pub write: StructureWriteArgs,
}
#[derive(Debug, Clone, Copy, clap::ValueEnum)]
pub enum AutoRecalcArg {
OnChange,
Minute,
Hour,
}
impl From<AutoRecalcArg> for crate::drive::sheets::types::RecalculationInterval {
fn from(arg: AutoRecalcArg) -> Self {
match arg {
AutoRecalcArg::OnChange => Self::OnChange,
AutoRecalcArg::Minute => Self::Minute,
AutoRecalcArg::Hour => Self::Hour,
}
}
}
#[derive(Debug, Clone, Copy, clap::ValueEnum)]
pub enum IterativeCalculationArg {
On,
Off,
}
impl From<IterativeCalculationArg> for IterativeCalculationToggle {
fn from(arg: IterativeCalculationArg) -> Self {
match arg {
IterativeCalculationArg::On => Self::On,
IterativeCalculationArg::Off => Self::Off,
}
}
}
#[derive(Parser)]
pub struct UpdateWorkbookPropertiesCommand {
#[command(flatten)]
pub target: SpreadsheetIdArg,
#[arg(long, value_name = "LOCALE")]
pub locale: Option<String>,
#[arg(long, value_name = "TIME_ZONE")]
pub time_zone: Option<String>,
#[arg(long, value_enum, value_name = "INTERVAL")]
pub auto_recalc: Option<AutoRecalcArg>,
#[arg(long, value_enum, value_name = "ON_OFF")]
pub iterative_calculation: Option<IterativeCalculationArg>,
#[arg(long, value_name = "N")]
pub iterative_calculation_max_iterations: Option<i64>,
#[arg(long, value_name = "THRESHOLD")]
pub iterative_calculation_convergence_threshold: Option<f64>,
#[command(flatten)]
pub write: StructureWriteArgs,
}
impl AddSheetCommand {
pub async fn execute(self, client: &DriveClient) -> Result<()> {
run_verb(
client,
self.target.spreadsheet_id,
StructureVerb::AddSheet {
title: self.title,
index: self.index,
rows: self.rows,
columns: self.columns,
},
self.write,
)
.await
}
}
impl RenameSheetCommand {
pub async fn execute(self, client: &DriveClient) -> Result<()> {
run_verb(
client,
self.target.spreadsheet_id,
StructureVerb::RenameSheet {
sheet: self.sheet,
new_title: self.title,
},
self.write,
)
.await
}
}
impl InsertRowsCommand {
pub async fn execute(self, client: &DriveClient) -> Result<()> {
run_verb(
client,
self.target.spreadsheet_id,
StructureVerb::InsertRows {
sheet: self.sheet,
at: self.at,
count: self.count,
},
self.write,
)
.await
}
}
impl InsertColumnsCommand {
pub async fn execute(self, client: &DriveClient) -> Result<()> {
run_verb(
client,
self.target.spreadsheet_id,
StructureVerb::InsertColumns {
sheet: self.sheet,
at: self.at,
count: self.count,
},
self.write,
)
.await
}
}
impl MoveRowsCommand {
pub async fn execute(self, client: &DriveClient) -> Result<()> {
run_verb(
client,
self.target.spreadsheet_id,
StructureVerb::MoveRows {
sheet: self.sheet,
at: self.at,
count: self.count,
before: self.before,
},
self.write,
)
.await
}
}
impl MoveColumnsCommand {
pub async fn execute(self, client: &DriveClient) -> Result<()> {
run_verb(
client,
self.target.spreadsheet_id,
StructureVerb::MoveColumns {
sheet: self.sheet,
at: self.at,
count: self.count,
before: self.before,
},
self.write,
)
.await
}
}
impl DeleteSheetCommand {
pub async fn execute(self, client: &DriveClient) -> Result<()> {
run_verb(
client,
self.target.spreadsheet_id,
StructureVerb::DeleteSheet { sheet: self.sheet },
self.write,
)
.await
}
}
impl DeleteRowsCommand {
pub async fn execute(self, client: &DriveClient) -> Result<()> {
run_verb(
client,
self.target.spreadsheet_id,
StructureVerb::DeleteRows {
sheet: self.sheet,
at: self.at,
count: self.count,
},
self.write,
)
.await
}
}
impl DeleteColumnsCommand {
pub async fn execute(self, client: &DriveClient) -> Result<()> {
run_verb(
client,
self.target.spreadsheet_id,
StructureVerb::DeleteColumns {
sheet: self.sheet,
at: self.at,
count: self.count,
},
self.write,
)
.await
}
}
impl InsertRangeCommand {
pub async fn execute(self, client: &DriveClient) -> Result<()> {
run_verb(
client,
self.target.spreadsheet_id,
StructureVerb::InsertRange {
sheet: self.sheet,
start_row: self.range.start_row,
end_row: self.range.end_row,
start_column: self.range.start_column,
end_column: self.range.end_column,
shift: self.range.shift.into(),
},
self.write,
)
.await
}
}
impl DeleteRangeCommand {
pub async fn execute(self, client: &DriveClient) -> Result<()> {
run_verb(
client,
self.target.spreadsheet_id,
StructureVerb::DeleteRange {
sheet: self.sheet,
start_row: self.range.start_row,
end_row: self.range.end_row,
start_column: self.range.start_column,
end_column: self.range.end_column,
shift: self.range.shift.into(),
},
self.write,
)
.await
}
}
impl DuplicateSheetCommand {
pub async fn execute(self, client: &DriveClient) -> Result<()> {
run_verb(
client,
self.target.spreadsheet_id,
StructureVerb::DuplicateSheet {
sheet: self.sheet,
title: self.title,
index: self.index,
},
self.write,
)
.await
}
}
impl ReorderSheetCommand {
pub async fn execute(self, client: &DriveClient) -> Result<()> {
run_verb(
client,
self.target.spreadsheet_id,
StructureVerb::ReorderSheet {
sheet: self.sheet,
index: self.index,
},
self.write,
)
.await
}
}
impl HideSheetCommand {
pub async fn execute(self, client: &DriveClient) -> Result<()> {
run_verb(
client,
self.target.spreadsheet_id,
StructureVerb::SetSheetVisibility {
sheet: self.sheet,
hidden: true,
},
self.write,
)
.await
}
}
impl ShowSheetCommand {
pub async fn execute(self, client: &DriveClient) -> Result<()> {
run_verb(
client,
self.target.spreadsheet_id,
StructureVerb::SetSheetVisibility {
sheet: self.sheet,
hidden: false,
},
self.write,
)
.await
}
}
impl UpdateSheetPropertiesCommand {
pub async fn execute(self, client: &DriveClient) -> Result<()> {
run_verb(
client,
self.target.spreadsheet_id,
StructureVerb::UpdateSheetProperties {
sheet: self.sheet,
freeze_rows: self.freeze_rows,
freeze_columns: self.freeze_columns,
tab_color: self.tab_color,
clear_tab_color: self.clear_tab_color,
right_to_left: self.right_to_left,
hide_gridlines: self.hide_gridlines,
},
self.write,
)
.await
}
}
impl UpdateWorkbookPropertiesCommand {
pub async fn execute(self, client: &DriveClient) -> Result<()> {
run_verb(
client,
self.target.spreadsheet_id,
StructureVerb::UpdateWorkbookProperties {
locale: self.locale,
time_zone: self.time_zone,
auto_recalc: self.auto_recalc.map(Into::into),
iterative_calculation: self.iterative_calculation.map(Into::into),
iterative_calculation_max_iterations: self.iterative_calculation_max_iterations,
iterative_calculation_convergence_threshold: self
.iterative_calculation_convergence_threshold,
},
self.write,
)
.await
}
}
async fn run_verb(
client: &DriveClient,
spreadsheet_id: String,
verb: StructureVerb,
write: StructureWriteArgs,
) -> Result<()> {
let opts = StructureOptions {
spreadsheet_id,
verb,
dry_run: write.dry_run,
lease_token: write.lease.lease,
ledger_path: helpers::resolve_ledger_path(write.dry_run)?,
};
run_structure(client, &opts, &write.output).await
}
async fn run_structure(
client: &DriveClient,
opts: &StructureOptions,
output: &OutputFormat,
) -> Result<()> {
let sheets = SheetsClient::from_drive_client(client)?;
let rules = helpers::active_account_rules()?;
let outcome = structure(client, &sheets, opts, &rules).await;
if output_as(&outcome, output)? {
return Ok(());
}
println!("{}", sanitize_rendered(&describe_lines(&outcome)));
Ok(())
}
fn sanitize_rendered(lines: &[String]) -> String {
lines
.iter()
.map(|line| sanitize_for_terminal(line))
.collect::<Vec<_>>()
.join("\n")
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
fn line(s: &str) -> Vec<String> {
vec![s.to_string()]
}
#[test]
fn add_sheet_still_parses_spreadsheet_id_positionally() {
let cmd =
AddSheetCommand::try_parse_from(["add-sheet", "abc123", "--title", "Sheet2"]).unwrap();
assert_eq!(cmd.target.spreadsheet_id, "abc123");
let err = AddSheetCommand::try_parse_from(["add-sheet", "--title", "Sheet2"])
.map(|_| ())
.unwrap_err();
assert_eq!(err.kind(), clap::error::ErrorKind::MissingRequiredArgument);
}
#[test]
fn rename_sheet_still_parses_spreadsheet_id_positionally() {
let cmd = RenameSheetCommand::try_parse_from([
"rename-sheet",
"abc123",
"--sheet",
"Sheet1",
"--title",
"Sheet2",
])
.unwrap();
assert_eq!(cmd.target.spreadsheet_id, "abc123");
let err = RenameSheetCommand::try_parse_from([
"rename-sheet",
"--sheet",
"Sheet1",
"--title",
"Sheet2",
])
.map(|_| ())
.unwrap_err();
assert_eq!(err.kind(), clap::error::ErrorKind::MissingRequiredArgument);
}
#[test]
fn update_sheet_properties_still_parses_spreadsheet_id_positionally() {
let cmd = UpdateSheetPropertiesCommand::try_parse_from([
"update-sheet-properties",
"abc123",
"--sheet",
"Sheet1",
"--freeze-rows",
"1",
])
.unwrap();
assert_eq!(cmd.target.spreadsheet_id, "abc123");
let err = UpdateSheetPropertiesCommand::try_parse_from([
"update-sheet-properties",
"--sheet",
"Sheet1",
"--freeze-rows",
"1",
])
.map(|_| ())
.unwrap_err();
assert_eq!(err.kind(), clap::error::ErrorKind::MissingRequiredArgument);
}
#[test]
fn shift_arg_maps_onto_the_engine_dimension() {
assert_eq!(
crate::drive::sheets::types::ShiftDimension::from(ShiftArg::Rows),
crate::drive::sheets::types::ShiftDimension::Rows
);
assert_eq!(
crate::drive::sheets::types::ShiftDimension::from(ShiftArg::Columns),
crate::drive::sheets::types::ShiftDimension::Columns
);
}
#[test]
fn auto_recalc_arg_maps_onto_the_engine_interval() {
use crate::drive::sheets::types::RecalculationInterval;
assert_eq!(
RecalculationInterval::from(AutoRecalcArg::OnChange),
RecalculationInterval::OnChange
);
assert_eq!(
RecalculationInterval::from(AutoRecalcArg::Minute),
RecalculationInterval::Minute
);
assert_eq!(
RecalculationInterval::from(AutoRecalcArg::Hour),
RecalculationInterval::Hour
);
}
#[test]
fn iterative_calculation_arg_maps_onto_the_engine_toggle() {
assert_eq!(
IterativeCalculationToggle::from(IterativeCalculationArg::On),
IterativeCalculationToggle::On
);
assert_eq!(
IterativeCalculationToggle::from(IterativeCalculationArg::Off),
IterativeCalculationToggle::Off
);
}
#[test]
fn sanitize_rendered_strips_escapes_from_an_injected_sheet_title() {
let clean = sanitize_rendered(&line(
"Refused: 'Budget' has no sheet titled 'Q9'. Available: '\u{1b}[31mQ1\u{7}'",
));
assert!(!clean.contains('\u{1b}'), "{clean}");
assert!(!clean.contains('\u{7}'), "{clean}");
assert!(clean.contains("Available: '[31mQ1'"), "{clean}");
}
#[test]
fn sanitize_rendered_keeps_the_inserts_own_second_line() {
let clean = sanitize_rendered(&[
"Would insert 3 row(s) before row 5 of 'Q2' in 'Budget'".to_string(),
" (500 rows -> 503; existing rows 5-500 shift down)".to_string(),
]);
assert_eq!(clean.lines().count(), 2, "{clean}");
assert!(clean.contains("500 rows -> 503"), "{clean}");
}
#[test]
fn a_newline_inside_a_line_cannot_forge_a_second_line() {
let clean = sanitize_rendered(&line(
"Would rename sheet 'Q2\n (500 rows -> 500; nothing shifts)' to 'Q3' in 'Budget'",
));
assert_eq!(clean.lines().count(), 1, "{clean}");
assert!(!clean.contains('\n'), "{clean}");
}
#[test]
fn sanitize_rendered_strips_bidi_overrides() {
let clean = sanitize_rendered(&line("Renamed sheet 'a\u{202E}b' to 'c' in 'Budget'"));
assert!(!clean.contains('\u{202E}'), "{clean}");
}
}