Skip to main content

nms_copilot/
commands.rs

1//! REPL command parsing -- reuses clap derive for consistent argument handling.
2
3use clap::{Parser, Subcommand};
4
5/// Top-level REPL command parser.
6///
7/// This is separate from the CLI parser because:
8/// - No `--save` flag (the model is already loaded)
9/// - Extra REPL-only commands (exit, help, status, set, reset)
10/// - Parsed from user input line, not process args
11#[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    /// Search planets by biome, distance, name.
26    Find {
27        /// Filter by biome (e.g., Lush, Toxic, Scorched).
28        #[arg(long)]
29        biome: Option<String>,
30
31        /// Only show infested planets.
32        #[arg(long)]
33        infested: bool,
34
35        /// Only within this radius in light-years.
36        #[arg(long)]
37        within: Option<f64>,
38
39        /// Show only the N nearest results.
40        #[arg(long)]
41        nearest: Option<usize>,
42
43        /// Only show named planets/systems.
44        #[arg(long)]
45        named: bool,
46
47        /// Filter by discoverer username (substring match).
48        #[arg(long)]
49        discoverer: Option<String>,
50
51        /// Distance from this base name (default: current position).
52        #[arg(long)]
53        from: Option<String>,
54    },
55
56    /// Show detailed information about a system or base.
57    Show {
58        #[command(subcommand)]
59        target: ShowTarget,
60    },
61
62    /// Display aggregate galaxy statistics.
63    Stats {
64        /// Show biome distribution table.
65        #[arg(long)]
66        biomes: bool,
67
68        /// Show discovery counts by type.
69        #[arg(long)]
70        discoveries: bool,
71    },
72
73    /// Convert between NMS coordinate formats.
74    Convert {
75        /// Portal glyphs as 12 hex digits or emoji.
76        #[arg(long, group = "input")]
77        glyphs: Option<String>,
78
79        /// Signal booster coordinates (XXXX:YYYY:ZZZZ:SSSS).
80        #[arg(long, group = "input")]
81        coords: Option<String>,
82
83        /// Galactic address as hex (0x...).
84        #[arg(long, group = "input")]
85        ga: Option<String>,
86
87        /// Voxel position as X,Y,Z (requires --ssi).
88        #[arg(long, group = "input")]
89        voxel: Option<String>,
90
91        /// Solar system index (required with --voxel).
92        #[arg(long)]
93        ssi: Option<u16>,
94
95        /// Planet index (0-15, defaults to 0).
96        #[arg(long, default_value = "0")]
97        planet: u8,
98
99        /// Galaxy index (0-255) or name.
100        #[arg(long, default_value = "0")]
101        galaxy: String,
102    },
103
104    /// Plan a route through discovered systems.
105    Route {
106        /// Filter targets by biome (e.g., Lush, Toxic).
107        #[arg(long)]
108        biome: Option<String>,
109
110        /// Named targets (bases or systems) to visit.
111        #[arg(long = "target", num_args = 1)]
112        targets: Vec<String>,
113
114        /// Start from this base name (default: current position).
115        #[arg(long)]
116        from: Option<String>,
117
118        /// Ship warp range in light-years (for hop constraints).
119        #[arg(long)]
120        warp_range: Option<f64>,
121
122        /// Only consider targets within this radius in light-years.
123        #[arg(long)]
124        within: Option<f64>,
125
126        /// Maximum number of targets to visit.
127        #[arg(long)]
128        max_targets: Option<usize>,
129
130        /// Routing algorithm: nn, nearest-neighbor, 2opt, two-opt.
131        #[arg(long)]
132        algo: Option<String>,
133
134        /// Return to starting system at the end.
135        #[arg(long)]
136        round_trip: bool,
137    },
138
139    /// Set session context (position, biome filter, warp range).
140    Set {
141        #[command(subcommand)]
142        target: SetTarget,
143    },
144
145    /// Reset session state.
146    Reset {
147        /// What to reset (position, biome, warp-range, all).
148        #[arg(default_value = "all")]
149        target: String,
150    },
151
152    /// List reference data or model collections.
153    List {
154        #[command(subcommand)]
155        target: ListTarget,
156    },
157
158    /// Open interactive galaxy map.
159    Map,
160
161    /// Show current session state.
162    Status,
163
164    /// Display save file summary.
165    Info,
166
167    /// Show help for REPL commands.
168    Help,
169
170    /// Exit the REPL.
171    Exit,
172
173    /// Exit the REPL.
174    Quit,
175}
176
177#[derive(Subcommand, Debug)]
178pub enum SetTarget {
179    /// Set reference position to a base name.
180    Position {
181        /// Base name or address.
182        name: String,
183    },
184    /// Set active biome filter.
185    Biome {
186        /// Biome name (e.g., Lush, Toxic).
187        name: String,
188    },
189    /// Set default warp range.
190    #[command(name = "warp-range")]
191    WarpRange {
192        /// Range in light-years.
193        ly: f64,
194    },
195}
196
197#[derive(Subcommand, Debug)]
198pub enum ListTarget {
199    /// List all 256 galaxies.
200    Galaxies {
201        /// Filter by galaxy type (Normal, Lush, Harsh, Empty).
202        #[arg(long = "type")]
203        galaxy_type: Option<String>,
204    },
205    /// List biome types and their variants.
206    Biomes,
207    /// List portal glyphs.
208    Glyphs,
209    /// List player bases.
210    Bases {
211        /// Maximum number of bases to display (0 for all).
212        #[arg(long, default_value = "0")]
213        limit: usize,
214
215        /// Show all bases (equivalent to --limit 0).
216        #[arg(long)]
217        all: bool,
218    },
219    /// List discovered systems.
220    Systems {
221        /// Maximum number of systems to display (0 for all).
222        #[arg(long, default_value = "50")]
223        limit: usize,
224
225        /// Show all systems (equivalent to --limit 0).
226        #[arg(long)]
227        all: bool,
228    },
229    /// List terrain generation types (GcBiomeSubType).
230    #[command(name = "terrain-types")]
231    TerrainTypes,
232}
233
234#[derive(Subcommand, Debug)]
235pub enum ShowTarget {
236    /// Show system details.
237    System {
238        /// System name or hex address.
239        name: String,
240    },
241    /// Show base details.
242    Base {
243        /// Base name (case-insensitive).
244        name: String,
245    },
246}
247
248/// Parse a REPL input line into a command.
249///
250/// Returns `None` for empty lines.
251/// Returns `Err` with clap's error message for invalid commands.
252pub 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    // Rewrite "<command> help" to "<command> --help" so clap generates
261    // per-command help even though the top-level help subcommand is disabled.
262    if args.len() >= 2 && args.last().map(|s| s.as_str()) == Some("help") {
263        // Don't rewrite "show help" or "set help" — those have subcommands
264        // that could legitimately be named "help". But for top-level commands
265        // like "find help", "route help", etc., rewrite to --help.
266        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                // Help text -- print it and return None
278                print!("{rendered}");
279                Ok(None)
280            }
281        }
282    }
283}
284
285/// Simple shell-like word splitting that respects double quotes.
286fn 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        // "find help" should be rewritten to "find --help" and produce help output
628        let result = parse_line("find help");
629        // clap prints help text and parse_line returns Ok(None)
630        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}