1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
//! Command-line argument parsing for `dure`, built on `clap`.
//!
//! The parser lives in the library so parse behavior can be tested without
//! spawning a subprocess.
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};
/// Clap-facing parser for the `dure` binary.
///
/// Translates argv into [`RunInput`] so [`crate::run`] does not depend on clap.
/// Version is omitted because this crate is unpublished; `--version` would
/// report a workspace placeholder rather than a released artifact.
#[derive(Debug, Parser)]
#[command(
name = "dure",
about = "Detachable Windows console sessions that outlive the terminal.",
disable_version_flag = true
)]
pub struct Cli {
/// Explain on stderr what each command inspects and decides.
#[arg(long, global = true)]
verbose: bool,
/// Override the session store root.
///
/// Hidden; integration tests use this so they never touch `LocalAppData`.
#[arg(long, global = true, hide = true)]
store_root: Option<PathBuf>,
#[command(subcommand)]
command: CliCommand,
}
#[derive(Debug, Subcommand)]
enum CliCommand {
/// Start a new session and attach immediately.
Run {
/// Command to execute directly, not through a shell.
#[arg(trailing_var_arg = true, allow_hyphen_values = true, required = true)]
command: Vec<String>,
},
/// Attach to a live session.
Resume {
/// Session id to attach to, skipping auto-detect.
#[arg(long)]
id: Option<NonZero<u32>>,
},
/// Print live sessions.
List,
/// Abruptly terminate the supervisor for a session.
Kill {
/// Session id to kill. Required; kill does not auto-detect.
#[arg(long)]
id: NonZero<u32>,
},
/// Hidden supervisor process started by `dure run`.
#[command(name = SUPERVISOR_COMMAND, hide = true)]
Supervisor {
/// One-shot startup pipe created by the client.
#[arg(long)]
startup_pipe: String,
/// Canonical launch directory for the app.
#[arg(long)]
launch_directory: PathBuf,
/// Command argv to execute.
#[arg(trailing_var_arg = true, allow_hyphen_values = true, required = true)]
command: Vec<String>,
},
}
/// A parse outcome that should terminate the program before execution.
#[derive(Debug)]
#[expect(
clippy::exhaustive_structs,
reason = "handoff struct read directly by the in-crate binary and tests"
)]
pub struct EarlyExit {
/// The rendered message (help text or error) to print.
pub output: String,
/// `Ok` for help or usage text, `Err` for a parse error.
///
/// Missing subcommand or argument is success because clap's implicit help
/// for that case is the usage text the user asked to see, not a failed
/// command.
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 {
/// Parses an argument vector into the typed CLI.
///
/// # Errors
///
/// Returns an [`EarlyExit`] when the arguments request help/usage or fail to
/// parse.
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))
}
/// Translates the parsed arguments into the [`RunInput`] the core logic consumes.
#[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_id() {
let input = parse(&["resume", "--id", "3"]);
assert_eq!(
input.command,
Command::Resume {
id: SessionId::from_u32(3),
}
);
}
#[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", "--id", "2"]);
assert_eq!(
input.command,
Command::Kill {
id: SessionId::from_u32(2).unwrap(),
}
);
}
#[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"));
}
}