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, 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, } #[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, }, /// 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, }, /// Check DATA or confirm conservative repair from the root recovery console. Recovery { #[command(subcommand)] command: Option, }, /// 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, pub command: Option, } #[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, #[command(subcommand)] command: Option, } impl TryFrom for InspectArgs { type Error = clap::Error; fn try_from(parsed: InspectParser) -> Result { // 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 { 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, } #[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, }, } #[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, }, } #[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::().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:?}"); } } }