Skip to main content

cossh/
args.rs

1//! Command-line argument parsing
2//!
3//! Parses CLI arguments using the clap library and provides structured access
4//! to user-provided options.
5
6use crate::{ssh_args, validation};
7use clap::{Arg, Command, error::ErrorKind};
8use std::ffi::OsString;
9
10#[derive(Debug, Clone, PartialEq, Eq)]
11/// Supported `cossh vault` subcommands.
12pub enum VaultCommand {
13    Init,
14    AddPass(String),
15    RemovePass(String),
16    List,
17    Unlock,
18    Lock,
19    Status,
20    SetMasterPassword,
21}
22
23#[derive(Debug, Clone, PartialEq, Eq)]
24/// Parsed arguments for `cossh rdp`.
25pub struct RdpCommandArgs {
26    /// Target host or configured alias.
27    pub target: String,
28    /// Optional username override.
29    pub user: Option<String>,
30    /// Optional domain override.
31    pub domain: Option<String>,
32    /// Optional port override.
33    pub port: Option<u16>,
34    /// Additional arguments forwarded to `xfreerdp3` or `xfreerdp`.
35    pub extra_args: Vec<String>,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq)]
39/// Parsed arguments for `cossh ssh`.
40pub struct SshCommandArgs {
41    /// Arguments to pass through to the SSH command.
42    pub ssh_args: Vec<String>,
43    /// Whether the SSH command is non-interactive (e.g., -G, -V, -O, -Q).
44    pub is_non_interactive: bool,
45}
46
47#[derive(Debug, Clone, PartialEq, Eq)]
48/// Protocol-specific command payloads.
49pub enum ProtocolCommand {
50    Ssh(SshCommandArgs),
51    Rdp(RdpCommandArgs),
52}
53
54#[derive(Debug, Clone, PartialEq, Eq)]
55/// Top-level dispatch command variants.
56pub enum MainCommand {
57    Protocol(ProtocolCommand),
58    Vault(VaultCommand),
59    MigrateInventory,
60    CompletionHosts(CompletionProtocol),
61    AgentServe,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq)]
65/// Protocol filter used by hidden completion plumbing.
66pub enum CompletionProtocol {
67    All,
68    Ssh,
69    Rdp,
70}
71
72/// Parsed top-level command-line arguments.
73#[derive(Debug, Clone)]
74pub struct MainArgs {
75    /// Debug verbosity requested on the CLI (`-d` safe, `-dd` raw).
76    pub debug_count: u8,
77    /// Enable SSH session logging to file
78    pub ssh_logging: bool,
79    /// In test mode, ignore config logging settings and only honor CLI logging flags
80    pub test_mode: bool,
81    /// Argument to pass for configuration profiles
82    pub profile: Option<String>,
83    /// Launch interactive session manager TUI
84    pub interactive: bool,
85    /// Override the password entry to use for a direct protocol launch.
86    pub pass_entry: Option<String>,
87    /// Selected command, if any.
88    pub command: Option<MainCommand>,
89}
90
91fn build_cli_command() -> Command {
92    Command::new("cossh")
93        .version(concat!("v", env!("CARGO_PKG_VERSION")))
94        .author("@karsyboy")
95        .about("A Rust-based SSH client wrapper with syntax highlighting and logging capabilities")
96        .propagate_version(true)
97        .arg(
98            Arg::new("debug")
99                .short('d')
100                .long("debug")
101                .help("Enable debug logging to ~/.color-ssh/logs/cossh.log; repeat (-dd) for raw terminal and argument tracing")
102                .action(clap::ArgAction::Count),
103        )
104        .arg(
105            Arg::new("log")
106                .short('l')
107                .long("log")
108                .help("Enable SSH session logging to ~/.color-ssh/logs/ssh_sessions/")
109                .action(clap::ArgAction::SetTrue),
110        )
111        .arg(
112            Arg::new("profile")
113                .short('P')
114                .long("profile")
115                .help("Specify a configuration profile to use")
116                .value_parser(clap::builder::ValueParser::new(validation::parse_profile_name))
117                .num_args(1)
118                .required(false),
119        )
120        .arg(
121            Arg::new("test")
122                .short('t')
123                .long("test")
124                .help("Ignore config logging settings; only use CLI -d/-l logging flags")
125                .action(clap::ArgAction::SetTrue),
126        )
127        .arg(
128            Arg::new("pass_entry")
129                .long("pass-entry")
130                .help("Override the optional password vault entry used for a direct protocol launch")
131                .num_args(1)
132                .value_name("name")
133                .value_parser(clap::builder::ValueParser::new(validation::parse_vault_entry_name)),
134        )
135        .arg(
136            Arg::new("migrate")
137                .long("migrate")
138                .help("Migrate ~/.ssh/config host entries into ~/.color-ssh/cossh-inventory.yaml")
139                .action(clap::ArgAction::SetTrue)
140                .conflicts_with_all(["log", "profile", "test", "pass_entry"]),
141        )
142        .subcommand(
143            Command::new("ssh")
144                .about("Launch an SSH session by forwarding arguments to the SSH command")
145                .arg(
146                    Arg::new("ssh_args")
147                        .help("SSH arguments to forward to the SSH command")
148                        .required(true)
149                        .num_args(1..)
150                        .trailing_var_arg(true)
151                        .allow_hyphen_values(true),
152                ),
153        )
154        .subcommand(
155            Command::new("rdp")
156                .about("Launch an RDP session using xfreerdp3 or xfreerdp")
157                .arg(Arg::new("target").help("RDP target host or configured alias").required(true))
158                .arg(Arg::new("user").short('u').long("user").help("Override the RDP username").num_args(1))
159                .arg(Arg::new("domain").short('D').long("domain").help("Override the RDP domain").num_args(1))
160                .arg(
161                    Arg::new("port")
162                        .short('p')
163                        .long("port")
164                        .help("Override the RDP port")
165                        .num_args(1)
166                        .value_parser(clap::value_parser!(u16)),
167                )
168                .arg(
169                    Arg::new("rdp_args")
170                        .help("Additional xfreerdp3/xfreerdp arguments")
171                        .num_args(0..)
172                        .trailing_var_arg(true)
173                        .allow_hyphen_values(true),
174                ),
175        )
176        .subcommand(
177            Command::new("vault")
178                .about("Manage the password vault")
179                .subcommand_required(true)
180                .arg_required_else_help(true)
181                .subcommand(Command::new("init").about("Initialize the password vault"))
182                .subcommand(
183                    Command::new("add").about("Create or replace a password vault entry interactively").arg(
184                        Arg::new("name")
185                            .help("Password entry name")
186                            .required(true)
187                            .value_parser(clap::builder::ValueParser::new(validation::parse_vault_entry_name)),
188                    ),
189                )
190                .subcommand(
191                    Command::new("remove").about("Remove a password vault entry").arg(
192                        Arg::new("name")
193                            .help("Password entry name")
194                            .required(true)
195                            .value_parser(clap::builder::ValueParser::new(validation::parse_vault_entry_name)),
196                    ),
197                )
198                .subcommand(Command::new("list").about("List password vault entries"))
199                .subcommand(Command::new("unlock").about("Unlock the shared password vault"))
200                .subcommand(Command::new("lock").about("Lock the shared password vault"))
201                .subcommand(Command::new("status").about("Show shared password vault status"))
202                .subcommand(Command::new("set-master-password").about("Create or rotate the password vault master password")),
203        )
204        .subcommand(
205            Command::new("agent")
206                .hide(true)
207                .subcommand_required(false)
208                .arg_required_else_help(false)
209                .arg(Arg::new("serve").long("serve").hide(true).action(clap::ArgAction::SetTrue)),
210        )
211        .subcommand(
212            Command::new("__complete")
213                .hide(true)
214                .subcommand_required(true)
215                .arg_required_else_help(true)
216                .subcommand(
217                    Command::new("hosts").hide(true).arg(
218                        Arg::new("protocol")
219                            .long("protocol")
220                            .num_args(1)
221                            .default_value("all")
222                            .value_parser(["all", "ssh", "rdp"]),
223                    ),
224                ),
225        )
226        .after_help(
227            r"
228cossh                                                     # Launch interactive session manager
229cossh -d ssh user@example.com                             # Safe debug enabled
230cossh --pass-entry office_fw <ssh/rdp> host.example.com   # Override the password entry for this launch
231cossh -l ssh user@example.com                             # SSH logging enabled
232cossh -l -P network ssh user@firewall.example.com         # Use 'network' config profile
233cossh -l ssh user@host -p 2222                            # Both modes with SSH args
234cossh ssh user@host -G                                    # Non-interactive command
235cossh rdp desktop01                                       # Launch a configured RDP host
236cossh --migrate                                           # Import ~/.ssh/config into the YAML inventory
237",
238        )
239}
240
241fn parse_completion_protocol(value: &str) -> CompletionProtocol {
242    match value.to_ascii_lowercase().as_str() {
243        "ssh" => CompletionProtocol::Ssh,
244        "rdp" => CompletionProtocol::Rdp,
245        _ => CompletionProtocol::All,
246    }
247}
248
249fn parse_completion_command(completion_matches: &clap::ArgMatches) -> Option<MainCommand> {
250    match completion_matches.subcommand() {
251        Some(("hosts", hosts_matches)) => {
252            let protocol = hosts_matches.get_one::<String>("protocol").map(|value| parse_completion_protocol(value));
253            Some(MainCommand::CompletionHosts(protocol.unwrap_or(CompletionProtocol::All)))
254        }
255        _ => None,
256    }
257}
258
259fn parse_ssh_command(ssh_matches: &clap::ArgMatches) -> Option<SshCommandArgs> {
260    let ssh_args: Vec<String> = ssh_matches
261        .get_many::<String>("ssh_args")
262        .map(|vals| vals.cloned().collect())
263        .unwrap_or_default();
264    if ssh_args.is_empty() {
265        return None;
266    }
267
268    Some(SshCommandArgs {
269        is_non_interactive: ssh_args::is_non_interactive_ssh_invocation(&ssh_args),
270        ssh_args,
271    })
272}
273
274fn parse_vault_command(vault_matches: &clap::ArgMatches) -> Option<VaultCommand> {
275    match vault_matches.subcommand() {
276        Some(("init", _)) => Some(VaultCommand::Init),
277        Some(("add", add_pass_matches)) => add_pass_matches.get_one::<String>("name").cloned().map(VaultCommand::AddPass),
278        Some(("remove", remove_pass_matches)) => remove_pass_matches.get_one::<String>("name").cloned().map(VaultCommand::RemovePass),
279        Some(("list", _)) => Some(VaultCommand::List),
280        Some(("unlock", _)) => Some(VaultCommand::Unlock),
281        Some(("lock", _)) => Some(VaultCommand::Lock),
282        Some(("status", _)) => Some(VaultCommand::Status),
283        Some(("set-master-password", _)) => Some(VaultCommand::SetMasterPassword),
284        _ => None,
285    }
286}
287
288fn parse_rdp_command(rdp_matches: &clap::ArgMatches) -> Option<RdpCommandArgs> {
289    let target = rdp_matches.get_one::<String>("target")?.trim().to_string();
290    if target.is_empty() {
291        return None;
292    }
293
294    Some(RdpCommandArgs {
295        target,
296        user: rdp_matches.get_one::<String>("user").cloned().filter(|value| !value.trim().is_empty()),
297        domain: rdp_matches.get_one::<String>("domain").cloned().filter(|value| !value.trim().is_empty()),
298        port: rdp_matches.get_one::<u16>("port").copied(),
299        extra_args: rdp_matches
300            .get_many::<String>("rdp_args")
301            .map(|values| values.cloned().collect())
302            .unwrap_or_default(),
303    })
304}
305
306fn parse_main_command(matches: &clap::ArgMatches) -> Option<MainCommand> {
307    if matches.get_flag("migrate") {
308        return Some(MainCommand::MigrateInventory);
309    }
310
311    match matches.subcommand()? {
312        ("ssh", ssh_matches) => parse_ssh_command(ssh_matches).map(ProtocolCommand::Ssh).map(MainCommand::Protocol),
313        ("rdp", rdp_matches) => parse_rdp_command(rdp_matches).map(ProtocolCommand::Rdp).map(MainCommand::Protocol),
314        ("vault", vault_matches) => parse_vault_command(vault_matches).map(MainCommand::Vault),
315        ("agent", agent_matches) if agent_matches.get_flag("serve") => Some(MainCommand::AgentServe),
316        ("__complete", completion_matches) => parse_completion_command(completion_matches),
317        _ => None,
318    }
319}
320
321fn validate_main_args(cmd: &Command, matches: &clap::ArgMatches, parsed: &MainArgs) -> Result<(), clap::Error> {
322    if matches.get_flag("migrate") && matches.subcommand_name().is_some() {
323        return Err(cmd
324            .clone()
325            .error(ErrorKind::ArgumentConflict, "`--migrate` cannot be combined with subcommands"));
326    }
327
328    if matches!(parsed.command, Some(MainCommand::MigrateInventory)) && parsed.interactive {
329        return Err(cmd
330            .clone()
331            .error(ErrorKind::ArgumentConflict, "`--migrate` cannot be combined with interactive mode"));
332    }
333
334    Ok(())
335}
336
337fn parse_main_args_from<I, T>(cmd: &Command, raw_args: I) -> MainArgs
338where
339    I: IntoIterator<Item = T>,
340    T: Into<OsString> + Clone,
341{
342    try_parse_main_args_from(cmd, raw_args).unwrap_or_else(|err| err.exit())
343}
344
345fn try_parse_main_args_from<I, T>(cmd: &Command, raw_args: I) -> Result<MainArgs, clap::Error>
346where
347    I: IntoIterator<Item = T>,
348    T: Into<OsString> + Clone,
349{
350    let raw_args: Vec<OsString> = raw_args.into_iter().map(Into::into).collect();
351
352    let matches = cmd.clone().try_get_matches_from(raw_args.clone())?;
353
354    let debug_count = matches.get_count("debug");
355    let ssh_logging = matches.get_flag("log");
356    let test_mode = matches.get_flag("test");
357    let profile = matches.get_one::<String>("profile").cloned().filter(|profile_name| !profile_name.is_empty());
358    let pass_entry = matches.get_one::<String>("pass_entry").cloned().filter(|value| !value.is_empty());
359    let command = parse_main_command(&matches);
360    let no_user_args = raw_args.len() <= 1;
361    let debug_only = debug_count > 0 && !ssh_logging && profile.is_none() && pass_entry.is_none() && command.is_none();
362    let interactive = (no_user_args || debug_only) && command.is_none();
363
364    let parsed = MainArgs {
365        debug_count,
366        ssh_logging,
367        test_mode,
368        profile,
369        interactive,
370        pass_entry,
371        command,
372    };
373    validate_main_args(cmd, &matches, &parsed)?;
374    Ok(parsed)
375}
376
377/// Parse process command-line arguments using clap.
378pub fn main_args() -> MainArgs {
379    let cmd = build_cli_command();
380    let parsed = parse_main_args_from(&cmd, std::env::args_os());
381
382    if matches!(parsed.command, Some(MainCommand::AgentServe) | Some(MainCommand::CompletionHosts(_))) {
383        return parsed;
384    }
385
386    if parsed.command.is_none() && !parsed.interactive {
387        let mut help_cmd = cmd;
388        let _ = help_cmd.print_long_help();
389        println!();
390        std::process::exit(2);
391    }
392
393    parsed
394}
395
396#[cfg(test)]
397#[path = "test/args.rs"]
398mod tests;