use anyhow::Result;
use clap::Parser;
use crate::cli::drive::format::{output_as, sanitize_for_terminal, OutputFormat};
use crate::cli::drive::helpers;
use crate::drive::client::DriveClient;
use crate::drive::sheets::client::SheetsClient;
use crate::drive::sheets::text_to_columns::{
describe_lines, text_to_columns, Delimiter, TextToColumnsOptions,
};
#[derive(Debug, Clone, Copy, clap::ValueEnum)]
pub enum DelimiterArg {
Comma,
Semicolon,
Period,
Space,
Auto,
Custom,
}
#[derive(Parser)]
pub struct TextToColumnsCommand {
pub spreadsheet_id: String,
#[arg(long, value_name = "NAME")]
pub sheet: Option<String>,
#[arg(long, value_name = "A1")]
pub source: String,
#[arg(long, value_enum)]
pub delimiter: DelimiterArg,
#[arg(
long,
value_name = "TEXT",
required_if_eq("delimiter", "custom"),
allow_hyphen_values = true
)]
pub custom_delimiter: Option<String>,
#[arg(long)]
pub dry_run: bool,
#[command(flatten)]
pub lease: crate::cli::drive::helpers::LeaseTokenArg,
#[arg(short = 'o', long, value_enum, default_value_t = OutputFormat::Table)]
pub output: OutputFormat,
}
impl TextToColumnsCommand {
pub async fn execute(self, client: &DriveClient) -> Result<()> {
if !matches!(self.delimiter, DelimiterArg::Custom) && self.custom_delimiter.is_some() {
anyhow::bail!("--custom-delimiter is only used with --delimiter custom");
}
let delimiter = match self.delimiter {
DelimiterArg::Comma => Delimiter::Comma,
DelimiterArg::Semicolon => Delimiter::Semicolon,
DelimiterArg::Period => Delimiter::Period,
DelimiterArg::Space => Delimiter::Space,
DelimiterArg::Auto => Delimiter::Auto,
DelimiterArg::Custom => Delimiter::Custom(self.custom_delimiter.unwrap_or_default()),
};
let opts = TextToColumnsOptions {
spreadsheet_id: self.spreadsheet_id,
sheet: self.sheet,
source: Some(self.source),
delimiter,
dry_run: self.dry_run,
lease_token: self.lease.lease,
ledger_path: helpers::resolve_ledger_path(self.dry_run)?,
};
run_text_to_columns(client, &opts, &self.output).await
}
}
async fn run_text_to_columns(
client: &DriveClient,
opts: &TextToColumnsOptions,
output: &OutputFormat,
) -> Result<()> {
let sheets = SheetsClient::from_drive_client(client)?;
let rules = helpers::active_account_rules()?;
let outcome = text_to_columns(client, &sheets, opts, &rules).await;
if output_as(&outcome, output)? {
return Ok(());
}
let lines: Vec<String> = describe_lines(&outcome)
.into_iter()
.map(|line| sanitize_for_terminal(&line))
.collect();
println!("{}", lines.join("\n"));
Ok(())
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
use crate::drive::sheets::client::SHEETS_API_URL;
use crate::utils::secret::Secret;
use clap::CommandFactory;
#[test]
fn custom_delimiter_is_required_with_delimiter_custom() {
let err = TextToColumnsCommand::command()
.try_get_matches_from([
"text-to-columns",
"sheet-1",
"--source",
"A1:A10",
"--delimiter",
"custom",
])
.unwrap_err();
assert_eq!(err.kind(), clap::error::ErrorKind::MissingRequiredArgument);
}
#[test]
fn a_fixed_delimiter_needs_no_custom_text() {
TextToColumnsCommand::command()
.try_get_matches_from([
"text-to-columns",
"sheet-1",
"--source",
"A1:A10",
"--delimiter",
"comma",
])
.unwrap();
}
#[test]
fn delimiter_is_required() {
let err = TextToColumnsCommand::command()
.try_get_matches_from(["text-to-columns", "sheet-1", "--source", "A1:A10"])
.unwrap_err();
assert_eq!(err.kind(), clap::error::ErrorKind::MissingRequiredArgument);
}
#[test]
fn source_is_required() {
let err = TextToColumnsCommand::command()
.try_get_matches_from(["text-to-columns", "sheet-1", "--delimiter", "comma"])
.unwrap_err();
assert_eq!(err.kind(), clap::error::ErrorKind::MissingRequiredArgument);
}
#[test]
fn custom_delimiter_parses_with_delimiter_custom() {
let matches = TextToColumnsCommand::command()
.try_get_matches_from([
"text-to-columns",
"sheet-1",
"--source",
"A1:A10",
"--delimiter",
"custom",
"--custom-delimiter",
"|",
])
.unwrap();
assert_eq!(
matches
.get_one::<String>("custom_delimiter")
.map(String::as_str),
Some("|")
);
}
async fn client(server: &wiremock::MockServer) -> DriveClient {
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/token"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"access_token": "test-token", "expires_in": 3600,
})),
)
.mount(server)
.await;
let credentials = DriveCredentials {
client_id: "client-1".into(),
client_secret: Secret::new("secret-1"),
refresh_token: Secret::new("refresh-1"),
scope: DriveGrantedScopes::READONLY,
};
let mut client = DriveClient::new(&server.uri(), &credentials).unwrap();
crate::drive::client::test_support::replace_session(
&mut client,
&credentials,
&format!("{}/token", server.uri()),
);
client
}
async fn mount_dry_run_prerequisites(server: &wiremock::MockServer) {
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/sheet-1"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "sheet-1", "name": "Budget",
"mimeType": "application/vnd.google-apps.spreadsheet",
"parents": ["parent-1"], "version": "1",
})),
)
.mount(server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/parent-1"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "parent-1", "name": "parent-1",
"mimeType": "application/vnd.google-apps.folder", "parents": [],
})),
)
.mount(server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"spreadsheetId": "sheet-1",
"sheets": [{"properties": {"sheetId": 0, "title": "Q1",
"gridProperties": {"rowCount": 1000, "columnCount": 26}}}],
})),
)
.mount(server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/v4/spreadsheets/sheet-1/values/'Q1'!A1:A10",
))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"values": [["a,b"]]})),
)
.mount(server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/v4/spreadsheets/sheet-1/values/'Q1'!B1:B10",
))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"values": []})),
)
.mount(server)
.await;
}
fn allow_settings() -> serde_json::Value {
serde_json::json!({
"rules": [{
"folder_id": "parent-1",
"recursive": true,
"allow": ["sheets-write"],
"require_lease": false,
}],
})
}
#[tokio::test]
async fn command_execute_builds_options_and_reaches_the_dry_run_engine() {
let guard = crate::drive::test_support::EnvGuard::take();
let dir = guard.clear_credentials();
let settings_path = dir.path().join(".omni-dev").join("settings.json");
crate::utils::settings::Settings::upsert_drive_account(
&settings_path,
"work",
&[("write_permissions", allow_settings())],
)
.unwrap();
let server = wiremock::MockServer::start().await;
let client = client(&server).await;
std::env::set_var(SHEETS_API_URL, server.uri());
mount_dry_run_prerequisites(&server).await;
TextToColumnsCommand {
spreadsheet_id: "sheet-1".to_string(),
sheet: Some("Q1".to_string()),
source: "A1:A10".to_string(),
delimiter: DelimiterArg::Comma,
custom_delimiter: None,
dry_run: true,
lease: helpers::LeaseTokenArg { lease: None },
output: OutputFormat::Table,
}
.execute(&client)
.await
.unwrap();
}
#[tokio::test]
async fn json_output_short_circuits_the_table_renderer() {
let guard = crate::drive::test_support::EnvGuard::take();
let dir = guard.clear_credentials();
let settings_path = dir.path().join(".omni-dev").join("settings.json");
crate::utils::settings::Settings::upsert_drive_account(
&settings_path,
"work",
&[("write_permissions", allow_settings())],
)
.unwrap();
let server = wiremock::MockServer::start().await;
let client = client(&server).await;
std::env::set_var(SHEETS_API_URL, server.uri());
mount_dry_run_prerequisites(&server).await;
TextToColumnsCommand {
spreadsheet_id: "sheet-1".to_string(),
sheet: Some("Q1".to_string()),
source: "A1:A10".to_string(),
delimiter: DelimiterArg::Comma,
custom_delimiter: None,
dry_run: true,
lease: helpers::LeaseTokenArg { lease: None },
output: OutputFormat::Json,
}
.execute(&client)
.await
.unwrap();
}
#[tokio::test]
async fn every_other_delimiter_variant_maps_through_to_the_engine() {
let guard = crate::drive::test_support::EnvGuard::take();
let dir = guard.clear_credentials();
let settings_path = dir.path().join(".omni-dev").join("settings.json");
crate::utils::settings::Settings::upsert_drive_account(
&settings_path,
"work",
&[("write_permissions", allow_settings())],
)
.unwrap();
let server = wiremock::MockServer::start().await;
let client = client(&server).await;
std::env::set_var(SHEETS_API_URL, server.uri());
mount_dry_run_prerequisites(&server).await;
for (delimiter, custom_delimiter) in [
(DelimiterArg::Semicolon, None),
(DelimiterArg::Period, None),
(DelimiterArg::Space, None),
(DelimiterArg::Auto, None),
(DelimiterArg::Custom, Some("|".to_string())),
] {
TextToColumnsCommand {
spreadsheet_id: "sheet-1".to_string(),
sheet: Some("Q1".to_string()),
source: "A1:A10".to_string(),
delimiter,
custom_delimiter,
dry_run: true,
lease: helpers::LeaseTokenArg { lease: None },
output: OutputFormat::Table,
}
.execute(&client)
.await
.unwrap();
}
}
#[tokio::test]
async fn a_fixed_delimiter_with_stray_custom_text_is_refused() {
let _guard = crate::drive::test_support::EnvGuard::take();
let server = wiremock::MockServer::start().await;
let client = client(&server).await;
std::env::set_var(SHEETS_API_URL, server.uri());
let err = TextToColumnsCommand {
spreadsheet_id: "sheet-1".to_string(),
sheet: Some("Q1".to_string()),
source: "A1:A10".to_string(),
delimiter: DelimiterArg::Comma,
custom_delimiter: Some("|".to_string()),
dry_run: true,
lease: helpers::LeaseTokenArg { lease: None },
output: OutputFormat::Table,
}
.execute(&client)
.await
.unwrap_err();
assert!(err.to_string().contains("--custom-delimiter"));
assert!(server.received_requests().await.unwrap().is_empty());
}
}