use std::path::PathBuf;
use clap::{CommandFactory, Parser, Subcommand, ValueEnum};
use crate::common::paths;
pub use crate::cli_config::{
ConfigAction, ConfigArgs, ConfigPathArgs, ConfigPrintArgs, ConfigPrintFormat,
ConfigValidateArgs,
};
pub use crate::cli_service::{ServiceAction, ServiceArgs};
pub const ENERGY_HELP: &str = "Energy Session (TUI):
Shows accumulated energy (kWh), avg power, and estimated cost since the
process started. Press R in the TUI to reset the session counter (the
Prometheus all_smi_energy_joules_total counter is unaffected).
Configure via [energy] in the TOML config (see `all-smi config path`
for the active path) or these environment variables (override the
config file):
ALL_SMI_ENERGY_PRICE $/kWh price (default 0.12; invalid hides cost)
ALL_SMI_ENERGY_CURRENCY Display currency code (default USD)
ALL_SMI_ENERGY_NO_COST=1 Hide cost column; still show kWh
ALL_SMI_ENERGY_WAL_PATH WAL file path (default <platform cache dir>/all-smi/energy-wal.bin)
ALL_SMI_ENERGY_NO_WAL=1 Disable disk WAL (in-memory counters only)
ALL_SMI_ENERGY_GAP_SECONDS Gap threshold for trapezoid→hold-last (1..=3600, default 10)";
#[derive(Parser)]
#[command(author, version, about, long_about = None)]
pub struct Cli {
#[arg(long, global = true, value_name = "PATH")]
pub config: Option<PathBuf>,
#[command(subcommand)]
pub command: Option<Commands>,
}
#[derive(Subcommand)]
pub enum Commands {
Api(ApiArgs),
Local(LocalArgs),
View(ViewArgs),
Snapshot(SnapshotArgs),
Record(RecordArgs),
Doctor(DoctorArgs),
Config(ConfigArgs),
Service(ServiceArgs),
}
#[derive(Parser, Clone)]
pub struct ApiArgs {
#[arg(short, long)]
pub port: Option<u16>,
#[arg(short, long)]
pub interval: Option<u64>,
#[arg(long, num_args = 0..=1, default_missing_value = "true")]
pub processes: Option<bool>,
#[cfg(unix)]
#[arg(short, long, num_args = 0..=1, default_missing_value = "")]
pub socket: Option<String>,
}
#[derive(Parser, Clone)]
pub struct LocalArgs {
#[arg(short, long)]
pub interval: Option<u64>,
#[arg(long)]
pub alert_temp: Option<u32>,
#[arg(long)]
pub alert_util_low_mins: Option<u32>,
}
#[derive(Parser, Clone)]
pub struct ViewArgs {
#[arg(long, num_args = 1..)]
pub hosts: Option<Vec<String>>,
#[arg(long)]
pub hostfile: Option<String>,
#[arg(short, long)]
pub interval: Option<u64>,
#[arg(long)]
pub alert_temp: Option<u32>,
#[arg(long)]
pub alert_util_low_mins: Option<u32>,
#[arg(long)]
pub replay: Option<PathBuf>,
#[arg(long, default_value_t = 1.0)]
pub speed: f32,
#[arg(long)]
pub start: Option<String>,
#[arg(long = "loop")]
pub replay_loop: bool,
#[arg(long)]
pub ssh: Option<String>,
#[arg(long = "ssh-hostfile")]
pub ssh_hostfile: Option<PathBuf>,
#[arg(long = "ssh-key")]
pub ssh_key: Option<PathBuf>,
#[arg(long = "ssh-config")]
pub ssh_config: Option<PathBuf>,
#[arg(long = "ssh-strict-host-key", default_value = "yes")]
pub ssh_strict_host_key: String,
#[arg(long = "ssh-timeout-secs", default_value_t = 10)]
pub ssh_timeout_secs: u64,
#[arg(long = "ssh-fallback")]
pub ssh_fallback: Option<String>,
#[arg(long = "ssh-known-hosts")]
pub ssh_known_hosts: Option<PathBuf>,
#[arg(long = "ssh-concurrency", default_value_t = 32)]
pub ssh_concurrency: usize,
}
impl ViewArgs {
pub fn empty() -> Self {
Self {
hosts: None,
hostfile: None,
interval: None,
alert_temp: None,
alert_util_low_mins: None,
replay: None,
speed: 1.0,
start: None,
replay_loop: false,
ssh: None,
ssh_hostfile: None,
ssh_key: None,
ssh_config: None,
ssh_strict_host_key: "yes".to_string(),
ssh_timeout_secs: 10,
ssh_fallback: None,
ssh_known_hosts: None,
ssh_concurrency: 32,
}
}
}
#[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum)]
pub enum SnapshotFormat {
Json,
Csv,
Prometheus,
}
impl std::fmt::Display for SnapshotFormat {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Json => write!(f, "json"),
Self::Csv => write!(f, "csv"),
Self::Prometheus => write!(f, "prometheus"),
}
}
}
#[derive(Parser, Clone)]
pub struct SnapshotArgs {
#[arg(long, value_enum, default_value_t = SnapshotFormat::Json)]
pub format: SnapshotFormat,
#[arg(long, num_args = 0..=1, default_missing_value = "true")]
pub pretty: Option<bool>,
#[arg(long, value_delimiter = ',', default_value = "gpu,cpu,memory,chassis")]
pub include: Vec<String>,
#[arg(long, value_delimiter = ',')]
pub query: Vec<String>,
#[arg(long, default_value_t = 1)]
pub samples: u32,
#[arg(long, default_value_t = 0)]
pub interval: u64,
#[arg(long, default_value_t = 5_000)]
pub timeout_ms: u64,
#[arg(long, short)]
pub output: Option<String>,
}
impl SnapshotArgs {
pub fn includes(&self) -> Result<SnapshotIncludes, String> {
let mut set = SnapshotIncludes::default();
for raw in &self.include {
let name = raw.trim().to_ascii_lowercase();
match name.as_str() {
"" => continue,
"gpu" => set.gpu = true,
"cpu" => set.cpu = true,
"memory" => set.memory = true,
"chassis" => set.chassis = true,
"process" | "processes" => set.process = true,
"storage" | "disk" => set.storage = true,
other => {
return Err(format!(
"unknown --include section `{other}` (valid: gpu, cpu, memory, chassis, process, storage)"
));
}
}
}
Ok(set)
}
}
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
pub struct SnapshotIncludes {
pub gpu: bool,
pub cpu: bool,
pub memory: bool,
pub chassis: bool,
pub process: bool,
pub storage: bool,
}
impl SnapshotIncludes {
pub fn is_empty(&self) -> bool {
!(self.gpu || self.cpu || self.memory || self.chassis || self.process || self.storage)
}
}
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, ValueEnum)]
pub enum RecordSource {
#[default]
Local,
Remote,
}
impl std::fmt::Display for RecordSource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Local => write!(f, "local"),
Self::Remote => write!(f, "remote"),
}
}
}
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, ValueEnum)]
pub enum RecordCompression {
#[default]
Zstd,
Gzip,
None,
}
impl std::fmt::Display for RecordCompression {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Zstd => write!(f, "zstd"),
Self::Gzip => write!(f, "gzip"),
Self::None => write!(f, "none"),
}
}
}
#[derive(Parser, Clone, Debug)]
pub struct RecordArgs {
#[arg(
long,
short = 'o',
long_help = "Output path for the NDJSON stream.\n\n\
When omitted, the file lands under the `record.output_dir` config \
value (if set), otherwise under the platform cache directory's \
`all-smi/records/` subdirectory, with basename \
`all-smi-record.ndjson.zst`. The platform cache directory is \
`$XDG_CACHE_HOME` (or `~/.cache`) on Linux, `~/Library/Caches` on \
macOS, and `%LOCALAPPDATA%` on Windows. If no home-like directory \
is resolvable, the file is written to the current working \
directory as `all-smi-record.ndjson.zst`.\n\n\
Extension drives compression: `.ndjson.zst` → zstd, \
`.ndjson.gz` → gzip, anything else → plain NDJSON. Use \
`--compress` to override detection."
)]
pub output: Option<PathBuf>,
#[arg(long, short = 'i', default_value_t = 3)]
pub interval: u64,
#[arg(long, default_value = "0")]
pub duration: String,
#[arg(long, value_enum, default_value_t = RecordSource::Local)]
pub source: RecordSource,
#[arg(long, num_args = 1..)]
pub hosts: Option<Vec<String>>,
#[arg(long)]
pub hostfile: Option<String>,
#[arg(long, value_delimiter = ',', default_value = "gpu,cpu,memory,chassis")]
pub include: Vec<String>,
#[arg(long, default_value = "100M")]
pub max_size: String,
#[arg(long, default_value_t = 10)]
pub max_files: u32,
#[arg(long, value_enum)]
pub compress: Option<RecordCompression>,
}
impl RecordArgs {
pub fn includes(&self) -> Result<SnapshotIncludes, String> {
let mut set = SnapshotIncludes::default();
for raw in &self.include {
let name = raw.trim().to_ascii_lowercase();
match name.as_str() {
"" => continue,
"gpu" => set.gpu = true,
"cpu" => set.cpu = true,
"memory" => set.memory = true,
"chassis" => set.chassis = true,
"process" | "processes" => set.process = true,
other => {
return Err(format!(
"unknown --include section `{other}` (valid: gpu, cpu, memory, chassis, process)"
));
}
}
}
Ok(set)
}
}
#[derive(Parser, Clone, Debug)]
pub struct DoctorArgs {
#[arg(long)]
pub json: bool,
#[arg(long)]
pub verbose: bool,
#[arg(long, value_name = "PATH")]
pub bundle: Option<PathBuf>,
#[arg(long)]
pub include_identifiers: bool,
#[arg(long = "remote-check", value_name = "HOST_OR_URL", num_args = 1..)]
pub remote_check: Vec<String>,
#[arg(long, value_name = "CHECK_ID", value_delimiter = ',')]
pub skip: Vec<String>,
#[arg(long, value_name = "CHECK_ID", value_delimiter = ',')]
pub only: Vec<String>,
}
pub fn config_help_block() -> String {
let resolved = paths::active_config_path();
let line = paths::format_path_with_existence(resolved.as_deref());
let others: String = paths::candidate_config_paths()
.iter()
.filter(|p| Some(p.as_path()) != resolved.as_deref())
.map(|p| format!("\n {}", paths::format_path_with_existence(Some(p))))
.collect();
let also = if others.is_empty() {
String::new()
} else {
format!("\n Also searched:{others}")
};
format!(
"Configuration file:\n \
Optional TOML file. Precedence: CLI flags > env vars > config file > built-in defaults.\n \
Active path (this platform):\n \
{line}{also}\n \
Inspect: all-smi config path Init: all-smi config init Print merged: all-smi config print\n \
Override path with --config <PATH>."
)
}
pub fn build_command_with_runtime_help() -> clap::Command {
let config_block = config_help_block();
let after = format!("{config_block}\n\n{ENERGY_HELP}");
Cli::command().after_help(after)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn snapshot_includes_is_empty_when_all_false() {
let inc = SnapshotIncludes::default();
assert!(inc.is_empty());
}
#[test]
fn snapshot_includes_not_empty_when_one_set() {
let inc = SnapshotIncludes {
gpu: true,
..Default::default()
};
assert!(!inc.is_empty());
}
#[test]
fn snapshot_args_includes_rejects_unknown_section() {
let args = SnapshotArgs {
format: SnapshotFormat::Json,
pretty: None,
include: vec!["gpu".to_string(), "unknown_section".to_string()],
query: Vec::new(),
samples: 1,
interval: 0,
timeout_ms: 5_000,
output: None,
};
let result = args.includes();
assert!(result.is_err());
let msg = result.unwrap_err();
assert!(
msg.contains("unknown_section"),
"error must name the unknown section, got: {msg}"
);
}
#[test]
fn snapshot_args_includes_accepts_process_alias() {
let args = SnapshotArgs {
format: SnapshotFormat::Json,
pretty: None,
include: vec!["processes".to_string(), "disk".to_string()],
query: Vec::new(),
samples: 1,
interval: 0,
timeout_ms: 5_000,
output: None,
};
let result = args
.includes()
.expect("process/disk aliases should be accepted");
assert!(result.process);
assert!(result.storage);
}
#[test]
fn snapshot_format_display() {
assert_eq!(SnapshotFormat::Json.to_string(), "json");
assert_eq!(SnapshotFormat::Csv.to_string(), "csv");
assert_eq!(SnapshotFormat::Prometheus.to_string(), "prometheus");
}
#[test]
fn help_text_contains_config_and_energy_blocks() {
let mut cmd = build_command_with_runtime_help();
let help = cmd.render_help().to_string();
assert!(
help.contains("Configuration file:"),
"help must contain the Configuration file block, got:\n{help}"
);
assert!(
help.contains("Active path (this platform):"),
"help must label the active path, got:\n{help}"
);
assert!(
help.contains("all-smi config path"),
"help must point at `all-smi config path`, got:\n{help}"
);
assert!(
help.contains("Energy Session"),
"existing Energy Session block must still render, got:\n{help}"
);
}
#[test]
fn config_flag_help_no_longer_only_points_at_init() {
let mut cmd = build_command_with_runtime_help();
let help = cmd.render_help().to_string();
assert!(
help.contains("config path"),
"help should mention `config path`, got:\n{help}"
);
}
#[test]
fn config_help_block_lists_the_non_active_candidates() {
let block = config_help_block();
let active = paths::active_config_path();
let others: Vec<_> = paths::candidate_config_paths()
.into_iter()
.filter(|p| Some(p.as_path()) != active.as_deref())
.collect();
if others.is_empty() {
assert!(
!block.contains("Also searched"),
"with a single candidate the block must stay short, got:\n{block}"
);
} else {
assert!(
block.contains("Also searched"),
"extra candidates must be labelled, got:\n{block}"
);
for p in others {
assert!(
block.contains(&p.display().to_string()),
"candidate {} missing from the help block:\n{block}",
p.display()
);
}
}
}
#[test]
fn service_subcommand_is_registered() {
let cmd = build_command_with_runtime_help();
let service = cmd
.get_subcommands()
.find(|s| s.get_name() == "service")
.expect("`all-smi service` must be a registered subcommand");
let mut actions: Vec<_> = service
.get_subcommands()
.map(|s| s.get_name().to_string())
.filter(|n| n != "help")
.collect();
actions.sort();
assert_eq!(
actions,
vec![
"install",
"restart",
"run",
"start",
"status",
"stop",
"uninstall"
],
"the service subcommand set is a cross-platform contract (#310, #311)"
);
for action in service.get_subcommands() {
let name = action.get_name();
let hidden = action.is_hide_set();
match name {
"run" => assert!(hidden, "`service run` must remain hidden from --help"),
"help" => {}
other => assert!(!hidden, "`service {other}` must stay visible in --help"),
}
}
}
#[test]
fn service_flags_match_the_documented_contract() {
let cmd = build_command_with_runtime_help();
let service = cmd
.get_subcommands()
.find(|s| s.get_name() == "service")
.expect("service subcommand");
let flags = |name: &str| -> Vec<String> {
service
.get_subcommands()
.find(|s| s.get_name() == name)
.unwrap_or_else(|| panic!("`service {name}` must exist"))
.get_arguments()
.filter_map(|a| a.get_long().map(str::to_string))
.filter(|l| l != "help" && l != "config")
.collect()
};
for expected in ["user", "service-user", "now", "force"] {
assert!(
flags("install").iter().any(|f| f == expected),
"`service install` must accept --{expected}, got: {:?}",
flags("install")
);
}
for action in ["start", "stop", "restart"] {
assert_eq!(flags(action), vec!["user".to_string()], "action: {action}");
}
assert!(flags("uninstall").iter().any(|f| f == "user"));
for expected in ["user", "json"] {
assert!(
flags("status").iter().any(|f| f == expected),
"`service status` must accept --{expected}"
);
}
}
#[test]
fn service_help_mentions_all_supported_backends_and_user_scope() {
let mut cmd = build_command_with_runtime_help();
let service = cmd
.find_subcommand_mut("service")
.expect("service subcommand");
let help = service.render_long_help().to_string();
assert!(
help.contains("Linux (systemd), macOS (launchd), and Windows (Service Control"),
"service help must list Linux, macOS, and Windows support, got:\n{help}"
);
assert!(
help.contains("On Linux and macOS, the system scope requires root"),
"service help must keep root wording scoped to Linux and macOS, got:\n{help}"
);
assert!(
help.contains("`--user` applies to Linux and macOS"),
"service help must describe where --user applies, got:\n{help}"
);
assert!(
!help.contains("tracked separately"),
"service help must not claim launchd is still pending, got:\n{help}"
);
}
#[test]
fn config_help_block_carries_existence_marker() {
let block = config_help_block();
assert!(
block.contains("(active)")
|| block.contains("(not found)")
|| block.contains("no config path"),
"config_help_block must carry an existence marker, got:\n{block}"
);
}
}