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