rainier_console/
console.rs1use std::sync::Arc;
4
5use rainier_container::Application;
6use rainier_support::Result;
7
8use crate::arguments::Arguments;
9
10pub mod exit {
12 pub const SUCCESS: i32 = 0;
14 pub const FAILURE: i32 = 1;
16 pub const USAGE: i32 = 2;
18}
19
20#[async_trait::async_trait]
41pub trait Command: Send + Sync + 'static {
42 fn name(&self) -> &str;
44
45 fn description(&self) -> &str {
47 ""
48 }
49
50 fn help(&self) -> Option<&str> {
52 None
53 }
54
55 async fn handle(&self, args: &Arguments, app: &Application) -> Result<i32>;
57}
58
59pub struct Console {
61 name: String,
62 commands: Vec<Arc<dyn Command>>,
63}
64
65impl Console {
66 pub fn new(name: impl Into<String>) -> Self {
68 Self { name: name.into(), commands: Vec::new() }
69 }
70
71 pub fn register(mut self, command: impl Command) -> Self {
73 self.commands.push(Arc::new(command));
74 self
75 }
76
77 pub fn register_arc(mut self, command: Arc<dyn Command>) -> Self {
79 self.commands.push(command);
80 self
81 }
82
83 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 pub fn find(&self, name: &str) -> Option<Arc<dyn Command>> {
92 self.commands.iter().find(|command| command.name() == name).cloned()
93 }
94
95 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 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 pub async fn run_from_env(&self, app: &Application) -> i32 {
154 self.run_argv(app, std::env::args().skip(1)).await
155 }
156
157 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 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 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 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 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}