From 1a6ce5f75afe8a19701f68ede9cbd449659e9094 Mon Sep 17 00:00:00 2001 From: Tom Hicks Date: Sun, 23 Aug 2026 17:14:15 -0700 Subject: [PATCH] Implements json, tsv, url only, and quiet upload options in the cli app. --- Tasks.md | 14 +-- cli/src/main.rs | 275 +++++++++++++++++++++++++++++++++++++++--------- 2 files changed, 235 insertions(+), 54 deletions(-) diff --git a/Tasks.md b/Tasks.md index 4a0c989..bedb7ef 100644 --- a/Tasks.md +++ b/Tasks.md @@ -69,7 +69,6 @@ This document defines the complete project roadmap and task tracking system for | ID | Title | Status | Type | |---|---|---|---| -| [NUT-009](#nut-009) | Implement CLI Output Formatting Options | Triage | Feature | | [NUT-010](#nut-010) | Implement CLI Multi-file Upload Support | Triage | Feature | | [NUT-011](#nut-011) | Implement CLI Progress Reporting + pv Support | Triage | Feature | | [NUT-012](#nut-012) | Implement GUI (Tauri) Frontend | Triage | Feature | @@ -90,6 +89,7 @@ This document defines the complete project roadmap and task tracking system for | [NUT-006](#nut-006) | Implement Credential Storage System | Fixed | Feature | | [NUT-007](#nut-007) | Implement Multi-Account Support (Backend) | Fixed | Feature | | [NUT-008](#nut-008) | Implement CLI Frontend | Fixed | Feature | +| [NUT-009](#nut-009) | Implement CLI Output Formatting Options | Fixed | Feature | --- @@ -255,20 +255,20 @@ Implement the CLI tool using the shared backend library, including interactive b - NUT-006 - + ### Implement CLI Output Formatting Options **ID:** NUT-009 -**Status:** Triage +**Status:** Fixed **Type:** Feature **Description:** Add output formatting options for scripting and automation. **Requirements:** -- [ ] JSON output -- [ ] TSV output -- [ ] Flags for path/filename/url -- [ ] Quiet mode +- [x] JSON output (`--json`) +- [x] TSV output (`--tsv`) +- [x] Flags for path/filename/url (`--url-only`, `--direct-url-only`) +- [x] Quiet mode (`--quiet` / `-q`) **Dependencies:** - NUT-008 diff --git a/cli/src/main.rs b/cli/src/main.rs index bba9309..11c6a77 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -1,4 +1,5 @@ use clap::{Args, Parser, Subcommand}; +use serde::Serialize; use std::io::{self, Read}; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -35,7 +36,7 @@ enum Commands { Account(AccountCommands), /// Quick alias to list all configured accounts - Accounts, + Accounts(AccountListArgs), } #[derive(Args, Debug)] @@ -52,6 +53,35 @@ struct LoginArgs { default: bool, } +#[derive(Args, Debug, Clone, Default)] +struct OutputFormatArgs { + /// Format output as JSON + #[arg(long, conflicts_with_all = ["tsv", "url_only", "direct_url_only"])] + json: bool, + + /// Format output as Tab-Separated Values (TSV) + #[arg(long, conflicts_with_all = ["json", "url_only", "direct_url_only"])] + tsv: bool, + + /// Output only the public share URL + #[arg(long, conflicts_with_all = ["json", "tsv", "direct_url_only"])] + url_only: bool, + + /// Output only the direct download URL + #[arg(long, conflicts_with_all = ["json", "tsv", "url_only"])] + direct_url_only: bool, + + /// Suppress progress and informational output + #[arg(short, long)] + quiet: bool, +} + +impl OutputFormatArgs { + pub fn is_machine_readable(&self) -> bool { + self.json || self.tsv || self.url_only || self.direct_url_only || self.quiet + } +} + #[derive(Args, Debug)] struct UploadArgs { /// Path to local file(s) to upload @@ -81,12 +111,15 @@ struct UploadArgs { /// Remote filename to use when uploading via stdin #[arg(long, default_value = "stdin_upload.txt")] filename: String, + + #[command(flatten)] + format: OutputFormatArgs, } #[derive(Subcommand, Debug)] enum AccountCommands { /// List all configured Nextcloud accounts - List, + List(AccountListArgs), /// Set the active default account Default { @@ -101,6 +134,28 @@ enum AccountCommands { }, } +#[derive(Args, Debug, Clone, Default)] +struct AccountListArgs { + /// Format output as JSON + #[arg(long, conflicts_with = "tsv")] + json: bool, + + /// Format output as Tab-Separated Values (TSV) + #[arg(long, conflicts_with = "json")] + tsv: bool, +} + +#[derive(Debug, Clone, Serialize)] +struct UploadRecord { + file: String, + remote_path: String, + bytes: u64, + #[serde(skip_serializing_if = "Option::is_none")] + share_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + direct_download_url: Option, +} + #[tokio::main] async fn main() { let cli = Cli::parse(); @@ -108,7 +163,9 @@ async fn main() { let result = match cli.command { Commands::Login(args) => handle_login(args).await, Commands::Upload(args) => handle_upload(args).await, - Commands::Account(AccountCommands::List) | Commands::Accounts => handle_account_list(), + Commands::Account(AccountCommands::List(args)) | Commands::Accounts(args) => { + handle_account_list(args) + } Commands::Account(AccountCommands::Default { account }) => { handle_account_set_default(&account) } @@ -185,45 +242,92 @@ async fn handle_login(args: LoginArgs) -> Result<()> { /// Handle file and stdin uploads. async fn handle_upload(args: UploadArgs) -> Result<()> { + if (args.format.url_only || args.format.direct_url_only) && !args.share { + return Err(nextcloud_client::NextcloudError::Other( + "Flag --url-only or --direct-url-only requires --share to generate a link.".into(), + )); + } + let client = match args.account.as_deref() { Some(acc) => CredentialStore::create_client_for_account(acc)?, None => CredentialStore::create_client_for_default()?, }; + let mut results: Vec = Vec::new(); + // Stdin Upload if args.stdin { - println!("\x1b[1;34m==>\x1b[0m Reading data from stdin..."); + if !args.format.is_machine_readable() { + println!("\x1b[1;34m==>\x1b[0m Reading data from stdin..."); + } let mut buffer = Vec::new(); io::stdin().read_to_end(&mut buffer)?; let len = buffer.len() as u64; - let remote_path = format!("{}/{}", args.remote_dir.trim_matches('/'), args.filename); - println!("\x1b[1;34m==>\x1b[0m Uploading {} bytes to '{}'...", len, remote_path); + let clean_dir = args.remote_dir.trim_matches('/'); + let remote_path = if clean_dir.is_empty() { + args.filename.clone() + } else { + format!("{clean_dir}/{}", args.filename) + }; + + if !args.format.is_machine_readable() { + println!("\x1b[1;34m==>\x1b[0m Uploading {} bytes to '{}'...", len, remote_path); + } + + let progress_cb = if args.format.is_machine_readable() { + None + } else { + Some(create_progress_callback(len)) + }; - let progress_cb = create_progress_callback(len); let cursor = io::Cursor::new(buffer); let bytes = client - .upload_reader(cursor, &remote_path, Some(len), Some(progress_cb)) + .upload_reader(cursor, &remote_path, Some(len), progress_cb) .await?; - println!("\n\x1b[1;32m✓\x1b[0m Upload complete! ({} bytes)", bytes); + let mut share_url = None; + let mut direct_download_url = None; if args.share { - create_and_print_share(&client, &remote_path, args.password.as_deref()).await?; + if !args.format.is_machine_readable() { + println!("\x1b[1;34m==>\x1b[0m Generating public share link..."); + } + let share = client.create_public_share(&remote_path, args.password.as_deref()).await?; + let direct = client.direct_download_url(&share.token)?; + share_url = Some(share.url); + direct_download_url = Some(direct.to_string()); + } + + results.push(UploadRecord { + file: "stdin".to_string(), + remote_path, + bytes, + share_url, + direct_download_url, + }); + } else { + if args.files.is_empty() { + return Err(nextcloud_client::NextcloudError::Other( + "No files specified for upload. Usage: nut upload ... or nut upload --stdin".into(), + )); + } + + for file_path in &args.files { + let record = upload_single_file( + &client, + file_path, + &args.remote_dir, + args.share, + args.password.as_deref(), + &args.format, + ) + .await?; + results.push(record); } - return Ok(()); - } - - if args.files.is_empty() { - return Err(nextcloud_client::NextcloudError::Other( - "No files specified for upload. Usage: nut upload ... or nut upload --stdin".into(), - )); - } - - for file_path in &args.files { - upload_single_file(&client, file_path, &args.remote_dir, args.share, args.password.as_deref()).await?; } + render_upload_results(&results, &args.format); Ok(()) } @@ -233,7 +337,8 @@ async fn upload_single_file( remote_dir: &str, create_share: bool, password: Option<&str>, -) -> Result<()> { + format: &OutputFormatArgs, +) -> Result { if !local_path.exists() { return Err(nextcloud_client::NextcloudError::NotFound { path: local_path.display().to_string(), @@ -255,9 +360,16 @@ async fn upload_single_file( let metadata = tokio::fs::metadata(local_path).await?; let file_size = metadata.len(); - println!("\x1b[1;34m==>\x1b[0m Uploading '{}' ({} bytes) -> '{}'...", local_path.display(), file_size, remote_path); + if !format.is_machine_readable() { + println!("\x1b[1;34m==>\x1b[0m Uploading '{}' ({} bytes) -> '{}'...", local_path.display(), file_size, remote_path); + } + + let progress_cb = if format.is_machine_readable() { + None + } else { + Some(create_progress_callback(file_size)) + }; - let progress_cb = create_progress_callback(file_size); let options = UploadOptions { remote_path: remote_path.clone(), create_share, @@ -266,33 +378,82 @@ async fn upload_single_file( }; let result = client - .upload_and_share(local_path, &options, Some(progress_cb)) + .upload_and_share(local_path, &options, progress_cb) .await?; - println!("\n\x1b[1;32m✓\x1b[0m Uploaded '{}' ({} bytes)", file_name, result.bytes_uploaded); - - if let Some(share_url) = result.share_url { - println!(" \x1b[1;32mShare Link:\x1b[0m {}", share_url); - } - if let Some(direct_url) = result.direct_download_url { - println!(" \x1b[1;32mDirect Download:\x1b[0m {}", direct_url); - } - - Ok(()) + Ok(UploadRecord { + file: local_path.display().to_string(), + remote_path, + bytes: result.bytes_uploaded, + share_url: result.share_url, + direct_download_url: result.direct_download_url, + }) } -async fn create_and_print_share( - client: &NextcloudClient, - remote_path: &str, - password: Option<&str>, -) -> Result<()> { - println!("\x1b[1;34m==>\x1b[0m Generating public share link..."); - let share = client.create_public_share(remote_path, password).await?; - let direct_url = client.direct_download_url(&share.token)?; +fn render_upload_results(results: &[UploadRecord], format: &OutputFormatArgs) { + if format.json { + if results.len() == 1 { + println!("{}", serde_json::to_string_pretty(&results[0]).unwrap()); + } else { + println!("{}", serde_json::to_string_pretty(&results).unwrap()); + } + return; + } - println!(" \x1b[1;32mShare Link:\x1b[0m {}", share.url); - println!(" \x1b[1;32mDirect Download:\x1b[0m {}", direct_url); - Ok(()) + if format.tsv { + for r in results { + println!( + "{}\t{}\t{}\t{}\t{}", + r.file, + r.remote_path, + r.bytes, + r.share_url.as_deref().unwrap_or(""), + r.direct_download_url.as_deref().unwrap_or("") + ); + } + return; + } + + if format.url_only { + for r in results { + if let Some(ref url) = r.share_url { + println!("{url}"); + } + } + return; + } + + if format.direct_url_only { + for r in results { + if let Some(ref url) = r.direct_download_url { + println!("{url}"); + } + } + return; + } + + if format.quiet { + // Quiet mode: if share URL exists, print that, otherwise print remote path + for r in results { + if let Some(ref url) = r.share_url { + println!("{url}"); + } else { + println!("{}", r.remote_path); + } + } + return; + } + + // Default human-readable terminal output + for r in results { + println!("\n\x1b[1;32m✓\x1b[0m Uploaded '{}' ({} bytes)", r.file, r.bytes); + if let Some(ref share_url) = r.share_url { + println!(" \x1b[1;32mShare Link:\x1b[0m {}", share_url); + } + if let Some(ref direct_url) = r.direct_download_url { + println!(" \x1b[1;32mDirect Download:\x1b[0m {}", direct_url); + } + } } fn create_progress_callback(total_bytes: u64) -> ProgressCallback { @@ -316,8 +477,28 @@ fn create_progress_callback(total_bytes: u64) -> ProgressCallback { } /// List stored accounts. -fn handle_account_list() -> Result<()> { +fn handle_account_list(args: AccountListArgs) -> Result<()> { let accounts = CredentialStore::list_accounts()?; + + if args.json { + println!("{}", serde_json::to_string_pretty(&accounts)?); + return Ok(()); + } + + if args.tsv { + for acc in &accounts { + println!( + "{}\t{}\t{}\t{}\t{}", + acc.id, + acc.username, + acc.server_url, + acc.is_default, + acc.label.as_deref().unwrap_or("") + ); + } + return Ok(()); + } + if accounts.is_empty() { println!("No Nextcloud accounts configured yet."); println!("Run \x1b[1;36mnut login \x1b[0m to connect an account.");