atagen

headroom

public
Star0
Watch0
c19702ae09bef745bdf668114c7fb23c209733d5
1
ssh://brewery@pls.lobotomise.me:33/atagen/headroom.git

Blame: crates/headroom-cli/src/main.rs

c19702ae09beatagen2026-05-27 15:49 UTC1
//! `headroom` cli — connects to the daemon over its uds; `daemon` runs core in-process.
c19702ae09beatagen2026-05-27 15:49 UTC2
c19702ae09beatagen2026-05-27 15:49 UTC3
#![forbid(unsafe_code)]
c19702ae09beatagen2026-05-27 15:49 UTC4
c19702ae09beatagen2026-05-27 15:49 UTC5
#[cfg(feature = "tui")]
c19702ae09beatagen2026-05-27 15:49 UTC6
mod tui;
c19702ae09beatagen2026-05-27 15:49 UTC7
c19702ae09beatagen2026-05-27 15:49 UTC8
use std::path::PathBuf;
c19702ae09beatagen2026-05-27 15:49 UTC9
use std::process::ExitCode;
c19702ae09beatagen2026-05-27 15:49 UTC10
c19702ae09beatagen2026-05-27 15:49 UTC11
use clap::{Parser, Subcommand, ValueEnum};
c19702ae09beatagen2026-05-27 15:49 UTC12
use headroom_client::{Client, ClientError, Route, Topic};
c19702ae09beatagen2026-05-27 15:49 UTC13
c19702ae09beatagen2026-05-27 15:49 UTC14
// debug-only: arms core's audio-thread `assert_no_alloc` blocks; no-op in release.
c19702ae09beatagen2026-05-27 15:49 UTC15
#[cfg(debug_assertions)]
c19702ae09beatagen2026-05-27 15:49 UTC16
#[global_allocator]
c19702ae09beatagen2026-05-27 15:49 UTC17
static ALLOCATOR: assert_no_alloc::AllocDisabler = assert_no_alloc::AllocDisabler;
c19702ae09beatagen2026-05-27 15:49 UTC18
c19702ae09beatagen2026-05-27 15:49 UTC19
/// headroom cli.
c19702ae09beatagen2026-05-27 15:49 UTC20
#[derive(Debug, Parser)]
c19702ae09beatagen2026-05-27 15:49 UTC21
#[command(version, about, long_about = None)]
c19702ae09beatagen2026-05-27 15:49 UTC22
struct Cli {
c19702ae09beatagen2026-05-27 15:49 UTC23
    /// override the daemon control socket path.
c19702ae09beatagen2026-05-27 15:49 UTC24
    #[arg(long, global = true)]
c19702ae09beatagen2026-05-27 15:49 UTC25
    socket: Option<PathBuf>,
c19702ae09beatagen2026-05-27 15:49 UTC26
c19702ae09beatagen2026-05-27 15:49 UTC27
    #[command(subcommand)]
c19702ae09beatagen2026-05-27 15:49 UTC28
    cmd: Cmd,
c19702ae09beatagen2026-05-27 15:49 UTC29
}
c19702ae09beatagen2026-05-27 15:49 UTC30
c19702ae09beatagen2026-05-27 15:49 UTC31
#[derive(Debug, Subcommand)]
c19702ae09beatagen2026-05-27 15:49 UTC32
enum Cmd {
c19702ae09beatagen2026-05-27 15:49 UTC33
    /// run the daemon in the foreground.
c19702ae09beatagen2026-05-27 15:49 UTC34
    Daemon {
c19702ae09beatagen2026-05-27 15:49 UTC35
        /// extra `*.toml` profile dir (repeatable). precedes shipped/user
c19702ae09beatagen2026-05-27 15:49 UTC36
        /// profiles; first dir is watched for live edits.
c19702ae09beatagen2026-05-27 15:49 UTC37
        #[arg(long = "profile-dir", value_name = "DIR")]
c19702ae09beatagen2026-05-27 15:49 UTC38
        profile_dir: Vec<PathBuf>,
c19702ae09beatagen2026-05-27 15:49 UTC39
    },
c19702ae09beatagen2026-05-27 15:49 UTC40
c19702ae09beatagen2026-05-27 15:49 UTC41
    /// show daemon status.
c19702ae09beatagen2026-05-27 15:49 UTC42
    Status,
c19702ae09beatagen2026-05-27 15:49 UTC43
c19702ae09beatagen2026-05-27 15:49 UTC44
    /// profile management.
c19702ae09beatagen2026-05-27 15:49 UTC45
    #[command(subcommand)]
c19702ae09beatagen2026-05-27 15:49 UTC46
    Profile(ProfileCmd),
c19702ae09beatagen2026-05-27 15:49 UTC47
c19702ae09beatagen2026-05-27 15:49 UTC48
    /// routing rules and per-stream decisions.
c19702ae09beatagen2026-05-27 15:49 UTC49
    #[command(subcommand)]
c19702ae09beatagen2026-05-27 15:49 UTC50
    Route(RouteCmd),
c19702ae09beatagen2026-05-27 15:49 UTC51
c19702ae09beatagen2026-05-27 15:49 UTC52
    /// per-app level control (layer a).
c19702ae09beatagen2026-05-27 15:49 UTC53
    #[command(subcommand)]
c19702ae09beatagen2026-05-27 15:49 UTC54
    PerApp(PerAppCmd),
c19702ae09beatagen2026-05-27 15:49 UTC55
c19702ae09beatagen2026-05-27 15:49 UTC56
    /// get a setting value from the active profile.
c19702ae09beatagen2026-05-27 15:49 UTC57
    Get {
c19702ae09beatagen2026-05-27 15:49 UTC58
        /// dotted setting key.
c19702ae09beatagen2026-05-27 15:49 UTC59
        key: String,
c19702ae09beatagen2026-05-27 15:49 UTC60
    },
c19702ae09beatagen2026-05-27 15:49 UTC61
c19702ae09beatagen2026-05-27 15:49 UTC62
    /// set a setting override (or clear with `--clear`). overrides persist
c19702ae09beatagen2026-05-27 15:49 UTC63
    /// across profile switches; `reset` clears all.
c19702ae09beatagen2026-05-27 15:49 UTC64
    Set {
c19702ae09beatagen2026-05-27 15:49 UTC65
        /// dotted setting key.
c19702ae09beatagen2026-05-27 15:49 UTC66
        key: String,
c19702ae09beatagen2026-05-27 15:49 UTC67
        /// new value, json-encoded. omit with `--clear`.
c19702ae09beatagen2026-05-27 15:49 UTC68
        // allow_hyphen_values so negatives aren't parsed as flags.
c19702ae09beatagen2026-05-27 15:49 UTC69
        #[arg(allow_hyphen_values = true)]
c19702ae09beatagen2026-05-27 15:49 UTC70
        value: Option<String>,
c19702ae09beatagen2026-05-27 15:49 UTC71
        /// clear this key's override instead of setting one.
c19702ae09beatagen2026-05-27 15:49 UTC72
        #[arg(long, conflicts_with = "value")]
c19702ae09beatagen2026-05-27 15:49 UTC73
        clear: bool,
c19702ae09beatagen2026-05-27 15:49 UTC74
    },
c19702ae09beatagen2026-05-27 15:49 UTC75
c19702ae09beatagen2026-05-27 15:49 UTC76
    /// toggle the global bypass kill switch.
c19702ae09beatagen2026-05-27 15:49 UTC77
    Bypass {
c19702ae09beatagen2026-05-27 15:49 UTC78
        /// `on` or `off`.
c19702ae09beatagen2026-05-27 15:49 UTC79
        #[arg(value_enum)]
c19702ae09beatagen2026-05-27 15:49 UTC80
        state: BypassState,
c19702ae09beatagen2026-05-27 15:49 UTC81
    },
c19702ae09beatagen2026-05-27 15:49 UTC82
c19702ae09beatagen2026-05-27 15:49 UTC83
    /// clear all setting overrides. leaves route/per-app/bypass intact.
c19702ae09beatagen2026-05-27 15:49 UTC84
    Reset,
c19702ae09beatagen2026-05-27 15:49 UTC85
c19702ae09beatagen2026-05-27 15:49 UTC86
    /// reload profile files from disk.
c19702ae09beatagen2026-05-27 15:49 UTC87
    Reload,
c19702ae09beatagen2026-05-27 15:49 UTC88
c19702ae09beatagen2026-05-27 15:49 UTC89
    /// live monitor. defaults to a tui; `--json` is the line-delimited stream.
c19702ae09beatagen2026-05-27 15:49 UTC90
    Monitor {
c19702ae09beatagen2026-05-27 15:49 UTC91
        /// topics to subscribe to (comma-separated). `--json` only; the tui
c19702ae09beatagen2026-05-27 15:49 UTC92
        /// subscribes to all.
c19702ae09beatagen2026-05-27 15:49 UTC93
        #[arg(value_delimiter = ',', default_value = "meters")]
c19702ae09beatagen2026-05-27 15:49 UTC94
        topics: Vec<MonitorTopic>,
c19702ae09beatagen2026-05-27 15:49 UTC95
c19702ae09beatagen2026-05-27 15:49 UTC96
        /// emit one json event per line instead of the tui.
c19702ae09beatagen2026-05-27 15:49 UTC97
        #[arg(long)]
c19702ae09beatagen2026-05-27 15:49 UTC98
        json: bool,
c19702ae09beatagen2026-05-27 15:49 UTC99
    },
c19702ae09beatagen2026-05-27 15:49 UTC100
}
c19702ae09beatagen2026-05-27 15:49 UTC101
c19702ae09beatagen2026-05-27 15:49 UTC102
#[derive(Debug, Clone, Copy, ValueEnum)]
c19702ae09beatagen2026-05-27 15:49 UTC103
enum MonitorTopic {
c19702ae09beatagen2026-05-27 15:49 UTC104
    Meters,
c19702ae09beatagen2026-05-27 15:49 UTC105
    Profile,
c19702ae09beatagen2026-05-27 15:49 UTC106
    Routing,
c19702ae09beatagen2026-05-27 15:49 UTC107
    Daemon,
c19702ae09beatagen2026-05-27 15:49 UTC108
}
c19702ae09beatagen2026-05-27 15:49 UTC109
c19702ae09beatagen2026-05-27 15:49 UTC110
impl From<MonitorTopic> for Topic {
c19702ae09beatagen2026-05-27 15:49 UTC111
    fn from(t: MonitorTopic) -> Self {
c19702ae09beatagen2026-05-27 15:49 UTC112
        match t {
c19702ae09beatagen2026-05-27 15:49 UTC113
            MonitorTopic::Meters => Topic::Meters,
c19702ae09beatagen2026-05-27 15:49 UTC114
            MonitorTopic::Profile => Topic::Profile,
c19702ae09beatagen2026-05-27 15:49 UTC115
            MonitorTopic::Routing => Topic::Routing,
c19702ae09beatagen2026-05-27 15:49 UTC116
            MonitorTopic::Daemon => Topic::Daemon,
c19702ae09beatagen2026-05-27 15:49 UTC117
        }
c19702ae09beatagen2026-05-27 15:49 UTC118
    }
c19702ae09beatagen2026-05-27 15:49 UTC119
}
c19702ae09beatagen2026-05-27 15:49 UTC120
c19702ae09beatagen2026-05-27 15:49 UTC121
#[derive(Debug, Subcommand)]
c19702ae09beatagen2026-05-27 15:49 UTC122
enum ProfileCmd {
c19702ae09beatagen2026-05-27 15:49 UTC123
    /// list known profiles.
c19702ae09beatagen2026-05-27 15:49 UTC124
    List,
c19702ae09beatagen2026-05-27 15:49 UTC125
    /// activate the named profile.
c19702ae09beatagen2026-05-27 15:49 UTC126
    Use {
c19702ae09beatagen2026-05-27 15:49 UTC127
        /// profile name.
c19702ae09beatagen2026-05-27 15:49 UTC128
        name: String,
c19702ae09beatagen2026-05-27 15:49 UTC129
    },
c19702ae09beatagen2026-05-27 15:49 UTC130
    /// show a profile in full.
c19702ae09beatagen2026-05-27 15:49 UTC131
    Show {
c19702ae09beatagen2026-05-27 15:49 UTC132
        /// profile name (defaults to the active profile).
c19702ae09beatagen2026-05-27 15:49 UTC133
        name: Option<String>,
c19702ae09beatagen2026-05-27 15:49 UTC134
    },
c19702ae09beatagen2026-05-27 15:49 UTC135
}
c19702ae09beatagen2026-05-27 15:49 UTC136
c19702ae09beatagen2026-05-27 15:49 UTC137
#[derive(Debug, Subcommand)]
c19702ae09beatagen2026-05-27 15:49 UTC138
enum RouteCmd {
c19702ae09beatagen2026-05-27 15:49 UTC139
    /// list routing rules and current per-stream decisions.
c19702ae09beatagen2026-05-27 15:49 UTC140
    List,
c19702ae09beatagen2026-05-27 15:49 UTC141
    /// add or replace a routing rule for an app.
c19702ae09beatagen2026-05-27 15:49 UTC142
    Set {
c19702ae09beatagen2026-05-27 15:49 UTC143
        /// app identifier (e.g. `application.process.binary`).
c19702ae09beatagen2026-05-27 15:49 UTC144
        app: String,
c19702ae09beatagen2026-05-27 15:49 UTC145
        /// where to route.
c19702ae09beatagen2026-05-27 15:49 UTC146
        #[arg(value_enum)]
c19702ae09beatagen2026-05-27 15:49 UTC147
        to: RouteArg,
c19702ae09beatagen2026-05-27 15:49 UTC148
    },
c19702ae09beatagen2026-05-27 15:49 UTC149
    /// remove an app's user routing rule.
c19702ae09beatagen2026-05-27 15:49 UTC150
    Unset {
c19702ae09beatagen2026-05-27 15:49 UTC151
        /// app identifier.
c19702ae09beatagen2026-05-27 15:49 UTC152
        app: String,
c19702ae09beatagen2026-05-27 15:49 UTC153
    },
c19702ae09beatagen2026-05-27 15:49 UTC154
    /// reroute a specific live stream by node id.
c19702ae09beatagen2026-05-27 15:49 UTC155
    Stream {
c19702ae09beatagen2026-05-27 15:49 UTC156
        /// pipewire node id.
c19702ae09beatagen2026-05-27 15:49 UTC157
        node_id: u32,
c19702ae09beatagen2026-05-27 15:49 UTC158
        /// where to route.
c19702ae09beatagen2026-05-27 15:49 UTC159
        #[arg(value_enum)]
c19702ae09beatagen2026-05-27 15:49 UTC160
        to: RouteArg,
c19702ae09beatagen2026-05-27 15:49 UTC161
    },
c19702ae09beatagen2026-05-27 15:49 UTC162
}
c19702ae09beatagen2026-05-27 15:49 UTC163
c19702ae09beatagen2026-05-27 15:49 UTC164
#[derive(Debug, Subcommand)]
c19702ae09beatagen2026-05-27 15:49 UTC165
enum PerAppCmd {
c19702ae09beatagen2026-05-27 15:49 UTC166
    /// show per-app layer a state for managed streams.
c19702ae09beatagen2026-05-27 15:49 UTC167
    Status {
c19702ae09beatagen2026-05-27 15:49 UTC168
        /// emit the snapshot list as json instead of a table.
c19702ae09beatagen2026-05-27 15:49 UTC169
        #[arg(long)]
c19702ae09beatagen2026-05-27 15:49 UTC170
        json: bool,
c19702ae09beatagen2026-05-27 15:49 UTC171
    },
c19702ae09beatagen2026-05-27 15:49 UTC172
    /// enable the layer a master switch (persisted).
c19702ae09beatagen2026-05-27 15:49 UTC173
    On,
c19702ae09beatagen2026-05-27 15:49 UTC174
    /// disable the layer a master switch (persisted).
c19702ae09beatagen2026-05-27 15:49 UTC175
    Off,
c19702ae09beatagen2026-05-27 15:49 UTC176
    /// enable or disable layer a for a specific app (persisted).
c19702ae09beatagen2026-05-27 15:49 UTC177
    Set {
c19702ae09beatagen2026-05-27 15:49 UTC178
        /// app identifier (process_binary or application_name).
c19702ae09beatagen2026-05-27 15:49 UTC179
        app: String,
c19702ae09beatagen2026-05-27 15:49 UTC180
        /// `on` or `off`.
c19702ae09beatagen2026-05-27 15:49 UTC181
        #[arg(value_enum)]
c19702ae09beatagen2026-05-27 15:49 UTC182
        state: BypassState,
c19702ae09beatagen2026-05-27 15:49 UTC183
    },
c19702ae09beatagen2026-05-27 15:49 UTC184
    /// clear a managed stream's deference lock.
c19702ae09beatagen2026-05-27 15:49 UTC185
    Reset {
c19702ae09beatagen2026-05-27 15:49 UTC186
        /// pipewire node id of the managed stream.
c19702ae09beatagen2026-05-27 15:49 UTC187
        node_id: u32,
c19702ae09beatagen2026-05-27 15:49 UTC188
    },
c19702ae09beatagen2026-05-27 15:49 UTC189
}
c19702ae09beatagen2026-05-27 15:49 UTC190
c19702ae09beatagen2026-05-27 15:49 UTC191
#[derive(Debug, Clone, Copy, ValueEnum)]
c19702ae09beatagen2026-05-27 15:49 UTC192
enum BypassState {
c19702ae09beatagen2026-05-27 15:49 UTC193
    On,
c19702ae09beatagen2026-05-27 15:49 UTC194
    Off,
c19702ae09beatagen2026-05-27 15:49 UTC195
}
c19702ae09beatagen2026-05-27 15:49 UTC196
c19702ae09beatagen2026-05-27 15:49 UTC197
#[derive(Debug, Clone, Copy, ValueEnum)]
c19702ae09beatagen2026-05-27 15:49 UTC198
enum RouteArg {
c19702ae09beatagen2026-05-27 15:49 UTC199
    Processed,
c19702ae09beatagen2026-05-27 15:49 UTC200
    Bypass,
c19702ae09beatagen2026-05-27 15:49 UTC201
}
c19702ae09beatagen2026-05-27 15:49 UTC202
c19702ae09beatagen2026-05-27 15:49 UTC203
impl From<RouteArg> for Route {
c19702ae09beatagen2026-05-27 15:49 UTC204
    fn from(r: RouteArg) -> Self {
c19702ae09beatagen2026-05-27 15:49 UTC205
        match r {
c19702ae09beatagen2026-05-27 15:49 UTC206
            RouteArg::Processed => Route::Processed,
c19702ae09beatagen2026-05-27 15:49 UTC207
            RouteArg::Bypass => Route::Bypass,
c19702ae09beatagen2026-05-27 15:49 UTC208
        }
c19702ae09beatagen2026-05-27 15:49 UTC209
    }
c19702ae09beatagen2026-05-27 15:49 UTC210
}
c19702ae09beatagen2026-05-27 15:49 UTC211
c19702ae09beatagen2026-05-27 15:49 UTC212
fn init_tracing() {
c19702ae09beatagen2026-05-27 15:49 UTC213
    let env_filter = tracing_subscriber::EnvFilter::try_from_default_env()
c19702ae09beatagen2026-05-27 15:49 UTC214
        .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("headroom=info"));
c19702ae09beatagen2026-05-27 15:49 UTC215
    tracing_subscriber::fmt()
c19702ae09beatagen2026-05-27 15:49 UTC216
        .with_env_filter(env_filter)
c19702ae09beatagen2026-05-27 15:49 UTC217
        .with_target(false)
c19702ae09beatagen2026-05-27 15:49 UTC218
        .compact()
c19702ae09beatagen2026-05-27 15:49 UTC219
        .init();
c19702ae09beatagen2026-05-27 15:49 UTC220
}
c19702ae09beatagen2026-05-27 15:49 UTC221
c19702ae09beatagen2026-05-27 15:49 UTC222
fn run() -> Result<(), CliError> {
c19702ae09beatagen2026-05-27 15:49 UTC223
    let cli = Cli::parse();
c19702ae09beatagen2026-05-27 15:49 UTC224
c19702ae09beatagen2026-05-27 15:49 UTC225
    // tui owns the terminal; keep `tracing` off it.
c19702ae09beatagen2026-05-27 15:49 UTC226
    let tui_mode = matches!(&cli.cmd, Cmd::Monitor { json: false, .. });
c19702ae09beatagen2026-05-27 15:49 UTC227
    if !tui_mode {
c19702ae09beatagen2026-05-27 15:49 UTC228
        init_tracing();
c19702ae09beatagen2026-05-27 15:49 UTC229
    }
c19702ae09beatagen2026-05-27 15:49 UTC230
c19702ae09beatagen2026-05-27 15:49 UTC231
    match cli.cmd {
c19702ae09beatagen2026-05-27 15:49 UTC232
        Cmd::Daemon { profile_dir } => {
c19702ae09beatagen2026-05-27 15:49 UTC233
            headroom_core::run_with_profile_dirs(profile_dir)
c19702ae09beatagen2026-05-27 15:49 UTC234
                .map_err(|e| CliError::Daemon(e.to_string()))?;
c19702ae09beatagen2026-05-27 15:49 UTC235
            Ok(())
c19702ae09beatagen2026-05-27 15:49 UTC236
        }
c19702ae09beatagen2026-05-27 15:49 UTC237
        Cmd::Monitor { json: false, .. } => {
c19702ae09beatagen2026-05-27 15:49 UTC238
            #[cfg(feature = "tui")]
c19702ae09beatagen2026-05-27 15:49 UTC239
            {
c19702ae09beatagen2026-05-27 15:49 UTC240
                // connect on the main thread so initial rpcs run before raw mode.
c19702ae09beatagen2026-05-27 15:49 UTC241
                let client = match cli.socket.as_deref() {
c19702ae09beatagen2026-05-27 15:49 UTC242
                    Some(p) => Client::connect_at(p)?,
c19702ae09beatagen2026-05-27 15:49 UTC243
                    None => Client::connect()?,
c19702ae09beatagen2026-05-27 15:49 UTC244
                };
c19702ae09beatagen2026-05-27 15:49 UTC245
                tui::run(client).map_err(CliError::Tui)
c19702ae09beatagen2026-05-27 15:49 UTC246
            }
c19702ae09beatagen2026-05-27 15:49 UTC247
            // no `tui` feature: point at the json stream, not an opaque failure.
c19702ae09beatagen2026-05-27 15:49 UTC248
            #[cfg(not(feature = "tui"))]
c19702ae09beatagen2026-05-27 15:49 UTC249
            {
c19702ae09beatagen2026-05-27 15:49 UTC250
                let _ = cli.socket.as_deref();
c19702ae09beatagen2026-05-27 15:49 UTC251
                Err(CliError::Other(
c19702ae09beatagen2026-05-27 15:49 UTC252
                    "this build was compiled without the TUI (`tui` feature disabled); \
c19702ae09beatagen2026-05-27 15:49 UTC253
                     rerun `headroom monitor --json` for the line-delimited event stream"
c19702ae09beatagen2026-05-27 15:49 UTC254
                        .into(),
c19702ae09beatagen2026-05-27 15:49 UTC255
                ))
c19702ae09beatagen2026-05-27 15:49 UTC256
            }
c19702ae09beatagen2026-05-27 15:49 UTC257
        }
c19702ae09beatagen2026-05-27 15:49 UTC258
        cmd => with_client(cli.socket.as_deref(), |c| dispatch(c, cmd)),
c19702ae09beatagen2026-05-27 15:49 UTC259
    }
c19702ae09beatagen2026-05-27 15:49 UTC260
}
c19702ae09beatagen2026-05-27 15:49 UTC261
c19702ae09beatagen2026-05-27 15:49 UTC262
fn with_client<F>(socket: Option<&std::path::Path>, f: F) -> Result<(), CliError>
c19702ae09beatagen2026-05-27 15:49 UTC263
where
c19702ae09beatagen2026-05-27 15:49 UTC264
    F: FnOnce(&mut Client) -> Result<(), CliError>,
c19702ae09beatagen2026-05-27 15:49 UTC265
{
c19702ae09beatagen2026-05-27 15:49 UTC266
    let mut client = match socket {
c19702ae09beatagen2026-05-27 15:49 UTC267
        Some(p) => Client::connect_at(p)?,
c19702ae09beatagen2026-05-27 15:49 UTC268
        None => Client::connect()?,
c19702ae09beatagen2026-05-27 15:49 UTC269
    };
c19702ae09beatagen2026-05-27 15:49 UTC270
    f(&mut client)
c19702ae09beatagen2026-05-27 15:49 UTC271
}
c19702ae09beatagen2026-05-27 15:49 UTC272
c19702ae09beatagen2026-05-27 15:49 UTC273
fn dispatch(client: &mut Client, cmd: Cmd) -> Result<(), CliError> {
c19702ae09beatagen2026-05-27 15:49 UTC274
    match cmd {
c19702ae09beatagen2026-05-27 15:49 UTC275
        Cmd::Daemon { .. } => unreachable!("handled in `run`"),
c19702ae09beatagen2026-05-27 15:49 UTC276
c19702ae09beatagen2026-05-27 15:49 UTC277
        Cmd::Status => {
c19702ae09beatagen2026-05-27 15:49 UTC278
            let status = client.status()?;
c19702ae09beatagen2026-05-27 15:49 UTC279
            println!("{}", serde_json::to_string_pretty(&status)?);
c19702ae09beatagen2026-05-27 15:49 UTC280
        }
c19702ae09beatagen2026-05-27 15:49 UTC281
c19702ae09beatagen2026-05-27 15:49 UTC282
        Cmd::Profile(ProfileCmd::List) => {
c19702ae09beatagen2026-05-27 15:49 UTC283
            let profiles = client.profile_list()?;
c19702ae09beatagen2026-05-27 15:49 UTC284
            for p in profiles {
c19702ae09beatagen2026-05-27 15:49 UTC285
                let marker = if p.active { '*' } else { ' ' };
c19702ae09beatagen2026-05-27 15:49 UTC286
                println!("{marker} {:<16} {}", p.name, p.description);
c19702ae09beatagen2026-05-27 15:49 UTC287
            }
c19702ae09beatagen2026-05-27 15:49 UTC288
        }
c19702ae09beatagen2026-05-27 15:49 UTC289
        Cmd::Profile(ProfileCmd::Use { name }) => {
c19702ae09beatagen2026-05-27 15:49 UTC290
            let active = client.profile_use(&name)?;
c19702ae09beatagen2026-05-27 15:49 UTC291
            println!("active profile: {active}");
c19702ae09beatagen2026-05-27 15:49 UTC292
        }
c19702ae09beatagen2026-05-27 15:49 UTC293
        Cmd::Profile(ProfileCmd::Show { name }) => {
c19702ae09beatagen2026-05-27 15:49 UTC294
            let body = client.profile_show(name.as_deref())?;
c19702ae09beatagen2026-05-27 15:49 UTC295
            println!("{}", serde_json::to_string_pretty(&body)?);
c19702ae09beatagen2026-05-27 15:49 UTC296
        }
c19702ae09beatagen2026-05-27 15:49 UTC297
c19702ae09beatagen2026-05-27 15:49 UTC298
        Cmd::Route(RouteCmd::List) => {
c19702ae09beatagen2026-05-27 15:49 UTC299
            let list = client.route_list()?;
c19702ae09beatagen2026-05-27 15:49 UTC300
            println!("{}", serde_json::to_string_pretty(&list)?);
c19702ae09beatagen2026-05-27 15:49 UTC301
        }
c19702ae09beatagen2026-05-27 15:49 UTC302
        Cmd::Route(RouteCmd::Set { app, to }) => {
c19702ae09beatagen2026-05-27 15:49 UTC303
            client.route_set(&app, to.into())?;
c19702ae09beatagen2026-05-27 15:49 UTC304
        }
c19702ae09beatagen2026-05-27 15:49 UTC305
        Cmd::Route(RouteCmd::Unset { app }) => {
c19702ae09beatagen2026-05-27 15:49 UTC306
            client.route_unset(&app)?;
c19702ae09beatagen2026-05-27 15:49 UTC307
        }
c19702ae09beatagen2026-05-27 15:49 UTC308
        Cmd::Route(RouteCmd::Stream { node_id, to }) => {
c19702ae09beatagen2026-05-27 15:49 UTC309
            client.route_stream(node_id, to.into())?;
c19702ae09beatagen2026-05-27 15:49 UTC310
        }
c19702ae09beatagen2026-05-27 15:49 UTC311
c19702ae09beatagen2026-05-27 15:49 UTC312
        Cmd::PerApp(PerAppCmd::Status { json }) => {
c19702ae09beatagen2026-05-27 15:49 UTC313
            let list = client.layer_a_list()?;
c19702ae09beatagen2026-05-27 15:49 UTC314
            if json {
c19702ae09beatagen2026-05-27 15:49 UTC315
                println!("{}", serde_json::to_string_pretty(&list)?);
c19702ae09beatagen2026-05-27 15:49 UTC316
            } else if list.is_empty() {
c19702ae09beatagen2026-05-27 15:49 UTC317
                println!("no streams under Layer A management");
c19702ae09beatagen2026-05-27 15:49 UTC318
            } else {
c19702ae09beatagen2026-05-27 15:49 UTC319
                println!(
c19702ae09beatagen2026-05-27 15:49 UTC320
                    "{:<8} {:<24} {:>10} {:>9} {:>9}",
c19702ae09beatagen2026-05-27 15:49 UTC321
                    "node", "app", "reduction", "ceiling", "deferred"
c19702ae09beatagen2026-05-27 15:49 UTC322
                );
c19702ae09beatagen2026-05-27 15:49 UTC323
                for s in &list {
c19702ae09beatagen2026-05-27 15:49 UTC324
                    let app = if s.app.len() > 24 {
c19702ae09beatagen2026-05-27 15:49 UTC325
                        format!("{}…", &s.app[..23])
c19702ae09beatagen2026-05-27 15:49 UTC326
                    } else {
c19702ae09beatagen2026-05-27 15:49 UTC327
                        s.app.clone()
c19702ae09beatagen2026-05-27 15:49 UTC328
                    };
c19702ae09beatagen2026-05-27 15:49 UTC329
                    let ceiling = s
c19702ae09beatagen2026-05-27 15:49 UTC330
                        .user_ceiling_lin
c19702ae09beatagen2026-05-27 15:49 UTC331
                        .map(|c| format!("{c:.2}"))
c19702ae09beatagen2026-05-27 15:49 UTC332
                        .unwrap_or_else(|| "—".to_string());
c19702ae09beatagen2026-05-27 15:49 UTC333
                    println!(
c19702ae09beatagen2026-05-27 15:49 UTC334
                        "{:<8} {:<24} {:>8.1}dB {:>9} {:>9}",
c19702ae09beatagen2026-05-27 15:49 UTC335
                        s.node_id, app, s.reduction_db, ceiling, s.deferred
c19702ae09beatagen2026-05-27 15:49 UTC336
                    );
c19702ae09beatagen2026-05-27 15:49 UTC337
                }
c19702ae09beatagen2026-05-27 15:49 UTC338
            }
c19702ae09beatagen2026-05-27 15:49 UTC339
        }
c19702ae09beatagen2026-05-27 15:49 UTC340
        Cmd::PerApp(PerAppCmd::On) => {
c19702ae09beatagen2026-05-27 15:49 UTC341
            client.per_app_master(true)?;
c19702ae09beatagen2026-05-27 15:49 UTC342
        }
c19702ae09beatagen2026-05-27 15:49 UTC343
        Cmd::PerApp(PerAppCmd::Off) => {
c19702ae09beatagen2026-05-27 15:49 UTC344
            client.per_app_master(false)?;
c19702ae09beatagen2026-05-27 15:49 UTC345
        }
c19702ae09beatagen2026-05-27 15:49 UTC346
        Cmd::PerApp(PerAppCmd::Set { app, state }) => {
c19702ae09beatagen2026-05-27 15:49 UTC347
            client.per_app_set(&app, matches!(state, BypassState::On))?;
c19702ae09beatagen2026-05-27 15:49 UTC348
        }
c19702ae09beatagen2026-05-27 15:49 UTC349
        Cmd::PerApp(PerAppCmd::Reset { node_id }) => {
c19702ae09beatagen2026-05-27 15:49 UTC350
            client.layer_a_reset(node_id)?;
c19702ae09beatagen2026-05-27 15:49 UTC351
        }
c19702ae09beatagen2026-05-27 15:49 UTC352
c19702ae09beatagen2026-05-27 15:49 UTC353
        Cmd::Get { key } => {
c19702ae09beatagen2026-05-27 15:49 UTC354
            let v = client.setting_get(&key)?;
c19702ae09beatagen2026-05-27 15:49 UTC355
            println!("{}", serde_json::to_string(&v)?);
c19702ae09beatagen2026-05-27 15:49 UTC356
        }
c19702ae09beatagen2026-05-27 15:49 UTC357
        Cmd::Set { key, value, clear } => {
c19702ae09beatagen2026-05-27 15:49 UTC358
            if clear {
c19702ae09beatagen2026-05-27 15:49 UTC359
                let existed = client.setting_clear(&key)?;
c19702ae09beatagen2026-05-27 15:49 UTC360
                if existed {
c19702ae09beatagen2026-05-27 15:49 UTC361
                    println!("cleared override for '{key}'");
c19702ae09beatagen2026-05-27 15:49 UTC362
                } else {
c19702ae09beatagen2026-05-27 15:49 UTC363
                    println!("no override set for '{key}'");
c19702ae09beatagen2026-05-27 15:49 UTC364
                }
c19702ae09beatagen2026-05-27 15:49 UTC365
            } else {
c19702ae09beatagen2026-05-27 15:49 UTC366
                let value = value.ok_or_else(|| {
c19702ae09beatagen2026-05-27 15:49 UTC367
                    CliError::Other("provide a value, or use --clear to remove the override".into())
c19702ae09beatagen2026-05-27 15:49 UTC368
                })?;
c19702ae09beatagen2026-05-27 15:49 UTC369
                let parsed: serde_json::Value = serde_json::from_str(&value)
c19702ae09beatagen2026-05-27 15:49 UTC370
                    .map_err(|e| CliError::Other(format!("value is not valid JSON: {e}")))?;
c19702ae09beatagen2026-05-27 15:49 UTC371
                client.setting_set(&key, parsed)?;
c19702ae09beatagen2026-05-27 15:49 UTC372
            }
c19702ae09beatagen2026-05-27 15:49 UTC373
        }
c19702ae09beatagen2026-05-27 15:49 UTC374
        Cmd::Bypass { state } => {
c19702ae09beatagen2026-05-27 15:49 UTC375
            client.bypass_set(matches!(state, BypassState::On))?;
c19702ae09beatagen2026-05-27 15:49 UTC376
        }
c19702ae09beatagen2026-05-27 15:49 UTC377
        Cmd::Reset => {
c19702ae09beatagen2026-05-27 15:49 UTC378
            let cleared = client.setting_reset()?;
c19702ae09beatagen2026-05-27 15:49 UTC379
            println!("cleared {cleared} setting override(s)");
c19702ae09beatagen2026-05-27 15:49 UTC380
        }
c19702ae09beatagen2026-05-27 15:49 UTC381
        Cmd::Reload => {
c19702ae09beatagen2026-05-27 15:49 UTC382
            let reloaded = client.profile_reload()?;
c19702ae09beatagen2026-05-27 15:49 UTC383
            println!("reloaded: {reloaded:?}");
c19702ae09beatagen2026-05-27 15:49 UTC384
        }
c19702ae09beatagen2026-05-27 15:49 UTC385
        Cmd::Monitor { topics, json } => {
c19702ae09beatagen2026-05-27 15:49 UTC386
            if json {
c19702ae09beatagen2026-05-27 15:49 UTC387
                let pw_topics: Vec<Topic> =
c19702ae09beatagen2026-05-27 15:49 UTC388
                    topics.iter().copied().map(Topic::from).collect();
c19702ae09beatagen2026-05-27 15:49 UTC389
                client.subscribe(&pw_topics)?;
c19702ae09beatagen2026-05-27 15:49 UTC390
                loop {
c19702ae09beatagen2026-05-27 15:49 UTC391
                    let ev = client.next_event()?;
c19702ae09beatagen2026-05-27 15:49 UTC392
                    println!(
c19702ae09beatagen2026-05-27 15:49 UTC393
                        "{} {}/{} {}",
c19702ae09beatagen2026-05-27 15:49 UTC394
                        chrono_like_now(),
c19702ae09beatagen2026-05-27 15:49 UTC395
                        ev.topic,
c19702ae09beatagen2026-05-27 15:49 UTC396
                        ev.event,
c19702ae09beatagen2026-05-27 15:49 UTC397
                        serde_json::to_string(&ev.data)?,
c19702ae09beatagen2026-05-27 15:49 UTC398
                    );
c19702ae09beatagen2026-05-27 15:49 UTC399
                }
c19702ae09beatagen2026-05-27 15:49 UTC400
            } else {
c19702ae09beatagen2026-05-27 15:49 UTC401
                unreachable!("TUI monitor is dispatched before `with_client`")
c19702ae09beatagen2026-05-27 15:49 UTC402
            }
c19702ae09beatagen2026-05-27 15:49 UTC403
        }
c19702ae09beatagen2026-05-27 15:49 UTC404
    }
c19702ae09beatagen2026-05-27 15:49 UTC405
    Ok(())
c19702ae09beatagen2026-05-27 15:49 UTC406
}
c19702ae09beatagen2026-05-27 15:49 UTC407
c19702ae09beatagen2026-05-27 15:49 UTC408
#[derive(Debug, thiserror::Error)]
c19702ae09beatagen2026-05-27 15:49 UTC409
enum CliError {
c19702ae09beatagen2026-05-27 15:49 UTC410
    #[error("client: {0}")]
c19702ae09beatagen2026-05-27 15:49 UTC411
    Client(#[from] ClientError),
c19702ae09beatagen2026-05-27 15:49 UTC412
c19702ae09beatagen2026-05-27 15:49 UTC413
    #[error("daemon: {0}")]
c19702ae09beatagen2026-05-27 15:49 UTC414
    Daemon(String),
c19702ae09beatagen2026-05-27 15:49 UTC415
c19702ae09beatagen2026-05-27 15:49 UTC416
    #[error("json: {0}")]
c19702ae09beatagen2026-05-27 15:49 UTC417
    Json(#[from] serde_json::Error),
c19702ae09beatagen2026-05-27 15:49 UTC418
c19702ae09beatagen2026-05-27 15:49 UTC419
    #[cfg(feature = "tui")]
c19702ae09beatagen2026-05-27 15:49 UTC420
    #[error("tui: {0}")]
c19702ae09beatagen2026-05-27 15:49 UTC421
    Tui(tui::TuiError),
c19702ae09beatagen2026-05-27 15:49 UTC422
c19702ae09beatagen2026-05-27 15:49 UTC423
    #[error("{0}")]
c19702ae09beatagen2026-05-27 15:49 UTC424
    Other(String),
c19702ae09beatagen2026-05-27 15:49 UTC425
}
c19702ae09beatagen2026-05-27 15:49 UTC426
c19702ae09beatagen2026-05-27 15:49 UTC427
/// timestamp label for monitor output; `SystemTime` to avoid chrono.
c19702ae09beatagen2026-05-27 15:49 UTC428
fn chrono_like_now() -> String {
c19702ae09beatagen2026-05-27 15:49 UTC429
    use std::time::{SystemTime, UNIX_EPOCH};
c19702ae09beatagen2026-05-27 15:49 UTC430
    let t = SystemTime::now()
c19702ae09beatagen2026-05-27 15:49 UTC431
        .duration_since(UNIX_EPOCH)
c19702ae09beatagen2026-05-27 15:49 UTC432
        .unwrap_or_default();
c19702ae09beatagen2026-05-27 15:49 UTC433
    format!("{}.{:03}", t.as_secs(), t.subsec_millis())
c19702ae09beatagen2026-05-27 15:49 UTC434
}
c19702ae09beatagen2026-05-27 15:49 UTC435
c19702ae09beatagen2026-05-27 15:49 UTC436
fn main() -> ExitCode {
c19702ae09beatagen2026-05-27 15:49 UTC437
    if let Err(e) = run() {
c19702ae09beatagen2026-05-27 15:49 UTC438
        eprintln!("headroom: {e}");
c19702ae09beatagen2026-05-27 15:49 UTC439
        return ExitCode::from(1);
c19702ae09beatagen2026-05-27 15:49 UTC440
    }
c19702ae09beatagen2026-05-27 15:49 UTC441
    ExitCode::SUCCESS
c19702ae09beatagen2026-05-27 15:49 UTC442
}