mod commands;
mod config;
mod execution;
mod rendering;
use crate::context::ContextRegistry;
use crate::setup::SetupError;
use crate::topics::{
display_with_pager, render_topic, render_topics_list, TopicRegistry, TopicRenderConfig,
};
use crate::TemplateRegistry;
use crate::{render_auto, OutputMode, Theme};
use clap::{Arg, ArgAction, ArgMatches, Command};
use serde::Serialize;
use std::cell::RefCell;
use std::collections::HashMap;
use std::path::PathBuf;
use std::rc::Rc;
use super::dispatch::DispatchFn;
use super::group::CommandRecipe;
use super::handler::{CommandContext, Extensions, HandlerResult, Output as HandlerOutput};
use super::help::{render_help, render_help_with_topics, CommandGroup, HelpConfig};
use super::hooks::{HookError, Hooks, RenderedOutput, TextOutput};
use super::result::HelpResult;
use standout_dispatch::verify::ExpectedArg;
struct PendingCommand {
recipe: Box<dyn CommandRecipe>,
template: String,
}
pub struct AppBuilder {
pub(crate) registry: TopicRegistry,
pub(crate) output_flag: Option<String>,
pub(crate) output_file_flag: Option<String>,
pub(crate) theme: Option<Theme>,
pub(crate) stylesheet_registry: Option<crate::StylesheetRegistry>,
pub(crate) template_registry: Option<Rc<TemplateRegistry>>,
pub(crate) default_theme_name: Option<String>,
pending_commands: RefCell<HashMap<String, PendingCommand>>,
finalized_commands: RefCell<Option<HashMap<String, DispatchFn>>>,
pub(crate) command_hooks: HashMap<String, Hooks>,
pub(crate) context_registry: ContextRegistry,
pub(crate) template_dir: Option<PathBuf>,
pub(crate) template_ext: String,
pub(crate) default_command: Option<String>,
pub(crate) include_framework_templates: bool,
pub(crate) include_framework_styles: bool,
pub(crate) app_state: Rc<Extensions>,
pub(crate) template_engine: Rc<Box<dyn standout_render::template::TemplateEngine>>,
pub(crate) help_command_groups: Option<Vec<CommandGroup>>,
}
impl Default for AppBuilder {
fn default() -> Self {
Self::new()
}
}
impl AppBuilder {
pub fn new() -> Self {
Self {
registry: TopicRegistry::new(),
output_flag: Some("output".to_string()), output_file_flag: Some("output-file-path".to_string()),
theme: None,
stylesheet_registry: None,
template_registry: None,
default_theme_name: None,
pending_commands: RefCell::new(HashMap::new()),
finalized_commands: RefCell::new(None),
command_hooks: HashMap::new(),
context_registry: ContextRegistry::new(),
template_dir: None,
template_ext: ".j2".to_string(),
default_command: None,
include_framework_templates: true,
include_framework_styles: true,
app_state: Rc::new(Extensions::new()),
template_engine: Rc::new(Box::new(standout_render::template::MiniJinjaEngine::new())),
help_command_groups: None,
}
}
pub fn builder() -> Self {
Self::new()
}
pub fn app_state<T: 'static>(mut self, value: T) -> Self {
Rc::get_mut(&mut self.app_state)
.expect("app_state Rc should be exclusively owned during builder phase")
.insert(value);
self
}
pub fn template_engine(
mut self,
engine: Box<dyn standout_render::template::TemplateEngine>,
) -> Self {
self.template_engine = Rc::new(engine);
self
}
fn ensure_commands_finalized(&self) {
if self.finalized_commands.borrow().is_some() {
return;
}
let context_registry = &self.context_registry;
let mut commands = HashMap::new();
for (path, pending) in self.pending_commands.borrow().iter() {
let dispatch = pending.recipe.create_dispatch(
&pending.template,
context_registry,
self.template_engine.clone(),
);
commands.insert(path.clone(), dispatch);
}
*self.finalized_commands.borrow_mut() = Some(commands);
}
fn get_commands(&self) -> std::cell::Ref<'_, HashMap<String, DispatchFn>> {
self.ensure_commands_finalized();
std::cell::Ref::map(self.finalized_commands.borrow(), |opt| {
opt.as_ref()
.expect("finalized_commands should be Some after ensure_commands_finalized")
})
}
#[cfg(test)]
pub(crate) fn has_command(&self, path: &str) -> bool {
self.pending_commands.borrow().contains_key(path)
}
pub fn build(mut self) -> Result<Self, SetupError> {
use crate::assets::FRAMEWORK_TEMPLATES;
if self.include_framework_templates {
match self.template_registry.as_mut() {
Some(arc) => {
if let Some(registry) = Rc::get_mut(arc) {
registry.add_framework_entries(FRAMEWORK_TEMPLATES);
} else {
panic!("template registry was shared before build completed");
}
}
None => {
let mut registry = TemplateRegistry::new();
registry.add_framework_entries(FRAMEWORK_TEMPLATES);
self.template_registry = Some(Rc::new(registry));
}
};
}
if let Some(registry) = &self.template_registry {
if let Some(engine_box) = Rc::get_mut(&mut self.template_engine) {
for name in registry.names() {
if let Ok(content) = registry.get_content(name) {
let _ = engine_box.add_template(name, &content);
}
}
}
}
if self.theme.is_none() {
if let Some(ref mut registry) = self.stylesheet_registry {
let resolved = if let Some(name) = &self.default_theme_name {
Some(
registry
.get(name)
.map_err(|_| SetupError::ThemeNotFound(name.to_string()))?,
)
} else {
registry
.get("default")
.or_else(|_| registry.get("theme"))
.or_else(|_| registry.get("base"))
.ok()
};
self.theme = resolved;
}
}
self.ensure_commands_finalized();
Ok(self)
}
pub fn parse(self, cmd: clap::Command) -> clap::ArgMatches {
self.build().expect("Failed to build App").parse_with(cmd)
}
pub fn registry(&self) -> &TopicRegistry {
&self.registry
}
pub fn registry_mut(&mut self) -> &mut TopicRegistry {
&mut self.registry
}
pub fn output_mode(&self) -> OutputMode {
OutputMode::Auto
}
pub fn get_hooks(&self, path: &str) -> Option<&Hooks> {
self.command_hooks.get(path)
}
pub fn get_default_theme(&self) -> Option<&Theme> {
self.theme.as_ref()
}
pub fn get_theme(&mut self, name: &str) -> Result<Theme, SetupError> {
self.stylesheet_registry
.as_mut()
.ok_or_else(|| SetupError::Config("No stylesheet registry configured".into()))?
.get(name)
.map_err(|_| SetupError::ThemeNotFound(name.to_string()))
}
pub fn template_names(&self) -> impl Iterator<Item = &str> {
self.template_registry
.as_ref()
.map(|r| r.names())
.into_iter()
.flatten()
}
pub fn theme_names(&self) -> Vec<String> {
self.stylesheet_registry
.as_ref()
.map(|r| r.names().map(String::from).collect())
.unwrap_or_default()
}
pub fn parse_with(&self, cmd: Command) -> clap::ArgMatches {
self.parse_from(cmd, std::env::args())
}
pub fn parse_from<I, T>(&self, cmd: Command, itr: I) -> clap::ArgMatches
where
I: IntoIterator<Item = T>,
T: Into<std::ffi::OsString> + Clone,
{
match self.get_matches_from(cmd, itr) {
HelpResult::Matches(m) => m,
HelpResult::Help(h) => {
println!("{}", h);
std::process::exit(0);
}
HelpResult::PagedHelp(h) => {
if display_with_pager(&h).is_err() {
println!("{}", h);
}
std::process::exit(0);
}
HelpResult::Error(e) => e.exit(),
}
}
pub fn get_matches(&self, cmd: Command) -> HelpResult {
self.get_matches_from(cmd, std::env::args())
}
pub fn get_matches_from<I, T>(&self, cmd: Command, itr: I) -> HelpResult
where
I: IntoIterator<Item = T>,
T: Into<std::ffi::OsString> + Clone,
{
let mut cmd = self.augment_command_with_help(cmd);
let matches = match cmd.clone().try_get_matches_from(itr) {
Ok(m) => m,
Err(e) => return HelpResult::Error(e),
};
let output_mode = self.extract_output_mode(&matches);
let config = HelpConfig {
output_mode: Some(output_mode),
theme: self.theme.clone(),
command_groups: self.help_command_groups.clone(),
..Default::default()
};
if let Some((name, sub_matches)) = matches.subcommand() {
if name == "help" {
let use_pager = sub_matches.get_flag("page");
if let Some(topic_args) = sub_matches.get_many::<String>("topic") {
let keywords: Vec<_> = topic_args.map(|s| s.as_str()).collect();
if !keywords.is_empty() {
return self.handle_help_request(
&mut cmd,
&keywords,
use_pager,
Some(config),
);
}
}
if let Ok(h) = render_help_with_topics(&cmd, &self.registry, Some(config)) {
return if use_pager {
HelpResult::PagedHelp(h)
} else {
HelpResult::Help(h)
};
}
}
}
HelpResult::Matches(matches)
}
fn handle_help_request(
&self,
cmd: &mut Command,
keywords: &[&str],
use_pager: bool,
config: Option<HelpConfig>,
) -> HelpResult {
let sub_name = keywords[0];
if sub_name == "topics" {
let topic_config = TopicRenderConfig {
output_mode: config.as_ref().and_then(|c| c.output_mode),
theme: config.as_ref().and_then(|c| c.theme.clone()),
..Default::default()
};
if let Ok(h) = render_topics_list(
&self.registry,
&format!("{} help", cmd.get_name()),
Some(topic_config),
) {
return if use_pager {
HelpResult::PagedHelp(h)
} else {
HelpResult::Help(h)
};
}
}
if super::app::find_subcommand(cmd, sub_name).is_some() {
if let Some(target) = super::app::find_subcommand_recursive(cmd, keywords) {
if let Ok(h) = render_help(target, config.clone()) {
return if use_pager {
HelpResult::PagedHelp(h)
} else {
HelpResult::Help(h)
};
}
}
}
if let Some(topic) = self.registry.get_topic(sub_name) {
let topic_config = TopicRenderConfig {
output_mode: config.as_ref().and_then(|c| c.output_mode),
theme: config.as_ref().and_then(|c| c.theme.clone()),
..Default::default()
};
if let Ok(h) = render_topic(topic, Some(topic_config)) {
return if use_pager {
HelpResult::PagedHelp(h)
} else {
HelpResult::Help(h)
};
}
}
let err = cmd.error(
clap::error::ErrorKind::InvalidSubcommand,
format!("The subcommand or topic '{}' wasn't recognized", sub_name),
);
HelpResult::Error(err)
}
pub fn augment_command_with_help(&self, cmd: Command) -> Command {
let cmd = cmd.disable_help_subcommand(true).subcommand(
Command::new("help")
.about("Print this message or the help of the given subcommand(s)")
.arg(
Arg::new("topic")
.action(ArgAction::Set)
.num_args(1..)
.help("The subcommand or topic to print help for"),
)
.arg(
Arg::new("page")
.long("page")
.action(ArgAction::SetTrue)
.help("Display help through a pager"),
),
);
self.augment_command_for_dispatch(cmd)
}
pub fn extract_output_mode(&self, matches: &ArgMatches) -> OutputMode {
if self.output_flag.is_some() {
match matches
.get_one::<String>("_output_mode")
.map(|s| s.as_str())
{
Some("term") => OutputMode::Term,
Some("text") => OutputMode::Text,
Some("term-debug") => OutputMode::TermDebug,
Some("json") => OutputMode::Json,
Some("yaml") => OutputMode::Yaml,
Some("xml") => OutputMode::Xml,
Some("csv") => OutputMode::Csv,
_ => OutputMode::Auto,
}
} else {
OutputMode::Auto
}
}
pub fn run_command<F, T>(
&self,
path: &str,
matches: &ArgMatches,
handler: F,
template: &str,
) -> Result<RenderedOutput, HookError>
where
F: FnOnce(&ArgMatches, &CommandContext) -> HandlerResult<T>,
T: Serialize,
{
let mut ctx = CommandContext::new(
path.split('.').map(String::from).collect(),
self.app_state.clone(),
);
let hooks = self.command_hooks.get(path);
if let Some(hooks) = hooks {
hooks.run_pre_dispatch(matches, &mut ctx)?;
}
let result = handler(matches, &ctx);
let output = match result {
Ok(HandlerOutput::Render(data)) => {
let mut json_data = serde_json::to_value(&data)
.map_err(|e| HookError::post_dispatch("Serialization error").with_source(e))?;
if let Some(hooks) = hooks {
json_data = hooks.run_post_dispatch(matches, &ctx, json_data)?;
}
let theme = self.theme.clone().unwrap_or_default();
match render_auto(template, &json_data, &theme, OutputMode::Auto) {
Ok(rendered) => RenderedOutput::Text(TextOutput::plain(rendered)),
Err(e) => return Err(HookError::post_output("Render error").with_source(e)),
}
}
Err(e) => {
return Err(HookError::post_output("Handler error").with_source(e));
}
Ok(HandlerOutput::Silent) => RenderedOutput::Silent,
Ok(HandlerOutput::Binary { data, filename }) => RenderedOutput::Binary(data, filename),
};
if let Some(hooks) = hooks {
hooks.run_post_output(matches, &ctx, output)
} else {
Ok(output)
}
}
pub fn verify_command(&self, cmd: &Command) -> Result<(), SetupError> {
let expected_args: HashMap<String, Vec<ExpectedArg>> = self
.pending_commands
.borrow()
.iter()
.map(|(path, cmd)| (path.clone(), cmd.recipe.expected_args()))
.collect();
super::app::verify_recursive(cmd, &expected_args, &[], true)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_builder_output_flag_enabled_by_default() {
let standout = AppBuilder::new().build().unwrap();
assert!(standout.output_flag.is_some());
assert_eq!(standout.output_flag.as_deref(), Some("output"));
}
#[test]
fn test_no_output_flag() {
let standout = AppBuilder::new().no_output_flag().build().unwrap();
assert!(standout.output_flag.is_none());
}
#[test]
fn test_custom_output_flag_name() {
let standout = AppBuilder::new()
.output_flag(Some("format"))
.build()
.unwrap();
assert_eq!(standout.output_flag.as_deref(), Some("format"));
}
#[test]
fn test_theme_fallback_precedence() {
use std::fs;
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
fs::write(temp_dir.path().join("base.yaml"), "style: { fg: blue }").unwrap();
let app = AppBuilder::new()
.styles_dir(temp_dir.path())
.unwrap()
.build()
.unwrap();
assert!(app.theme.is_some());
let theme = app.theme.as_ref().unwrap();
assert_eq!(theme.name(), Some("base"));
fs::write(temp_dir.path().join("theme.yaml"), "style: { fg: red }").unwrap();
let app = AppBuilder::new()
.styles_dir(temp_dir.path())
.unwrap()
.build()
.unwrap();
assert_eq!(app.theme.as_ref().unwrap().name(), Some("theme"));
fs::write(temp_dir.path().join("default.yaml"), "style: { fg: green }").unwrap();
let app = AppBuilder::new()
.styles_dir(temp_dir.path())
.unwrap()
.build()
.unwrap();
assert_eq!(app.theme.as_ref().unwrap().name(), Some("default"));
}
#[test]
fn test_app_state_single_type() {
struct Database {
url: String,
}
let app = AppBuilder::new()
.app_state(Database {
url: "postgres://localhost".into(),
})
.build()
.unwrap();
let db = app.app_state.get::<Database>().unwrap();
assert_eq!(db.url, "postgres://localhost");
}
#[test]
fn test_app_state_multiple_types() {
struct Database {
url: String,
}
struct Config {
debug: bool,
}
let app = AppBuilder::new()
.app_state(Database {
url: "postgres://localhost".into(),
})
.app_state(Config { debug: true })
.build()
.unwrap();
let db = app.app_state.get::<Database>().unwrap();
assert_eq!(db.url, "postgres://localhost");
let config = app.app_state.get::<Config>().unwrap();
assert!(config.debug);
}
#[test]
fn test_app_state_replacement() {
struct Config {
value: i32,
}
let app = AppBuilder::new()
.app_state(Config { value: 1 })
.app_state(Config { value: 2 }) .build()
.unwrap();
let config = app.app_state.get::<Config>().unwrap();
assert_eq!(config.value, 2);
}
#[test]
fn test_app_state_empty_by_default() {
struct NotSet;
let app = AppBuilder::new().build().unwrap();
assert!(app.app_state.is_empty());
assert!(app.app_state.get::<NotSet>().is_none());
}
}