Skip to main content

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}