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