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