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