From 5dfb5b4ce521a44eb4e9173d20c58e665c78e979 Mon Sep 17 00:00:00 2001 From: salmonik Date: Mon, 2 Nov 2026 13:35:56 +0100 Subject: ctl: interactive shell with history, editing and completion `rsp ctl` opens a prompt in the spirit of bluetoothctl. Lines are parsed with the same clap definitions as the one-shot CLI, so every command and flag works exactly as it does from a terminal, `help ` included. Tab completes subcommands, and track/album/artist paths for `play` through `db-complete` (run out of process, keeping its 100 ms timeout away from the input loop). History lives in the state directory. Piped input falls back to a plain line loop, so `rsp ctl < commands` works without a terminal. --- src/ctl.rs | 359 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 359 insertions(+) create mode 100644 src/ctl.rs (limited to 'src/ctl.rs') diff --git a/src/ctl.rs b/src/ctl.rs new file mode 100644 index 0000000..5cb2f3b --- /dev/null +++ b/src/ctl.rs @@ -0,0 +1,359 @@ +/* + * This file is a part of RSPlayer + * Licensed under the GPL-3.0 License + * Copyright (C) 2026 Salmonik @ salmonik@tuta.io + * https://codeberg.org/salmonik/RSPlayer +*/ + +//! Interactive control shell (`rsp ctl`), in the spirit of `bluetoothctl`. +//! +//! Every line is parsed with the same clap definitions as the one-shot CLI, so +//! the shell understands exactly the same commands (`play --now …`, +//! `volume +5`, `settings set …`) instead of keeping a second, drifting copy. +//! History is kept in the state directory, and Tab completes commands plus +//! track/album paths for `play`. + +use crate::cli::Cli; +use clap::{CommandFactory, Parser}; +use rustyline::completion::{Completer, Pair}; +use rustyline::error::ReadlineError; +use rustyline::highlight::Highlighter; +use rustyline::hint::Hinter; +use rustyline::history::DefaultHistory; +use rustyline::validate::Validator; +use rustyline::{Context, Editor, Helper}; +use std::io::IsTerminal; +use std::process::{Command, Stdio}; + +const PROMPT: &str = "rsp> "; + +/// Commands the shell handles itself. +const LOCAL_COMMANDS: [&str; 5] = ["help", "quit", "exit", "clear", "?"]; + +struct CtlHelper { + commands: Vec, +} + +impl Default for CtlHelper { + fn default() -> Self { + let mut commands: Vec = Cli::command() + .get_subcommands() + .map(|command| command.get_name().to_string()) + .chain(LOCAL_COMMANDS.iter().map(|c| c.to_string())) + .collect(); + commands.sort(); + commands.dedup(); + Self { commands } + } +} + +impl Helper for CtlHelper {} +impl Highlighter for CtlHelper {} +impl Hinter for CtlHelper { + type Hint = String; +} +impl Validator for CtlHelper {} + +impl Completer for CtlHelper { + type Candidate = Pair; + + fn complete( + &self, + line: &str, + pos: usize, + _ctx: &Context<'_>, + ) -> rustyline::Result<(usize, Vec)> { + let before = &line[..pos]; + let Some(start) = before.rfind(char::is_whitespace).map(|i| i + 1) else { + let prefix = before.to_string(); + let matches = self + .commands + .iter() + .filter(|command| command.starts_with(&prefix)) + .map(|command| Pair { + display: command.clone(), + replacement: command.clone(), + }) + .collect(); + return Ok((0, matches)); + }; + + let word = &before[start..]; + let command = before[..start].trim(); + let matches = match command { + "play" | "play-album" => complete_music(word), + _ => Vec::new(), + }; + Ok((start, matches)) + } +} + +/// Candidate tracks/albums for the current prefix, from `rsp db-complete`. +/// +/// Completion runs out of process on purpose: `db-complete` already applies the +/// 100 ms database timeout and the filesystem fallback, so a slow or missing +/// index cannot hang the shell's input loop. +fn complete_music(prefix: &str) -> Vec { + let Ok(exe) = std::env::current_exe() else { + return Vec::new(); + }; + let Ok(output) = Command::new(exe) + .arg("db-complete") + .arg(prefix) + .stderr(Stdio::null()) + .output() + else { + return Vec::new(); + }; + + String::from_utf8_lossy(&output.stdout) + .lines() + .map(str::trim) + .filter(|candidate| !candidate.is_empty()) + // The CLI escapes spaces for shells; the shell's own reader needs the + // real text back and re-quotes it below. + .map(unescape) + .map(|candidate| Pair { + display: candidate.clone(), + replacement: quote(&candidate), + }) + .collect() +} + +/// Undo the shell escaping `rsp db-complete` applies. +fn unescape(candidate: &str) -> String { + let mut out = String::with_capacity(candidate.len()); + let mut chars = candidate.chars(); + while let Some(c) = chars.next() { + if c == '\\' { + if let Some(next) = chars.next() { + out.push(next); + } + } else { + out.push(c); + } + } + out +} + +/// Quote a candidate that contains spaces, so it stays one argument. +fn quote(candidate: &str) -> String { + if candidate.contains(char::is_whitespace) { + format!("'{}'", candidate.replace('\'', r"'\''")) + } else { + candidate.to_string() + } +} + +/// Split a command line into arguments, honouring quotes and backslash +/// escapes so that track names with spaces survive. +fn split_args(line: &str) -> Vec { + let mut args = Vec::new(); + let mut current = String::new(); + let mut quote: Option = None; + let mut in_word = false; + let mut chars = line.chars(); + + while let Some(c) = chars.next() { + match quote { + Some(q) => { + if c == q { + quote = None; + } else if c == '\\' && q == '"' { + current.extend(chars.next()); + } else { + current.push(c); + } + } + None => match c { + '\'' | '"' => { + quote = Some(c); + in_word = true; + } + '\\' => { + current.extend(chars.next()); + in_word = true; + } + c if c.is_whitespace() => { + if in_word { + args.push(std::mem::take(&mut current)); + in_word = false; + } + } + c => { + current.push(c); + in_word = true; + } + }, + } + } + if in_word { + args.push(current); + } + args +} + +enum Step { + Continue, + Quit, +} + +fn dispatch(line: &str) -> Result> { + let mut args = split_args(line); + let Some(command) = args.first().cloned() else { + return Ok(Step::Continue); + }; + + match command.as_str() { + "quit" | "exit" | "q" => return Ok(Step::Quit), + "clear" => { + print!("\x1b[2J\x1b[H"); + return Ok(Step::Continue); + } + "help" | "?" => { + let mut cli = Cli::command(); + match args.get(1).and_then(|name| cli.find_subcommand_mut(name)) { + Some(subcommand) => { + subcommand.print_help()?; + println!(); + } + None => { + cli.print_help()?; + println!(); + } + } + return Ok(Step::Continue); + } + // These replace or block the process the shell runs in. + "start-daemon" | "ctl" => { + return Err(format!( + "`{}` would take over the shell — run it from a normal terminal", + command + ) + .into()); + } + _ => {} + } + + args.insert(0, "rsp".to_string()); + match Cli::try_parse_from(args) { + Ok(cli) => cli.run()?, + Err(e) + if matches!( + e.kind(), + clap::error::ErrorKind::DisplayHelp | clap::error::ErrorKind::DisplayVersion + ) => + { + print!("{}", e); + } + Err(e) => eprintln!("{}", e.render()), + } + Ok(Step::Continue) +} + +pub fn run() -> Result<(), Box> { + if !std::io::stdin().is_terminal() { + return run_piped(); + } + + let history_path = crate::config::runtime_dir().join("ctl_history"); + let mut editor: Editor = Editor::default()?; + let _ = editor.load_history(&history_path); + + if !crate::config::Config::get_socket_path().exists() { + eprintln!("note: no daemon socket — start it with `start-daemon` or the service"); + } + println!("RSPlayer control shell — `help` lists commands, `quit` leaves."); + loop { + match editor.readline(PROMPT) { + Ok(line) => { + let line = line.trim().to_string(); + if line.is_empty() { + continue; + } + let _ = editor.add_history_entry(line.as_str()); + match dispatch(&line) { + Ok(Step::Continue) => {} + Ok(Step::Quit) => break, + Err(e) => eprintln!("Error: {}", e), + } + } + // Ctrl-C drops the line, Ctrl-D leaves. + Err(ReadlineError::Interrupted) => continue, + Err(ReadlineError::Eof) => break, + Err(e) => { + eprintln!("Input error: {}", e); + break; + } + } + } + + let _ = editor.save_history(&history_path); + Ok(()) +} + +/// Same commands without an editor, for pipes and scripts +/// (`rsp ctl < commands.txt`), where rustyline has no terminal to drive. +fn run_piped() -> Result<(), Box> { + use std::io::BufRead; + + for line in std::io::stdin().lock().lines() { + match dispatch(line?.trim()) { + Ok(Step::Continue) => {} + Ok(Step::Quit) => break, + Err(e) => eprintln!("Error: {}", e), + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use clap::Parser; + + #[test] + fn splits_plain_and_quoted_arguments() { + assert_eq!(split_args("seek 1:30"), vec!["seek", "1:30"]); + assert_eq!( + split_args("play 'Some Album/01 Track.mp3'"), + vec!["play", "Some Album/01 Track.mp3"] + ); + assert_eq!( + split_args(r#"play "A \"quoted\" name""#), + vec!["play", r#"A "quoted" name"#] + ); + assert_eq!(split_args(" status "), vec!["status"]); + assert!(split_args(" ").is_empty()); + } + + #[test] + fn quoted_arguments_round_trip_through_the_cli() { + let args = split_args("seek +15"); + let mut full = vec!["rsp".to_string()]; + full.extend(args); + let cli = Cli::try_parse_from(full).expect("seek +15 is a valid command"); + assert!(matches!(cli.command, crate::cli::Commands::Seek { .. })); + } + + #[test] + fn quotes_single_word_arguments_only_when_needed() { + assert_eq!(quote("AFX"), "AFX"); + assert_eq!( + quote("Analogue Bubblebath, Vol 3.1"), + "'Analogue Bubblebath, Vol 3.1'" + ); + } + + #[test] + fn completes_command_names() { + let helper = CtlHelper::default(); + for expected in ["play", "seek", "settings", "status", "quit"] { + assert!( + helper.commands.iter().any(|c| c == expected), + "completion is missing `{}`", + expected + ); + } + } +} -- cgit v1.2.3