Skip to main content

start_command/
args_parser.rs

1//! Argument Parser for start-command wrapper options
2//!
3//! Supports two syntax patterns:
4//! 1. $ [wrapper-options] -- [command-options]
5//! 2. $ [wrapper-options] command [command-options]
6//!
7//! Wrapper Options:
8//! --isolated, --isolation, -i <backend> Run in isolated environment (screen, tmux, docker, ssh)
9//! --attached, -a                   Run in attached mode (foreground)
10//! --detached, -d                   Run in detached mode (background)
11//! --session, -s <name>             Session name for isolation
12//! --image <image>                  Docker image (optional, defaults to OS-matched image)
13//! --volume, -v <host:container[:mode]> Docker bind mount/volume (repeatable, docker only)
14//! --mount <mount-spec>             Docker --mount spec (repeatable, docker only)
15//! --env, -e <KEY=VALUE>            Environment variable for docker container (repeatable, docker only)
16//! --privileged                     Run docker container in privileged mode (docker only)
17//! --network <name>                 Connect docker container to a named network (repeatable, docker only)
18//! --network-alias <alias>          Add an alias to the first network (repeatable, docker only)
19//! --endpoint <endpoint>            SSH endpoint (required for ssh isolation, e.g., user@host)
20//! --isolated-user, -u [username]   Create isolated user with same permissions
21//! --keep-user                      Keep isolated user after command completes
22//! --keep-alive, -k                 Keep isolation environment alive after command exits
23//! --auto-remove-docker-container   Always remove docker container after exit (compatibility alias)
24//! --always-cleanup-container       Always remove docker container after exit
25//! --keep-container                 Keep docker container filesystem after exit
26//! --keep-container-on-fail         Remove successful docker containers, keep failed or OOM-killed ones
27//! --shell <shell>                  Shell to use in isolation environments: auto, bash, zsh, sh (default: auto)
28//! --status <uuid-or-session-name>  Show status of a tracked execution
29//! --list                           List all tracked command executions
30//! --upload-log <uuid-or-session>   Upload the stored log for a tracked execution
31//! --stop <uuid-or-session-name>    Ask a detached execution to stop gracefully
32//! --terminate <uuid-or-session-name> Terminate a detached execution immediately
33//! --list [--running]               Optionally restrict the listing to running executions
34//! --attach <uuid-or-session-name>  Attach to a running detached execution
35//! --read-only                      With --attach, follow output without sending input
36//! --resume <uuid-or-session-name>  Restart a stopped detached execution (optionally `-- <command>`)
37//! --resume-all                     Re-attach or reconcile every execution still marked running
38
39use std::env;
40
41use crate::args_parser_queries::{parse_query_option, validate_query_options};
42use crate::docker_recovery_options::{
43    parse_docker_recovery_option, validate_docker_recovery_options,
44};
45use crate::isolation::get_default_docker_image;
46
47/// Valid isolation backends
48pub const VALID_BACKENDS: [&str; 4] = ["screen", "tmux", "docker", "ssh"];
49
50/// Valid shell options for --shell
51pub const VALID_SHELLS: [&str; 4] = ["auto", "bash", "zsh", "sh"];
52
53/// Valid output formats for query output
54pub const VALID_OUTPUT_FORMATS: [&str; 3] = ["links-notation", "json", "text"];
55
56/// UUID v4 regex pattern for validation
57const UUID_REGEX: &str = r"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$";
58
59/// Check if a string is a valid UUID v4
60pub fn is_valid_uuid(s: &str) -> bool {
61    regex::Regex::new(UUID_REGEX)
62        .map(|re| re.is_match(&s.to_lowercase()))
63        .unwrap_or(false)
64}
65
66/// Generate a UUID v4
67pub fn generate_uuid() -> String {
68    uuid::Uuid::new_v4().to_string()
69}
70
71/// Wrapper options parsed from command line
72#[derive(Debug, Clone)]
73pub struct WrapperOptions {
74    /// Isolation backend: screen, tmux, docker, ssh
75    pub isolated: Option<String>,
76    /// Run in attached mode
77    pub attached: bool,
78    /// Run in detached mode
79    pub detached: bool,
80    /// Session name
81    pub session: Option<String>,
82    /// Session ID (UUID) for tracking - auto-generated if not provided
83    pub session_id: Option<String>,
84    /// Docker image
85    pub image: Option<String>,
86    /// Docker bind mounts/volumes (-v/--volume), applied to docker isolation
87    pub volumes: Vec<String>,
88    /// Docker --mount specs, applied to docker isolation
89    pub mounts: Vec<String>,
90    /// Docker environment variables (-e/--env, KEY=VALUE), applied to docker isolation
91    pub env: Vec<String>,
92    /// Run docker container in privileged mode
93    pub privileged: bool,
94    /// First Docker network name (compatibility accessor)
95    pub network: Option<String>,
96    /// Ordered Docker network names
97    pub networks: Vec<String>,
98    /// Docker network-scoped aliases
99    pub network_aliases: Vec<String>,
100    /// Resume up to N times when the docker main process is killed (issue #176)
101    pub on_kill_resume: Option<u32>,
102    /// Command run in the same container on such a resume (issue #176)
103    pub recovery_command: Option<String>,
104    /// SSH endpoint (e.g., user@host)
105    pub endpoint: Option<String>,
106    /// Create isolated user
107    pub user: bool,
108    /// Optional custom username for isolated user
109    pub user_name: Option<String>,
110    /// Keep isolated user after command completes
111    pub keep_user: bool,
112    /// Keep environment alive after command exits
113    pub keep_alive: bool,
114    /// Auto-remove docker container after exit
115    pub auto_remove_docker_container: bool,
116    /// Force docker container cleanup after exit
117    pub always_cleanup_container: bool,
118    /// Keep docker container filesystem after exit
119    pub keep_container: bool,
120    /// Keep docker container filesystem when command fails or OOM-kills
121    pub keep_container_on_fail: bool,
122    /// Shell to use in isolation environments: auto, bash, zsh, sh
123    pub shell: String,
124    /// Use command-stream library for command execution
125    pub use_command_stream: bool,
126    /// UUID to query status for
127    pub status: Option<String>,
128    /// List all tracked execution records
129    pub list: bool,
130    /// With --list, only report executions that are still running
131    pub running: bool,
132    /// UUID/session name whose stored log should be uploaded
133    pub upload_log: Option<String>,
134    /// Output format for status/list (links-notation, json, text)
135    pub output_format: Option<String>,
136    /// UUID/session name to stop gracefully
137    pub stop: Option<String>,
138    /// UUID/session name to terminate immediately
139    pub terminate: Option<String>,
140    /// UUID/session name to attach to
141    pub attach: Option<String>,
142    /// With --attach, follow output without forwarding stdin
143    pub read_only: bool,
144    /// UUID/session name to resume
145    pub resume: Option<String>,
146    /// Resume every execution still marked running
147    pub resume_all: bool,
148    /// Clean up stale "executing" records
149    pub cleanup: bool,
150    /// Show what would be cleaned without actually cleaning
151    pub cleanup_dry_run: bool,
152}
153
154impl Default for WrapperOptions {
155    fn default() -> Self {
156        WrapperOptions {
157            isolated: None,
158            attached: false,
159            detached: false,
160            session: None,
161            session_id: None,
162            image: None,
163            volumes: Vec::new(),
164            mounts: Vec::new(),
165            env: Vec::new(),
166            privileged: false,
167            network: None,
168            networks: Vec::new(),
169            network_aliases: Vec::new(),
170            on_kill_resume: None,
171            recovery_command: None,
172            endpoint: None,
173            user: false,
174            user_name: None,
175            keep_user: false,
176            keep_alive: false,
177            auto_remove_docker_container: false,
178            always_cleanup_container: false,
179            keep_container: false,
180            keep_container_on_fail: false,
181            shell: "auto".to_string(),
182            use_command_stream: false,
183            status: None,
184            list: false,
185            running: false,
186            upload_log: None,
187            output_format: None,
188            stop: None,
189            terminate: None,
190            attach: None,
191            read_only: false,
192            resume: None,
193            resume_all: false,
194            cleanup: false,
195            cleanup_dry_run: false,
196        }
197    }
198}
199
200/// Result of parsing arguments
201#[derive(Debug)]
202pub struct ParsedArgs {
203    /// Wrapper options
204    pub wrapper_options: WrapperOptions,
205    /// The command to execute: argv re-quoted so the inner shell keeps the
206    /// argument boundaries the user typed (issue #164)
207    pub command: String,
208    /// Raw command arguments
209    pub raw_command: Vec<String>,
210}
211
212/// Parse command line arguments into wrapper options and command
213pub fn parse_args(args: &[String]) -> Result<ParsedArgs, String> {
214    let mut wrapper_options = WrapperOptions::default();
215    let mut command_args: Vec<String> = Vec::new();
216
217    // Find the separator '--' or detect where command starts
218    let separator_index = args.iter().position(|a| a == "--");
219
220    if let Some(sep_idx) = separator_index {
221        // Pattern 1: explicit separator
222        let wrapper_args: Vec<String> = args[..sep_idx].to_vec();
223        command_args = args[sep_idx + 1..].to_vec();
224        parse_wrapper_args(&wrapper_args, &mut wrapper_options)?;
225    } else {
226        // Pattern 2: parse until we hit a non-option argument
227        let mut i = 0;
228        while i < args.len() {
229            let arg = &args[i];
230            if arg.starts_with('-') {
231                match parse_option(args, i, &mut wrapper_options)? {
232                    0 => {
233                        return Err(format!("Unknown wrapper option: {}", arg));
234                    }
235                    consumed => {
236                        i += consumed;
237                    }
238                }
239            } else {
240                // Non-option argument, rest is command
241                command_args = args[i..].to_vec();
242                break;
243            }
244        }
245    }
246
247    // Validate options and apply defaults
248    validate_options(&mut wrapper_options)?;
249
250    Ok(ParsedArgs {
251        wrapper_options,
252        command: crate::isolation::build_command_string(&command_args),
253        raw_command: command_args,
254    })
255}
256
257/// Parse wrapper arguments
258fn parse_wrapper_args(args: &[String], options: &mut WrapperOptions) -> Result<(), String> {
259    let mut i = 0;
260    while i < args.len() {
261        match parse_option(args, i, options)? {
262            0 => {
263                if args[i].starts_with('-') {
264                    return Err(format!("Unknown wrapper option: {}", args[i]));
265                }
266                if env::var("START_DEBUG").is_ok_and(|v| v == "1" || v == "true") {
267                    eprintln!("Unknown wrapper option: {}", args[i]);
268                }
269                i += 1;
270            }
271            consumed => {
272                i += consumed;
273            }
274        }
275    }
276    Ok(())
277}
278
279/// Parse a single option from args array
280/// Returns number of arguments consumed (0 if not recognized)
281fn parse_option(
282    args: &[String],
283    index: usize,
284    options: &mut WrapperOptions,
285) -> Result<usize, String> {
286    let arg = &args[index];
287
288    // --isolated, --isolation, or -i
289    if arg == "--isolated" || arg == "--isolation" || arg == "-i" {
290        if index + 1 < args.len() && !args[index + 1].starts_with('-') {
291            options.isolated = Some(args[index + 1].to_lowercase());
292            return Ok(2);
293        } else {
294            return Err(format!(
295                "Option {} requires a backend argument (screen, tmux, docker, ssh)",
296                arg
297            ));
298        }
299    }
300
301    // --isolated=<value> or --isolation=<value>
302    if arg.starts_with("--isolated=") || arg.starts_with("--isolation=") {
303        options.isolated = Some(arg.split('=').nth(1).unwrap_or("").to_lowercase());
304        return Ok(1);
305    }
306
307    // --attached or -a
308    if arg == "--attached" || arg == "-a" {
309        options.attached = true;
310        return Ok(1);
311    }
312
313    // --detached or -d
314    if arg == "--detached" || arg == "-d" {
315        options.detached = true;
316        return Ok(1);
317    }
318
319    // --session or -s
320    if arg == "--session" || arg == "-s" {
321        if index + 1 < args.len() && !args[index + 1].starts_with('-') {
322            options.session = Some(args[index + 1].clone());
323            return Ok(2);
324        } else {
325            return Err(format!("Option {} requires a session name argument", arg));
326        }
327    }
328
329    // --session=<value>
330    if arg.starts_with("--session=") {
331        options.session = Some(arg.split('=').nth(1).unwrap_or("").to_string());
332        return Ok(1);
333    }
334
335    // --image (for docker)
336    if arg == "--image" {
337        if index + 1 < args.len() && !args[index + 1].starts_with('-') {
338            options.image = Some(args[index + 1].clone());
339            return Ok(2);
340        } else {
341            return Err(format!("Option {} requires an image name argument", arg));
342        }
343    }
344
345    // --image=<value>
346    if arg.starts_with("--image=") {
347        options.image = Some(arg.split('=').nth(1).unwrap_or("").to_string());
348        return Ok(1);
349    }
350
351    // --volume or -v (for docker) - repeatable bind mount / volume
352    if arg == "--volume" || arg == "-v" {
353        if index + 1 < args.len() && !args[index + 1].starts_with('-') {
354            options.volumes.push(args[index + 1].clone());
355            return Ok(2);
356        } else {
357            return Err(format!(
358                "Option {} requires a volume argument (host:container[:mode])",
359                arg
360            ));
361        }
362    }
363
364    // --volume=<value> or -v=<value>
365    if arg.starts_with("--volume=") || arg.starts_with("-v=") {
366        options
367            .volumes
368            .push(arg[arg.find('=').unwrap() + 1..].to_string());
369        return Ok(1);
370    }
371
372    // --mount (for docker) - repeatable mount spec
373    if arg == "--mount" {
374        if index + 1 < args.len() && !args[index + 1].starts_with('-') {
375            options.mounts.push(args[index + 1].clone());
376            return Ok(2);
377        } else {
378            return Err(format!("Option {} requires a mount spec argument", arg));
379        }
380    }
381
382    // --mount=<value>
383    if let Some(value) = arg.strip_prefix("--mount=") {
384        options.mounts.push(value.to_string());
385        return Ok(1);
386    }
387
388    // --env or -e (for docker) - repeatable environment variable
389    if arg == "--env" || arg == "-e" {
390        if index + 1 < args.len() && !args[index + 1].starts_with('-') {
391            options.env.push(args[index + 1].clone());
392            return Ok(2);
393        } else {
394            return Err(format!("Option {} requires a KEY=VALUE argument", arg));
395        }
396    }
397
398    // --env=<value> or -e=<value>
399    if arg.starts_with("--env=") || arg.starts_with("-e=") {
400        options
401            .env
402            .push(arg[arg.find('=').unwrap() + 1..].to_string());
403        return Ok(1);
404    }
405
406    // --privileged (for docker)
407    if arg == "--privileged" {
408        options.privileged = true;
409        return Ok(1);
410    }
411
412    // --network (for docker)
413    if arg == "--network" {
414        if index + 1 < args.len() && !args[index + 1].starts_with('-') {
415            let network = args[index + 1].clone();
416            options.networks.push(network.clone());
417            if options.network.is_none() {
418                options.network = Some(network);
419            }
420            return Ok(2);
421        }
422        return Err(format!("Option {} requires a network name argument", arg));
423    }
424
425    // --network=<value>
426    if let Some(value) = arg.strip_prefix("--network=") {
427        let network = value.to_string();
428        options.networks.push(network.clone());
429        if options.network.is_none() {
430            options.network = Some(network);
431        }
432        return Ok(1);
433    }
434
435    // --network-alias (for docker) - repeatable network-scoped alias
436    if arg == "--network-alias" {
437        if index + 1 < args.len() && !args[index + 1].starts_with('-') {
438            options.network_aliases.push(args[index + 1].clone());
439            return Ok(2);
440        }
441        return Err(format!("Option {} requires an alias argument", arg));
442    }
443
444    // --network-alias=<value>
445    if let Some(value) = arg.strip_prefix("--network-alias=") {
446        options.network_aliases.push(value.to_string());
447        return Ok(1);
448    }
449
450    // --endpoint (for ssh)
451    if arg == "--endpoint" {
452        if index + 1 < args.len() && !args[index + 1].starts_with('-') {
453            options.endpoint = Some(args[index + 1].clone());
454            return Ok(2);
455        } else {
456            return Err(format!("Option {} requires an endpoint argument", arg));
457        }
458    }
459
460    // --endpoint=<value>
461    if arg.starts_with("--endpoint=") {
462        options.endpoint = Some(arg.split('=').nth(1).unwrap_or("").to_string());
463        return Ok(1);
464    }
465
466    // --isolated-user or -u [optional-username]
467    if arg == "--isolated-user" || arg == "-u" {
468        options.user = true;
469        // Check if next arg is an optional username (not starting with -)
470        if index + 1 < args.len() && !args[index + 1].starts_with('-') {
471            let next_arg = &args[index + 1];
472            // Check if next arg matches username format
473            let username_regex = regex::Regex::new(r"^[a-zA-Z0-9_-]+$").unwrap();
474            if username_regex.is_match(next_arg) && next_arg.len() <= 32 {
475                options.user_name = Some(next_arg.clone());
476                return Ok(2);
477            }
478        }
479        return Ok(1);
480    }
481
482    // --isolated-user=<value>
483    if arg.starts_with("--isolated-user=") {
484        options.user = true;
485        options.user_name = Some(arg.split('=').nth(1).unwrap_or("").to_string());
486        return Ok(1);
487    }
488
489    // --keep-user
490    if arg == "--keep-user" {
491        options.keep_user = true;
492        return Ok(1);
493    }
494
495    // --keep-alive or -k
496    if arg == "--keep-alive" || arg == "-k" {
497        options.keep_alive = true;
498        return Ok(1);
499    }
500
501    // --auto-remove-docker-container
502    if arg == "--auto-remove-docker-container" {
503        options.auto_remove_docker_container = true;
504        return Ok(1);
505    }
506
507    // --always-cleanup-container
508    if arg == "--always-cleanup-container" {
509        options.always_cleanup_container = true;
510        return Ok(1);
511    }
512
513    // --keep-container
514    if arg == "--keep-container" {
515        options.keep_container = true;
516        return Ok(1);
517    }
518
519    // --keep-container-on-fail
520    if arg == "--keep-container-on-fail" {
521        options.keep_container_on_fail = true;
522        return Ok(1);
523    }
524
525    // --shell <shell>
526    if arg == "--shell" {
527        if index + 1 < args.len() && !args[index + 1].starts_with('-') {
528            options.shell = args[index + 1].to_lowercase();
529            return Ok(2);
530        } else {
531            return Err(format!(
532                "Option {} requires a shell argument (auto, bash, zsh, sh)",
533                arg
534            ));
535        }
536    }
537
538    // --shell=<value>
539    if arg.starts_with("--shell=") {
540        options.shell = arg.split('=').nth(1).unwrap_or("").to_lowercase();
541        return Ok(1);
542    }
543
544    // --use-command-stream
545    if arg == "--use-command-stream" {
546        options.use_command_stream = true;
547        return Ok(1);
548    }
549
550    // --session-id or --session-name (alias) <uuid>
551    if arg == "--session-id" || arg == "--session-name" {
552        if index + 1 < args.len() && !args[index + 1].starts_with('-') {
553            options.session_id = Some(args[index + 1].clone());
554            return Ok(2);
555        } else {
556            return Err(format!("Option {} requires a UUID argument", arg));
557        }
558    }
559
560    // --session-id=<value> or --session-name=<value>
561    if arg.starts_with("--session-id=") || arg.starts_with("--session-name=") {
562        options.session_id = Some(arg.split('=').nth(1).unwrap_or("").to_string());
563        return Ok(1);
564    }
565
566    // Launch-time kill recovery (--on-kill-resume, --recovery-command)
567    let recovery_consumed = parse_docker_recovery_option(args, index, options)?;
568    if recovery_consumed > 0 {
569        return Ok(recovery_consumed);
570    }
571
572    // Query/control options (--status, --list, --attach, --resume, ...)
573    let query_consumed = parse_query_option(args, index, options)?;
574    if query_consumed > 0 {
575        return Ok(query_consumed);
576    }
577
578    // Not a recognized wrapper option
579    Ok(0)
580}
581
582/// Validate parsed options and apply defaults
583pub fn validate_options(options: &mut WrapperOptions) -> Result<(), String> {
584    // Check attached and detached conflict
585    if options.attached && options.detached {
586        return Err(
587            "Cannot use both --attached and --detached at the same time. Please choose only one mode."
588                .to_string(),
589        );
590    }
591
592    // Validate isolation backend
593    if let Some(ref backend) = options.isolated {
594        if !VALID_BACKENDS.contains(&backend.as_str()) {
595            return Err(format!(
596                "Invalid isolation backend: \"{}\". Valid options are: {}",
597                backend,
598                VALID_BACKENDS.join(", ")
599            ));
600        }
601
602        // Docker uses --image or defaults to OS-matched image
603        if backend == "docker" && options.image.is_none() {
604            options.image = Some(get_default_docker_image());
605        }
606
607        // SSH requires --endpoint
608        if backend == "ssh" && options.endpoint.is_none() {
609            return Err(
610                "SSH isolation requires --endpoint option to specify the remote server (e.g., user@host)"
611                    .to_string(),
612            );
613        }
614    }
615
616    // Session name is only valid with isolation
617    if options.session.is_some() && options.isolated.is_none() {
618        return Err("--session option is only valid with --isolated".to_string());
619    }
620
621    // Image is only valid with docker
622    if options.image.is_some() && options.isolated.as_deref() != Some("docker") {
623        return Err("--image option is only valid with --isolated docker".to_string());
624    }
625
626    // Docker runtime options (--volume, --mount, --env, --privileged) are only valid with docker
627    let is_docker = options.isolated.as_deref() == Some("docker");
628    if !options.volumes.is_empty() && !is_docker {
629        return Err("--volume option is only valid with --isolated docker".to_string());
630    }
631    if !options.mounts.is_empty() && !is_docker {
632        return Err("--mount option is only valid with --isolated docker".to_string());
633    }
634    if !options.env.is_empty() && !is_docker {
635        return Err("--env option is only valid with --isolated docker".to_string());
636    }
637    if options.privileged && !is_docker {
638        return Err("--privileged option is only valid with --isolated docker".to_string());
639    }
640    if options.network.is_some() && !is_docker {
641        return Err("--network option is only valid with --isolated docker".to_string());
642    }
643    if !options.network_aliases.is_empty() && !is_docker {
644        return Err("--network-alias option is only valid with --isolated docker".to_string());
645    }
646
647    validate_docker_recovery_options(options)?;
648
649    // Endpoint is only valid with ssh
650    if options.endpoint.is_some() && options.isolated.as_deref() != Some("ssh") {
651        return Err("--endpoint option is only valid with --isolated ssh".to_string());
652    }
653
654    // Keep-alive is only valid with isolation
655    if options.keep_alive && options.isolated.is_none() {
656        return Err("--keep-alive option is only valid with --isolated".to_string());
657    }
658
659    // Auto-remove-docker-container is only valid with docker isolation
660    let cleanup_flags = [
661        (
662            "--auto-remove-docker-container",
663            options.auto_remove_docker_container,
664        ),
665        (
666            "--always-cleanup-container",
667            options.always_cleanup_container,
668        ),
669        ("--keep-container", options.keep_container),
670        ("--keep-container-on-fail", options.keep_container_on_fail),
671    ];
672    for (flag, enabled) in cleanup_flags {
673        if enabled && !is_docker {
674            return Err(format!(
675                "{} option is only valid with --isolated docker",
676                flag
677            ));
678        }
679    }
680
681    let selected_cleanup_policies = [
682        options.auto_remove_docker_container || options.always_cleanup_container,
683        options.keep_container,
684        options.keep_container_on_fail,
685    ]
686    .into_iter()
687    .filter(|enabled| *enabled)
688    .count();
689    if selected_cleanup_policies > 1 {
690        return Err(
691            "Cannot combine docker container cleanup policies. Choose only one of --always-cleanup-container, --keep-container, or --keep-container-on-fail."
692                .to_string(),
693        );
694    }
695
696    // User isolation validation
697    if options.user {
698        // User isolation is not supported with Docker
699        if options.isolated.as_deref() == Some("docker") {
700            return Err(
701                "--isolated-user is not supported with Docker isolation. Docker uses its own user namespace for isolation."
702                    .to_string(),
703            );
704        }
705        // Validate custom username if provided
706        if let Some(ref username) = options.user_name {
707            let username_regex = regex::Regex::new(r"^[a-zA-Z0-9_-]+$").unwrap();
708            if !username_regex.is_match(username) {
709                return Err(format!(
710                    "Invalid username format for --isolated-user: \"{}\". Username should contain only letters, numbers, hyphens, and underscores.",
711                    username
712                ));
713            }
714            if username.len() > 32 {
715                return Err(format!(
716                    "Username too long for --isolated-user: \"{}\". Maximum length is 32 characters.",
717                    username
718                ));
719            }
720        }
721    }
722
723    // Keep-user validation
724    if options.keep_user && !options.user {
725        return Err("--keep-user option is only valid with --isolated-user".to_string());
726    }
727
728    // Query/control option combinations (--status, --attach, --resume, ...)
729    validate_query_options(options)?;
730
731    // Validate shell option
732    if !VALID_SHELLS.contains(&options.shell.as_str()) {
733        return Err(format!(
734            "Invalid shell: \"{}\". Valid options are: {}",
735            options.shell,
736            VALID_SHELLS.join(", ")
737        ));
738    }
739
740    // Validate session ID is a valid UUID if provided
741    if let Some(ref session_id) = options.session_id {
742        if !is_valid_uuid(session_id) {
743            return Err(format!(
744                "Invalid session ID: \"{}\". Session ID must be a valid UUID v4.",
745                session_id
746            ));
747        }
748    }
749
750    Ok(())
751}
752
753/// Generate a unique session name
754pub fn generate_session_name(prefix: Option<&str>) -> String {
755    use std::cell::RefCell;
756    use std::time::{SystemTime, UNIX_EPOCH};
757
758    thread_local! {
759        static STATE: RefCell<u64> = RefCell::new(
760            SystemTime::now()
761                .duration_since(UNIX_EPOCH)
762                .unwrap()
763                .as_nanos() as u64
764        );
765    }
766
767    fn next_random() -> u64 {
768        STATE.with(|state| {
769            let mut s = state.borrow_mut();
770            *s ^= *s << 13;
771            *s ^= *s >> 7;
772            *s ^= *s << 17;
773            *s
774        })
775    }
776
777    let prefix = prefix.unwrap_or("start");
778    let timestamp = chrono::Utc::now().timestamp_millis();
779    let random: String = (0..6)
780        .map(|_| {
781            let idx = (next_random() % 36) as u8;
782            if idx < 10 {
783                (b'0' + idx) as char
784            } else {
785                (b'a' + idx - 10) as char
786            }
787        })
788        .collect();
789    format!("{}-{}-{}", prefix, timestamp, random)
790}
791
792/// Check if any isolation options are present
793pub fn has_isolation(options: &WrapperOptions) -> bool {
794    options.isolated.is_some()
795}
796
797/// Get the effective mode for isolation
798/// Multiplexers default to attached, docker defaults to attached
799pub fn get_effective_mode(options: &WrapperOptions) -> &'static str {
800    if options.detached {
801        "detached"
802    } else {
803        // Default to attached for all backends
804        "attached"
805    }
806}
807
808#[cfg(test)]
809#[path = "args_parser_cases.rs"]
810mod tests;