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