From fff78c8f22e6d6313804506738ece3105034ed8e Mon Sep 17 00:00:00 2001 From: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com> Date: Fri, 8 May 2026 12:30:30 +0200 Subject: [PATCH] refactor(cli): convert cli.rs to module dir; extract parse layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Convert the 1485-LOC cli.rs into a cli/ module directory and lift the parse-only layer into its own submodule. cli/parse.rs now owns: - the CliCommand / PlayerCliCmd / RepeatCliMode / SearchCliScope / MixCliMode enums - the CliActionRegistry parser (reads shortcutActions.ts at startup) - all wants_* flag helpers (--version, --info, --logs, --tail, --json, --quiet, --follow, logs_tail_lines) - parse_cli_command + parse_player_cli_at + parse_repeat_mode cli/mod.rs keeps the rest for now: print_*_human presenters, exchange write_/read_ functions, run_info_and_exit, run_tail_and_exit, Linux single-instance + IPC forwarding, describe_cli_command, emit_player_cli_cmd, handle_cli_on_primary_instance. Those are the next split candidates (presenters / exchange / linux_forward). Public surface preserved via `pub use parse::*;` so main.rs and lib.rs keep importing cli::wants_version, cli::parse_cli_command etc. unchanged. cli/mod.rs goes from 1485 → 1151 LOC. Behaviour-preserving — same parsing rules, same compile-time include of shortcutActions.ts, same registry-driven verb resolution. --- src-tauri/src/{cli.rs => cli/mod.rs} | 344 +-------------------------- src-tauri/src/cli/parse.rs | 341 ++++++++++++++++++++++++++ 2 files changed, 346 insertions(+), 339 deletions(-) rename src-tauri/src/{cli.rs => cli/mod.rs} (80%) create mode 100644 src-tauri/src/cli/parse.rs diff --git a/src-tauri/src/cli.rs b/src-tauri/src/cli/mod.rs similarity index 80% rename from src-tauri/src/cli.rs rename to src-tauri/src/cli/mod.rs index 3bdfc5a3..7ed82276 100644 --- a/src-tauri/src/cli.rs +++ b/src-tauri/src/cli/mod.rs @@ -1,5 +1,10 @@ //! CLI surface for scripting / compositor bindings (e.g. Hyprland `exec`). +mod parse; + +pub use parse::*; +use parse::{cli_registry_entry_by_command, cli_action_registry_entries}; + // Bundled at compile time for `psysonic completions bash|zsh` (no extra files in packages). const COMPLETIONS_BASH: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../completions/psysonic.bash")); const COMPLETIONS_ZSH: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../completions/_psysonic")); @@ -15,217 +20,6 @@ use std::{ use serde_json::Value; use tauri::{AppHandle, Emitter, Runtime}; -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum RepeatCliMode { - Off, - All, - One, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum SearchCliScope { - Track, - Album, - Artist, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum PlayerCliCmd { - NoArgCommand(String), - PlayOpaqueId(String), - Seek { delta_secs: i32 }, - Volume { percent: u8 }, - Repeat(RepeatCliMode), - Rating { stars: u8 }, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum MixCliMode { - Append, - New, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum CliCommand { - Player(PlayerCliCmd), - AudioDeviceList, - /// `None` → follow host default output (same as Settings “system default”). - AudioDeviceSet(Option), - LibraryList, - /// `"all"` or a music-folder id from `library list`. - LibrarySet(String), - Mix(MixCliMode), - ServerList, - ServerSet(String), - Search { - scope: SearchCliScope, - query: String, - }, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -struct CliActionRegistryEntry { - command: String, - verb: String, - description: String, -} - -fn shortcut_actions_registry_source() -> &'static str { - include_str!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/../src/config/shortcutActions.ts" - )) -} - -fn extract_quoted_field(line: &str, key: &str) -> Option { - let needle = format!("{key}: '"); - let start = line.find(&needle)? + needle.len(); - let tail = &line[start..]; - let end = tail.find('\'')?; - Some(tail[..end].to_string()) -} - -fn parse_registry_action_id(line: &str) -> Option { - let trimmed = line.trim_start(); - if !trimmed.ends_with('{') { - return None; - } - if trimmed.starts_with('\'') { - let rest = &trimmed[1..]; - let end = rest.find('\'')?; - let id = &rest[..end]; - let tail = rest[end + 1..].trim_start(); - if !tail.starts_with(':') { - return None; - } - return Some(id.to_string()); - } - let brace_idx = trimmed.find(':')?; - let candidate = trimmed[..brace_idx].trim(); - if candidate.is_empty() || !trimmed[brace_idx + 1..].trim_start().starts_with('{') { - return None; - } - if !candidate - .chars() - .all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-') - { - return None; - } - Some(candidate.to_string()) -} - -fn parse_cli_action_registry_entries() -> Vec { - let mut entries = Vec::new(); - let mut current_action_id: Option = None; - - for line in shortcut_actions_registry_source().lines() { - if let Some(id) = parse_registry_action_id(line) { - current_action_id = Some(id); - continue; - } - let trimmed = line.trim(); - if !trimmed.starts_with("cli: {") { - continue; - } - let Some(action_id) = current_action_id.clone() else { - continue; - }; - let Some(verb) = extract_quoted_field(trimmed, "verb") else { - continue; - }; - let Some(description) = extract_quoted_field(trimmed, "description") else { - continue; - }; - let command = extract_quoted_field(trimmed, "command").unwrap_or_else(|| action_id.clone()); - entries.push(CliActionRegistryEntry { - command, - verb, - description, - }); - } - - entries -} - -fn cli_action_registry_entries() -> &'static Vec { - static ENTRIES: OnceLock> = OnceLock::new(); - ENTRIES.get_or_init(parse_cli_action_registry_entries) -} - -fn cli_registry_entry_by_verb(verb: &str) -> Option<&'static CliActionRegistryEntry> { - cli_action_registry_entries().iter().find(|entry| entry.verb == verb) -} - -fn cli_registry_entry_by_command(command: &str) -> Option<&'static CliActionRegistryEntry> { - cli_action_registry_entries() - .iter() - .find(|entry| entry.command == command) -} - -pub fn wants_version(args: &[String]) -> bool { - args.iter() - .skip(1) - .any(|a| a == "--version" || a == "-V") -} - -pub fn wants_help(args: &[String]) -> bool { - args.iter().skip(1).any(|a| a == "--help" || a == "-h") -} - -pub fn wants_info(args: &[String]) -> bool { - args.iter().skip(1).any(|a| a == "--info") -} - -pub fn wants_info_json(args: &[String]) -> bool { - wants_info(args) && args.iter().skip(1).any(|a| a == "--json") -} - -pub fn wants_tail(args: &[String]) -> bool { - args.iter().skip(1).any(|a| a == "--tail") -} - -pub fn wants_logs(args: &[String]) -> bool { - args.iter().skip(1).any(|a| a == "--logs") -} - -pub fn wants_follow(args: &[String]) -> bool { - args.iter().skip(1).any(|a| a == "-f" || a == "--follow") -} - -pub fn logs_tail_lines(args: &[String]) -> Result, String> { - let mut i = 1usize; - while i < args.len() { - if args[i] == "--tail" { - let Some(raw) = args.get(i + 1) else { - return Err("--tail requires a numeric value".to_string()); - }; - if raw.starts_with('-') { - return Err("--tail requires a positive integer value".to_string()); - } - let n: usize = raw - .parse() - .map_err(|_| "--tail requires a positive integer value".to_string())?; - if n == 0 { - return Err("--tail must be greater than 0".to_string()); - } - return Ok(Some(n)); - } - i += 1; - } - Ok(None) -} - -pub fn wants_quiet(args: &[String]) -> bool { - args.iter() - .skip(1) - .any(|a| a == "--quiet" || a == "-q") -} - -/// Machine-readable output for `--json` with list/search commands (`audio-device`, `library`, `server`, `search`). -pub fn wants_cli_json_output(args: &[String]) -> bool { - args.iter().skip(1).any(|a| a == "--json") -} - pub fn print_version() { println!("{}", env!("CARGO_PKG_VERSION")); } @@ -959,134 +753,6 @@ fn value_inline(v: &Value) -> String { } } -fn parse_repeat_mode(arg: &str) -> Option { - match arg { - "off" => Some(RepeatCliMode::Off), - "all" => Some(RepeatCliMode::All), - "one" => Some(RepeatCliMode::One), - _ => None, - } -} - -fn parse_player_cli_at(args: &[String], pos: usize) -> Option { - let verb = args.get(pos + 1)?.as_str(); - if let Some(entry) = cli_registry_entry_by_verb(verb).filter(|entry| entry.command == "play") { - return match args.get(pos + 2).map(|s| s.as_str()) { - None => Some(PlayerCliCmd::NoArgCommand(entry.command.clone())), - Some(flag) if flag.starts_with('-') => None, - Some(extra) => { - if extra.is_empty() { - return None; - } - Some(PlayerCliCmd::PlayOpaqueId(extra.to_string())) - } - }; - } - match verb { - "repeat" => { - let m = parse_repeat_mode(args.get(pos + 2)?.as_str())?; - Some(PlayerCliCmd::Repeat(m)) - } - "rating" => { - let raw = args.get(pos + 2)?; - let n: u8 = raw.parse().ok()?; - if n > 5 { - return None; - } - Some(PlayerCliCmd::Rating { stars: n }) - } - "seek" => { - let raw = args.get(pos + 2)?; - let delta_secs: i32 = raw.parse().ok()?; - Some(PlayerCliCmd::Seek { delta_secs }) - } - "volume" => { - let raw = args.get(pos + 2)?; - let v: i64 = raw.parse().ok()?; - if !(0..=100).contains(&v) { - return None; - } - Some(PlayerCliCmd::Volume { - percent: v as u8, - }) - } - _ => cli_registry_entry_by_verb(verb) - .map(|entry| PlayerCliCmd::NoArgCommand(entry.command.clone())), - } -} - -/// Parse transport / playback / device / mix `psysonic --player …` argv. -pub fn parse_cli_command(args: &[String]) -> Option { - let pos = args.iter().position(|a| a == "--player")?; - let verb = args.get(pos + 1)?.as_str(); - match verb { - "audio-device" => { - let sub = args.get(pos + 2)?.as_str(); - match sub { - "list" => Some(CliCommand::AudioDeviceList), - "set" => { - let arg = args.get(pos + 3)?; - let name = if arg == "default" { - None - } else { - Some(arg.clone()) - }; - Some(CliCommand::AudioDeviceSet(name)) - } - _ => None, - } - } - "mix" => { - let sub = args.get(pos + 2)?.as_str(); - match sub { - "append" => Some(CliCommand::Mix(MixCliMode::Append)), - "new" => Some(CliCommand::Mix(MixCliMode::New)), - _ => None, - } - } - "library" => { - let sub = args.get(pos + 2)?.as_str(); - match sub { - "list" => Some(CliCommand::LibraryList), - "set" => { - let arg = args.get(pos + 3)?; - Some(CliCommand::LibrarySet(arg.clone())) - } - _ => None, - } - } - "server" => { - let sub = args.get(pos + 2)?.as_str(); - match sub { - "list" => Some(CliCommand::ServerList), - "set" => { - let id = args.get(pos + 3)?; - if id.is_empty() { - return None; - } - Some(CliCommand::ServerSet(id.clone())) - } - _ => None, - } - } - "search" => { - let scope_raw = args.get(pos + 2)?.as_str(); - let scope = match scope_raw { - "track" => SearchCliScope::Track, - "album" => SearchCliScope::Album, - "artist" => SearchCliScope::Artist, - _ => return None, - }; - let tail = args.get(pos + 3..)?; - let query = tail.join(" ").trim().to_string(); - if query.is_empty() { - return None; - } - Some(CliCommand::Search { scope, query }) - } - _ => parse_player_cli_at(args, pos).map(CliCommand::Player), - } -} pub fn write_audio_device_cli_response(engine: &crate::audio::AudioEngine) -> Result<(), String> { let devices = crate::audio::audio_list_devices_for_engine(engine); diff --git a/src-tauri/src/cli/parse.rs b/src-tauri/src/cli/parse.rs new file mode 100644 index 00000000..a135e5d3 --- /dev/null +++ b/src-tauri/src/cli/parse.rs @@ -0,0 +1,341 @@ +use std::sync::OnceLock; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RepeatCliMode { + Off, + All, + One, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SearchCliScope { + Track, + Album, + Artist, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum PlayerCliCmd { + NoArgCommand(String), + PlayOpaqueId(String), + Seek { delta_secs: i32 }, + Volume { percent: u8 }, + Repeat(RepeatCliMode), + Rating { stars: u8 }, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MixCliMode { + Append, + New, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum CliCommand { + Player(PlayerCliCmd), + AudioDeviceList, + /// `None` → follow host default output (same as Settings “system default”). + AudioDeviceSet(Option), + LibraryList, + /// `"all"` or a music-folder id from `library list`. + LibrarySet(String), + Mix(MixCliMode), + ServerList, + ServerSet(String), + Search { + scope: SearchCliScope, + query: String, + }, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct CliActionRegistryEntry { + pub(super) command: String, + pub(super) verb: String, + pub(super) description: String, +} + +fn shortcut_actions_registry_source() -> &'static str { + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../src/config/shortcutActions.ts" + )) +} + +fn extract_quoted_field(line: &str, key: &str) -> Option { + let needle = format!("{key}: '"); + let start = line.find(&needle)? + needle.len(); + let tail = &line[start..]; + let end = tail.find('\'')?; + Some(tail[..end].to_string()) +} + +fn parse_registry_action_id(line: &str) -> Option { + let trimmed = line.trim_start(); + if !trimmed.ends_with('{') { + return None; + } + if trimmed.starts_with('\'') { + let rest = &trimmed[1..]; + let end = rest.find('\'')?; + let id = &rest[..end]; + let tail = rest[end + 1..].trim_start(); + if !tail.starts_with(':') { + return None; + } + return Some(id.to_string()); + } + let brace_idx = trimmed.find(':')?; + let candidate = trimmed[..brace_idx].trim(); + if candidate.is_empty() || !trimmed[brace_idx + 1..].trim_start().starts_with('{') { + return None; + } + if !candidate + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-') + { + return None; + } + Some(candidate.to_string()) +} + +fn parse_cli_action_registry_entries() -> Vec { + let mut entries = Vec::new(); + let mut current_action_id: Option = None; + + for line in shortcut_actions_registry_source().lines() { + if let Some(id) = parse_registry_action_id(line) { + current_action_id = Some(id); + continue; + } + let trimmed = line.trim(); + if !trimmed.starts_with("cli: {") { + continue; + } + let Some(action_id) = current_action_id.clone() else { + continue; + }; + let Some(verb) = extract_quoted_field(trimmed, "verb") else { + continue; + }; + let Some(description) = extract_quoted_field(trimmed, "description") else { + continue; + }; + let command = extract_quoted_field(trimmed, "command").unwrap_or_else(|| action_id.clone()); + entries.push(CliActionRegistryEntry { + command, + verb, + description, + }); + } + + entries +} + +pub(super) fn cli_action_registry_entries() -> &'static Vec { + static ENTRIES: OnceLock> = OnceLock::new(); + ENTRIES.get_or_init(parse_cli_action_registry_entries) +} + +pub(super) fn cli_registry_entry_by_verb(verb: &str) -> Option<&'static CliActionRegistryEntry> { + cli_action_registry_entries().iter().find(|entry| entry.verb == verb) +} + +pub(super) fn cli_registry_entry_by_command(command: &str) -> Option<&'static CliActionRegistryEntry> { + cli_action_registry_entries() + .iter() + .find(|entry| entry.command == command) +} + +pub fn wants_version(args: &[String]) -> bool { + args.iter() + .skip(1) + .any(|a| a == "--version" || a == "-V") +} + +pub fn wants_help(args: &[String]) -> bool { + args.iter().skip(1).any(|a| a == "--help" || a == "-h") +} + +pub fn wants_info(args: &[String]) -> bool { + args.iter().skip(1).any(|a| a == "--info") +} + +pub fn wants_info_json(args: &[String]) -> bool { + wants_info(args) && args.iter().skip(1).any(|a| a == "--json") +} + +pub fn wants_tail(args: &[String]) -> bool { + args.iter().skip(1).any(|a| a == "--tail") +} + +pub fn wants_logs(args: &[String]) -> bool { + args.iter().skip(1).any(|a| a == "--logs") +} + +pub fn wants_follow(args: &[String]) -> bool { + args.iter().skip(1).any(|a| a == "-f" || a == "--follow") +} + +pub fn logs_tail_lines(args: &[String]) -> Result, String> { + let mut i = 1usize; + while i < args.len() { + if args[i] == "--tail" { + let Some(raw) = args.get(i + 1) else { + return Err("--tail requires a numeric value".to_string()); + }; + if raw.starts_with('-') { + return Err("--tail requires a positive integer value".to_string()); + } + let n: usize = raw + .parse() + .map_err(|_| "--tail requires a positive integer value".to_string())?; + if n == 0 { + return Err("--tail must be greater than 0".to_string()); + } + return Ok(Some(n)); + } + i += 1; + } + Ok(None) +} + +pub fn wants_quiet(args: &[String]) -> bool { + args.iter() + .skip(1) + .any(|a| a == "--quiet" || a == "-q") +} + +/// Machine-readable output for `--json` with list/search commands (`audio-device`, `library`, `server`, `search`). +pub fn wants_cli_json_output(args: &[String]) -> bool { + args.iter().skip(1).any(|a| a == "--json") +} + +fn parse_repeat_mode(arg: &str) -> Option { + match arg { + "off" => Some(RepeatCliMode::Off), + "all" => Some(RepeatCliMode::All), + "one" => Some(RepeatCliMode::One), + _ => None, + } +} + +fn parse_player_cli_at(args: &[String], pos: usize) -> Option { + let verb = args.get(pos + 1)?.as_str(); + if let Some(entry) = cli_registry_entry_by_verb(verb).filter(|entry| entry.command == "play") { + return match args.get(pos + 2).map(|s| s.as_str()) { + None => Some(PlayerCliCmd::NoArgCommand(entry.command.clone())), + Some(flag) if flag.starts_with('-') => None, + Some(extra) => { + if extra.is_empty() { + return None; + } + Some(PlayerCliCmd::PlayOpaqueId(extra.to_string())) + } + }; + } + match verb { + "repeat" => { + let m = parse_repeat_mode(args.get(pos + 2)?.as_str())?; + Some(PlayerCliCmd::Repeat(m)) + } + "rating" => { + let raw = args.get(pos + 2)?; + let n: u8 = raw.parse().ok()?; + if n > 5 { + return None; + } + Some(PlayerCliCmd::Rating { stars: n }) + } + "seek" => { + let raw = args.get(pos + 2)?; + let delta_secs: i32 = raw.parse().ok()?; + Some(PlayerCliCmd::Seek { delta_secs }) + } + "volume" => { + let raw = args.get(pos + 2)?; + let v: i64 = raw.parse().ok()?; + if !(0..=100).contains(&v) { + return None; + } + Some(PlayerCliCmd::Volume { + percent: v as u8, + }) + } + _ => cli_registry_entry_by_verb(verb) + .map(|entry| PlayerCliCmd::NoArgCommand(entry.command.clone())), + } +} + +/// Parse transport / playback / device / mix `psysonic --player …` argv. +pub fn parse_cli_command(args: &[String]) -> Option { + let pos = args.iter().position(|a| a == "--player")?; + let verb = args.get(pos + 1)?.as_str(); + match verb { + "audio-device" => { + let sub = args.get(pos + 2)?.as_str(); + match sub { + "list" => Some(CliCommand::AudioDeviceList), + "set" => { + let arg = args.get(pos + 3)?; + let name = if arg == "default" { + None + } else { + Some(arg.clone()) + }; + Some(CliCommand::AudioDeviceSet(name)) + } + _ => None, + } + } + "mix" => { + let sub = args.get(pos + 2)?.as_str(); + match sub { + "append" => Some(CliCommand::Mix(MixCliMode::Append)), + "new" => Some(CliCommand::Mix(MixCliMode::New)), + _ => None, + } + } + "library" => { + let sub = args.get(pos + 2)?.as_str(); + match sub { + "list" => Some(CliCommand::LibraryList), + "set" => { + let arg = args.get(pos + 3)?; + Some(CliCommand::LibrarySet(arg.clone())) + } + _ => None, + } + } + "server" => { + let sub = args.get(pos + 2)?.as_str(); + match sub { + "list" => Some(CliCommand::ServerList), + "set" => { + let id = args.get(pos + 3)?; + if id.is_empty() { + return None; + } + Some(CliCommand::ServerSet(id.clone())) + } + _ => None, + } + } + "search" => { + let scope_raw = args.get(pos + 2)?.as_str(); + let scope = match scope_raw { + "track" => SearchCliScope::Track, + "album" => SearchCliScope::Album, + "artist" => SearchCliScope::Artist, + _ => return None, + }; + let tail = args.get(pos + 3..)?; + let query = tail.join(" ").trim().to_string(); + if query.is_empty() { + return None; + } + Some(CliCommand::Search { scope, query }) + } + _ => parse_player_cli_at(args, pos).map(CliCommand::Player), + } +}