From bc3bc20cfd9c41efe39e3a4cd863f96a13d0a3ae Mon Sep 17 00:00:00 2001 From: Aly Raffauf Date: Mon, 3 Aug 2026 00:33:18 -0400 Subject: [PATCH] Simplify CLI command handling --- src/cli.rs | 209 +++++++++++++++++++++++++++++------------------ src/cli/tests.rs | 11 ++- 2 files changed, 140 insertions(+), 80 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index 0daf9d1..7684e43 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,10 +1,11 @@ use clap::{Parser, Subcommand}; use clap_complete::Shell; +use qrcode::render::unicode::Dense1x2; use std::{ fs, io::{self, Read}, - path::PathBuf, + path::{Path, PathBuf}, }; use crate::app::AppaService; @@ -14,6 +15,38 @@ mod setup; mod status; mod tooling; +const DEFAULT_LOG_FILTER: &str = "info,noq_proto=error"; + +struct InviteOutput { + copy_to_clipboard: bool, + render_qr: bool, + output_path: Option, +} + +enum FolderRemoval { + Forget, + Leave, +} + +impl FolderRemoval { + fn prompt(&self) -> &'static str { + match self { + Self::Forget => "Forget this folder's Appa sync state? Files will stay in place", + Self::Leave => "Leave this Appa folder? Your files will stay in place", + } + } + + fn print_confirmation(&self, folder: &Path) { + match self { + Self::Forget => println!( + "Forgot Appa sync state for {}. Files were not changed.", + folder.display() + ), + Self::Leave => println!("Left {}. Your files were not changed.", folder.display()), + } + } +} + #[derive(Debug, Parser)] #[command(name = "appa", about = "Local-first peer-to-peer folder sync")] struct CommandLine { @@ -77,6 +110,7 @@ enum Command { }, Restore { folder: String, + #[arg(allow_negative_numbers = true)] revision: i64, }, Conflicts { @@ -164,55 +198,66 @@ async fn run_app_command(command: Command) -> anyhow::Result<()> { let appa = AppaService::open()?; match command { - Command::Init { folder } => initialize_folder(&appa, PathBuf::from(folder))?, + Command::Init { folder } => initialize_folder(&appa, Path::new(&folder))?, Command::Identity { command } => setup::run_identity_command(&appa, command)?, Command::Invite { folder, copy, qr, file, - } => print_invite(&appa, PathBuf::from(folder), copy, qr, file).await?, + } => { + print_invite( + &appa, + Path::new(&folder), + InviteOutput { + copy_to_clipboard: copy, + render_qr: qr, + output_path: file, + }, + ) + .await? + } Command::Join { folder, ticket, stdin, yes, - } => join_folder(&appa, PathBuf::from(folder), ticket, stdin, yes)?, - Command::Forget { folder, yes } => forget_folder(&appa, PathBuf::from(folder), yes)?, - Command::Leave { folder, yes } => leave_folder(&appa, PathBuf::from(folder), yes)?, + } => join_folder(&appa, Path::new(&folder), ticket, stdin, yes)?, + Command::Forget { folder, yes } => forget_folder(&appa, Path::new(&folder), yes)?, + Command::Leave { folder, yes } => leave_folder(&appa, Path::new(&folder), yes)?, Command::Status { folder, json, watch, } => { - let folder = folder.map(PathBuf::from); + let folder = folder.as_deref().map(Path::new); if watch { - status::watch_status(&appa, folder.as_deref()).await?; + status::watch_status(&appa, folder).await?; } else { - status::print_status(&appa, folder.as_deref(), json)?; + status::print_status(&appa, folder, json)?; } } - Command::Peers { folder, json } => print_peers(&appa, PathBuf::from(folder), json)?, - Command::History { folder } => print_history(&appa, PathBuf::from(folder))?, + Command::Peers { folder, json } => print_peers(&appa, Path::new(&folder), json)?, + Command::History { folder } => print_history(&appa, Path::new(&folder))?, Command::Restore { folder, revision } => { - restore_revision(&appa, PathBuf::from(folder), revision).await? + restore_revision(&appa, Path::new(&folder), revision).await? } - Command::Conflicts { folder } => print_conflicts(&appa, PathBuf::from(folder))?, - Command::Members { folder } => print_members(&appa, PathBuf::from(folder))?, + Command::Conflicts { folder } => print_conflicts(&appa, Path::new(&folder))?, + Command::Members { folder } => print_members(&appa, Path::new(&folder))?, Command::Revoke { folder, device_id } => { - revoke_member(&appa, PathBuf::from(folder), &device_id)? + revoke_member(&appa, Path::new(&folder), &device_id)? } Command::Completions { .. } => unreachable!("completions are handled before opening Appa"), Command::Config { command } => setup::run_config(&appa, command)?, - Command::Run { folder } => run_folder(&appa, folder.map(PathBuf::from)).await?, + Command::Run { folder } => run_folder(&appa, folder.as_deref().map(Path::new)).await?, Command::Doctor { json } => print_doctor(&appa, json)?, Command::Service { .. } => unreachable!("service commands are handled before opening Appa"), } Ok(()) } -fn initialize_folder(appa: &AppaService, folder: PathBuf) -> anyhow::Result<()> { - let configured_folder = appa.register_folder(&folder)?; +fn initialize_folder(appa: &AppaService, folder: &Path) -> anyhow::Result<()> { + let configured_folder = appa.register_folder(folder)?; println!( "Appa is ready to sync {}.", configured_folder.path.display() @@ -222,37 +267,32 @@ fn initialize_folder(appa: &AppaService, folder: PathBuf) -> anyhow::Result<()> async fn print_invite( appa: &AppaService, - folder: PathBuf, - copy: bool, - qr: bool, - file: Option, + folder: &Path, + output: InviteOutput, ) -> anyhow::Result<()> { - let ticket = appa.create_invite(&folder).await?; + let ticket = appa.create_invite(folder).await?; println!( "Share this reusable folder capability only with trusted devices. It expires in 24 hours and has no use limit:\n{ticket}" ); - if let Some(path) = file { + if let Some(path) = output.output_path { fs::write(&path, format!("{ticket}\n"))?; println!("Wrote invitation to {}.", path.display()); } - if copy { + if output.copy_to_clipboard { arboard::Clipboard::new()?.set_text(ticket.clone())?; println!("Copied invitation to the clipboard."); } - if qr { + if output.render_qr { let code = qrcode::QrCode::new(ticket.as_bytes())?; - println!( - "{}", - code.render::().build() - ); + println!("{}", code.render::().build()); } Ok(()) } -fn print_peers(appa: &AppaService, folder: PathBuf, json: bool) -> anyhow::Result<()> { - let peers = appa.peers(&folder)?; +fn print_peers(appa: &AppaService, folder: &Path, json: bool) -> anyhow::Result<()> { + let peers = appa.peers(folder)?; if json { - println!("{}", serde_json::json!(peers.iter().map(|peer| serde_json::json!({"device_id": peer.device_id, "last_seen": peer.last_seen.to_string()})).collect::>())); + println!("{}", peers_as_json(&peers)); return Ok(()); } for peer in peers { @@ -261,8 +301,20 @@ fn print_peers(appa: &AppaService, folder: PathBuf, json: bool) -> anyhow::Resul Ok(()) } -fn print_history(appa: &AppaService, folder: PathBuf) -> anyhow::Result<()> { - for revision in appa.history(&folder)? { +fn peers_as_json(peers: &[crate::storage::PeerInfo]) -> serde_json::Value { + serde_json::json!( + peers + .iter() + .map(|peer| serde_json::json!({ + "device_id": peer.device_id, + "last_seen": peer.last_seen.to_string(), + })) + .collect::>() + ) +} + +fn print_history(appa: &AppaService, folder: &Path) -> anyhow::Result<()> { + for revision in appa.history(folder)? { println!( "{}\t{}\t{} entries", revision.revision, @@ -273,18 +325,14 @@ fn print_history(appa: &AppaService, folder: PathBuf) -> anyhow::Result<()> { Ok(()) } -async fn restore_revision( - appa: &AppaService, - folder: PathBuf, - revision: i64, -) -> anyhow::Result<()> { - let restored_count = appa.restore(&folder, revision).await?; +async fn restore_revision(appa: &AppaService, folder: &Path, revision: i64) -> anyhow::Result<()> { + let restored_count = appa.restore(folder, revision).await?; println!("Restored revision {revision} ({restored_count} file writes)."); Ok(()) } -fn print_conflicts(appa: &AppaService, folder: PathBuf) -> anyhow::Result<()> { - for conflict in appa.conflicts(&folder)? { +fn print_conflicts(appa: &AppaService, folder: &Path) -> anyhow::Result<()> { + for conflict in appa.conflicts(folder)? { println!( "{}\n authored by {} at {}", conflict.path, conflict.author_device_id, conflict.modified_at @@ -293,16 +341,16 @@ fn print_conflicts(appa: &AppaService, folder: PathBuf) -> anyhow::Result<()> { Ok(()) } -fn print_members(appa: &AppaService, folder: PathBuf) -> anyhow::Result<()> { - for member in appa.members(&folder)? { +fn print_members(appa: &AppaService, folder: &Path) -> anyhow::Result<()> { + for member in appa.members(folder)? { let label = member.label.as_deref().unwrap_or("unnamed"); println!("{label}\n {}", member.device_id); } Ok(()) } -fn revoke_member(appa: &AppaService, folder: PathBuf, device_id: &str) -> anyhow::Result<()> { - appa.revoke_member(&folder, device_id)?; +fn revoke_member(appa: &AppaService, folder: &Path, device_id: &str) -> anyhow::Result<()> { + appa.revoke_member(folder, device_id)?; println!( "Revoked {device_id}; the folder capability has rotated. Issue fresh invitations to remaining members." ); @@ -311,20 +359,16 @@ fn revoke_member(appa: &AppaService, folder: PathBuf, device_id: &str) -> anyhow fn join_folder( appa: &AppaService, - folder: PathBuf, + folder: &Path, ticket: Option, stdin: bool, yes: bool, ) -> anyhow::Result<()> { - if !yes - && !dialoguer::Confirm::new() - .with_prompt("Join this Appa folder?") - .interact()? - { + if !confirm(yes, "Join this Appa folder?")? { return Ok(()); } let ticket = resolve_ticket(ticket, stdin)?; - let configured_folder = appa.join_folder(&folder, &ticket)?; + let configured_folder = appa.join_folder(folder, &ticket)?; println!( "Joined {} at {}.", configured_folder.name, @@ -333,36 +377,43 @@ fn join_folder( Ok(()) } -fn forget_folder(appa: &AppaService, folder: PathBuf, yes: bool) -> anyhow::Result<()> { - if !yes - && !dialoguer::Confirm::new() - .with_prompt("Forget this folder's Appa sync state? Files will stay in place") - .interact()? - { - return Ok(()); - } - appa.forget_folder(&folder)?; - println!( - "Forgot Appa sync state for {}. Files were not changed.", - folder.display() - ); +fn forget_folder(appa: &AppaService, folder: &Path, yes: bool) -> anyhow::Result<()> { + remove_folder_state(appa, folder, yes, FolderRemoval::Forget)?; + Ok(()) +} + +fn leave_folder(appa: &AppaService, folder: &Path, yes: bool) -> anyhow::Result<()> { + remove_folder_state(appa, folder, yes, FolderRemoval::Leave)?; Ok(()) } -fn leave_folder(appa: &AppaService, folder: PathBuf, yes: bool) -> anyhow::Result<()> { - if !yes - && !dialoguer::Confirm::new() - .with_prompt("Leave this Appa folder? Your files will stay in place") - .interact()? - { +fn remove_folder_state( + appa: &AppaService, + folder: &Path, + yes: bool, + removal: FolderRemoval, +) -> anyhow::Result<()> { + if !confirm(yes, removal.prompt())? { return Ok(()); } - appa.forget_folder(&folder)?; - println!("Left {}. Your files were not changed.", folder.display()); + appa.forget_folder(folder)?; + removal.print_confirmation(folder); Ok(()) } +fn confirm(yes: bool, prompt: &str) -> anyhow::Result { + Ok(yes || dialoguer::Confirm::new().with_prompt(prompt).interact()?) +} + fn resolve_ticket(ticket: Option, stdin: bool) -> anyhow::Result { + resolve_ticket_from(ticket, stdin, &mut io::stdin()) +} + +fn resolve_ticket_from( + ticket: Option, + stdin: bool, + reader: &mut impl Read, +) -> anyhow::Result { if let Some(ticket) = ticket { if stdin { anyhow::bail!("pass either an invitation argument or --stdin, not both"); @@ -373,13 +424,13 @@ fn resolve_ticket(ticket: Option, stdin: bool) -> anyhow::Result anyhow::bail!("pass an invitation argument or use --stdin"); } let mut ticket = String::new(); - io::stdin().read_to_string(&mut ticket)?; + reader.read_to_string(&mut ticket)?; Ok(ticket.trim().to_owned()) } -async fn run_folder(appa: &AppaService, folder: Option) -> anyhow::Result<()> { +async fn run_folder(appa: &AppaService, folder: Option<&Path>) -> anyhow::Result<()> { println!("Appa is running. Press Ctrl-C to stop."); - appa.run_forever(folder.as_deref()).await?; + appa.run_forever(folder).await?; Ok(()) } @@ -410,7 +461,7 @@ fn initialize_logging() { tracing_subscriber::fmt() .with_env_filter( tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info,noq_proto=error")), + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(DEFAULT_LOG_FILTER)), ) .with_target(false) .init(); diff --git a/src/cli/tests.rs b/src/cli/tests.rs index 343d89e..caad8e6 100644 --- a/src/cli/tests.rs +++ b/src/cli/tests.rs @@ -1,6 +1,15 @@ -use super::resolve_ticket; +use std::io::Cursor; + +use super::{resolve_ticket, resolve_ticket_from}; #[test] fn rejects_ambiguous_ticket_input() { assert!(resolve_ticket(Some("appa://ticket".to_owned()), true).is_err()); } + +#[test] +fn trims_ticket_read_from_standard_input() { + let mut input = Cursor::new(" appa://ticket\n"); + let ticket = resolve_ticket_from(None, true, &mut input).expect("ticket should parse"); + assert_eq!(ticket, "appa://ticket"); +} -- 2.51.2