diff options
| author | salmonik <salmonik@tuta.io> | 2026-11-02 13:35:56 +0100 |
|---|---|---|
| committer | salmonik <salmonik@tuta.io> | 2026-11-02 13:35:56 +0100 |
| commit | 5dfb5b4ce521a44eb4e9173d20c58e665c78e979 (patch) | |
| tree | 514508f9bef90444b867a68abfeb2ad365c44d14 | |
| parent | audio: open the output at the track's rate instead of pw-alsa's 44100 (diff) | |
| download | rsplayer-5dfb5b4ce521a44eb4e9173d20c58e665c78e979.tar.gz rsplayer-5dfb5b4ce521a44eb4e9173d20c58e665c78e979.zip | |
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 <command>` 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.
| -rw-r--r-- | Cargo.lock | 82 | ||||
| -rw-r--r-- | Cargo.toml | 1 | ||||
| -rw-r--r-- | README.md | 1 | ||||
| -rw-r--r-- | src/cli.rs | 5 | ||||
| -rw-r--r-- | src/ctl.rs | 359 | ||||
| -rw-r--r-- | src/lib.rs | 1 | ||||
| -rw-r--r-- | src/main.rs | 1 |
7 files changed, 450 insertions, 0 deletions
| @@ -19,6 +19,7 @@ dependencies = [ | |||
| 19 | "rand 0.10.1", | 19 | "rand 0.10.1", |
| 20 | "reqwest", | 20 | "reqwest", |
| 21 | "rusqlite", | 21 | "rusqlite", |
| 22 | "rustyline", | ||
| 22 | "serde", | 23 | "serde", |
| 23 | "serde_json", | 24 | "serde_json", |
| 24 | "symphonia", | 25 | "symphonia", |
| @@ -422,6 +423,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 422 | checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" | 423 | checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" |
| 423 | 424 | ||
| 424 | [[package]] | 425 | [[package]] |
| 426 | name = "clipboard-win" | ||
| 427 | version = "5.4.3" | ||
| 428 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 429 | checksum = "052af7878466feccda12ba7a6a67d019d712f296809559b47c12c8cc196d3a9a" | ||
| 430 | dependencies = [ | ||
| 431 | "error-code", | ||
| 432 | ] | ||
| 433 | |||
| 434 | [[package]] | ||
| 425 | name = "cmake" | 435 | name = "cmake" |
| 426 | version = "0.1.58" | 436 | version = "0.1.58" |
| 427 | source = "registry+https://github.com/rust-lang/crates.io-index" | 437 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -667,6 +677,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 667 | checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" | 677 | checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" |
| 668 | 678 | ||
| 669 | [[package]] | 679 | [[package]] |
| 680 | name = "endian-type" | ||
| 681 | version = "0.2.0" | ||
| 682 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 683 | checksum = "869b0adbda23651a9c5c0c3d270aac9fcb52e8622a8f2b17e57802d7791962f2" | ||
| 684 | |||
| 685 | [[package]] | ||
| 670 | name = "enumflags2" | 686 | name = "enumflags2" |
| 671 | version = "0.7.12" | 687 | version = "0.7.12" |
| 672 | source = "registry+https://github.com/rust-lang/crates.io-index" | 688 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -704,6 +720,12 @@ dependencies = [ | |||
| 704 | ] | 720 | ] |
| 705 | 721 | ||
| 706 | [[package]] | 722 | [[package]] |
| 723 | name = "error-code" | ||
| 724 | version = "3.4.0" | ||
| 725 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 726 | checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" | ||
| 727 | |||
| 728 | [[package]] | ||
| 707 | name = "event-listener" | 729 | name = "event-listener" |
| 708 | version = "5.4.1" | 730 | version = "5.4.1" |
| 709 | source = "registry+https://github.com/rust-lang/crates.io-index" | 731 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -961,6 +983,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 961 | checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" | 983 | checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" |
| 962 | 984 | ||
| 963 | [[package]] | 985 | [[package]] |
| 986 | name = "home" | ||
| 987 | version = "0.5.12" | ||
| 988 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 989 | checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" | ||
| 990 | dependencies = [ | ||
| 991 | "windows-sys 0.61.2", | ||
| 992 | ] | ||
| 993 | |||
| 994 | [[package]] | ||
| 964 | name = "http" | 995 | name = "http" |
| 965 | version = "1.4.0" | 996 | version = "1.4.0" |
| 966 | source = "registry+https://github.com/rust-lang/crates.io-index" | 997 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -1459,6 +1490,15 @@ dependencies = [ | |||
| 1459 | ] | 1490 | ] |
| 1460 | 1491 | ||
| 1461 | [[package]] | 1492 | [[package]] |
| 1493 | name = "nibble_vec" | ||
| 1494 | version = "0.1.0" | ||
| 1495 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1496 | checksum = "77a5d83df9f36fe23f0c3648c6bbb8b0298bb5f1939c8f2704431371f4b84d43" | ||
| 1497 | dependencies = [ | ||
| 1498 | "smallvec", | ||
| 1499 | ] | ||
| 1500 | |||
| 1501 | [[package]] | ||
| 1462 | name = "nix" | 1502 | name = "nix" |
| 1463 | version = "0.31.2" | 1503 | version = "0.31.2" |
| 1464 | source = "registry+https://github.com/rust-lang/crates.io-index" | 1504 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -1861,6 +1901,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 1861 | checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" | 1901 | checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" |
| 1862 | 1902 | ||
| 1863 | [[package]] | 1903 | [[package]] |
| 1904 | name = "radix_trie" | ||
| 1905 | version = "0.3.0" | ||
| 1906 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1907 | checksum = "3b4431027dcd37fc2a73ef740b5f233aa805897935b8bce0195e41bbf9a3289a" | ||
| 1908 | dependencies = [ | ||
| 1909 | "endian-type", | ||
| 1910 | "nibble_vec", | ||
| 1911 | ] | ||
| 1912 | |||
| 1913 | [[package]] | ||
| 1864 | name = "rand" | 1914 | name = "rand" |
| 1865 | version = "0.9.4" | 1915 | version = "0.9.4" |
| 1866 | source = "registry+https://github.com/rust-lang/crates.io-index" | 1916 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -2115,6 +2165,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 2115 | checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" | 2165 | checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" |
| 2116 | 2166 | ||
| 2117 | [[package]] | 2167 | [[package]] |
| 2168 | name = "rustyline" | ||
| 2169 | version = "19.0.0" | ||
| 2170 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2171 | checksum = "56e1d6bd81c2d5feafd2419138df9183c45a10a492c4e873b8b0acb8eb2b549e" | ||
| 2172 | dependencies = [ | ||
| 2173 | "bitflags", | ||
| 2174 | "clipboard-win", | ||
| 2175 | "home", | ||
| 2176 | "libc", | ||
| 2177 | "log", | ||
| 2178 | "memchr", | ||
| 2179 | "nix", | ||
| 2180 | "radix_trie", | ||
| 2181 | "unicode-segmentation", | ||
| 2182 | "unicode-width", | ||
| 2183 | "utf8parse", | ||
| 2184 | "windows-sys 0.61.2", | ||
| 2185 | ] | ||
| 2186 | |||
| 2187 | [[package]] | ||
| 2118 | name = "ryu" | 2188 | name = "ryu" |
| 2119 | version = "1.0.23" | 2189 | version = "1.0.23" |
| 2120 | source = "registry+https://github.com/rust-lang/crates.io-index" | 2190 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -2843,6 +2913,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 2843 | checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" | 2913 | checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" |
| 2844 | 2914 | ||
| 2845 | [[package]] | 2915 | [[package]] |
| 2916 | name = "unicode-segmentation" | ||
| 2917 | version = "1.13.3" | ||
| 2918 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2919 | checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" | ||
| 2920 | |||
| 2921 | [[package]] | ||
| 2922 | name = "unicode-width" | ||
| 2923 | version = "0.2.3" | ||
| 2924 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2925 | checksum = "ca718471876992059f251e33aafc334346ada08042b7661dd1fe313a9bced557" | ||
| 2926 | |||
| 2927 | [[package]] | ||
| 2846 | name = "unicode-xid" | 2928 | name = "unicode-xid" |
| 2847 | version = "0.2.6" | 2929 | version = "0.2.6" |
| 2848 | source = "registry+https://github.com/rust-lang/crates.io-index" | 2930 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -36,6 +36,7 @@ notify-rust = "4.17.0" | |||
| 36 | md-5 = "0.11.0" | 36 | md-5 = "0.11.0" |
| 37 | hex = "0.4.3" | 37 | hex = "0.4.3" |
| 38 | zbus = { version = "5", default-features = false, features = ["blocking-api"] } | 38 | zbus = { version = "5", default-features = false, features = ["blocking-api"] } |
| 39 | rustyline = "19.0.0" | ||
| 39 | 40 | ||
| 40 | [profile.release] | 41 | [profile.release] |
| 41 | opt-level = "z" | 42 | opt-level = "z" |
| @@ -69,4 +69,5 @@ these are normal commands: | |||
| 69 | - `status` Show player status | 69 | - `status` Show player status |
| 70 | - `scan` Scan music paths to populate database | 70 | - `scan` Scan music paths to populate database |
| 71 | - `settings` Settings management | 71 | - `settings` Settings management |
| 72 | - `ctl` Interactive shell with history and tab completion (like bluetoothctl) | ||
| 72 | - `help` Print this message or the help of the given subcommand(s) | 73 | - `help` Print this message or the help of the given subcommand(s) |
| @@ -104,6 +104,8 @@ pub enum Commands { | |||
| 104 | }, | 104 | }, |
| 105 | /// display lyrics | 105 | /// display lyrics |
| 106 | Lyrics, | 106 | Lyrics, |
| 107 | /// Interactive control shell (like bluetoothctl) | ||
| 108 | Ctl, | ||
| 107 | } | 109 | } |
| 108 | 110 | ||
| 109 | #[derive(Subcommand)] | 111 | #[derive(Subcommand)] |
| @@ -350,6 +352,9 @@ impl Cli { | |||
| 350 | Commands::Lyrics => { | 352 | Commands::Lyrics => { |
| 351 | let _ = crate::daemon::send_command("lyrics".to_string()); | 353 | let _ = crate::daemon::send_command("lyrics".to_string()); |
| 352 | } | 354 | } |
| 355 | Commands::Ctl => { | ||
| 356 | crate::ctl::run()?; | ||
| 357 | } | ||
| 353 | } | 358 | } |
| 354 | Ok(()) | 359 | Ok(()) |
| 355 | } | 360 | } |
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 @@ | |||
| 1 | /* | ||
| 2 | * This file is a part of RSPlayer | ||
| 3 | * Licensed under the GPL-3.0 License | ||
| 4 | * Copyright (C) 2026 Salmonik @ salmonik@tuta.io | ||
| 5 | * https://codeberg.org/salmonik/RSPlayer | ||
| 6 | */ | ||
| 7 | |||
| 8 | //! Interactive control shell (`rsp ctl`), in the spirit of `bluetoothctl`. | ||
| 9 | //! | ||
| 10 | //! Every line is parsed with the same clap definitions as the one-shot CLI, so | ||
| 11 | //! the shell understands exactly the same commands (`play --now …`, | ||
| 12 | //! `volume +5`, `settings set …`) instead of keeping a second, drifting copy. | ||
| 13 | //! History is kept in the state directory, and Tab completes commands plus | ||
| 14 | //! track/album paths for `play`. | ||
| 15 | |||
| 16 | use crate::cli::Cli; | ||
| 17 | use clap::{CommandFactory, Parser}; | ||
| 18 | use rustyline::completion::{Completer, Pair}; | ||
| 19 | use rustyline::error::ReadlineError; | ||
| 20 | use rustyline::highlight::Highlighter; | ||
| 21 | use rustyline::hint::Hinter; | ||
| 22 | use rustyline::history::DefaultHistory; | ||
| 23 | use rustyline::validate::Validator; | ||
| 24 | use rustyline::{Context, Editor, Helper}; | ||
| 25 | use std::io::IsTerminal; | ||
| 26 | use std::process::{Command, Stdio}; | ||
| 27 | |||
| 28 | const PROMPT: &str = "rsp> "; | ||
| 29 | |||
| 30 | /// Commands the shell handles itself. | ||
| 31 | const LOCAL_COMMANDS: [&str; 5] = ["help", "quit", "exit", "clear", "?"]; | ||
| 32 | |||
| 33 | struct CtlHelper { | ||
| 34 | commands: Vec<String>, | ||
| 35 | } | ||
| 36 | |||
| 37 | impl Default for CtlHelper { | ||
| 38 | fn default() -> Self { | ||
| 39 | let mut commands: Vec<String> = Cli::command() | ||
| 40 | .get_subcommands() | ||
| 41 | .map(|command| command.get_name().to_string()) | ||
| 42 | .chain(LOCAL_COMMANDS.iter().map(|c| c.to_string())) | ||
| 43 | .collect(); | ||
| 44 | commands.sort(); | ||
| 45 | commands.dedup(); | ||
| 46 | Self { commands } | ||
| 47 | } | ||
| 48 | } | ||
| 49 | |||
| 50 | impl Helper for CtlHelper {} | ||
| 51 | impl Highlighter for CtlHelper {} | ||
| 52 | impl Hinter for CtlHelper { | ||
| 53 | type Hint = String; | ||
| 54 | } | ||
| 55 | impl Validator for CtlHelper {} | ||
| 56 | |||
| 57 | impl Completer for CtlHelper { | ||
| 58 | type Candidate = Pair; | ||
| 59 | |||
| 60 | fn complete( | ||
| 61 | &self, | ||
| 62 | line: &str, | ||
| 63 | pos: usize, | ||
| 64 | _ctx: &Context<'_>, | ||
| 65 | ) -> rustyline::Result<(usize, Vec<Pair>)> { | ||
| 66 | let before = &line[..pos]; | ||
| 67 | let Some(start) = before.rfind(char::is_whitespace).map(|i| i + 1) else { | ||
| 68 | let prefix = before.to_string(); | ||
| 69 | let matches = self | ||
| 70 | .commands | ||
| 71 | .iter() | ||
| 72 | .filter(|command| command.starts_with(&prefix)) | ||
| 73 | .map(|command| Pair { | ||
| 74 | display: command.clone(), | ||
| 75 | replacement: command.clone(), | ||
| 76 | }) | ||
| 77 | .collect(); | ||
| 78 | return Ok((0, matches)); | ||
| 79 | }; | ||
| 80 | |||
| 81 | let word = &before[start..]; | ||
| 82 | let command = before[..start].trim(); | ||
| 83 | let matches = match command { | ||
| 84 | "play" | "play-album" => complete_music(word), | ||
| 85 | _ => Vec::new(), | ||
| 86 | }; | ||
| 87 | Ok((start, matches)) | ||
| 88 | } | ||
| 89 | } | ||
| 90 | |||
| 91 | /// Candidate tracks/albums for the current prefix, from `rsp db-complete`. | ||
| 92 | /// | ||
| 93 | /// Completion runs out of process on purpose: `db-complete` already applies the | ||
| 94 | /// 100 ms database timeout and the filesystem fallback, so a slow or missing | ||
| 95 | /// index cannot hang the shell's input loop. | ||
| 96 | fn complete_music(prefix: &str) -> Vec<Pair> { | ||
| 97 | let Ok(exe) = std::env::current_exe() else { | ||
| 98 | return Vec::new(); | ||
| 99 | }; | ||
| 100 | let Ok(output) = Command::new(exe) | ||
| 101 | .arg("db-complete") | ||
| 102 | .arg(prefix) | ||
| 103 | .stderr(Stdio::null()) | ||
| 104 | .output() | ||
| 105 | else { | ||
| 106 | return Vec::new(); | ||
| 107 | }; | ||
| 108 | |||
| 109 | String::from_utf8_lossy(&output.stdout) | ||
| 110 | .lines() | ||
| 111 | .map(str::trim) | ||
| 112 | .filter(|candidate| !candidate.is_empty()) | ||
| 113 | // The CLI escapes spaces for shells; the shell's own reader needs the | ||
| 114 | // real text back and re-quotes it below. | ||
| 115 | .map(unescape) | ||
| 116 | .map(|candidate| Pair { | ||
| 117 | display: candidate.clone(), | ||
| 118 | replacement: quote(&candidate), | ||
| 119 | }) | ||
| 120 | .collect() | ||
| 121 | } | ||
| 122 | |||
| 123 | /// Undo the shell escaping `rsp db-complete` applies. | ||
| 124 | fn unescape(candidate: &str) -> String { | ||
| 125 | let mut out = String::with_capacity(candidate.len()); | ||
| 126 | let mut chars = candidate.chars(); | ||
| 127 | while let Some(c) = chars.next() { | ||
| 128 | if c == '\\' { | ||
| 129 | if let Some(next) = chars.next() { | ||
| 130 | out.push(next); | ||
| 131 | } | ||
| 132 | } else { | ||
| 133 | out.push(c); | ||
| 134 | } | ||
| 135 | } | ||
| 136 | out | ||
| 137 | } | ||
| 138 | |||
| 139 | /// Quote a candidate that contains spaces, so it stays one argument. | ||
| 140 | fn quote(candidate: &str) -> String { | ||
| 141 | if candidate.contains(char::is_whitespace) { | ||
| 142 | format!("'{}'", candidate.replace('\'', r"'\''")) | ||
| 143 | } else { | ||
| 144 | candidate.to_string() | ||
| 145 | } | ||
| 146 | } | ||
| 147 | |||
| 148 | /// Split a command line into arguments, honouring quotes and backslash | ||
| 149 | /// escapes so that track names with spaces survive. | ||
| 150 | fn split_args(line: &str) -> Vec<String> { | ||
| 151 | let mut args = Vec::new(); | ||
| 152 | let mut current = String::new(); | ||
| 153 | let mut quote: Option<char> = None; | ||
| 154 | let mut in_word = false; | ||
| 155 | let mut chars = line.chars(); | ||
| 156 | |||
| 157 | while let Some(c) = chars.next() { | ||
| 158 | match quote { | ||
| 159 | Some(q) => { | ||
| 160 | if c == q { | ||
| 161 | quote = None; | ||
| 162 | } else if c == '\\' && q == '"' { | ||
| 163 | current.extend(chars.next()); | ||
| 164 | } else { | ||
| 165 | current.push(c); | ||
| 166 | } | ||
| 167 | } | ||
| 168 | None => match c { | ||
| 169 | '\'' | '"' => { | ||
| 170 | quote = Some(c); | ||
| 171 | in_word = true; | ||
| 172 | } | ||
| 173 | '\\' => { | ||
| 174 | current.extend(chars.next()); | ||
| 175 | in_word = true; | ||
| 176 | } | ||
| 177 | c if c.is_whitespace() => { | ||
| 178 | if in_word { | ||
| 179 | args.push(std::mem::take(&mut current)); | ||
| 180 | in_word = false; | ||
| 181 | } | ||
| 182 | } | ||
| 183 | c => { | ||
| 184 | current.push(c); | ||
| 185 | in_word = true; | ||
| 186 | } | ||
| 187 | }, | ||
| 188 | } | ||
| 189 | } | ||
| 190 | if in_word { | ||
| 191 | args.push(current); | ||
| 192 | } | ||
| 193 | args | ||
| 194 | } | ||
| 195 | |||
| 196 | enum Step { | ||
| 197 | Continue, | ||
| 198 | Quit, | ||
| 199 | } | ||
| 200 | |||
| 201 | fn dispatch(line: &str) -> Result<Step, Box<dyn std::error::Error>> { | ||
| 202 | let mut args = split_args(line); | ||
| 203 | let Some(command) = args.first().cloned() else { | ||
| 204 | return Ok(Step::Continue); | ||
| 205 | }; | ||
| 206 | |||
| 207 | match command.as_str() { | ||
| 208 | "quit" | "exit" | "q" => return Ok(Step::Quit), | ||
| 209 | "clear" => { | ||
| 210 | print!("\x1b[2J\x1b[H"); | ||
| 211 | return Ok(Step::Continue); | ||
| 212 | } | ||
| 213 | "help" | "?" => { | ||
| 214 | let mut cli = Cli::command(); | ||
| 215 | match args.get(1).and_then(|name| cli.find_subcommand_mut(name)) { | ||
| 216 | Some(subcommand) => { | ||
| 217 | subcommand.print_help()?; | ||
| 218 | println!(); | ||
| 219 | } | ||
| 220 | None => { | ||
| 221 | cli.print_help()?; | ||
| 222 | println!(); | ||
| 223 | } | ||
| 224 | } | ||
| 225 | return Ok(Step::Continue); | ||
| 226 | } | ||
| 227 | // These replace or block the process the shell runs in. | ||
| 228 | "start-daemon" | "ctl" => { | ||
| 229 | return Err(format!( | ||
| 230 | "`{}` would take over the shell — run it from a normal terminal", | ||
| 231 | command | ||
| 232 | ) | ||
| 233 | .into()); | ||
| 234 | } | ||
| 235 | _ => {} | ||
| 236 | } | ||
| 237 | |||
| 238 | args.insert(0, "rsp".to_string()); | ||
| 239 | match Cli::try_parse_from(args) { | ||
| 240 | Ok(cli) => cli.run()?, | ||
| 241 | Err(e) | ||
| 242 | if matches!( | ||
| 243 | e.kind(), | ||
| 244 | clap::error::ErrorKind::DisplayHelp | clap::error::ErrorKind::DisplayVersion | ||
| 245 | ) => | ||
| 246 | { | ||
| 247 | print!("{}", e); | ||
| 248 | } | ||
| 249 | Err(e) => eprintln!("{}", e.render()), | ||
| 250 | } | ||
| 251 | Ok(Step::Continue) | ||
| 252 | } | ||
| 253 | |||
| 254 | pub fn run() -> Result<(), Box<dyn std::error::Error>> { | ||
| 255 | if !std::io::stdin().is_terminal() { | ||
| 256 | return run_piped(); | ||
| 257 | } | ||
| 258 | |||
| 259 | let history_path = crate::config::runtime_dir().join("ctl_history"); | ||
| 260 | let mut editor: Editor<CtlHelper, DefaultHistory> = Editor::default()?; | ||
| 261 | let _ = editor.load_history(&history_path); | ||
| 262 | |||
| 263 | if !crate::config::Config::get_socket_path().exists() { | ||
| 264 | eprintln!("note: no daemon socket — start it with `start-daemon` or the service"); | ||
| 265 | } | ||
| 266 | println!("RSPlayer control shell — `help` lists commands, `quit` leaves."); | ||
| 267 | loop { | ||
| 268 | match editor.readline(PROMPT) { | ||
| 269 | Ok(line) => { | ||
| 270 | let line = line.trim().to_string(); | ||
| 271 | if line.is_empty() { | ||
| 272 | continue; | ||
| 273 | } | ||
| 274 | let _ = editor.add_history_entry(line.as_str()); | ||
| 275 | match dispatch(&line) { | ||
| 276 | Ok(Step::Continue) => {} | ||
| 277 | Ok(Step::Quit) => break, | ||
| 278 | Err(e) => eprintln!("Error: {}", e), | ||
| 279 | } | ||
| 280 | } | ||
| 281 | // Ctrl-C drops the line, Ctrl-D leaves. | ||
| 282 | Err(ReadlineError::Interrupted) => continue, | ||
| 283 | Err(ReadlineError::Eof) => break, | ||
| 284 | Err(e) => { | ||
| 285 | eprintln!("Input error: {}", e); | ||
| 286 | break; | ||
| 287 | } | ||
| 288 | } | ||
| 289 | } | ||
| 290 | |||
| 291 | let _ = editor.save_history(&history_path); | ||
| 292 | Ok(()) | ||
| 293 | } | ||
| 294 | |||
| 295 | /// Same commands without an editor, for pipes and scripts | ||
| 296 | /// (`rsp ctl < commands.txt`), where rustyline has no terminal to drive. | ||
| 297 | fn run_piped() -> Result<(), Box<dyn std::error::Error>> { | ||
| 298 | use std::io::BufRead; | ||
| 299 | |||
| 300 | for line in std::io::stdin().lock().lines() { | ||
| 301 | match dispatch(line?.trim()) { | ||
| 302 | Ok(Step::Continue) => {} | ||
| 303 | Ok(Step::Quit) => break, | ||
| 304 | Err(e) => eprintln!("Error: {}", e), | ||
| 305 | } | ||
| 306 | } | ||
| 307 | Ok(()) | ||
| 308 | } | ||
| 309 | |||
| 310 | #[cfg(test)] | ||
| 311 | mod tests { | ||
| 312 | use super::*; | ||
| 313 | use clap::Parser; | ||
| 314 | |||
| 315 | #[test] | ||
| 316 | fn splits_plain_and_quoted_arguments() { | ||
| 317 | assert_eq!(split_args("seek 1:30"), vec!["seek", "1:30"]); | ||
| 318 | assert_eq!( | ||
| 319 | split_args("play 'Some Album/01 Track.mp3'"), | ||
| 320 | vec!["play", "Some Album/01 Track.mp3"] | ||
| 321 | ); | ||
| 322 | assert_eq!( | ||
| 323 | split_args(r#"play "A \"quoted\" name""#), | ||
| 324 | vec!["play", r#"A "quoted" name"#] | ||
| 325 | ); | ||
| 326 | assert_eq!(split_args(" status "), vec!["status"]); | ||
| 327 | assert!(split_args(" ").is_empty()); | ||
| 328 | } | ||
| 329 | |||
| 330 | #[test] | ||
| 331 | fn quoted_arguments_round_trip_through_the_cli() { | ||
| 332 | let args = split_args("seek +15"); | ||
| 333 | let mut full = vec!["rsp".to_string()]; | ||
| 334 | full.extend(args); | ||
| 335 | let cli = Cli::try_parse_from(full).expect("seek +15 is a valid command"); | ||
| 336 | assert!(matches!(cli.command, crate::cli::Commands::Seek { .. })); | ||
| 337 | } | ||
| 338 | |||
| 339 | #[test] | ||
| 340 | fn quotes_single_word_arguments_only_when_needed() { | ||
| 341 | assert_eq!(quote("AFX"), "AFX"); | ||
| 342 | assert_eq!( | ||
| 343 | quote("Analogue Bubblebath, Vol 3.1"), | ||
| 344 | "'Analogue Bubblebath, Vol 3.1'" | ||
| 345 | ); | ||
| 346 | } | ||
| 347 | |||
| 348 | #[test] | ||
| 349 | fn completes_command_names() { | ||
| 350 | let helper = CtlHelper::default(); | ||
| 351 | for expected in ["play", "seek", "settings", "status", "quit"] { | ||
| 352 | assert!( | ||
| 353 | helper.commands.iter().any(|c| c == expected), | ||
| 354 | "completion is missing `{}`", | ||
| 355 | expected | ||
| 356 | ); | ||
| 357 | } | ||
| 358 | } | ||
| 359 | } | ||
| @@ -15,6 +15,7 @@ pub fn debug_eprintln(args: core::fmt::Arguments<'_>) { | |||
| 15 | pub mod audio; | 15 | pub mod audio; |
| 16 | pub mod cli; | 16 | pub mod cli; |
| 17 | pub mod config; | 17 | pub mod config; |
| 18 | pub mod ctl; | ||
| 18 | pub mod daemon; | 19 | pub mod daemon; |
| 19 | pub mod database; | 20 | pub mod database; |
| 20 | pub mod mpris; | 21 | pub mod mpris; |
diff --git a/src/main.rs b/src/main.rs index 4bc4498..0138ea2 100644 --- a/src/main.rs +++ b/src/main.rs | |||
| @@ -8,6 +8,7 @@ | |||
| 8 | mod audio; | 8 | mod audio; |
| 9 | mod cli; | 9 | mod cli; |
| 10 | mod config; | 10 | mod config; |
| 11 | mod ctl; | ||
| 11 | mod daemon; | 12 | mod daemon; |
| 12 | mod database; | 13 | mod database; |
| 13 | mod mpris; | 14 | mod mpris; |
