//! Shared command-line definitions for the crabidy binaries //! (`crabidy-server`, `cbd-tui`, `cbd`), plus a feature-gated gRPC executor //! for the remote `library`/`queue`/`global` commands. //! //! See `architecture/cli.md`. The clap types live here so every binary shares //! one command surface and each binary's `build.rs` can generate shell //! completions and man pages from its own top-level [`clap::Command`] with the //! default (clap-only) feature set. The `client` feature adds [`run_remote`], //! which the binaries call to execute a remote command against a running //! server. use clap::{Args, Parser, Subcommand, ValueEnum}; /// A server auth role. The `ValueEnum` names double as the basic-auth user /// names the server expects. #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] pub enum Role { /// Full control (the normal user). Owner, /// Anything on the queue and playback, no library writes. QueueOwner, /// May browse and append to the queue; nothing else. Appender, } impl Role { /// The basic-auth user name for this role. pub fn user_name(self) -> &'static str { match self { Role::Owner => "owner", Role::QueueOwner => "queue-owner", Role::Appender => "queue-appender", } } } /// Connection flags shared by every remote command. Defaults match the client /// config; a flag overrides the config-file value. #[derive(Debug, Clone, Args)] pub struct RemoteArgs { /// Server address (default: the client config's, else localhost). #[arg(short, long)] pub address: Option, /// Basic-auth user / role name (empty against an open server). #[arg(short, long)] pub user: Option, /// Basic-auth password. #[arg(short, long)] pub password: Option, } /// Library operations against a running server. #[derive(Debug, Subcommand)] pub enum LibraryCmd { /// List a node's child nodes and tracks (default path `/`). List { #[arg(default_value = "/")] path: String, }, /// Create a child node under a creatable parent (e.g. a search term). Create { parent: String, title: String }, /// Rename an editable node. Rename { path: String, title: String }, /// Delete a deletable node or track (never touches the content store). Delete { path: String }, /// Save a subtree as a link bookmark under `/crabidy/`. Save { path: String, name: String }, /// Capture a subtree under `/crabidy/` (downloads audio). Capture { path: String, name: String }, } /// Queue operations against a running server. #[derive(Debug, Subcommand)] pub enum QueueCmd { /// Print the current queue. Show, /// Append tracks/subtrees (by library path) to the end of the queue. Append { paths: Vec }, /// Insert tracks/subtrees at a position. Insert { position: u32, paths: Vec }, /// Replace the whole queue with the given tracks/subtrees. Replace { paths: Vec }, /// Remove queue entries by position. Remove { positions: Vec }, /// Clear the queue (optionally keeping the current track). Clear { #[arg(long)] keep_current: bool, }, /// Jump to a queue position. SetCurrent { position: u32 }, /// Link-save the current queue as `/crabidy/`. Save { name: String }, /// Capture the current queue into `/crabidy/` (downloads audio). Capture { name: String }, /// Toggle shuffle. Shuffle, /// Toggle repeat. Repeat, } /// Playback and global operations against a running server. #[derive(Debug, Subcommand)] pub enum GlobalCmd { /// Toggle play/pause. Play, /// Stop playback. Stop, /// Skip to the next track. Next, /// Go to the previous track. Prev, /// Restart the current track. Restart, /// Toggle mute. Mute, /// Change the volume by a delta in [-1.0, 1.0] (e.g. `0.05`, `-0.1`). Volume { delta: f32 }, } /// The remote command groups an executor can run (`client` feature). #[derive(Debug, Subcommand)] pub enum RemoteCmd { /// Library operations. #[command(subcommand)] Library(LibraryCmd), /// Queue operations. #[command(subcommand)] Queue(QueueCmd), /// Playback / global operations. #[command(subcommand)] Global(GlobalCmd), } /// `guard [password]`: hash a role password and (unless `--no-config`) /// write it into `crabidy-server.toml`'s `[auth]`. #[derive(Debug, Args)] pub struct GuardArgs { /// Which role's password to set. pub role: Role, /// The password. If omitted, it is read from stdin (pipe-friendly, and /// keeps it out of the process list). pub password: Option, /// Only print the PHC hash; do not modify `crabidy-server.toml`. #[arg(long)] pub no_config: bool, } /// `scan [--capture|--move]`: index a music folder. #[derive(Debug, Args)] pub struct ScanArgs { /// Directory to walk for playable files. pub path: std::path::PathBuf, /// Copy each file into the content store; the toml points there. #[arg(long)] pub capture: bool, /// Move each file into the content store instead of copying. #[arg(long, name = "move")] pub move_: bool, } /// `auth [password]`: write a role + cleartext password into the client /// config. #[derive(Debug, Args)] pub struct AuthArgs { /// Which role to authenticate as. pub role: Role, /// The cleartext password (read from stdin if omitted). pub password: Option, /// Also set the server address in the client config. #[arg(short, long)] pub address: Option, } /// `completions `: print a completion script to stdout. #[derive(Debug, Args)] pub struct CompletionsArgs { /// Target shell. pub shell: clap_complete::Shell, } /// Subcommands of `crabidy-server`. #[derive(Debug, Subcommand)] pub enum ServerCommand { /// Set a role password (hash + write config). Guard(GuardArgs), /// Index a music folder with `.cbd-track.toml` files. Scan(ScanArgs), /// Library operations against a running server. #[command(subcommand)] Library(LibraryCmd), /// Queue operations against a running server. #[command(subcommand)] Queue(QueueCmd), /// Playback / global operations against a running server. #[command(subcommand)] Global(GlobalCmd), /// List the audio output devices (to pick `[audio] device`). AudioDevices, /// Print a shell completion script. Completions(CompletionsArgs), } /// `crabidy-server`: no subcommand runs the server. #[derive(Debug, Parser)] #[command(name = "crabidy-server", author, version, about)] pub struct ServerCli { #[command(flatten)] pub remote: RemoteArgs, #[command(subcommand)] pub command: Option, } /// Subcommands of `cbd-tui`. #[derive(Debug, Subcommand)] pub enum TuiCommand { /// Write a role + password into the client config. Auth(AuthArgs), #[command(subcommand)] Library(LibraryCmd), #[command(subcommand)] Queue(QueueCmd), #[command(subcommand)] Global(GlobalCmd), /// Print a shell completion script. Completions(CompletionsArgs), } /// `cbd-tui`: no subcommand runs the TUI. #[derive(Debug, Parser)] #[command(name = "cbd-tui", author, version, about)] pub struct TuiCli { #[command(flatten)] pub remote: RemoteArgs, /// Show the frequency-spectrum bars under the track progress. #[arg(long)] pub spectrum: Option, #[command(subcommand)] pub command: Option, } /// Subcommands of `cbd` — the union of the server and client commands. #[derive(Debug, Subcommand)] pub enum CbdCommand { Guard(GuardArgs), Scan(ScanArgs), Auth(AuthArgs), #[command(subcommand)] Library(LibraryCmd), #[command(subcommand)] Queue(QueueCmd), #[command(subcommand)] Global(GlobalCmd), /// List the audio output devices (to pick `[audio] device`). AudioDevices, /// Print a shell completion script. Completions(CompletionsArgs), } /// `cbd`: no subcommand runs the in-process server + TUI. #[derive(Debug, Parser)] #[command(name = "cbd", author, version, about)] pub struct CbdCli { #[command(flatten)] pub remote: RemoteArgs, #[arg(long)] pub spectrum: Option, #[command(subcommand)] pub command: Option, } /// Resolved connection settings for the executor (owned, unlike the TUI's /// `&'static ServerConfig`). #[derive(Debug, Clone)] pub struct Connection { pub address: String, pub user: String, pub password: String, } /// Writes bash/zsh/fish completions and a man page for `cmd` into `dir`. /// Used by each binary's `build.rs` (architecture/cli.md D7). pub fn generate_assets( mut cmd: clap::Command, bin_name: &str, dir: &std::path::Path, ) -> std::io::Result<()> { use clap_complete::Shell; std::fs::create_dir_all(dir.join("completions"))?; std::fs::create_dir_all(dir.join("man"))?; for shell in [Shell::Bash, Shell::Zsh, Shell::Fish] { clap_complete::generate_to(shell, &mut cmd, bin_name, dir.join("completions"))?; } let man = clap_mangen::Man::new(cmd); let mut out = Vec::new(); man.render(&mut out)?; std::fs::write(dir.join("man").join(format!("{bin_name}.1")), out)?; Ok(()) } /// Prints a completion script for `shell` to stdout (the `completions` /// subcommand). pub fn print_completions(shell: clap_complete::Shell, cmd: &mut clap::Command, bin_name: &str) { clap_complete::generate(shell, cmd, bin_name, &mut std::io::stdout()); } #[cfg(feature = "client")] mod client; #[cfg(feature = "client")] pub use client::run_remote; #[cfg(test)] mod tests { use super::*; use clap::Parser; #[test] fn no_subcommand_parses_on_every_binary() { assert!(ServerCli::try_parse_from(["crabidy-server"]) .expect("server") .command .is_none()); assert!(TuiCli::try_parse_from(["cbd-tui"]) .expect("tui") .command .is_none()); assert!(CbdCli::try_parse_from(["cbd"]) .expect("cbd") .command .is_none()); } #[test] fn remote_flags_override_when_present_and_are_none_otherwise() { let cli = TuiCli::try_parse_from(["cbd-tui", "--address", "http://x:1", "-u", "owner"]) .expect("parse"); assert_eq!(cli.remote.address.as_deref(), Some("http://x:1")); assert_eq!(cli.remote.user.as_deref(), Some("owner")); assert!(cli.remote.password.is_none()); let bare = TuiCli::try_parse_from(["cbd-tui"]).expect("bare"); assert!(bare.remote.address.is_none()); assert!(bare.remote.user.is_none()); } #[test] fn library_list_defaults_to_root() { let cli = ServerCli::try_parse_from(["crabidy-server", "library", "list"]).expect("parse"); match cli.command { Some(ServerCommand::Library(LibraryCmd::List { path })) => assert_eq!(path, "/"), other => panic!("unexpected: {other:?}"), } let cli = ServerCli::try_parse_from(["crabidy-server", "library", "list", "/tidal"]) .expect("parse"); match cli.command { Some(ServerCommand::Library(LibraryCmd::List { path })) => assert_eq!(path, "/tidal"), other => panic!("unexpected: {other:?}"), } } #[test] fn queue_append_collects_many_paths() { let cli = TuiCli::try_parse_from(["cbd-tui", "queue", "append", "/a", "/b", "/c"]) .expect("parse"); match cli.command { Some(TuiCommand::Queue(QueueCmd::Append { paths })) => { assert_eq!(paths, vec!["/a", "/b", "/c"]); } other => panic!("unexpected: {other:?}"), } } #[test] fn queue_clear_keep_current_is_a_flag() { let cli = TuiCli::try_parse_from(["cbd-tui", "queue", "clear", "--keep-current"]).expect("parse"); match cli.command { Some(TuiCommand::Queue(QueueCmd::Clear { keep_current })) => assert!(keep_current), other => panic!("unexpected: {other:?}"), } } #[test] fn global_volume_parses_a_signed_delta() { let cli = TuiCli::try_parse_from(["cbd-tui", "global", "volume", "--", "-0.1"]).expect("parse"); match cli.command { Some(TuiCommand::Global(GlobalCmd::Volume { delta })) => { assert!((delta - -0.1).abs() < f32::EPSILON); } other => panic!("unexpected: {other:?}"), } } #[test] fn guard_reads_a_role_and_optional_password() { let cli = ServerCli::try_parse_from(["crabidy-server", "guard", "owner"]).expect("parse"); match cli.command { Some(ServerCommand::Guard(args)) => { assert_eq!(args.role, Role::Owner); assert!(args.password.is_none()); assert!(!args.no_config); } other => panic!("unexpected: {other:?}"), } let cli = ServerCli::try_parse_from([ "crabidy-server", "guard", "queue-owner", "secret", "--no-config", ]) .expect("parse"); match cli.command { Some(ServerCommand::Guard(args)) => { assert_eq!(args.role, Role::QueueOwner); assert_eq!(args.password.as_deref(), Some("secret")); assert!(args.no_config); } other => panic!("unexpected: {other:?}"), } } #[test] fn scan_flags_parse() { let cli = ServerCli::try_parse_from(["crabidy-server", "scan", "/music", "--capture"]) .expect("p"); match cli.command { Some(ServerCommand::Scan(args)) => { assert_eq!(args.path, std::path::PathBuf::from("/music")); assert!(args.capture); assert!(!args.move_); } other => panic!("unexpected: {other:?}"), } } #[test] fn cbd_is_the_union_of_server_and_client_commands() { assert!(matches!( CbdCli::try_parse_from(["cbd", "guard", "owner"]) .expect("p") .command, Some(CbdCommand::Guard(_)) )); assert!(matches!( CbdCli::try_parse_from(["cbd", "auth", "owner", "pw"]) .expect("p") .command, Some(CbdCommand::Auth(_)) )); assert!(matches!( CbdCli::try_parse_from(["cbd", "global", "play"]) .expect("p") .command, Some(CbdCommand::Global(GlobalCmd::Play)) )); } }