Introduce initial implementation of the vmic CLI with PipeWire integration

- 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.
This commit is contained in:
2026-08-11 13:28:33 +02:00
parent 76c129a5dc
commit e19f398397
18 changed files with 2151 additions and 1 deletions

194
src/main.rs Normal file
View File

@@ -0,0 +1,194 @@
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))))
}