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