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