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_flag = true,
16    disable_help_subcommand = true,
17    disable_version_flag = true
18)]
19pub struct ReplCommand {
20    #[command(subcommand)]
21    pub action: Option<Action>,
22}
23
24#[derive(Subcommand, Debug)]
25pub enum Action {
26    /// Search planets by biome, distance, name.
27    Find {
28        /// Filter by biome (e.g., Lush, Toxic, Scorched).
29        #[arg(long)]
30        biome: Option<String>,
31
32        /// Only show infested planets.
33        #[arg(long)]
34        infested: bool,
35
36        /// Only within this radius in light-years.
37        #[arg(long)]
38        within: Option<f64>,
39
40        /// Show only the N nearest results.
41        #[arg(long)]
42        nearest: Option<usize>,
43
44        /// Only show named planets/systems.
45        #[arg(long)]
46        named: bool,
47
48        /// Filter by discoverer username (substring match).
49        #[arg(long)]
50        discoverer: Option<String>,
51
52        /// Distance from this base name (default: current position).
53        #[arg(long)]
54        from: Option<String>,
55    },
56
57    /// Show detailed information about a system or base.
58    Show {
59        #[command(subcommand)]
60        target: ShowTarget,
61    },
62
63    /// Display aggregate galaxy statistics.
64    Stats {
65        /// Show biome distribution table.
66        #[arg(long)]
67        biomes: bool,
68
69        /// Show discovery counts by type.
70        #[arg(long)]
71        discoveries: bool,
72    },
73
74    /// Convert between NMS coordinate formats.
75    Convert {
76        /// Portal glyphs as 12 hex digits or emoji.
77        #[arg(long, group = "input")]
78        glyphs: Option<String>,
79
80        /// Signal booster coordinates (XXXX:YYYY:ZZZZ:SSSS).
81        #[arg(long, group = "input")]
82        coords: Option<String>,
83
84        /// Galactic address as hex (0x...).
85        #[arg(long, group = "input")]
86        ga: Option<String>,
87
88        /// Voxel position as X,Y,Z (requires --ssi).
89        #[arg(long, group = "input")]
90        voxel: Option<String>,
91
92        /// Solar system index (required with --voxel).
93        #[arg(long)]
94        ssi: Option<u16>,
95
96        /// Planet index (0-15, defaults to 0).
97        #[arg(long, default_value = "0")]
98        planet: u8,
99
100        /// Galaxy index (0-255) or name.
101        #[arg(long, default_value = "0")]
102        galaxy: String,
103    },
104
105    /// Plan a route through discovered systems.
106    Route {
107        /// Filter targets by biome (e.g., Lush, Toxic).
108        #[arg(long)]
109        biome: Option<String>,
110
111        /// Named targets (bases or systems) to visit.
112        #[arg(long = "target", num_args = 1)]
113        targets: Vec<String>,
114
115        /// Start from this base name (default: current position).
116        #[arg(long)]
117        from: Option<String>,
118
119        /// Ship warp range in light-years (for hop constraints).
120        #[arg(long)]
121        warp_range: Option<f64>,
122
123        /// Only consider targets within this radius in light-years.
124        #[arg(long)]
125        within: Option<f64>,
126
127        /// Maximum number of targets to visit.
128        #[arg(long)]
129        max_targets: Option<usize>,
130
131        /// Routing algorithm: nn, nearest-neighbor, 2opt, two-opt.
132        #[arg(long)]
133        algo: Option<String>,
134
135        /// Return to starting system at the end.
136        #[arg(long)]
137        round_trip: bool,
138    },
139
140    /// Set session context (position, biome filter, warp range).
141    Set {
142        #[command(subcommand)]
143        target: SetTarget,
144    },
145
146    /// Reset session state.
147    Reset {
148        /// What to reset (position, biome, warp-range, all).
149        #[arg(default_value = "all")]
150        target: String,
151    },
152
153    /// Show current session state.
154    Status,
155
156    /// Display save file summary.
157    Info,
158
159    /// Show help for REPL commands.
160    Help,
161
162    /// Exit the REPL.
163    Exit,
164
165    /// Exit the REPL.
166    Quit,
167}
168
169#[derive(Subcommand, Debug)]
170pub enum SetTarget {
171    /// Set reference position to a base name.
172    Position {
173        /// Base name or address.
174        name: String,
175    },
176    /// Set active biome filter.
177    Biome {
178        /// Biome name (e.g., Lush, Toxic).
179        name: String,
180    },
181    /// Set default warp range.
182    #[command(name = "warp-range")]
183    WarpRange {
184        /// Range in light-years.
185        ly: f64,
186    },
187}
188
189#[derive(Subcommand, Debug)]
190pub enum ShowTarget {
191    /// Show system details.
192    System {
193        /// System name or hex address.
194        name: String,
195    },
196    /// Show base details.
197    Base {
198        /// Base name (case-insensitive).
199        name: String,
200    },
201}
202
203/// Parse a REPL input line into a command.
204///
205/// Returns `None` for empty lines.
206/// Returns `Err` with clap's error message for invalid commands.
207pub fn parse_line(line: &str) -> Result<Option<Action>, String> {
208    let line = line.trim();
209    if line.is_empty() {
210        return Ok(None);
211    }
212
213    let args = shell_words(line);
214
215    match ReplCommand::try_parse_from(args) {
216        Ok(cmd) => Ok(cmd.action),
217        Err(e) => {
218            let rendered = e.render().to_string();
219            if e.use_stderr() {
220                Err(rendered)
221            } else {
222                // Help text -- print it and return None
223                print!("{rendered}");
224                Ok(None)
225            }
226        }
227    }
228}
229
230/// Simple shell-like word splitting that respects double quotes.
231fn shell_words(input: &str) -> Vec<String> {
232    let mut words = Vec::new();
233    let mut current = String::new();
234    let mut in_quotes = false;
235
236    for ch in input.chars() {
237        match ch {
238            '"' => in_quotes = !in_quotes,
239            ' ' if !in_quotes => {
240                if !current.is_empty() {
241                    words.push(std::mem::take(&mut current));
242                }
243            }
244            _ => current.push(ch),
245        }
246    }
247
248    if !current.is_empty() {
249        words.push(current);
250    }
251
252    words
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258
259    #[test]
260    fn test_parse_empty_line() {
261        assert!(parse_line("").unwrap().is_none());
262        assert!(parse_line("   ").unwrap().is_none());
263    }
264
265    #[test]
266    fn test_parse_exit() {
267        let action = parse_line("exit").unwrap().unwrap();
268        assert!(matches!(action, Action::Exit));
269    }
270
271    #[test]
272    fn test_parse_quit() {
273        let action = parse_line("quit").unwrap().unwrap();
274        assert!(matches!(action, Action::Quit));
275    }
276
277    #[test]
278    fn test_parse_help() {
279        let action = parse_line("help").unwrap().unwrap();
280        assert!(matches!(action, Action::Help));
281    }
282
283    #[test]
284    fn test_parse_find_with_biome() {
285        let action = parse_line("find --biome Lush --nearest 5")
286            .unwrap()
287            .unwrap();
288        match action {
289            Action::Find { biome, nearest, .. } => {
290                assert_eq!(biome.as_deref(), Some("Lush"));
291                assert_eq!(nearest, Some(5));
292            }
293            _ => panic!("Expected Find"),
294        }
295    }
296
297    #[test]
298    fn test_parse_show_base_quoted() {
299        let action = parse_line("show base \"Acadia National Park\"")
300            .unwrap()
301            .unwrap();
302        match action {
303            Action::Show {
304                target: ShowTarget::Base { name },
305            } => {
306                assert_eq!(name, "Acadia National Park");
307            }
308            _ => panic!("Expected Show Base"),
309        }
310    }
311
312    #[test]
313    fn test_parse_unknown_command() {
314        assert!(parse_line("foobar").is_err());
315    }
316
317    #[test]
318    fn test_shell_words_basic() {
319        let words = shell_words("find --biome Lush");
320        assert_eq!(words, vec!["find", "--biome", "Lush"]);
321    }
322
323    #[test]
324    fn test_shell_words_quoted() {
325        let words = shell_words("show base \"My Base Name\"");
326        assert_eq!(words, vec!["show", "base", "My Base Name"]);
327    }
328
329    #[test]
330    fn test_parse_stats_flags() {
331        let action = parse_line("stats --biomes").unwrap().unwrap();
332        match action {
333            Action::Stats {
334                biomes,
335                discoveries,
336            } => {
337                assert!(biomes);
338                assert!(!discoveries);
339            }
340            _ => panic!("Expected Stats"),
341        }
342    }
343
344    #[test]
345    fn test_parse_info() {
346        let action = parse_line("info").unwrap().unwrap();
347        assert!(matches!(action, Action::Info));
348    }
349
350    #[test]
351    fn test_parse_status() {
352        let action = parse_line("status").unwrap().unwrap();
353        assert!(matches!(action, Action::Status));
354    }
355
356    #[test]
357    fn test_parse_set_biome() {
358        let action = parse_line("set biome Lush").unwrap().unwrap();
359        match action {
360            Action::Set {
361                target: SetTarget::Biome { name },
362            } => assert_eq!(name, "Lush"),
363            _ => panic!("Expected Set Biome"),
364        }
365    }
366
367    #[test]
368    fn test_parse_set_position() {
369        let action = parse_line("set position \"Home Base\"").unwrap().unwrap();
370        match action {
371            Action::Set {
372                target: SetTarget::Position { name },
373            } => assert_eq!(name, "Home Base"),
374            _ => panic!("Expected Set Position"),
375        }
376    }
377
378    #[test]
379    fn test_parse_set_warp_range() {
380        let action = parse_line("set warp-range 2500").unwrap().unwrap();
381        match action {
382            Action::Set {
383                target: SetTarget::WarpRange { ly },
384            } => assert_eq!(ly, 2500.0),
385            _ => panic!("Expected Set WarpRange"),
386        }
387    }
388
389    #[test]
390    fn test_parse_reset_default() {
391        let action = parse_line("reset").unwrap().unwrap();
392        match action {
393            Action::Reset { target } => assert_eq!(target, "all"),
394            _ => panic!("Expected Reset"),
395        }
396    }
397
398    #[test]
399    fn test_parse_route_with_biome_and_warp_range() {
400        let action = parse_line("route --biome Lush --warp-range 2500")
401            .unwrap()
402            .unwrap();
403        match action {
404            Action::Route {
405                biome, warp_range, ..
406            } => {
407                assert_eq!(biome.as_deref(), Some("Lush"));
408                assert_eq!(warp_range, Some(2500.0));
409            }
410            _ => panic!("Expected Route"),
411        }
412    }
413
414    #[test]
415    fn test_parse_route_with_targets() {
416        let action = parse_line("route --target \"Alpha Base\" --target \"Beta Base\"")
417            .unwrap()
418            .unwrap();
419        match action {
420            Action::Route { targets, .. } => {
421                assert_eq!(targets.len(), 2);
422                assert_eq!(targets[0], "Alpha Base");
423                assert_eq!(targets[1], "Beta Base");
424            }
425            _ => panic!("Expected Route"),
426        }
427    }
428
429    #[test]
430    fn test_parse_route_round_trip() {
431        let action = parse_line("route --biome Lush --round-trip")
432            .unwrap()
433            .unwrap();
434        match action {
435            Action::Route { round_trip, .. } => {
436                assert!(round_trip);
437            }
438            _ => panic!("Expected Route"),
439        }
440    }
441
442    #[test]
443    fn test_parse_reset_biome() {
444        let action = parse_line("reset biome").unwrap().unwrap();
445        match action {
446            Action::Reset { target } => assert_eq!(target, "biome"),
447            _ => panic!("Expected Reset"),
448        }
449    }
450}