- Added `Cargo.toml` to define dependencies and project metadata. - Implemented core CLI functionality for managing virtual microphones (`create`, `edit`, `delete`, `list`, `route`, and `wipe` commands) using `clap`. - Integrated `rusqlite` for persistent state storage and `pipewire` to manage PipeWire nodes. - Ensured graceful handling of feedback loops and system defaults during virtual mic creation and deletion. - Added error handling via `thiserror` for cleaner error definitions.
195 lines
6.7 KiB
Rust
195 lines
6.7 KiB
Rust
mod cli;
|
|
mod commands;
|
|
mod error;
|
|
mod pw;
|
|
mod state;
|
|
mod ui;
|
|
|
|
use clap::{CommandFactory, Parser};
|
|
use clap_complete::{generate, Shell};
|
|
use cli::{Cli, Commands};
|
|
|
|
fn main() {
|
|
let args = normalize_args(std::env::args().collect());
|
|
|
|
// Plain `vmic`, `-h`/`--help`, or `help` at the top level: show every
|
|
// subcommand's own flags inline instead of making the user drill into
|
|
// each one with its own `--help`.
|
|
if wants_top_level_help(&args) {
|
|
print_full_help();
|
|
std::process::exit(if args.len() <= 1 { 2 } else { 0 });
|
|
}
|
|
|
|
let cli = match Cli::try_parse_from(&args) {
|
|
Ok(cli) => cli,
|
|
Err(e) => e.exit(),
|
|
};
|
|
|
|
let result = match cli.command {
|
|
Commands::Create(args) => commands::create::run(args),
|
|
Commands::Route(args) => commands::route::run(args),
|
|
Commands::Edit(args) => commands::edit::run(args),
|
|
Commands::Delete(args) => commands::delete::run(args),
|
|
Commands::List => commands::list::run(),
|
|
Commands::Wipe => commands::wipe::run(),
|
|
Commands::Completions { shell } => {
|
|
print_completions(shell);
|
|
Ok(())
|
|
}
|
|
};
|
|
|
|
if let Err(e) = result {
|
|
ui::err(&e.to_string());
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
|
|
/// clap's `#[arg(short)]` only supports single-character short flags, so
|
|
/// `-sv` can't be a real short flag. Rewrite it to `--source-volume` before
|
|
/// clap ever sees it.
|
|
fn normalize_args(args: Vec<String>) -> Vec<String> {
|
|
args.into_iter()
|
|
.map(|a| if a == "-sv" { "--source-volume".to_string() } else { a })
|
|
.collect()
|
|
}
|
|
|
|
fn print_completions(shell: Shell) {
|
|
let mut cmd = Cli::command();
|
|
let name = cmd.get_name().to_string();
|
|
generate(shell, &mut cmd, name, &mut std::io::stdout());
|
|
}
|
|
|
|
/// True for a bare `vmic` invocation, or top-level `-h`/`--help`/`help` -
|
|
/// i.e. anything that should show the expanded help rather than being
|
|
/// handled (or rejected) by a specific subcommand.
|
|
fn wants_top_level_help(args: &[String]) -> bool {
|
|
match args.get(1..) {
|
|
Some([]) => true,
|
|
Some([a]) => a == "-h" || a == "--help" || a == "help",
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
/// Prints one screen of help: every subcommand's positional args and flags
|
|
/// summarized inline (e.g. `<name> [-l/--loopback]`), so nothing requires
|
|
/// drilling into a subcommand's own `--help` just to see what it takes.
|
|
fn print_full_help() {
|
|
let mut cmd = Cli::command();
|
|
cmd.build(); // resolve default value names etc. before introspecting
|
|
|
|
if let Some(about) = cmd.get_about() {
|
|
println!("{about}");
|
|
println!();
|
|
}
|
|
println!("{} vmic {} {}", ui::blue("Usage:"), ui::cyan("<COMMAND>"), ui::yellow("[ARGS]"));
|
|
println!();
|
|
|
|
println!("{}", ui::blue("Commands:"));
|
|
let rows: Vec<(String, String, String, String)> = cmd
|
|
.get_subcommands()
|
|
.filter(|s| s.get_name() != "help")
|
|
.map(|s| {
|
|
let aliases = s.get_visible_aliases().collect::<Vec<_>>().join("/");
|
|
let about = s.get_about().map(|a| a.to_string()).unwrap_or_default();
|
|
(s.get_name().to_string(), compact_args(s), about, aliases)
|
|
})
|
|
.collect();
|
|
print_table(&rows);
|
|
println!();
|
|
|
|
println!("{}", ui::blue("Options:"));
|
|
let opt_rows: Vec<(String, String)> = cmd
|
|
.get_arguments()
|
|
.filter(|a| !a.is_positional())
|
|
.filter_map(|a| Some((flag_names(a)?, a.get_help().map(|h| h.to_string()).unwrap_or_default())))
|
|
.collect();
|
|
let flag_w = opt_rows.iter().map(|(f, _)| f.len()).max().unwrap_or(0);
|
|
for (flag, help) in &opt_rows {
|
|
println!(" {flag:flag_w$} {help}");
|
|
}
|
|
}
|
|
|
|
/// `<name>` for each positional, `[-x/--long <VALUE>]` or `[-x/--long]` for
|
|
/// each flag, in one space-joined line - a compact stand-in for the
|
|
/// `[OPTIONS] <NAME>` a normal usage line would collapse this to.
|
|
///
|
|
/// Colors match the original fish script's `vmic_usage_line`: positional
|
|
/// `<...>` args in cyan, optional `[...]` flag groups (whole bracket,
|
|
/// including any value placeholder inside) in yellow.
|
|
fn compact_args(cmd: &clap::Command) -> String {
|
|
let mut parts: Vec<String> = cmd
|
|
.get_positionals()
|
|
.map(|a| ui::cyan(&format!("<{}>", a.get_id().as_str())))
|
|
.collect();
|
|
|
|
for arg in cmd.get_arguments() {
|
|
if arg.is_positional() || arg.get_id().as_str() == "help" {
|
|
continue;
|
|
}
|
|
let Some(flag) = flag_names(arg) else { continue };
|
|
let group = if matches!(arg.get_action(), clap::ArgAction::Set | clap::ArgAction::Append) {
|
|
let value = arg
|
|
.get_value_names()
|
|
.and_then(|v| v.first())
|
|
.map(|v| v.to_string())
|
|
.unwrap_or_else(|| arg.get_id().as_str().to_uppercase());
|
|
format!("[{flag} <{value}>]")
|
|
} else {
|
|
format!("[{flag}]")
|
|
};
|
|
parts.push(ui::yellow(&group));
|
|
}
|
|
parts.join(" ")
|
|
}
|
|
|
|
/// `-x, --long` / `-x` / `--long` for a non-positional arg, or `None` for
|
|
/// one with no visible flag at all.
|
|
fn flag_names(arg: &clap::Arg) -> Option<String> {
|
|
match (arg.get_short(), arg.get_long()) {
|
|
(Some(s), Some(l)) => Some(format!("-{s}/--{l}")),
|
|
(Some(s), None) => Some(format!("-{s}")),
|
|
(None, Some(l)) => Some(format!("--{l}")),
|
|
(None, None) => None,
|
|
}
|
|
}
|
|
|
|
/// Prints `(name, args, about, aliases)` rows with the name and args
|
|
/// columns padded to their widest entry, so the `about` column lines up.
|
|
/// `args` carries ANSI color codes (see `compact_args`), so padding by
|
|
/// `.len()`/`{:width$}` would count invisible escape bytes as columns and
|
|
/// misalign everything - `pad_visual` pads by visible width instead.
|
|
/// `aliases`, if non-empty, trails after `| `.
|
|
fn print_table(rows: &[(String, String, String, String)]) {
|
|
let name_w = rows.iter().map(|(n, ..)| n.len()).max().unwrap_or(0);
|
|
let args_w = rows.iter().map(|(_, a, ..)| visual_width(a)).max().unwrap_or(0);
|
|
for (name, args, about, aliases) in rows {
|
|
print!(" {name:name_w$} {} {about}", pad_visual(args, args_w));
|
|
if !aliases.is_empty() {
|
|
print!(" | {aliases}");
|
|
}
|
|
println!();
|
|
}
|
|
}
|
|
|
|
/// Number of visible columns in `s`, skipping any `\x1b[...m` ANSI SGR
|
|
/// escape sequences it contains.
|
|
fn visual_width(s: &str) -> usize {
|
|
let mut width = 0;
|
|
let mut in_escape = false;
|
|
for c in s.chars() {
|
|
if in_escape {
|
|
in_escape = c != 'm';
|
|
} else if c == '\x1b' {
|
|
in_escape = true;
|
|
} else {
|
|
width += 1;
|
|
}
|
|
}
|
|
width
|
|
}
|
|
|
|
/// Right-pads `s` with spaces to `width` visible columns, per `visual_width`.
|
|
fn pad_visual(s: &str, width: usize) -> String {
|
|
format!("{s}{}", " ".repeat(width.saturating_sub(visual_width(s))))
|
|
}
|