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