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