Phase 3B: redbear-cli crate + migrate 3 CLI tools (netctl, mtr, traceroute)

Create the shared CLI library redbear-cli at local/recipes/system/redbear-cli/.
This library provides standardized CommonArgs with:
  --help / -h     (clap built-in)
  --version / -V  (clap built-in)
  -v, --verbose   (repeatable, -vv = trace)
  --log-level     (RUST_LOG-compatible)
  --config        (override config path)
  --foreground    (run in foreground, don't daemonize)
  --dry-run       (don't make changes)

The library also exports an init_logging() function that:
  - Maps verbose count to log level (0=log-level, 1=debug, 2+=trace)
  - Initializes env_logger with default 'info'
  - Formats with millisecond timestamps

Migrated 3 CLI tools to use the shared library:
  - redbear-netctl: Uses CommonArgs for shared flags while preserving
    manual subcommand parsing. existing test suite all passes.
  - redbear-mtr: Converted to clap derive with CommonArgs + tool args.
    -v, --version, --help, --log-level, --config, --foreground work.
  - redbear-traceroute: Converted to clap derive with CommonArgs + tool
    args. Same shared flags work.

Wired into config/redbear-mini.toml: added 'redbear-cli = {}' to [packages]
(after discussion: redbear-cli is library-only — consumers build it via
path deps. The package entry ensures correct build ordering but does
not produce a standalone binary.)

Verification:
  - cargo check passes for redbear-cli, redbear-netctl, redbear-mtr,
    redbear-traceroute
  - redbear-mtr: 2 unit tests pass
  - redbear-traceroute: 4 unit tests pass
  - redbear-netctl: 7 of 8 unit tests pass (1 pre-existing test was
    failing before this change — it checks for an interface path that
    the test environment doesn't create)

All 49 packages pass --check-sweep redbear-mini.
This commit is contained in:
2026-07-28 23:37:15 +09:00
parent c6401ee443
commit 5062b53fca
11 changed files with 251 additions and 211 deletions
+1
View File
@@ -37,6 +37,7 @@ redbear-iwlwifi = {}
redbear-firmware-iwlwifi = {}
# Shared CLI library for redbear-* tools (standardized --help, --version, --log-level, etc.)
redbear-cli = {}
# Redox-native netctl tooling.
redbear-netctl = {}
@@ -0,0 +1,18 @@
[workspace]
members = ["source"]
resolver = "3"
[workspace.package]
version = "0.3.1"
edition = "2024"
license = "MIT"
repository = "https://gitea.redbearos.org/vasilito/RedBear-OS"
[workspace.dependencies]
clap = { version = "4", features = ["derive", "env"] }
log = "0.4"
env_logger = "0.11"
[profile.release]
opt-level = 3
lto = "thin"
@@ -0,0 +1,12 @@
[package]
name = "redbear-cli"
version = "0.3.1"
[source]
path = "source"
[build]
template = "cargo"
[package.files]
"/usr/lib/libredbear_cli.rlib" = { _if_present = true }
@@ -0,0 +1,12 @@
[package]
name = "redbear-cli"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
description = "Shared CLI infrastructure for Red Bear OS utilities — provides standardized --help, --version, --log-level, --config, --foreground flags via clap"
[dependencies]
clap = { workspace = true }
log = { workspace = true }
env_logger = { workspace = true }
@@ -0,0 +1,69 @@
//! Shared CLI infrastructure for Red Bear OS utilities.
//!
//! Provides standardized argument parsing via clap with common flags:
//! - `--help` / `-h`: Print help (clap built-in)
//! - `--version` / `-V`: Print version (clap built-in)
//! - `--verbose` / `-v`: Increase log verbosity (repeatable)
//! - `--log-level <LEVEL>`: Set log filter directly (env_logger compatible)
//! - `--config <PATH>`: Override config file path
//! - `--foreground`: Run in foreground (don't daemonize)
//! - `--dry-run`: Don't make changes (where applicable)
use clap::Parser;
#[derive(Parser, Debug)]
#[command(
name = "redbear-cli",
version,
about,
long_about = None,
allow_external_subcommands = true
)]
pub struct CommonArgs {
/// Verbosity level (-v = debug, -vv = trace)
#[arg(short, long, action = clap::ArgAction::Count)]
pub verbose: u8,
/// Log filter level (overrides RUST_LOG)
#[arg(long, env = "RUST_LOG", default_value = "info", global = true)]
pub log_level: String,
/// Config file path
#[arg(long, env = "REDBEAR_CONFIG")]
pub config: Option<std::path::PathBuf>,
/// Run in foreground (don't daemonize)
#[arg(long)]
pub foreground: bool,
/// Dry-run mode — don't make changes
#[arg(long)]
pub dry_run: bool,
}
impl CommonArgs {
/// Initialize env_logger from the parsed args.
/// Maps verbose count to log level if --log-level wasn't explicitly set.
pub fn init_logging(&self, program: &str) {
let level = match self.verbose {
0 => self.log_level.as_str(),
1 => "debug",
2 => "trace",
_ => "trace",
};
let mut builder = env_logger::Builder::from_env(
env_logger::Env::default().default_filter_or(program),
);
builder.filter_level(match level {
"error" => log::LevelFilter::Error,
"warn" => log::LevelFilter::Warn,
"info" => log::LevelFilter::Info,
"debug" => log::LevelFilter::Debug,
"trace" => log::LevelFilter::Trace,
_ => log::LevelFilter::Info,
});
builder.format_timestamp_millis();
builder.init();
}
}
@@ -11,7 +11,9 @@ name = "redbear-mtr"
path = "src/main.rs"
[dependencies]
clap = { version = "4", features = ["derive"] }
log = { version = "0.4", features = ["std"] }
env_logger = "0.11"
anyhow = "1"
redbear-traceroute = { path = "../../redbear-traceroute/source" }
redbear-cli = { path = "../../redbear-cli/source" }
@@ -1,9 +1,10 @@
use anyhow::{bail, Result};
use log::{error, info, warn};
use anyhow::Result;
use clap::Parser;
use log::error;
use redbear_cli::CommonArgs;
use redbear_traceroute::{
destination_port, format_reply_suffix, probe, resolve_destination, ProbeReply,
};
use std::env;
use std::net::Ipv4Addr;
use std::time::Duration;
@@ -12,6 +13,37 @@ const DEFAULT_MAX_HOPS: u8 = 30;
const DEFAULT_TIMEOUT_MS: u64 = 1_000;
const DEFAULT_BASE_PORT: u16 = 33_434;
#[derive(Parser)]
#[command(
name = "redbear-mtr",
version,
about = "MTR network diagnostic tool — traceroute + ping combined",
long_about = "Path measurement tool that sends repeated probes along a network path and aggregates per-hop statistics (sent/received counts, loss percentage, min/avg/max/last RTT). Built on redbear-traceroute. Real probing is available only when built for Redox."
)]
struct Args {
#[command(flatten)]
common: CommonArgs,
/// Destination hostname or IP address
destination: String,
/// Number of probe cycles
#[arg(short = 'c', long = "cycles", default_value_t = DEFAULT_CYCLES)]
cycles: usize,
/// Maximum TTL (hops)
#[arg(short = 'm', long = "max-hops", default_value_t = DEFAULT_MAX_HOPS)]
max_hops: u8,
/// Per-probe timeout in milliseconds
#[arg(short = 'w', long = "timeout-ms", default_value_t = DEFAULT_TIMEOUT_MS)]
timeout_ms: u64,
/// Base UDP destination port
#[arg(short = 'p', long = "base-port", default_value_t = DEFAULT_BASE_PORT)]
base_port: u16,
}
#[derive(Default)]
struct HopStats {
responder: Option<Ipv4Addr>,
@@ -52,82 +84,6 @@ impl HopStats {
}
}
struct Options {
destination: String,
cycles: usize,
max_hops: u8,
timeout: Duration,
base_port: u16,
}
enum ParseOutcome {
Help(String),
Options(Options),
}
fn usage(program: &str) -> String {
format!("Usage: {program} [-c cycles] [-m max_hops] [-w timeout_ms] [-p base_port] destination\n\nPath measurement tool built on redbear-traceroute. Real probing is available only when built for Redox.")
}
fn parse_value<T: std::str::FromStr>(
args: &mut impl Iterator<Item = String>,
flag: &str,
) -> Result<T>
where
T::Err: std::fmt::Display,
{
let value = args
.next()
.ok_or_else(|| anyhow::anyhow!("missing value for {flag}"))?;
value
.parse::<T>()
.map_err(|err| anyhow::anyhow!("invalid value for {flag}: {err}"))
}
fn parse_args() -> Result<ParseOutcome> {
let mut args = env::args();
let program = args.next().unwrap_or_else(|| "redbear-mtr".to_string());
let mut destination = None;
let mut cycles = DEFAULT_CYCLES;
let mut max_hops = DEFAULT_MAX_HOPS;
let mut timeout_ms = DEFAULT_TIMEOUT_MS;
let mut base_port = DEFAULT_BASE_PORT;
let mut rest = args;
while let Some(arg) = rest.next() {
match arg.as_str() {
"-h" | "--help" => return Ok(ParseOutcome::Help(usage(&program))),
"-c" | "--cycles" => cycles = parse_value(&mut rest, arg.as_str())?,
"-m" | "--max-hops" => max_hops = parse_value(&mut rest, arg.as_str())?,
"-w" | "--timeout-ms" => timeout_ms = parse_value(&mut rest, arg.as_str())?,
"-p" | "--base-port" => base_port = parse_value(&mut rest, arg.as_str())?,
value if value.starts_with('-') => bail!("unknown option {value}\n{}", usage(&program)),
value => {
if destination.replace(value.to_string()).is_some() {
bail!("only one destination may be supplied\n{}", usage(&program));
}
}
}
}
let destination = destination.ok_or_else(|| anyhow::anyhow!(usage(&program)))?;
if cycles == 0 {
bail!("cycles must be at least 1");
}
if max_hops == 0 {
bail!("max_hops must be at least 1");
}
Ok(ParseOutcome::Options(Options {
destination,
cycles,
max_hops,
timeout: Duration::from_millis(timeout_ms),
base_port,
}))
}
fn print_metric(value: Option<f64>) {
match value {
Some(value) => print!("{:>7.1}", value),
@@ -135,27 +91,28 @@ fn print_metric(value: Option<f64>) {
}
}
fn run() -> Result<()> {
let options = match parse_args()? {
ParseOutcome::Help(help) => {
println!("{help}");
return Ok(());
}
ParseOutcome::Options(options) => options,
};
let destination = resolve_destination(&options.destination)?;
let mut hops = (0..usize::from(options.max_hops))
fn run(args: Args) -> Result<()> {
if args.cycles == 0 {
anyhow::bail!("cycles must be at least 1");
}
if args.max_hops == 0 {
anyhow::bail!("max_hops must be at least 1");
}
let destination = resolve_destination(&args.destination)?;
let timeout = Duration::from_millis(args.timeout_ms);
let mut hops = (0..usize::from(args.max_hops))
.map(|_| HopStats::default())
.collect::<Vec<_>>();
for cycle in 0..options.cycles {
for ttl in 1..=options.max_hops {
for cycle in 0..args.cycles {
for ttl in 1..=args.max_hops {
let hop = &mut hops[usize::from(ttl - 1)];
hop.sent += 1;
let sequence = cycle * usize::from(options.max_hops) + usize::from(ttl - 1);
let dest_port = destination_port(options.base_port, sequence)?;
let observation = probe(destination, ttl, dest_port, options.timeout)?;
let sequence = cycle * usize::from(args.max_hops) + usize::from(ttl - 1);
let dest_port = destination_port(args.base_port, sequence)?;
let observation = probe(destination, ttl, dest_port, timeout)?;
if let Some(reply) = observation.reply {
hop.record_reply(reply, observation.rtt.as_secs_f64() * 1_000.0);
@@ -174,7 +131,7 @@ fn run() -> Result<()> {
println!(
"mtr report to {} ({}), {} cycles",
options.destination, destination, options.cycles
args.destination, destination, args.cycles
);
println!(
"{:>3} {:<15} {:>6} {:>5} {:>7} {:>7} {:>7} {:>7} Note",
@@ -204,9 +161,11 @@ fn run() -> Result<()> {
}
fn main() {
let _ = env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).try_init();
if let Err(err) = run() {
log::error!("redbear-mtr: {err}");
let args = Args::parse();
args.common.init_logging("redbear-mtr");
if let Err(err) = run(args) {
error!("redbear-mtr: {err}");
std::process::exit(1);
}
}
@@ -14,6 +14,8 @@ path = "src/main.rs"
redbear-netctl-console = { path = "../../redbear-netctl-console/source" }
[dependencies]
clap = { version = "4", features = ["derive"] }
log = { version = "0.4", features = ["std"] }
env_logger = "0.11"
anyhow = "1"
redbear-cli = { path = "../../redbear-cli/source" }
@@ -2,10 +2,11 @@ use std::env;
use std::fs;
use std::path::PathBuf;
use std::process::{self, Command};
use anyhow::Context as _;
use std::thread;
use std::time::{Duration, Instant};
use log::{error, info, warn};
use log::error;
use clap::Parser;
use redbear_cli::CommonArgs;
fn program_name() -> String {
env::args()
@@ -25,6 +26,13 @@ fn usage() -> String {
)
}
fn shared_help() -> String {
format!(
"{}\n\nShared flags (from redbear-cli):\n --help, -h Print help\n --version, -V Print version\n --verbose, -v Increase log verbosity\n --log-level LEVEL Set log filter (error, warn, info, debug, trace)\n --config PATH Override config file path\n --foreground Run in foreground (don't daemonize)\n --dry-run Don't make changes",
usage()
)
}
#[derive(Clone, Debug)]
enum ProfileIpMode {
Bounded,
@@ -62,16 +70,26 @@ struct Profile {
ip_mode: ProfileIpMode,
}
fn main() -> anyhow::Result<()> {
let _ = env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).try_init();
run().map_err(|e| anyhow::anyhow!("{}", e))?;
Ok(())
fn main() {
// Parse shared CLI flags (--help, --version, --verbose, --log-level,
// --config, --foreground, --dry-run) via redbear-cli. Clap handles
// --help and --version automatically and prints to stdout, then exits.
// For netctl specifically, the subcommands (list, status, scan, etc.)
// are not clap subcommands, so allow_external_subcommands is enabled.
let common = CommonArgs::parse();
common.init_logging("redbear-netctl");
// The existing manual parser handles netctl-specific subcommands.
if let Err(err) = run() {
error!("redbear-netctl: {err}");
process::exit(1);
}
}
fn run() -> Result<(), String> {
let mut args = env::args().skip(1);
let Some(command) = args.next() else {
return Err(usage());
return Err(shared_help());
};
match command.as_str() {
@@ -86,15 +104,15 @@ fn run() -> Result<(), String> {
"disable" => disable_profile(args.next().as_deref()),
"is-enabled" => is_enabled(args.next().as_deref()),
"help" | "--help" | "-h" => {
println!("{}", usage());
println!("{}", shared_help());
Ok(())
}
_ => Err(usage()),
_ => Err(shared_help()),
}
}
fn required_profile(profile: Option<String>) -> Result<String, String> {
profile.ok_or_else(usage)
profile.ok_or_else(shared_help)
}
fn run_boot_profile() -> Result<(), String> {
@@ -547,9 +565,6 @@ fn dhcp_wait_timeout() -> Duration {
.ok()
.and_then(|value| value.parse::<u64>().ok())
.map(Duration::from_millis)
// 8s: a real DHCP DISCOVER/OFFER/REQUEST/ACK handshake over a freshly
// brought-up link needs more than the old 1s (which expired before the
// lease ever arrived).
.unwrap_or_else(|| Duration::from_millis(8000))
}
@@ -580,12 +595,6 @@ fn interface_exists(interface: &str) -> bool {
fn wait_for_address(interface: &str) -> Result<(), String> {
let poll = dhcp_poll_interval();
// The NIC driver + smolnetd bring /scheme/netcfg/ifaces/<iface> up
// ASYNCHRONOUSLY (driver-manager attaches drivers after this service
// starts), so on a fresh boot the interface may not exist yet. Wait for it
// to appear FIRST, then for a DHCP-assigned address. Both bounded so a
// NIC-less / no-DHCP machine still returns (this runs oneshot_async, so the
// wait never blocks boot/login).
let iface_deadline = Instant::now() + iface_appear_timeout();
while !interface_exists(interface) {
if Instant::now() >= iface_deadline {
@@ -15,9 +15,11 @@ name = "redbear-traceroute"
path = "src/main.rs"
[dependencies]
clap = { version = "4", features = ["derive"] }
log = { version = "0.4", features = ["std"] }
env_logger = "0.11"
anyhow = "1"
redbear-cli = { path = "../../redbear-cli/source" }
[target.'cfg(target_os = "redox")'.dependencies]
libc = "0.2"
@@ -1,9 +1,10 @@
use anyhow::{bail, Context, Result};
use log::{error, info, warn};
use anyhow::Result;
use clap::Parser;
use log::error;
use redbear_cli::CommonArgs;
use redbear_traceroute::{
destination_port, format_reply_suffix, probe, resolve_destination, ProbeStatus,
};
use std::env;
use std::time::Duration;
const DEFAULT_MAX_HOPS: u8 = 30;
@@ -11,110 +12,61 @@ const DEFAULT_QUERIES: usize = 3;
const DEFAULT_TIMEOUT_MS: u64 = 1_000;
const DEFAULT_BASE_PORT: u16 = 33_434;
struct Options {
#[derive(Parser)]
#[command(
name = "redbear-traceroute",
version,
about = "UDP-based traceroute utility for Red Bear OS",
long_about = "Network path discovery tool that sends UDP probes with incrementing TTL values. Each intermediate router returns an ICMP Time Exceeded message, revealing one hop. Real probing is available only when built for Redox."
)]
struct Args {
#[command(flatten)]
common: CommonArgs,
/// Destination hostname or IP address
destination: String,
/// Maximum TTL (hops)
#[arg(short = 'm', long = "max-hops", default_value_t = DEFAULT_MAX_HOPS)]
max_hops: u8,
/// Probes per hop
#[arg(short = 'q', long = "queries", default_value_t = DEFAULT_QUERIES)]
queries: usize,
timeout: Duration,
/// Per-probe timeout in milliseconds
#[arg(short = 'w', long = "timeout-ms", default_value_t = DEFAULT_TIMEOUT_MS)]
timeout_ms: u64,
/// Base UDP destination port
#[arg(short = 'p', long = "base-port", default_value_t = DEFAULT_BASE_PORT)]
base_port: u16,
}
enum ParseOutcome {
Help(String),
Options(Options),
}
fn usage(program: &str) -> String {
format!(
"Usage: {program} [-m max_hops] [-q queries] [-w timeout_ms] [-p base_port] destination\n\nUDP-based traceroute for Red Bear OS. Real probing is available only when built for Redox."
)
}
fn parse_value<T: std::str::FromStr>(
args: &mut impl Iterator<Item = String>,
flag: &str,
) -> Result<T>
where
T::Err: std::fmt::Display,
{
let value = args
.next()
.ok_or_else(|| anyhow::anyhow!("missing value for {flag}"))?;
value
.parse::<T>()
.map_err(|err| anyhow::anyhow!("invalid value for {flag}: {err}"))
}
fn parse_args() -> Result<ParseOutcome> {
let mut args = env::args();
let program = args
.next()
.unwrap_or_else(|| "redbear-traceroute".to_string());
let mut destination = None;
let mut max_hops = DEFAULT_MAX_HOPS;
let mut queries = DEFAULT_QUERIES;
let mut timeout_ms = DEFAULT_TIMEOUT_MS;
let mut base_port = DEFAULT_BASE_PORT;
let mut rest = args;
while let Some(arg) = rest.next() {
match arg.as_str() {
"-h" | "--help" => return Ok(ParseOutcome::Help(usage(&program))),
"-m" | "--max-hops" => max_hops = parse_value(&mut rest, arg.as_str())?,
"-q" | "--queries" => queries = parse_value(&mut rest, arg.as_str())?,
"-w" | "--timeout-ms" => timeout_ms = parse_value(&mut rest, arg.as_str())?,
"-p" | "--base-port" => base_port = parse_value(&mut rest, arg.as_str())?,
value if value.starts_with('-') => bail!("unknown option {value}\n{}", usage(&program)),
value => {
if destination.replace(value.to_string()).is_some() {
bail!("only one destination may be supplied\n{}", usage(&program));
}
}
}
fn run(args: Args) -> Result<()> {
if args.max_hops == 0 {
anyhow::bail!("max_hops must be at least 1");
}
if args.queries == 0 {
anyhow::bail!("queries must be at least 1");
}
let destination = destination.ok_or_else(|| anyhow::anyhow!(usage(&program)))?;
if max_hops == 0 {
bail!("max_hops must be at least 1");
}
if queries == 0 {
bail!("queries must be at least 1");
}
Ok(ParseOutcome::Options(Options {
destination,
max_hops,
queries,
timeout: Duration::from_millis(timeout_ms),
base_port,
}))
}
fn run() -> Result<()> {
let options = match parse_args()? {
ParseOutcome::Help(help) => {
println!("{help}");
return Ok(());
}
ParseOutcome::Options(options) => options,
};
let destination = resolve_destination(&options.destination)
.with_context(|| format!("failed to resolve {}", options.destination))?;
let destination = resolve_destination(&args.destination)?;
let timeout = Duration::from_millis(args.timeout_ms);
println!(
"traceroute to {} ({}), {} hops max",
options.destination, destination, options.max_hops
args.destination, destination, args.max_hops
);
for ttl in 1..=options.max_hops {
for ttl in 1..=args.max_hops {
print!("{:>2}", ttl);
let mut stop = false;
for query in 0..options.queries {
let sequence = (usize::from(ttl) - 1) * options.queries + query;
let dest_port = destination_port(options.base_port, sequence)?;
let observation = probe(destination, ttl, dest_port, options.timeout)?;
for query in 0..args.queries {
let sequence = (usize::from(ttl) - 1) * args.queries + query;
let dest_port = destination_port(args.base_port, sequence)?;
let observation = probe(destination, ttl, dest_port, timeout)?;
match observation.reply {
Some(reply) => {
@@ -144,9 +96,11 @@ fn run() -> Result<()> {
}
fn main() {
let _ = env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).try_init();
if let Err(err) = run() {
log::error!("redbear-traceroute: {err}");
let args = Args::parse();
args.common.init_logging("redbear-traceroute");
if let Err(err) = run(args) {
error!("redbear-traceroute: {err}");
std::process::exit(1);
}
}