use std::collections::BTreeMap;
use std::io::Write;
use futures_util::future::BoxFuture;
use crate::app::PreparedServer;
use crate::error::Error;
use crate::state::AppState;
pub const BUILT_IN_COMMANDS: &[&str] = &["serve", "migrate", "migrations", "config", "openapi", "help"];
pub trait AppCommand: Send + Sync + 'static {
fn name(&self) -> &'static str;
fn about(&self) -> &'static str;
fn usage(&self) -> &'static str {
""
}
fn run<'a>(&'a self, ctx: CommandCtx<'a>, args: &'a [String]) -> BoxFuture<'a, Result<(), Error>>;
}
pub struct CommandCtx<'a> {
server: &'a PreparedServer,
out: &'a mut (dyn Write + Send),
}
impl std::fmt::Debug for CommandCtx<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CommandCtx").field("server", self.server).finish_non_exhaustive()
}
}
impl<'a> CommandCtx<'a> {
pub(crate) fn new(server: &'a PreparedServer, out: &'a mut (dyn Write + Send)) -> Self {
Self { server, out }
}
pub fn state(&self) -> &AppState {
self.server.state()
}
pub fn server(&self) -> &PreparedServer {
self.server
}
pub fn println(&mut self, line: impl std::fmt::Display) -> Result<(), Error> {
writeln!(self.out, "{line}").map_err(|e| Error::io("writing output", e))
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct CommandArgs {
positionals: Vec<String>,
flags: Vec<String>,
options: BTreeMap<String, String>,
}
impl CommandArgs {
pub fn parse(args: &[String], options: &[&str], flags: &[&str]) -> Result<CommandArgs, Error> {
let mut parsed = CommandArgs::default();
let mut iter = args.iter();
let mut only_positionals = false;
while let Some(arg) = iter.next() {
if only_positionals || !arg.starts_with("--") {
parsed.positionals.push(arg.clone());
continue;
}
if arg == "--" {
only_positionals = true;
continue;
}
let (name, inline) = match arg.split_once('=') {
Some((name, value)) => (name, Some(value.to_string())),
None => (arg.as_str(), None),
};
if options.contains(&name) {
let value = match inline {
Some(value) => value,
None => iter.next().cloned().ok_or_else(|| Error::Cli(format!("{name} needs a value")))?,
};
if parsed.options.insert(name.to_string(), value).is_some() {
return Err(Error::Cli(format!("{name} is given twice")));
}
} else if flags.contains(&name) && inline.is_none() {
parsed.flags.push(name.to_string());
} else {
return Err(Error::Cli(format!("unknown option {name}")));
}
}
Ok(parsed)
}
pub fn positionals(&self) -> &[String] {
&self.positionals
}
pub fn required(&self, index: usize, name: &str) -> Result<&str, Error> {
self.positionals.get(index).map(String::as_str).ok_or_else(|| Error::Cli(format!("missing argument <{name}>")))
}
pub fn flag(&self, name: &str) -> bool {
self.flags.iter().any(|f| f == name)
}
pub fn option(&self, name: &str) -> Option<&str> {
self.options.get(name).map(String::as_str)
}
}
pub fn validate_command_name(name: &str) -> Result<(), String> {
let ok = name.split(':').all(|part| {
part.bytes().next().is_some_and(|b| b.is_ascii_lowercase())
&& part.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_' || b == b'-')
});
if !ok || name.len() > 64 {
return Err(format!("command name `{name}` must be [a-z][a-z0-9_-]* parts joined by `:` (at most 64 bytes)"));
}
if BUILT_IN_COMMANDS.contains(&name) {
return Err(format!("command name `{name}` is a built-in command"));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn args(list: &[&str]) -> Vec<String> {
list.iter().map(|s| s.to_string()).collect()
}
#[test]
fn parsing() {
let parsed =
CommandArgs::parse(&args(&["ada@example.com", "--admin", "--name", "Ada", "--role=mod", "--", "--x"]), &["--name", "--role"], &["--admin"]);
let parsed = parsed.unwrap_or_default();
assert_eq!(parsed.positionals(), ["ada@example.com", "--x"]);
assert!(parsed.flag("--admin") && !parsed.flag("--verified"));
assert_eq!(parsed.option("--name"), Some("Ada"));
assert_eq!(parsed.option("--role"), Some("mod"));
assert_eq!(parsed.required(0, "email").ok(), Some("ada@example.com"));
assert!(parsed.required(2, "x").is_err());
assert!(CommandArgs::parse(&args(&["--nope"]), &[], &[]).is_err());
assert!(CommandArgs::parse(&args(&["--name"]), &["--name"], &[]).is_err());
assert!(CommandArgs::parse(&args(&["--admin=1"]), &[], &["--admin"]).is_err());
assert!(CommandArgs::parse(&args(&["--n", "a", "--n", "b"]), &["--n"], &[]).is_err());
}
#[test]
fn names() {
for ok in ["user:create", "sessions:revoke", "game", "a:b-c:d_1"] {
assert!(validate_command_name(ok).is_ok(), "{ok}");
}
for bad in ["", "User", ":x", "x:", "x::y", "serve", "migrate", "1x", "x y"] {
assert!(validate_command_name(bad).is_err(), "{bad}");
}
}
}