use clap::{CommandFactory, FromArgMatches, Parser, Subcommand};
use clap_complete::Shell;
#[derive(Parser, Debug)]
#[command(
name = "fez",
version,
about = "Agent-native management CLI for Fedora/RHEL"
)]
pub struct Cli {
#[arg(long, global = true)]
pub host: Option<String>,
#[arg(long, global = true)]
pub json: bool,
#[arg(long, global = true)]
pub dry_run: bool,
#[arg(long, global = true)]
pub force: bool,
#[command(subcommand)]
pub command: TopCommand,
}
impl Cli {
#[must_use]
pub fn resolved_host(&self) -> String {
crate::transport::from_host(self.host.as_deref()).host_label()
}
}
pub fn raw_command() -> clap::Command {
<Cli as CommandFactory>::command()
}
pub fn command() -> clap::Command {
crate::capability::help::inject(raw_command())
}
fn wants_json<I, S>(args: I) -> bool
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
for arg in args {
let arg = arg.as_ref();
if arg == "--" {
return false;
}
if arg == "--json" {
return true;
}
}
false
}
pub fn parse_or_render() -> std::result::Result<Cli, i32> {
let argv: Vec<std::ffi::OsString> = std::env::args_os().collect();
match command().try_get_matches_from(&argv) {
Ok(matches) => Ok(Cli::from_arg_matches(&matches).expect("clap validated args")),
Err(err) => {
use clap::error::ErrorKind;
if matches!(
err.kind(),
ErrorKind::DisplayHelp
| ErrorKind::DisplayVersion
| ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
) {
let _ = err.print();
return Err(0);
}
let json = wants_json(argv.iter().map(|s| s.to_string_lossy().into_owned()));
if json {
let message = clap_error_message(&err);
let env = crate::envelope::Envelope::error(
"Error",
"localhost",
crate::envelope::ApiError {
code: "usage".into(),
message,
detail: None,
},
);
println!("{}", env.to_json_string());
Err(2)
} else {
let _ = err.print();
Err(err.exit_code())
}
}
}
}
fn clap_error_message(err: &clap::Error) -> String {
let rendered = err.render().to_string();
let mut parts: Vec<String> = Vec::new();
for raw in rendered.lines() {
let line = raw.trim();
if line.is_empty() {
break;
}
parts.push(line.to_string());
}
if parts.is_empty() {
return "usage error".to_string();
}
let joined = parts.join(" ");
joined
.strip_prefix("error: ")
.unwrap_or(&joined)
.to_string()
}
#[derive(Subcommand, Debug)]
pub enum TopCommand {
Capabilities,
Describe {
capability: String,
},
Guide,
Completions {
#[arg(value_enum)]
shell: Shell,
},
#[command(hide = true)]
Man,
Services {
#[command(subcommand)]
action: ServicesAction,
},
Packages {
#[command(subcommand)]
action: PackagesAction,
},
Network {
#[command(subcommand)]
action: NetworkAction,
},
Firewall {
#[command(subcommand)]
action: FirewallAction,
},
Mcp {
#[arg(long)]
expanded_tools: bool,
},
}
#[derive(Subcommand, Debug)]
pub enum ServicesAction {
List {
#[arg(long)]
state: Option<String>,
},
Status {
unit: String,
},
Logs {
unit: String,
#[arg(long)]
since: Option<String>,
#[arg(long)]
priority: Option<String>,
#[arg(long)]
lines: Option<u32>,
#[arg(long)]
follow: bool,
},
Start {
unit: String,
},
Stop {
unit: String,
},
Restart {
unit: String,
},
Reload {
unit: String,
},
Enable {
unit: String,
#[arg(long)]
now: bool,
},
Disable {
unit: String,
#[arg(long)]
now: bool,
},
}
#[derive(Subcommand, Debug)]
pub enum PackagesAction {
List {
#[arg(long, conflicts_with = "available")]
installed: bool,
#[arg(long)]
available: bool,
#[arg(long = "repo")]
repo: Vec<String>,
#[arg(long)]
name: Option<String>,
#[arg(long)]
limit: Option<usize>,
#[arg(long, default_value_t = 0)]
offset: usize,
},
Info {
spec: String,
},
Search {
pattern: String,
},
CheckUpdate,
Repolist {
#[arg(long, conflicts_with_all = ["disabled", "all"])]
enabled: bool,
#[arg(long, conflicts_with = "all")]
disabled: bool,
#[arg(long)]
all: bool,
},
Install {
#[arg(required = true)]
specs: Vec<String>,
},
Remove {
#[arg(required = true)]
specs: Vec<String>,
},
Upgrade {
specs: Vec<String>,
},
}
#[derive(Subcommand, Debug)]
pub enum NetworkAction {
List {
#[arg(long)]
all: bool,
},
Show {
device: String,
},
}
#[derive(Subcommand, Debug)]
pub enum FirewallAction {
Status,
List,
Show {
zone: String,
},
Services,
AddService {
service: String,
#[arg(long)]
zone: Option<String>,
#[arg(long)]
timeout: Option<u32>,
},
RemoveService {
service: String,
#[arg(long)]
zone: Option<String>,
},
AddPort {
port: String,
#[arg(long)]
zone: Option<String>,
#[arg(long)]
timeout: Option<u32>,
},
RemovePort {
port: String,
#[arg(long)]
zone: Option<String>,
},
SetDefaultZone {
zone: String,
},
Reload,
Confirm,
Panic {
#[arg(value_parser = ["on", "off"])]
state: String,
},
Masquerade {
#[arg(value_parser = ["on", "off"])]
state: String,
#[arg(long)]
zone: Option<String>,
#[arg(long)]
timeout: Option<u32>,
},
}
#[cfg(test)]
mod tests {
use super::*;
fn cli(args: &[&str]) -> Cli {
Cli::try_parse_from(args).expect("args parse")
}
#[test]
fn wants_json_detects_flag_anywhere() {
assert!(wants_json(["fez", "--json", "services", "status"]));
assert!(wants_json(["fez", "services", "status", "--json"]));
assert!(!wants_json(["fez", "services", "status"]));
}
#[test]
fn wants_json_respects_double_dash() {
assert!(!wants_json(["fez", "--", "--json"]));
assert!(wants_json(["fez", "--json", "--", "x"]));
}
#[test]
fn clap_error_message_joins_missing_args_and_strips_prefix() {
let err = Cli::try_parse_from(["fez", "services", "status"]).unwrap_err();
let msg = clap_error_message(&err);
assert!(!msg.starts_with("error:"), "prefix not stripped: {msg}");
assert!(msg.contains("UNIT"), "arg name missing: {msg}");
assert!(!msg.contains('\n'), "message should be one line: {msg}");
}
#[test]
fn clap_error_message_renders_unknown_flag() {
let err = Cli::try_parse_from(["fez", "services", "list", "--bogus"]).unwrap_err();
let msg = clap_error_message(&err);
assert!(msg.contains("--bogus"), "{msg}");
}
#[test]
fn resolved_host_defaults_to_localhost() {
assert_eq!(
cli(&["fez", "services", "list"]).resolved_host(),
"localhost"
);
}
#[test]
fn resolved_host_normalizes_local_alias() {
assert_eq!(
cli(&["fez", "--host", "local", "services", "list"]).resolved_host(),
"localhost"
);
}
#[test]
fn resolved_host_passes_through_explicit_host() {
assert_eq!(
cli(&["fez", "--host", "fedora@box.example", "services", "list"]).resolved_host(),
"fedora@box.example"
);
}
#[test]
fn firewall_masquerade_parses_state_zone_timeout() {
let c = cli(&[
"fez",
"firewall",
"masquerade",
"on",
"--zone",
"public",
"--timeout",
"60",
]);
match c.command {
TopCommand::Firewall {
action:
FirewallAction::Masquerade {
state,
zone,
timeout,
},
} => {
assert_eq!(state, "on");
assert_eq!(zone.as_deref(), Some("public"));
assert_eq!(timeout, Some(60));
}
other => panic!("unexpected parse: {other:?}"),
}
}
#[test]
fn firewall_masquerade_rejects_bad_state() {
assert!(Cli::try_parse_from(["fez", "firewall", "masquerade", "maybe"]).is_err());
}
}