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