use std::num::NonZero;
use std::path::PathBuf;
use clap::error::ErrorKind;
use clap::{Parser, Subcommand};
use crate::constants::SUPERVISOR_COMMAND;
use crate::{AppCommand, Command, Invocation, SessionId};
#[derive(Debug, Parser)]
#[command(
name = "dure",
about = "Detachable Windows console sessions that outlive the terminal.",
version
)]
pub struct Cli {
#[arg(long, global = true)]
verbose: bool,
#[cfg(any(test, feature = "private-test-util"))]
#[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
);
Self {
output: error.to_string(),
status: if success { Ok(()) } else { Err(()) },
}
}
fn failure(message: &str) -> Self {
Self {
output: format!("error: {message}"),
status: 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))
}
pub fn into_invocation(self) -> Result<Invocation, EarlyExit> {
let command = match self.command {
CliCommand::Run { command } => Command::Run {
command: app_command(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: app_command(command)?,
},
};
Ok(Invocation {
verbose: self.verbose,
#[cfg(any(test, feature = "private-test-util"))]
store_root: self.store_root,
command,
})
}
}
fn app_command(argv: Vec<String>) -> Result<AppCommand, EarlyExit> {
AppCommand::from_argv(argv)
.ok_or_else(|| EarlyExit::failure("dure run requires a command to execute"))
}
#[cfg(test)]
#[cfg_attr(coverage_nightly, coverage(off))]
mod tests {
use super::*;
fn parse(args: &[&str]) -> Invocation {
Cli::from_args(&["dure"], args)
.unwrap()
.into_invocation()
.unwrap()
}
fn command(argv: &[&str]) -> AppCommand {
AppCommand::from_argv(argv.iter().map(|arg| (*arg).to_string()).collect()).unwrap()
}
#[test]
fn parse_run_after_double_dash() {
let input = parse(&["run", "--", "copilot.exe", "--foo"]);
assert_eq!(
input.command,
Command::Run {
command: command(&["copilot.exe", "--foo"]),
}
);
}
#[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"));
}
#[test]
fn version_reports_the_package_release() {
let err = Cli::from_args(&["dure"], &["--version"]).unwrap_err();
assert!(err.status.is_ok());
assert!(err.output.contains(env!("CARGO_PKG_VERSION")));
}
#[test]
fn naming_no_command_is_a_failure() {
let err = Cli::from_args(&["dure"], &[]).unwrap_err();
assert!(err.status.is_err());
}
#[test]
fn run_refuses_an_argv_that_names_nothing_to_run() {
let exit = Cli::from_args(&["dure"], &["run", ""])
.unwrap()
.into_invocation()
.unwrap_err();
assert!(exit.status.is_err());
}
#[test]
fn run_accepts_a_command_with_or_without_the_separator() {
let with = parse(&["run", "--", "copilot.exe", "--foo"]);
let without = parse(&["run", "copilot.exe", "--foo"]);
assert_eq!(with.command, without.command);
}
#[test]
fn subcommand_help_explains_how_a_session_is_chosen() {
let err = Cli::from_args(&["dure"], &["resume", "--help"]).unwrap_err();
assert!(err.output.contains("launched from the current directory"));
let err = Cli::from_args(&["dure"], &["run", "--help"]).unwrap_err();
assert!(err.output.contains("Always creates a new session"));
}
}