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