1use clap::{Parser, Subcommand};
4
5#[derive(Parser, Debug)]
12#[command(
13 name = "",
14 no_binary_name = true,
15 disable_help_subcommand = true,
16 disable_version_flag = true
17)]
18pub struct ReplCommand {
19 #[command(subcommand)]
20 pub action: Option<Action>,
21}
22
23#[derive(Subcommand, Debug)]
24pub enum Action {
25 Find {
27 #[arg(long)]
29 biome: Option<String>,
30
31 #[arg(long)]
33 infested: bool,
34
35 #[arg(long)]
37 within: Option<f64>,
38
39 #[arg(long)]
41 nearest: Option<usize>,
42
43 #[arg(long)]
45 named: bool,
46
47 #[arg(long)]
49 discoverer: Option<String>,
50
51 #[arg(long)]
53 from: Option<String>,
54 },
55
56 Show {
58 #[command(subcommand)]
59 target: ShowTarget,
60 },
61
62 Stats {
64 #[arg(long)]
66 biomes: bool,
67
68 #[arg(long)]
70 discoveries: bool,
71 },
72
73 Convert {
75 #[arg(long, group = "input")]
77 glyphs: Option<String>,
78
79 #[arg(long, group = "input")]
81 coords: Option<String>,
82
83 #[arg(long, group = "input")]
85 ga: Option<String>,
86
87 #[arg(long, group = "input")]
89 voxel: Option<String>,
90
91 #[arg(long)]
93 ssi: Option<u16>,
94
95 #[arg(long, default_value = "0")]
97 planet: u8,
98
99 #[arg(long, default_value = "0")]
101 galaxy: String,
102 },
103
104 Route {
106 #[arg(long)]
108 biome: Option<String>,
109
110 #[arg(long = "target", num_args = 1)]
112 targets: Vec<String>,
113
114 #[arg(long)]
116 from: Option<String>,
117
118 #[arg(long)]
120 warp_range: Option<f64>,
121
122 #[arg(long)]
124 within: Option<f64>,
125
126 #[arg(long)]
128 max_targets: Option<usize>,
129
130 #[arg(long)]
132 algo: Option<String>,
133
134 #[arg(long)]
136 round_trip: bool,
137 },
138
139 Set {
141 #[command(subcommand)]
142 target: SetTarget,
143 },
144
145 Reset {
147 #[arg(default_value = "all")]
149 target: String,
150 },
151
152 List {
154 #[command(subcommand)]
155 target: ListTarget,
156 },
157
158 Map,
160
161 Status,
163
164 Info,
166
167 Help,
169
170 Exit,
172
173 Quit,
175}
176
177#[derive(Subcommand, Debug)]
178pub enum SetTarget {
179 Position {
181 name: String,
183 },
184 Biome {
186 name: String,
188 },
189 #[command(name = "warp-range")]
191 WarpRange {
192 ly: f64,
194 },
195}
196
197#[derive(Subcommand, Debug)]
198pub enum ListTarget {
199 Galaxies {
201 #[arg(long = "type")]
203 galaxy_type: Option<String>,
204 },
205 Biomes,
207 Glyphs,
209 Bases {
211 #[arg(long, default_value = "0")]
213 limit: usize,
214
215 #[arg(long)]
217 all: bool,
218 },
219 Systems {
221 #[arg(long, default_value = "50")]
223 limit: usize,
224
225 #[arg(long)]
227 all: bool,
228 },
229 #[command(name = "terrain-types")]
231 TerrainTypes,
232}
233
234#[derive(Subcommand, Debug)]
235pub enum ShowTarget {
236 System {
238 name: String,
240 },
241 Base {
243 name: String,
245 },
246}
247
248pub fn parse_line(line: &str) -> Result<Option<Action>, String> {
253 let line = line.trim();
254 if line.is_empty() {
255 return Ok(None);
256 }
257
258 let mut args = shell_words(line);
259
260 if args.len() >= 2 && args.last().map(|s| s.as_str()) == Some("help") {
263 let last = args.len() - 1;
267 args[last] = "--help".to_string();
268 }
269
270 match ReplCommand::try_parse_from(args) {
271 Ok(cmd) => Ok(cmd.action),
272 Err(e) => {
273 let rendered = e.render().to_string();
274 if e.use_stderr() {
275 Err(rendered)
276 } else {
277 print!("{rendered}");
279 Ok(None)
280 }
281 }
282 }
283}
284
285fn shell_words(input: &str) -> Vec<String> {
287 let mut words = Vec::new();
288 let mut current = String::new();
289 let mut in_quotes = false;
290
291 for ch in input.chars() {
292 match ch {
293 '"' => in_quotes = !in_quotes,
294 ' ' if !in_quotes => {
295 if !current.is_empty() {
296 words.push(std::mem::take(&mut current));
297 }
298 }
299 _ => current.push(ch),
300 }
301 }
302
303 if !current.is_empty() {
304 words.push(current);
305 }
306
307 words
308}
309
310#[cfg(test)]
311mod tests {
312 use super::*;
313
314 #[test]
315 fn test_parse_empty_line() {
316 assert!(parse_line("").unwrap().is_none());
317 assert!(parse_line(" ").unwrap().is_none());
318 }
319
320 #[test]
321 fn test_parse_exit() {
322 let action = parse_line("exit").unwrap().unwrap();
323 assert!(matches!(action, Action::Exit));
324 }
325
326 #[test]
327 fn test_parse_quit() {
328 let action = parse_line("quit").unwrap().unwrap();
329 assert!(matches!(action, Action::Quit));
330 }
331
332 #[test]
333 fn test_parse_help() {
334 let action = parse_line("help").unwrap().unwrap();
335 assert!(matches!(action, Action::Help));
336 }
337
338 #[test]
339 fn test_parse_find_with_biome() {
340 let action = parse_line("find --biome Lush --nearest 5")
341 .unwrap()
342 .unwrap();
343 match action {
344 Action::Find { biome, nearest, .. } => {
345 assert_eq!(biome.as_deref(), Some("Lush"));
346 assert_eq!(nearest, Some(5));
347 }
348 _ => panic!("Expected Find"),
349 }
350 }
351
352 #[test]
353 fn test_parse_show_base_quoted() {
354 let action = parse_line("show base \"Acadia National Park\"")
355 .unwrap()
356 .unwrap();
357 match action {
358 Action::Show {
359 target: ShowTarget::Base { name },
360 } => {
361 assert_eq!(name, "Acadia National Park");
362 }
363 _ => panic!("Expected Show Base"),
364 }
365 }
366
367 #[test]
368 fn test_parse_unknown_command() {
369 assert!(parse_line("foobar").is_err());
370 }
371
372 #[test]
373 fn test_shell_words_basic() {
374 let words = shell_words("find --biome Lush");
375 assert_eq!(words, vec!["find", "--biome", "Lush"]);
376 }
377
378 #[test]
379 fn test_shell_words_quoted() {
380 let words = shell_words("show base \"My Base Name\"");
381 assert_eq!(words, vec!["show", "base", "My Base Name"]);
382 }
383
384 #[test]
385 fn test_parse_stats_flags() {
386 let action = parse_line("stats --biomes").unwrap().unwrap();
387 match action {
388 Action::Stats {
389 biomes,
390 discoveries,
391 } => {
392 assert!(biomes);
393 assert!(!discoveries);
394 }
395 _ => panic!("Expected Stats"),
396 }
397 }
398
399 #[test]
400 fn test_parse_info() {
401 let action = parse_line("info").unwrap().unwrap();
402 assert!(matches!(action, Action::Info));
403 }
404
405 #[test]
406 fn test_parse_status() {
407 let action = parse_line("status").unwrap().unwrap();
408 assert!(matches!(action, Action::Status));
409 }
410
411 #[test]
412 fn test_parse_set_biome() {
413 let action = parse_line("set biome Lush").unwrap().unwrap();
414 match action {
415 Action::Set {
416 target: SetTarget::Biome { name },
417 } => assert_eq!(name, "Lush"),
418 _ => panic!("Expected Set Biome"),
419 }
420 }
421
422 #[test]
423 fn test_parse_set_position() {
424 let action = parse_line("set position \"Home Base\"").unwrap().unwrap();
425 match action {
426 Action::Set {
427 target: SetTarget::Position { name },
428 } => assert_eq!(name, "Home Base"),
429 _ => panic!("Expected Set Position"),
430 }
431 }
432
433 #[test]
434 fn test_parse_set_warp_range() {
435 let action = parse_line("set warp-range 2500").unwrap().unwrap();
436 match action {
437 Action::Set {
438 target: SetTarget::WarpRange { ly },
439 } => assert_eq!(ly, 2500.0),
440 _ => panic!("Expected Set WarpRange"),
441 }
442 }
443
444 #[test]
445 fn test_parse_reset_default() {
446 let action = parse_line("reset").unwrap().unwrap();
447 match action {
448 Action::Reset { target } => assert_eq!(target, "all"),
449 _ => panic!("Expected Reset"),
450 }
451 }
452
453 #[test]
454 fn test_parse_route_with_biome_and_warp_range() {
455 let action = parse_line("route --biome Lush --warp-range 2500")
456 .unwrap()
457 .unwrap();
458 match action {
459 Action::Route {
460 biome, warp_range, ..
461 } => {
462 assert_eq!(biome.as_deref(), Some("Lush"));
463 assert_eq!(warp_range, Some(2500.0));
464 }
465 _ => panic!("Expected Route"),
466 }
467 }
468
469 #[test]
470 fn test_parse_route_with_targets() {
471 let action = parse_line("route --target \"Alpha Base\" --target \"Beta Base\"")
472 .unwrap()
473 .unwrap();
474 match action {
475 Action::Route { targets, .. } => {
476 assert_eq!(targets.len(), 2);
477 assert_eq!(targets[0], "Alpha Base");
478 assert_eq!(targets[1], "Beta Base");
479 }
480 _ => panic!("Expected Route"),
481 }
482 }
483
484 #[test]
485 fn test_parse_route_round_trip() {
486 let action = parse_line("route --biome Lush --round-trip")
487 .unwrap()
488 .unwrap();
489 match action {
490 Action::Route { round_trip, .. } => {
491 assert!(round_trip);
492 }
493 _ => panic!("Expected Route"),
494 }
495 }
496
497 #[test]
498 fn test_parse_reset_biome() {
499 let action = parse_line("reset biome").unwrap().unwrap();
500 match action {
501 Action::Reset { target } => assert_eq!(target, "biome"),
502 _ => panic!("Expected Reset"),
503 }
504 }
505
506 #[test]
507 fn test_parse_list_galaxies() {
508 let action = parse_line("list galaxies").unwrap().unwrap();
509 match action {
510 Action::List {
511 target: ListTarget::Galaxies { galaxy_type },
512 } => assert!(galaxy_type.is_none()),
513 _ => panic!("Expected List Galaxies"),
514 }
515 }
516
517 #[test]
518 fn test_parse_list_galaxies_with_type() {
519 let action = parse_line("list galaxies --type Lush").unwrap().unwrap();
520 match action {
521 Action::List {
522 target: ListTarget::Galaxies { galaxy_type },
523 } => assert_eq!(galaxy_type.as_deref(), Some("Lush")),
524 _ => panic!("Expected List Galaxies"),
525 }
526 }
527
528 #[test]
529 fn test_parse_list_biomes() {
530 let action = parse_line("list biomes").unwrap().unwrap();
531 assert!(matches!(
532 action,
533 Action::List {
534 target: ListTarget::Biomes
535 }
536 ));
537 }
538
539 #[test]
540 fn test_parse_list_glyphs() {
541 let action = parse_line("list glyphs").unwrap().unwrap();
542 assert!(matches!(
543 action,
544 Action::List {
545 target: ListTarget::Glyphs
546 }
547 ));
548 }
549
550 #[test]
551 fn test_parse_list_bases() {
552 let action = parse_line("list bases").unwrap().unwrap();
553 assert!(matches!(
554 action,
555 Action::List {
556 target: ListTarget::Bases { .. }
557 }
558 ));
559 }
560
561 #[test]
562 fn test_parse_list_bases_with_all() {
563 let action = parse_line("list bases --all").unwrap().unwrap();
564 match action {
565 Action::List {
566 target: ListTarget::Bases { all, .. },
567 } => assert!(all),
568 _ => panic!("Expected List Bases"),
569 }
570 }
571
572 #[test]
573 fn test_parse_list_systems() {
574 let action = parse_line("list systems").unwrap().unwrap();
575 match action {
576 Action::List {
577 target: ListTarget::Systems { limit, all },
578 } => {
579 assert_eq!(limit, 50);
580 assert!(!all);
581 }
582 _ => panic!("Expected List Systems"),
583 }
584 }
585
586 #[test]
587 fn test_parse_list_systems_with_all() {
588 let action = parse_line("list systems --all").unwrap().unwrap();
589 match action {
590 Action::List {
591 target: ListTarget::Systems { all, .. },
592 } => assert!(all),
593 _ => panic!("Expected List Systems"),
594 }
595 }
596
597 #[test]
598 fn test_parse_list_systems_with_limit() {
599 let action = parse_line("list systems --limit 10").unwrap().unwrap();
600 match action {
601 Action::List {
602 target: ListTarget::Systems { limit, .. },
603 } => assert_eq!(limit, 10),
604 _ => panic!("Expected List Systems"),
605 }
606 }
607
608 #[test]
609 fn test_parse_list_terrain_types() {
610 let action = parse_line("list terrain-types").unwrap().unwrap();
611 assert!(matches!(
612 action,
613 Action::List {
614 target: ListTarget::TerrainTypes
615 }
616 ));
617 }
618
619 #[test]
620 fn test_parse_map() {
621 let action = parse_line("map").unwrap().unwrap();
622 assert!(matches!(action, Action::Map));
623 }
624
625 #[test]
626 fn test_parse_command_help_shows_subcommand_help() {
627 let result = parse_line("find help");
629 assert!(result.unwrap().is_none());
631 }
632
633 #[test]
634 fn test_parse_command_dash_help_shows_subcommand_help() {
635 let result = parse_line("find --help");
636 assert!(result.unwrap().is_none());
637 }
638}