410 lines
13 KiB
Rust
410 lines
13 KiB
Rust
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:?}");
|
||
}
|
||
}
|
||
}
|