use std::fmt::Write as _;
use std::sync::Arc;
use bevy_app::prelude::*;
use bevy_ecs::prelude::*;
use rig_harness_protocol::{RESERVED_COMMANDS, SessionDir, StartReason};
use rig_memory::{SessionError, Store};
use super::launcher::Invoked;
use super::session::{AppStart, StoreOpener};
use crate::describe::Describing;
use crate::secrets::Secret;
#[derive(Component, Clone, Debug, PartialEq, Eq)]
pub struct Flag {
pub name: &'static str,
pub value: Option<&'static str>,
pub help: &'static str,
}
impl Flag {
pub fn new(name: &'static str, help: &'static str) -> Self {
Self {
name,
value: None,
help,
}
}
pub fn value(mut self, value: &'static str) -> Self {
self.value = Some(value);
self
}
fn usage(&self) -> String {
let option = match self.value {
Some(value) => format!("--{} {value}", self.name),
None => format!("--{}", self.name),
};
format!(" {option:<23}{}", self.help)
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Command {
pub name: &'static str,
pub usage: &'static str,
pub help: &'static str,
}
#[derive(Component, Clone, Debug)]
pub struct Front {
pub name: &'static str,
pub terminal: bool,
pub headless: bool,
pub flag: Option<Flag>,
pub command: Option<Command>,
pub sessionless: bool,
pub session_store: Option<StoreOpener>,
}
impl Front {
pub fn new(name: &'static str) -> Self {
Self {
name,
terminal: false,
headless: false,
flag: None,
command: None,
sessionless: false,
session_store: None,
}
}
pub fn on_terminal(mut self) -> Self {
self.terminal = true;
self
}
pub fn headless(mut self) -> Self {
self.headless = true;
self
}
pub fn sessionless(mut self) -> Self {
self.sessionless = true;
self
}
pub fn session_store(
mut self,
open: impl Fn(&SessionDir) -> Result<Arc<dyn Store>, SessionError> + Send + Sync + 'static,
) -> Self {
self.session_store = Some(StoreOpener::new(open));
self
}
pub fn on_flag(mut self, flag: &'static str, help: &'static str) -> Self {
self.flag = Some(Flag::new(flag, help));
self
}
pub fn on_command(
mut self,
name: &'static str,
usage: &'static str,
help: &'static str,
) -> Self {
self.command = Some(Command { name, usage, help });
self
}
}
#[derive(Resource, Default, Debug)]
pub struct Cli {
declared: Vec<Entity>,
chosen: Option<Result<Option<Front>, String>>,
}
impl Cli {
pub fn front(&self) -> Option<&'static str> {
self.chosen().map(|front| front.name)
}
pub fn sessionless(&self) -> bool {
self.chosen().is_some_and(|front| front.sessionless)
}
pub(crate) fn chosen(&self) -> Option<&Front> {
self.chosen.as_ref()?.as_ref().ok()?.as_ref()
}
}
pub(crate) fn declared<T: Component + Clone>(world: &mut World) -> Vec<(Entity, T)> {
let order = world
.get_resource::<Cli>()
.map(|cli| cli.declared.clone())
.unwrap_or_default();
let mut query = world.query::<&T>();
order
.into_iter()
.filter_map(|entity| Some((entity, query.get(world, entity).ok()?.clone())))
.collect()
}
pub(crate) fn flags(world: &mut World) -> Vec<Flag> {
let fronts = declared::<Front>(world);
declared::<Flag>(world)
.into_iter()
.map(|(_, flag)| flag)
.chain(fronts.into_iter().filter_map(|(_, front)| front.flag))
.collect()
}
fn refusal_of(world: &mut World, invoked: &Invoked) -> Option<String> {
let flags = flags(world);
for (name, value) in &invoked.args.flags {
let Some(flag) = flags.iter().find(|flag| flag.name == name) else {
let known: String = flags.iter().fold(String::new(), |mut text, flag| {
let _ = writeln!(text, "{}", flag.usage());
text
});
return Some(if known.is_empty() {
format!("unknown option `--{name}`: no plugin of this build takes options")
} else {
format!("unknown option `--{name}`; this build's plugins take:\n{known}")
});
};
match (flag.value, value) {
(Some(value_name), None) => {
return Some(format!("`--{name}` needs a value: --{name} {value_name}"));
}
(None, Some(_)) => return Some(format!("`--{name}` takes no value")),
_ => {}
}
}
let resumed = world
.get_resource::<AppStart>()
.is_some_and(|start| start.0 == StartReason::Resumed);
match world
.get_resource::<Cli>()
.and_then(|cli| cli.chosen.as_ref())
{
Some(Err(why)) => Some(why.clone()),
Some(Ok(Some(front))) if front.sessionless && resumed => Some(format!(
"the front `{}` keeps no session, so it cannot resume one",
front.name
)),
_ => None,
}
}
pub(crate) fn choose(world: &mut World) -> Option<&'static str> {
if world.get_resource_or_init::<Cli>().chosen.is_none() {
let chosen = if world.contains_resource::<Describing>() {
Ok(None)
} else {
let invoked = world.get_resource::<Invoked>().cloned().unwrap_or_default();
let fronts: Vec<Front> = declared::<Front>(world)
.into_iter()
.map(|(_, front)| front)
.collect();
front_of(&fronts, &invoked)
};
world.resource_mut::<Cli>().chosen = Some(chosen);
}
world.resource::<Cli>().front()
}
fn front_of(fronts: &[Front], invoked: &Invoked) -> Result<Option<Front>, String> {
let command = invoked.command().map(|(name, _)| name);
let given: Vec<&Front> = fronts
.iter()
.filter(|front| {
let flagged = front
.flag
.as_ref()
.is_some_and(|flag| invoked.flag(flag.name));
let commanded = front
.command
.as_ref()
.is_some_and(|given| Some(given.name) == command);
flagged || commanded
})
.collect();
let interactive = invoked.interactive();
match given.as_slice() {
[front] => Ok(Some((*front).clone())),
[] => match command {
Some(command) => Err(format!(
"unknown command `{command}`: no plugin of this build adds it"
)),
None => Ok(fronts
.iter()
.filter(|front| front.command.is_none() && front.flag.is_none())
.find(|front| {
if interactive {
front.terminal
} else {
front.headless
}
})
.cloned()),
},
several => {
let names: Vec<String> = several
.iter()
.filter_map(|front| match (&front.command, &front.flag) {
(Some(given), _) if Some(given.name) == command => {
Some(format!("the command `{}`", given.name))
}
(_, Some(flag)) => Some(format!("--{}", flag.name)),
_ => None,
})
.collect();
Err(format!(
"{} each choose the front; give one",
names.join(" and ")
))
}
}
}
pub trait AppCliExt {
fn add_flag(&mut self, flag: Flag) -> &mut Self;
fn add_front(&mut self, front: Front) -> &mut Self;
fn add_secret(&mut self, secret: Secret) -> &mut Self;
fn runs_front(&mut self, name: &str) -> bool;
}
fn declare(app: &mut App, name: String, what: impl Bundle) {
let world = app.world_mut();
let entity = world.spawn((Name::new(name), what)).id();
world.get_resource_or_init::<Cli>().declared.push(entity);
}
impl AppCliExt for App {
fn add_flag(&mut self, flag: Flag) -> &mut Self {
declare(self, format!("flag:{}", flag.name), flag);
self
}
fn add_front(&mut self, front: Front) -> &mut Self {
declare(self, format!("front:{}", front.name), front);
self
}
fn add_secret(&mut self, secret: Secret) -> &mut Self {
declare(self, format!("secret:{}", secret.name), secret);
self
}
fn runs_front(&mut self, name: &str) -> bool {
choose(self.world_mut()) == Some(name)
}
}
impl Invoked {
pub fn flag(&self, name: &str) -> bool {
self.args.flags.iter().any(|(flag, _)| flag == name)
}
pub fn flag_value(&self, name: &str) -> Option<&str> {
self.args
.flags
.iter()
.find(|(flag, _)| flag == name)
.and_then(|(_, value)| value.as_deref())
}
pub fn command(&self) -> Option<(&str, &[String])> {
let command = self.args.command.as_deref()?;
Some((command, &self.args.arguments))
}
}
pub(crate) fn command_refusal(command: &Command) -> Option<String> {
if RESERVED_COMMANDS.contains(&command.name) {
return Some(format!(
"the command `{}` is the launcher's own",
command.name
));
}
(!rig_harness_protocol::is_name(command.name)).then(|| {
format!(
"the command `{}` is not lowercase letters, digits and dashes",
command.name
)
})
}
pub(crate) fn refusal(app: &mut App) -> Option<String> {
let world = app.world_mut();
if world.contains_resource::<Describing>() {
return None;
}
let invoked = world.get_resource::<Invoked>().cloned().unwrap_or_default();
let front = choose(world);
if let Some(why) = refusal_of(world, &invoked) {
return Some(why);
}
(invoked.interactive() && front.is_none()).then(|| {
"this build has no front for an interactive terminal: enable rig-tui \
(`rig plugin add rig_tui::TuiPlugin --crate rig-tui`), or run with -p or with stdin \
piped in"
.to_owned()
})
}
#[cfg(test)]
mod tests;