1use std::env;
25
26use crate::isolation::get_default_docker_image;
27
28pub const VALID_BACKENDS: [&str; 4] = ["screen", "tmux", "docker", "ssh"];
30
31pub const VALID_SHELLS: [&str; 4] = ["auto", "bash", "zsh", "sh"];
33
34pub const VALID_OUTPUT_FORMATS: [&str; 3] = ["links-notation", "json", "text"];
36
37const 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
40pub 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
47pub fn generate_uuid() -> String {
49 uuid::Uuid::new_v4().to_string()
50}
51
52#[derive(Debug, Clone)]
54pub struct WrapperOptions {
55 pub isolated: Option<String>,
57 pub attached: bool,
59 pub detached: bool,
61 pub session: Option<String>,
63 pub session_id: Option<String>,
65 pub image: Option<String>,
67 pub endpoint: Option<String>,
69 pub user: bool,
71 pub user_name: Option<String>,
73 pub keep_user: bool,
75 pub keep_alive: bool,
77 pub auto_remove_docker_container: bool,
79 pub shell: String,
81 pub use_command_stream: bool,
83 pub status: Option<String>,
85 pub list: bool,
87 pub output_format: Option<String>,
89 pub stop: Option<String>,
91 pub terminate: Option<String>,
93 pub cleanup: bool,
95 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#[derive(Debug)]
129pub struct ParsedArgs {
130 pub wrapper_options: WrapperOptions,
132 pub command: String,
134 pub raw_command: Vec<String>,
136}
137
138pub 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 let separator_index = args.iter().position(|a| a == "--");
145
146 if let Some(sep_idx) = separator_index {
147 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 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 command_args = args[i..].to_vec();
161 break;
162 }
163 consumed => {
164 i += consumed;
165 }
166 }
167 } else {
168 command_args = args[i..].to_vec();
170 break;
171 }
172 }
173 }
174
175 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
185fn 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 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
205fn parse_option(
208 args: &[String],
209 index: usize,
210 options: &mut WrapperOptions,
211) -> Result<usize, String> {
212 let arg = &args[index];
213
214 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 if arg.starts_with("--isolated=") {
229 options.isolated = Some(arg.split('=').nth(1).unwrap_or("").to_lowercase());
230 return Ok(1);
231 }
232
233 if arg == "--attached" || arg == "-a" {
235 options.attached = true;
236 return Ok(1);
237 }
238
239 if arg == "--detached" || arg == "-d" {
241 options.detached = true;
242 return Ok(1);
243 }
244
245 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 if arg.starts_with("--session=") {
257 options.session = Some(arg.split('=').nth(1).unwrap_or("").to_string());
258 return Ok(1);
259 }
260
261 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 if arg.starts_with("--image=") {
273 options.image = Some(arg.split('=').nth(1).unwrap_or("").to_string());
274 return Ok(1);
275 }
276
277 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 if arg.starts_with("--endpoint=") {
289 options.endpoint = Some(arg.split('=').nth(1).unwrap_or("").to_string());
290 return Ok(1);
291 }
292
293 if arg == "--isolated-user" || arg == "-u" {
295 options.user = true;
296 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
298 let next_arg = &args[index + 1];
299 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 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 if arg == "--keep-user" {
318 options.keep_user = true;
319 return Ok(1);
320 }
321
322 if arg == "--keep-alive" || arg == "-k" {
324 options.keep_alive = true;
325 return Ok(1);
326 }
327
328 if arg == "--auto-remove-docker-container" {
330 options.auto_remove_docker_container = true;
331 return Ok(1);
332 }
333
334 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 if arg.starts_with("--shell=") {
349 options.shell = arg.split('=').nth(1).unwrap_or("").to_lowercase();
350 return Ok(1);
351 }
352
353 if arg == "--use-command-stream" {
355 options.use_command_stream = true;
356 return Ok(1);
357 }
358
359 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 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 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 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 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 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 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 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 if arg == "--list" {
443 options.list = true;
444 return Ok(1);
445 }
446
447 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 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 if arg == "--cleanup" {
465 options.cleanup = true;
466 return Ok(1);
467 }
468
469 if arg == "--cleanup-dry-run" {
471 options.cleanup = true;
472 options.cleanup_dry_run = true;
473 return Ok(1);
474 }
475
476 Ok(0)
478}
479
480pub fn validate_options(options: &mut WrapperOptions) -> Result<(), String> {
482 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 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 if backend == "docker" && options.image.is_none() {
502 options.image = Some(get_default_docker_image());
503 }
504
505 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 if options.session.is_some() && options.isolated.is_none() {
516 return Err("--session option is only valid with --isolated".to_string());
517 }
518
519 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 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 if options.keep_alive && options.isolated.is_none() {
531 return Err("--keep-alive option is only valid with --isolated".to_string());
532 }
533
534 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 if options.user {
544 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 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 if options.keep_user && !options.user {
571 return Err("--keep-user option is only valid with --isolated-user".to_string());
572 }
573
574 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 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 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 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 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
631pub 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
670pub fn has_isolation(options: &WrapperOptions) -> bool {
672 options.isolated.is_some()
673}
674
675pub fn get_effective_mode(options: &WrapperOptions) -> &'static str {
678 if options.detached {
679 "detached"
680 } else {
681 "attached"
683 }
684}
685
686#[cfg(test)]
687#[path = "args_parser_cases.rs"]
688mod tests;