/* * 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::config::{CompletionType, Config}; 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()) // Show the real name, but insert it escaped: the editor replaces the // word with this text, and an unescaped space would turn one argument // into two on the next Enter. .map(|candidate| { let name = unescape(candidate); Pair { display: name.clone(), replacement: escape(&name), } }) .collect() } /// Escape a candidate so it stays one argument when inserted into the line. fn escape(candidate: &str) -> String { let mut out = String::with_capacity(candidate.len()); for c in candidate.chars() { if matches!(c, ' ' | '\\' | '\'' | '"') { out.push('\\'); } out.push(c); } out } /// 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 } /// 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"); // Bash-style completion: extend to the longest common prefix and print the // candidates when the word stays ambiguous, instead of silently cycling // through them (which hides what is actually available). let config = Config::builder() .completion_type(CompletionType::List) .completion_show_all_if_ambiguous(true) .build(); let mut editor: Editor = Editor::with_default(config)?; 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 escapes_candidates_before_inserting_them() { assert_eq!(escape("AFX"), "AFX"); assert_eq!(escape("Analogue Bubblebath/"), r"Analogue\ Bubblebath/"); assert_eq!(escape(r"a\\b"), r"a\\\\b"); // What the editor inserts must come back as a single argument. let line = format!("play {}", escape("AFX/Analogue Bubblebath, Vol 3.1/")); assert_eq!( split_args(&line), vec!["play", "AFX/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 ); } } }