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