Skip to main content

rainier_console/
console.rs

1//! The console kernel — [`Command`] and [`Console`].
2
3use std::sync::Arc;
4
5use rainier_container::Application;
6use rainier_support::Result;
7
8use crate::arguments::Arguments;
9
10/// Exit codes, following the shell convention.
11pub mod exit {
12    /// Everything worked.
13    pub const SUCCESS: i32 = 0;
14    /// The command ran and reported a failure.
15    pub const FAILURE: i32 = 1;
16    /// The command line was wrong — an unknown command, a missing argument.
17    pub const USAGE: i32 = 2;
18}
19
20/// A console command.
21///
22/// ```
23/// use rainier_console::{Arguments, Command};
24/// use rainier_container::Application;
25/// use rainier_support::Result;
26///
27/// struct Greet;
28///
29/// #[async_trait::async_trait]
30/// impl Command for Greet {
31///     fn name(&self) -> &str { "greet" }
32///     fn description(&self) -> &str { "Say hello" }
33///
34///     async fn handle(&self, args: &Arguments, _app: &Application) -> Result<i32> {
35///         println!("Hello, {}", args.argument(0).unwrap_or("world"));
36///         Ok(0)
37///     }
38/// }
39/// ```
40#[async_trait::async_trait]
41pub trait Command: Send + Sync + 'static {
42    /// The name it is invoked by — `route:list`.
43    fn name(&self) -> &str;
44
45    /// A one-line summary, shown in the command list.
46    fn description(&self) -> &str {
47        ""
48    }
49
50    /// Longer help, shown for `<command> --help`.
51    fn help(&self) -> Option<&str> {
52        None
53    }
54
55    /// Run it, returning an exit code.
56    async fn handle(&self, args: &Arguments, app: &Application) -> Result<i32>;
57}
58
59/// The console kernel: a registry of commands and the dispatcher for them.
60pub struct Console {
61    name: String,
62    commands: Vec<Arc<dyn Command>>,
63}
64
65impl Console {
66    /// A console named `name`, as shown in its usage line.
67    pub fn new(name: impl Into<String>) -> Self {
68        Self { name: name.into(), commands: Vec::new() }
69    }
70
71    /// Register a command.
72    pub fn register(mut self, command: impl Command) -> Self {
73        self.commands.push(Arc::new(command));
74        self
75    }
76
77    /// Register an already-shared command.
78    pub fn register_arc(mut self, command: Arc<dyn Command>) -> Self {
79        self.commands.push(command);
80        self
81    }
82
83    /// Every registered command, sorted by name.
84    pub fn commands(&self) -> Vec<Arc<dyn Command>> {
85        let mut commands = self.commands.clone();
86        commands.sort_by(|a, b| a.name().cmp(b.name()));
87        commands
88    }
89
90    /// Look a command up by name.
91    pub fn find(&self, name: &str) -> Option<Arc<dyn Command>> {
92        self.commands.iter().find(|command| command.name() == name).cloned()
93    }
94
95    /// Run the command named in `args`, returning its exit code.
96    ///
97    /// Never panics and never propagates: a console's job is to report and
98    /// exit with a code, so an error becomes a message on stderr and
99    /// [`exit::FAILURE`].
100    pub async fn run(&self, app: &Application, args: Arguments) -> i32 {
101        if args.is_empty() {
102            println!("{}", self.usage());
103            return exit::SUCCESS;
104        }
105
106        if args.command() == "list" {
107            println!("{}", self.usage());
108            return exit::SUCCESS;
109        }
110
111        if args.command() == "help" {
112            return match args.argument(0) {
113                Some(name) => self.print_help(name),
114                None => {
115                    println!("{}", self.usage());
116                    exit::SUCCESS
117                }
118            };
119        }
120
121        let Some(command) = self.find(args.command()) else {
122            eprintln!("Command `{}` is not defined.", args.command());
123            if let Some(suggestion) = self.closest(args.command()) {
124                eprintln!("Did you mean `{suggestion}`?");
125            }
126            eprintln!("\nRun `{} list` to see what is available.", self.name);
127            return exit::USAGE;
128        };
129
130        if args.flag("help") || args.flag("h") {
131            return self.print_help(command.name());
132        }
133
134        match command.handle(&args, app).await {
135            Ok(code) => code,
136            Err(e) => {
137                eprintln!("{}: {e}", command.name());
138                exit::FAILURE
139            }
140        }
141    }
142
143    /// Parse `argv` (without the program name) and run it.
144    pub async fn run_argv<I, S>(&self, app: &Application, argv: I) -> i32
145    where
146        I: IntoIterator<Item = S>,
147        S: Into<String>,
148    {
149        self.run(app, Arguments::parse(argv)).await
150    }
151
152    /// Parse the process's own arguments and run.
153    pub async fn run_from_env(&self, app: &Application) -> i32 {
154        self.run_argv(app, std::env::args().skip(1)).await
155    }
156
157    /// The usage text: a header and the command list, aligned.
158    pub fn usage(&self) -> String {
159        use std::fmt::Write as _;
160
161        let mut out = String::new();
162        let _ = writeln!(out, "{}\n", self.name);
163        let _ = writeln!(out, "Usage:\n  {} <command> [options]\n", self.name);
164
165        if self.commands.is_empty() {
166            let _ = writeln!(out, "No commands are registered.");
167            return out;
168        }
169
170        let width = self.commands.iter().map(|c| c.name().len()).max().unwrap_or(0);
171        let _ = writeln!(out, "Available commands:");
172        for command in self.commands() {
173            let _ = writeln!(
174                out,
175                "  {:width$}  {}",
176                command.name(),
177                command.description(),
178                width = width
179            );
180        }
181        out
182    }
183
184    fn print_help(&self, name: &str) -> i32 {
185        let Some(command) = self.find(name) else {
186            eprintln!("Command `{name}` is not defined.");
187            return exit::USAGE;
188        };
189
190        println!("{}\n", command.name());
191        if !command.description().is_empty() {
192            println!("{}\n", command.description());
193        }
194        match command.help() {
195            Some(help) => println!("{help}"),
196            None => println!("Usage:\n  {} {name} [options]", self.name),
197        }
198        exit::SUCCESS
199    }
200
201    /// The registered command closest to `name`, for a "did you mean?".
202    ///
203    /// Prefix and substring matching rather than an edit distance: a console
204    /// with namespaced commands (`queue:work`, `queue:retry`) makes a typo in
205    /// the namespace far more likely than a transposition, and `queue:` should
206    /// suggest something.
207    fn closest(&self, name: &str) -> Option<String> {
208        let lower = name.to_lowercase();
209
210        self.commands()
211            .iter()
212            .map(|command| command.name().to_string())
213            .filter(|candidate| {
214                let candidate = candidate.to_lowercase();
215                candidate.starts_with(&lower)
216                    || lower.starts_with(&candidate)
217                    || candidate.contains(&lower)
218                    || lower.contains(&candidate)
219            })
220            .min_by_key(|candidate| candidate.len())
221    }
222}
223
224impl std::fmt::Debug for Console {
225    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
226        f.debug_struct("Console")
227            .field("name", &self.name)
228            .field(
229                "commands",
230                &self.commands().iter().map(|c| c.name().to_string()).collect::<Vec<_>>(),
231            )
232            .finish()
233    }
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239    use rainier_support::Error;
240    use std::sync::atomic::{AtomicUsize, Ordering};
241    use std::sync::Mutex;
242
243    static RAN: Mutex<Vec<String>> = Mutex::new(Vec::new());
244    static CALLS: AtomicUsize = AtomicUsize::new(0);
245
246    struct Greet;
247
248    #[async_trait::async_trait]
249    impl Command for Greet {
250        fn name(&self) -> &str {
251            "greet"
252        }
253        fn description(&self) -> &str {
254            "Say hello"
255        }
256        async fn handle(&self, args: &Arguments, _: &Application) -> Result<i32> {
257            CALLS.fetch_add(1, Ordering::SeqCst);
258            RAN.lock().unwrap().push(args.argument(0).unwrap_or("world").to_string());
259            Ok(exit::SUCCESS)
260        }
261    }
262
263    struct Failing;
264
265    #[async_trait::async_trait]
266    impl Command for Failing {
267        fn name(&self) -> &str {
268            "queue:work"
269        }
270        fn description(&self) -> &str {
271            "Process queued jobs"
272        }
273        async fn handle(&self, _: &Arguments, _: &Application) -> Result<i32> {
274            Err(Error::internal("the queue is unreachable"))
275        }
276    }
277
278    struct NonZero;
279
280    #[async_trait::async_trait]
281    impl Command for NonZero {
282        fn name(&self) -> &str {
283            "check"
284        }
285        async fn handle(&self, _: &Arguments, _: &Application) -> Result<i32> {
286            Ok(3)
287        }
288    }
289
290    fn console() -> Console {
291        Console::new("rainier").register(Greet).register(Failing).register(NonZero)
292    }
293
294    fn app() -> Application {
295        Application::new(".")
296    }
297
298    async fn run(line: &str) -> i32 {
299        console().run_argv(&app(), line.split_whitespace()).await
300    }
301
302    #[tokio::test]
303    async fn runs_a_registered_command() {
304        RAN.lock().unwrap().clear();
305        assert_eq!(run("greet Ada").await, exit::SUCCESS);
306        assert_eq!(*RAN.lock().unwrap(), vec!["Ada"]);
307    }
308
309    #[tokio::test]
310    async fn a_commands_exit_code_is_returned() {
311        assert_eq!(run("check").await, 3);
312    }
313
314    #[tokio::test]
315    async fn an_erroring_command_reports_a_failure_rather_than_propagating() {
316        // A console's contract is a message and an exit code, not a `Result`.
317        assert_eq!(run("queue:work").await, exit::FAILURE);
318    }
319
320    #[tokio::test]
321    async fn an_unknown_command_is_a_usage_error() {
322        assert_eq!(run("nonsense").await, exit::USAGE);
323    }
324
325    #[tokio::test]
326    async fn no_command_prints_the_list() {
327        assert_eq!(console().run_argv(&app(), Vec::<String>::new()).await, exit::SUCCESS);
328        assert_eq!(run("list").await, exit::SUCCESS);
329    }
330
331    #[tokio::test]
332    async fn help_is_available_for_a_command() {
333        assert_eq!(run("help greet").await, exit::SUCCESS);
334        assert_eq!(run("help nonsense").await, exit::USAGE);
335        assert_eq!(run("help").await, exit::SUCCESS);
336    }
337
338    #[tokio::test]
339    async fn a_help_flag_shows_help_instead_of_running() {
340        let before = CALLS.load(Ordering::SeqCst);
341        assert_eq!(run("greet --help").await, exit::SUCCESS);
342        assert_eq!(CALLS.load(Ordering::SeqCst), before, "the command must not have run");
343    }
344
345    #[test]
346    fn the_usage_text_lists_every_command_aligned() {
347        let usage = console().usage();
348
349        assert!(usage.contains("Available commands:"), "{usage}");
350        assert!(usage.contains("greet"), "{usage}");
351        assert!(usage.contains("Say hello"), "{usage}");
352        assert!(usage.contains("queue:work"), "{usage}");
353        // Sorted, so the list is stable between runs.
354        assert!(
355            usage.find("check").unwrap() < usage.find("greet").unwrap(),
356            "commands should be sorted: {usage}"
357        );
358    }
359
360    #[test]
361    fn an_empty_console_says_so() {
362        assert!(Console::new("rainier").usage().contains("No commands"));
363    }
364
365    #[test]
366    fn a_near_miss_is_suggested() {
367        let console = console();
368        assert_eq!(console.closest("greet2").as_deref(), Some("greet"));
369        assert_eq!(console.closest("gree").as_deref(), Some("greet"));
370        // The namespace case: a typo in `queue:` should still find something.
371        assert_eq!(console.closest("queue").as_deref(), Some("queue:work"));
372        assert_eq!(console.closest("zzz"), None);
373    }
374
375    #[test]
376    fn commands_can_be_looked_up() {
377        let console = console();
378        assert!(console.find("greet").is_some());
379        assert!(console.find("nope").is_none());
380        assert_eq!(console.commands().len(), 3);
381    }
382}