use std::num::NonZero;
use std::path::PathBuf;
use clap::error::ErrorKind;
use clap::{Parser, Subcommand};
use crate::constants::SUPERVISOR_COMMAND;
use crate::session_id::SessionId;
use crate::types::{Command, RunInput};
#[derive(Debug, Parser)]
#[command(
name = "dure",
about = "Detachable Windows console sessions that outlive the terminal.",
disable_version_flag = true
)]
pub struct Cli {
#[arg(long, global = true)]
verbose: bool,
#[arg(long, global = true, hide = true)]
store_root: Option<PathBuf>,
#[command(subcommand)]
command: CliCommand,
}
#[derive(Debug, Subcommand)]
enum CliCommand {
Run {
#[arg(trailing_var_arg = true, allow_hyphen_values = true, required = true)]
command: Vec<String>,
},
Resume {
id: Option<NonZero<u32>>,
},
List,
Kill {
id: NonZero<u32>,
},
#[command(name = SUPERVISOR_COMMAND, hide = true)]
Supervisor {
#[arg(long)]
startup_pipe: String,
#[arg(long)]
launch_directory: PathBuf,
#[arg(trailing_var_arg = true, allow_hyphen_values = true, required = true)]
command: Vec<String>,
},
}
#[derive(Debug)]
#[expect(
clippy::exhaustive_structs,
reason = "handoff struct read directly by the in-crate binary and tests"
)]
pub struct EarlyExit {
pub output: String,
pub status: Result<(), ()>,
}
impl EarlyExit {
fn from_clap(error: &clap::Error) -> Self {
let success = matches!(
error.kind(),
ErrorKind::DisplayHelp
| ErrorKind::DisplayVersion
| ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
);
Self {
output: error.to_string(),
status: if success { Ok(()) } else { Err(()) },
}
}
}
impl Cli {
pub fn from_args(command_name: &[&str], args: &[&str]) -> Result<Self, EarlyExit> {
let argv: Vec<&str> = command_name.iter().chain(args).copied().collect();
Self::try_parse_from(argv).map_err(|error| EarlyExit::from_clap(&error))
}
#[must_use]
pub fn into_input(self) -> RunInput {
let command = match self.command {
CliCommand::Run { command } => Command::Run { command },
CliCommand::Resume { id } => Command::Resume {
id: id.map(SessionId::new),
},
CliCommand::List => Command::List,
CliCommand::Kill { id } => Command::Kill {
id: SessionId::new(id),
},
CliCommand::Supervisor {
startup_pipe,
launch_directory,
command,
} => Command::Supervisor {
startup_pipe,
launch_directory,
command,
},
};
RunInput {
verbose: self.verbose,
store_root: self.store_root,
command,
}
}
}
#[cfg(test)]
#[cfg_attr(coverage_nightly, coverage(off))]
mod tests {
use super::*;
fn parse(args: &[&str]) -> RunInput {
Cli::from_args(&["dure"], args).unwrap().into_input()
}
#[test]
fn parse_run_after_double_dash() {
let input = parse(&["run", "--", "copilot.exe", "--foo"]);
assert_eq!(
input.command,
Command::Run {
command: vec!["copilot.exe".to_string(), "--foo".to_string()],
}
);
}
#[test]
fn parse_resume_without_id() {
let input = parse(&["resume"]);
assert_eq!(input.command, Command::Resume { id: None });
}
#[test]
fn parse_resume_with_positional_id() {
let input = parse(&["resume", "3"]);
assert_eq!(
input.command,
Command::Resume {
id: SessionId::from_u32(3),
}
);
}
#[test]
fn parse_resume_rejects_id_option() {
Cli::from_args(&["dure"], &["resume", "--id", "3"]).unwrap_err();
}
#[test]
fn resume_help_shows_positional_id() {
let err = Cli::from_args(&["dure"], &["resume", "--help"]).unwrap_err();
assert!(err.status.is_ok());
assert!(err.output.contains("[ID]"));
assert!(!err.output.contains("--id"));
}
#[test]
fn parse_list() {
assert_eq!(parse(&["list"]).command, Command::List);
}
#[test]
fn parse_kill_requires_id() {
Cli::from_args(&["dure"], &["kill"]).unwrap_err();
let input = parse(&["kill", "2"]);
assert_eq!(
input.command,
Command::Kill {
id: SessionId::from_u32(2).unwrap(),
}
);
}
#[test]
fn parse_kill_rejects_id_option() {
Cli::from_args(&["dure"], &["kill", "--id", "2"]).unwrap_err();
}
#[test]
fn kill_help_shows_positional_id() {
let err = Cli::from_args(&["dure"], &["kill", "--help"]).unwrap_err();
assert!(err.status.is_ok());
assert!(err.output.contains("<ID>"));
assert!(!err.output.contains("--id"));
}
#[test]
fn parse_verbose_and_store_root() {
let input = parse(&["--verbose", "--store-root", r"C:\tmp", "list"]);
assert!(input.verbose);
assert_eq!(
input.store_root.as_deref(),
Some(std::path::Path::new(r"C:\tmp"))
);
}
#[test]
fn help_is_early_exit_success() {
let err = Cli::from_args(&["dure"], &["--help"]).unwrap_err();
assert!(err.status.is_ok());
assert!(err.output.contains("dure"));
}
}