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
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
//! 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::{AppCommand, Command, Invocation, SessionId};
/// Clap-facing parser for the `dure` binary.
///
/// Translates argv into [`Invocation`] so [`crate::run`] does not depend on clap.
#[derive(Debug, Parser)]
#[command(
name = "dure",
about = "Detachable Windows console sessions that outlive the terminal.",
version
)]
pub struct Cli {
/// Explain on stderr what each command inspects and decides.
#[arg(long, global = true)]
verbose: bool,
/// Override the session store root.
///
/// Available only through the private test surface.
#[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 {
/// Start a new session and attach immediately.
///
/// Always creates a new session, never reconnecting to an existing one. The
/// command runs directly rather than through a shell, in the current
/// directory, which also becomes the launch directory `resume` matches on.
Run {
/// Command to execute directly, not through a shell.
///
/// A leading `--` is optional and only needed to keep an argument that
/// starts with a hyphen away from `dure`'s own options.
#[arg(trailing_var_arg = true, allow_hyphen_values = true, required = true)]
command: Vec<String>,
},
/// Attach to a live session.
///
/// Without an id, attaches to the single live session launched from the
/// current directory; if that match is not unique, the live sessions are
/// listed and an id is read from the terminal. Attaching displaces whatever
/// client held the session before.
Resume {
/// Session id to attach to, skipping auto-detect.
id: Option<NonZero<u32>>,
},
/// Print live sessions.
///
/// The ids, attachment state, and launch directories shown here are what an
/// explicit `resume <id>` or `kill <id>` is chosen from.
List,
/// Abruptly terminate the supervisor for a session.
///
/// The app and its ordinary descendants die with the supervisor.
Kill {
/// Session id to kill. Required; kill does not auto-detect.
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 version text the user asked for, `Err` otherwise.
pub status: Result<(), ()>,
}
impl EarlyExit {
fn from_clap(error: &clap::Error) -> Self {
// Only an explicit request for help or version is work the invocation
// asked for and got. An invocation that named no command performed no
// session operation, so a wrapper must not read its usage screen as
// success.
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 {
/// 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 [`Invocation`] the core logic consumes.
///
/// This is where the argv shape becomes the values the rest of the crate
/// relies on, so a command that names nothing to run is refused here rather
/// than travelling as an argv that every later layer has to re-check.
///
/// # Errors
///
/// Returns an [`EarlyExit`] when a subcommand's arguments do not describe
/// something the tool can act on.
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 clap::CommandFactory;
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()
}
fn subcommand_help(name: &str) -> EarlyExit {
if cfg!(miri) {
// These assertions concern one command's help. Interpret its real schema without
// parsing through the root; native tests retain the full root-to-command path.
let mut root = Cli::command();
let error = root
.find_subcommand_mut(name)
.unwrap()
.try_get_matches_from_mut([name, "--help"])
.unwrap_err();
EarlyExit::from_clap(&error)
} else {
Cli::from_args(&["dure"], &[name, "--help"]).unwrap_err()
}
}
#[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 = subcommand_help("resume");
assert!(err.status.is_ok());
assert!(err.output.contains("[ID]"));
assert!(!err.output.contains("--id"));
assert!(err.output.contains("launched from the current directory"));
}
#[test]
fn parse_list() {
assert_eq!(parse(&["list"]).command, Command::List);
}
#[test]
fn parse_kill_requires_id() {
Cli::from_args(&["dure"], &["kill"]).unwrap_err();
}
#[test]
fn parse_kill_with_positional_id() {
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 = subcommand_help("kill");
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 parse_run_without_double_dash() {
let input = parse(&["run", "copilot.exe", "--foo"]);
assert_eq!(
input.command,
Command::Run {
command: command(&["copilot.exe", "--foo"]),
}
);
}
#[test]
fn run_help_explains_that_it_always_creates_a_session() {
let err = subcommand_help("run");
assert!(err.status.is_ok());
assert!(err.output.contains("Always creates a new session"));
}
}