460 lines
15 KiB
Rust
460 lines
15 KiB
Rust
//! 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<String>,
|
|
/// Basic-auth user / role name (empty against an open server).
|
|
#[arg(short, long)]
|
|
pub user: Option<String>,
|
|
/// Basic-auth password.
|
|
#[arg(short, long)]
|
|
pub password: Option<String>,
|
|
}
|
|
|
|
/// 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/<name>`.
|
|
Save { path: String, name: String },
|
|
/// Capture a subtree under `/crabidy/<name>` (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<String> },
|
|
/// Insert tracks/subtrees at a position.
|
|
Insert { position: u32, paths: Vec<String> },
|
|
/// Replace the whole queue with the given tracks/subtrees.
|
|
Replace { paths: Vec<String> },
|
|
/// Remove queue entries by position.
|
|
Remove { positions: Vec<u32> },
|
|
/// 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/<name>`.
|
|
Save { name: String },
|
|
/// Capture the current queue into `/crabidy/<name>` (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 <role> [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<String>,
|
|
/// Only print the PHC hash; do not modify `crabidy-server.toml`.
|
|
#[arg(long)]
|
|
pub no_config: bool,
|
|
}
|
|
|
|
/// `scan <path> [--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 <role> [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<String>,
|
|
/// Also set the server address in the client config.
|
|
#[arg(short, long)]
|
|
pub address: Option<String>,
|
|
}
|
|
|
|
/// `completions <shell>`: 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<ServerCommand>,
|
|
}
|
|
|
|
/// 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<bool>,
|
|
#[command(subcommand)]
|
|
pub command: Option<TuiCommand>,
|
|
}
|
|
|
|
/// 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<bool>,
|
|
#[command(subcommand)]
|
|
pub command: Option<CbdCommand>,
|
|
}
|
|
|
|
/// 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))
|
|
));
|
|
}
|
|
}
|