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