Files
fds-os/rust/fds-cli/src/cli.rs
T

410 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum};
use fds_burn::cli::{BurnCommand, FormatCommand};
use fds_common::Bay;
use std::path::PathBuf;
#[derive(Debug, Parser)]
#[command(multicall = true)]
pub struct Cli {
#[command(subcommand)]
pub applet: Applet,
}
#[derive(Debug, Subcommand)]
pub enum Applet {
#[command(version = env!("FDS_BUILD_VERSION"), about = "FDS/OS system and cartridge control")]
Fds(FdsArgs),
/// Inspect a bay, image, or cartridge manifest.
#[command(version = env!("FDS_BUILD_VERSION"))]
FdsInspect {
#[arg(long, global = true)]
json: bool,
#[command(flatten)]
args: InspectArgs,
},
/// Unmount a cartridge before removal.
#[command(version = env!("FDS_BUILD_VERSION"))]
FdsEject {
#[arg(long, global = true)]
json: bool,
#[command(flatten)]
args: BayArgs,
},
/// Prepare native shutdown or inspect its status.
#[command(version = env!("FDS_BUILD_VERSION"))]
FdsPower {
#[arg(long, global = true)]
json: bool,
#[command(flatten)]
args: PowerArgs,
},
}
impl Cli {
pub fn into_command(self) -> (Option<Action>, bool) {
match self.applet {
Applet::Fds(args) => (args.command, args.json),
Applet::FdsInspect { json, args } => (Some(Action::Inspect(args)), json),
Applet::FdsEject { json, args } => (Some(Action::Eject(args)), json),
Applet::FdsPower { json, args } => (Some(Action::Power(args)), json),
}
}
}
#[derive(Debug, Args)]
pub struct FdsArgs {
/// Emit machine-readable JSON where supported.
#[arg(long, global = true)]
pub json: bool,
#[command(subcommand)]
pub command: Option<Action>,
}
#[derive(Debug, Subcommand)]
pub enum Action {
/// Show the system and tool identity.
Info,
/// Show all twelve bays.
Bays,
/// Show one bay and its cartridge state.
#[command(visible_alias = "cartridge")]
Bay(BayArgs),
/// Unmount a cartridge before removal.
Eject(BayArgs),
/// Select writable DATA.
Data {
#[command(subcommand)]
command: DataCommand,
},
/// Start a managed DATA job or PROGRAM executable.
Run {
#[arg(value_parser = fds_burn::client::bay)]
bay: Bay,
/// Executable and its arguments; separate them from FDS options with --.
#[arg(last = true, required = true, num_args = 1.., value_name = "EXECUTABLE_AND_ARGS")]
arguments: Vec<String>,
},
/// Show desktop and network state.
Profiles,
/// Activate a desktop or return to the console.
Profile {
#[command(subcommand)]
command: ProfileCommand,
},
/// Enable or stop DHCP networking.
Network {
#[arg(value_enum)]
state: Switch,
},
/// Refresh cartridge inventory.
Rescan,
/// Show USB paths for bay calibration.
Topology,
/// Inspect a bay, image, or manifest file.
Inspect(InspectArgs),
/// Preview and confirm a cartridge write, or manage an existing operation.
Burn {
#[command(subcommand)]
command: BurnCommand,
},
/// Create a cartridge and preview a confirmed write.
Format {
#[command(subcommand)]
command: FormatCommand,
},
/// Manage persistent machine settings and saved diagnostics.
Machine {
#[command(subcommand)]
command: Option<MachineCommand>,
},
/// Check DATA or confirm conservative repair from the root recovery console.
Recovery {
#[command(subcommand)]
command: Option<RecoveryCommand>,
},
/// Verify DATA and request native shutdown.
Poweroff,
/// Verify DATA and request a reboot.
Reboot,
/// Show, resume, or request native shutdown preparation.
Power(PowerArgs),
/// Show measured boot events.
BootProfile,
/// Show the tool version.
Version,
}
#[derive(Debug, Args)]
pub struct BayArgs {
#[arg(value_parser = fds_burn::client::bay)]
pub bay: Bay,
}
#[derive(Debug, Subcommand)]
pub enum DataCommand {
/// Select a DATA cartridge for /data.
Use(BayArgs),
}
#[derive(Debug, Subcommand)]
pub enum ProfileCommand {
/// Activate a profile.
Activate {
#[arg(value_parser = ["windowmaker", "cli"])]
name: String,
},
/// Return to the console.
Deactivate,
}
#[derive(Clone, Copy, Debug, ValueEnum)]
pub enum Switch {
On,
Off,
}
#[derive(Debug)]
pub struct InspectArgs {
pub target: Option<PathBuf>,
pub command: Option<InspectCommand>,
}
#[derive(Debug, Args)]
#[command(subcommand_negates_reqs = true)]
struct InspectParser {
/// A bay number (1–12 or BAY1–BAY12), or a manifest file path.
#[arg(required = true, value_name = "BAY_OR_MANIFEST")]
target: Option<PathBuf>,
#[command(subcommand)]
command: Option<InspectCommand>,
}
impl TryFrom<InspectParser> for InspectArgs {
type Error = clap::Error;
fn try_from(parsed: InspectParser) -> Result<Self, Self::Error> {
// Clap's args_conflicts_with_subcommands also conflicts with global
// --json. Check only these two typed alternatives during conversion.
if parsed.target.is_some() && parsed.command.is_some() {
return Err(clap::Error::raw(
clap::error::ErrorKind::ArgumentConflict,
"Choose a bay/manifest target or the image subcommand, not both",
));
}
Ok(Self {
target: parsed.target,
command: parsed.command,
})
}
}
impl clap::FromArgMatches for InspectArgs {
fn from_arg_matches(matches: &clap::ArgMatches) -> Result<Self, clap::Error> {
InspectParser::from_arg_matches(matches)?.try_into()
}
fn update_from_arg_matches(&mut self, matches: &clap::ArgMatches) -> Result<(), clap::Error> {
let mut parsed = InspectParser {
target: self.target.clone(),
command: self.command.clone(),
};
parsed.update_from_arg_matches(matches)?;
*self = parsed.try_into()?;
Ok(())
}
}
impl Args for InspectArgs {
fn augment_args(command: clap::Command) -> clap::Command {
InspectParser::augment_args(command)
}
fn augment_args_for_update(command: clap::Command) -> clap::Command {
InspectParser::augment_args_for_update(command)
}
}
#[derive(Clone, Debug, Subcommand)]
pub enum InspectCommand {
/// Validate a regular cartridge image and calculate its SHA-256 digest.
Image { image: PathBuf },
}
#[derive(Debug, Args)]
pub struct PowerArgs {
#[command(subcommand)]
pub command: Option<PowerCommand>,
}
#[derive(Debug, Subcommand)]
pub enum PowerCommand {
/// Show preparation status or its blocking error (the default).
Status,
/// Resume operations before native shutdown starts.
Resume,
/// Verify DATA and request native shutdown.
Poweroff,
/// Verify DATA and request a reboot.
Reboot,
#[command(long_flag = "shutdown-hook", hide = true)]
ShutdownHook,
#[command(long_flag = "hold-shutdown", hide = true)]
HoldShutdown,
#[command(long_flag = "record-final", hide = true)]
RecordFinal {
#[arg(value_parser = ["failed"])]
outcome: Option<String>,
},
}
#[derive(Debug, Subcommand)]
pub enum RecoveryCommand {
/// Unmount and check DATA without repairs.
Check(BayArgs),
/// Preview repair, or confirm the exact token returned by the preview.
Repair {
#[arg(value_parser = fds_burn::client::bay)]
bay: Bay,
#[arg(long, value_name = "TOKEN")]
confirm: Option<String>,
},
}
#[derive(Debug, Subcommand)]
pub enum MachineCommand {
/// Show the active machine identity and settings source.
Status,
/// Export this boot's active settings into a new directory.
Export { new_directory: PathBuf },
/// Validate machine.toml, bays.toml and hardware-catalog.toml.
Validate { directory: PathBuf },
/// Validate and pack settings into a new JSON file.
Pack {
directory: PathBuf,
new_json_file: PathBuf,
},
/// Install settings for the next boot (root recovery console only).
Install { directory: PathBuf },
/// Save an explicit diagnostic of up to 16 MiB (root only).
Store { name: String, file: PathBuf },
/// Retrieve a saved diagnostic into a new file (root only).
Fetch { name: String, new_file: PathBuf },
#[command(long_flag = "load", hide = true)]
Load,
}
pub fn help(subcommand: Option<&str>) -> std::io::Result<()> {
let mut root = Cli::command();
root.build();
let fds = root.find_subcommand_mut("fds").expect("fds applet");
let command = match subcommand {
Some(name) => fds.find_subcommand_mut(name).expect("known subcommand"),
None => fds,
};
command.print_help()?;
println!();
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn command_tree_and_installed_aliases() {
Cli::command().debug_assert();
for arguments in [
vec!["fds", "--json", "inspect", "BAY12"],
vec!["/usr/bin/fds-inspect", "BAY12", "--json"],
vec!["fds-inspect", "image", "card.img"],
vec!["fds-eject", "--json", "12"],
vec!["fds-power", "status", "--json"],
vec!["fds", "cartridge", "12"],
vec![
"fds",
"recovery",
"repair",
"BAY1",
"--confirm",
"exact phrase",
],
vec!["fds", "machine", "pack", "settings", "new.json"],
vec!["fds", "machine", "--load"],
vec!["fds", "power", "--shutdown-hook"],
vec!["fds-power", "--hold-shutdown"],
vec!["fds-power", "--record-final", "failed"],
] {
assert!(Cli::try_parse_from(&arguments).is_ok(), "{arguments:?}");
}
for name in ["fds", "fds-inspect", "fds-eject", "fds-power"] {
let error = Cli::try_parse_from([name, "--help"]).unwrap_err();
assert_eq!(error.kind(), clap::error::ErrorKind::DisplayHelp);
assert!(error.to_string().contains(&format!("Usage: {name}")));
}
}
#[test]
fn run_preserves_every_child_argument_after_separator() {
let (command, json) = Cli::try_parse_from([
"fds", "run", "BAY2", "--", "/bin/app", "--json", "--help", "-x", "", "--", "a b",
])
.unwrap()
.into_command();
assert!(!json);
let Some(Action::Run { bay, arguments }) = command else {
panic!("run command")
};
assert_eq!(bay, "2".parse::<Bay>().unwrap());
assert_eq!(
arguments,
["/bin/app", "--json", "--help", "-x", "", "--", "a b"]
);
let (_, json) = Cli::try_parse_from(["fds", "run", "--json", "2", "--", "/bin/app"])
.unwrap()
.into_command();
assert!(json);
}
#[test]
fn inspect_image_accepts_global_options_in_every_position() {
for arguments in [
vec!["fds", "--json", "inspect", "image", "card.img"],
vec!["fds", "inspect", "--json", "image", "card.img"],
vec!["fds", "inspect", "image", "card.img", "--json"],
vec!["fds-inspect", "--json", "image", "card.img"],
vec!["fds-inspect", "image", "--json", "card.img"],
] {
let (command, json) = Cli::try_parse_from(&arguments).unwrap().into_command();
assert!(json);
assert!(matches!(
command,
Some(Action::Inspect(InspectArgs {
command: Some(InspectCommand::Image { .. }),
target: None,
}))
));
}
}
#[test]
fn rejects_incomplete_or_conflicting_requests_before_execution() {
for arguments in [
vec!["fds", "eject", "0"],
vec!["fds-eject", "13"],
vec!["fds", "run", "1", "/bin/app"],
vec!["fds", "run", "1", "--"],
vec!["fds", "inspect", "image"],
vec!["fds-inspect", "1", "image", "card.img"],
vec!["fds", "recovery", "check", "1", "--confirm", "token"],
vec!["fds", "recovery", "repair", "1", "--confirm"],
vec![
"fds",
"recovery",
"repair",
"1",
"--confirm",
"one",
"--confirm",
"two",
],
vec!["fds", "power", "--shutdown-hook", "--record-final"],
vec!["fds", "power", "--record-final", "success"],
vec!["fds", "machine", "--load", "settings"],
vec!["fds", "network", "maybe"],
vec!["fds", "profile", "activate", "unknown"],
vec!["fds", "unknown"],
] {
assert!(Cli::try_parse_from(&arguments).is_err(), "{arguments:?}");
}
}
}