crabidy/cbd-cli/src/lib.rs

456 lines
14 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),
/// 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),
/// 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))
));
}
}