renox_core/command.rs
1//! The app's own commands, run like the built-in ones: `my-app admin:create
2//! --email a@b.c` (or `rnx admin:create …` during development).
3//!
4//! ```
5//! # use renox::prelude::*;
6//! use renox::command::Args;
7//!
8//! async fn create_admin(args: Args, state: AppState) -> Result {
9//! let (Some(email), Some(password)) = (args.value("--email"), args.value("--password")) else {
10//! return Err(Error::BadRequest("usage: admin:create --email E --password P".into()));
11//! };
12//! User::register(&state.db, "Admin", email, password).await?;
13//! println!("Created {email}.");
14//! Ok(())
15//! }
16//!
17//! # let _ =
18//! App::new().command("admin:create", "Create an admin user (--email, --password)", create_admin)
19//! # ;
20//! ```
21//!
22//! Commands run after the app boots (the database is connected, migrations
23//! are not run for you) and appear in `my-app help`.
24//!
25//! A typed command declares its arguments with clap, so they're parsed and
26//! checked for you, and `my-app catalog:import --help` prints its usage:
27//!
28//! ```
29//! # use renox::prelude::*;
30//! use renox::clap;
31//! use renox::command::AppCommand;
32//!
33//! /// Import products from a CSV file.
34//! #[derive(clap::Parser)]
35//! #[command(name = "catalog:import")]
36//! struct ImportCatalog {
37//! /// The CSV file.
38//! file: std::path::PathBuf,
39//! /// Show what would change without saving.
40//! #[arg(long)]
41//! dry_run: bool,
42//! #[arg(long, default_value_t = 500)]
43//! batch: usize,
44//! }
45//!
46//! impl AppCommand for ImportCatalog {
47//! async fn run(self, state: AppState) -> Result {
48//! println!("importing {} ({} a batch)", self.file.display(), self.batch);
49//! # let _ = state;
50//! Ok(())
51//! }
52//! }
53//!
54//! # let _ =
55//! App::new().typed_command::<ImportCatalog>()
56//! # ;
57//! ```
58//!
59//! Ask for what's missing with [`crate::prompt`].
60
61use std::future::Future;
62use std::pin::Pin;
63use std::sync::Arc;
64
65use crate::{AppState, Result};
66
67/// The words after the command's name.
68#[derive(Debug, Clone, Default, PartialEq, Eq)]
69pub struct Args(Vec<String>);
70
71impl Args {
72 /// Arguments from these words, e.g. for `Kernel::call` in tests.
73 pub fn new(args: impl IntoIterator<Item = impl Into<String>>) -> Self {
74 Self(args.into_iter().map(Into::into).collect())
75 }
76
77 /// Every word, as given.
78 pub fn all(&self) -> &[String] {
79 &self.0
80 }
81
82 /// The words that aren't flags or flag values, e.g. `["report.csv"]` for
83 /// `import report.csv --dry-run`.
84 pub fn positional(&self) -> Vec<&str> {
85 let mut out = Vec::new();
86 let mut words = self.0.iter().peekable();
87 while let Some(word) = words.next() {
88 if word.starts_with("--") {
89 // `--flag value`: skip the value too.
90 if !word.contains('=') && words.peek().is_some_and(|next| !next.starts_with("--")) {
91 words.next();
92 }
93 } else {
94 out.push(word.as_str());
95 }
96 }
97 out
98 }
99
100 /// The value of `--flag value` or `--flag=value`.
101 pub fn value(&self, flag: &str) -> Option<&str> {
102 let mut words = self.0.iter();
103 while let Some(word) = words.next() {
104 if word == flag {
105 return words
106 .next()
107 .map(String::as_str)
108 .filter(|v| !v.starts_with("--"));
109 }
110 if let Some(value) = word
111 .strip_prefix(flag)
112 .and_then(|rest| rest.strip_prefix('='))
113 {
114 return Some(value);
115 }
116 }
117 None
118 }
119
120 /// Whether `--flag` was given (with or without a value).
121 pub fn has(&self, flag: &str) -> bool {
122 self.0
123 .iter()
124 .any(|word| word == flag || word.starts_with(&format!("{flag}=")))
125 }
126}
127
128/// A command whose arguments are a clap `Parser`: its `#[command(name = …)]`
129/// is the command's name, and its doc comment (or `about`) the line in
130/// `my-app help`. Register it with [`App::typed_command`](crate::App::typed_command).
131pub trait AppCommand: clap::Parser + Send + 'static {
132 /// Runs the command with its parsed arguments.
133 fn run(self, state: AppState) -> impl Future<Output = Result> + Send;
134}
135
136pub(crate) type CommandFn =
137 Arc<dyn Fn(AppState, Args) -> Pin<Box<dyn Future<Output = Result> + Send>> + Send + Sync>;
138
139#[derive(Clone)]
140pub(crate) struct Command {
141 pub name: String,
142 pub about: String,
143 pub run: CommandFn,
144}
145
146pub(crate) fn command<F, Fut>(name: &str, about: &str, run: F) -> Command
147where
148 F: Fn(Args, AppState) -> Fut + Send + Sync + 'static,
149 Fut: Future<Output = Result> + Send + 'static,
150{
151 Command {
152 name: name.to_owned(),
153 about: about.to_owned(),
154 run: Arc::new(move |state, args| Box::pin(run(args, state))),
155 }
156}
157
158pub(crate) fn typed<T: AppCommand>() -> Command {
159 let definition = T::command();
160 let name = definition.get_name().to_owned();
161 let about = definition
162 .get_about()
163 .map(|about| about.to_string())
164 .unwrap_or_default();
165 let bin = name.clone();
166 Command {
167 name,
168 about,
169 run: Arc::new(move |state, args: Args| {
170 let bin = bin.clone();
171 Box::pin(async move {
172 match T::try_parse_from(std::iter::once(bin).chain(args.0)) {
173 Ok(command) => command.run(state).await,
174 // `--help` / `--version`: printed, and that's a success.
175 Err(err)
176 if matches!(
177 err.kind(),
178 clap::error::ErrorKind::DisplayHelp
179 | clap::error::ErrorKind::DisplayVersion
180 ) =>
181 {
182 print!("{}", err.render());
183 Ok(())
184 }
185 // What's wrong, then how to call it (clap includes the
186 // usage for some errors; `Error: ` is added by main).
187 Err(err) => {
188 let rendered = err.render().to_string();
189 let message = rendered.trim_end();
190 let message = message.strip_prefix("error: ").unwrap_or(message);
191 Err(crate::Error::Internal(if message.contains("Usage:") {
192 anyhow::anyhow!("{message}")
193 } else {
194 anyhow::anyhow!("{message}\n\n{}", T::command().render_usage())
195 }))
196 }
197 }
198 })
199 }),
200 }
201}
202
203#[cfg(test)]
204mod tests {
205 use super::*;
206
207 #[test]
208 fn reads_flags_and_positional_words() {
209 let args = Args::new(["report.csv", "--email", "a@b.c", "--dry-run", "--limit=5"]);
210 assert_eq!(args.value("--email"), Some("a@b.c"));
211 assert_eq!(args.value("--limit"), Some("5"));
212 assert_eq!(args.value("--dry-run"), None);
213 assert!(args.has("--dry-run") && args.has("--limit") && !args.has("--force"));
214 assert_eq!(args.positional(), ["report.csv"]);
215 }
216}