#![allow(clippy::result_large_err)]
use std::marker::PhantomData;
use std::string::String;
use std::vec::Vec;
use crate::builder::Config;
use crate::color::should_use_color;
use crate::completions::{Shell, generate_completions_for_shape};
use crate::config_value::ConfigValue;
use crate::config_value_parser::{fill_defaults_from_schema, from_config_value};
use crate::dump::dump_config_with_schema;
use crate::enum_conflicts::detect_enum_conflicts;
use crate::env_subst::{EnvSubstError, RealEnv, substitute_env_vars};
use crate::help::generate_help_for_subcommand;
use crate::help::generate_help_list_for_subcommand;
use crate::help::implementation_source_for_subcommand_path;
use crate::layers::{cli::parse_cli, env::parse_env, file::parse_file};
use crate::merge::merge_layers;
use crate::missing::{
build_corrected_command_diagnostics, collect_missing_fields, format_missing_fields_summary,
};
use crate::path::Path;
use crate::provenance::{FileResolution, Override, Provenance};
use crate::span::Span;
use crate::span_registry::assign_virtual_spans;
use facet_core::Facet;
#[derive(Debug, Default)]
pub struct LayerOutput {
pub value: Option<ConfigValue>,
pub unused_keys: Vec<UnusedKey>,
pub diagnostics: Vec<Diagnostic>,
pub source_text: Option<String>,
pub config_file_path: Option<camino::Utf8PathBuf>,
pub help_list_mode: Option<HelpListMode>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HelpListMode {
Full,
Short,
}
#[derive(Debug)]
pub struct UnusedKey {
pub key: Path,
pub provenance: Provenance,
}
#[derive(Debug, Default)]
pub struct ConfigLayers {
pub defaults: LayerOutput,
pub file: LayerOutput,
pub env: LayerOutput,
pub cli: LayerOutput,
}
pub struct Driver<T> {
config: Config<T>,
core: DriverCore,
_phantom: PhantomData<T>,
}
#[derive(Debug, Default)]
pub struct DriverCore;
impl DriverCore {
fn new() -> Self {
Self
}
}
impl<T: Facet<'static>> Driver<T> {
pub fn new(config: Config<T>) -> Self {
Self {
config,
core: DriverCore::new(),
_phantom: PhantomData,
}
}
pub fn run(mut self) -> DriverOutcome<T> {
let _ = self.core;
let mut layers = ConfigLayers::default();
let mut all_diagnostics = Vec::new();
let mut file_resolution = None;
let cli_args_source = self
.config
.cli_config
.as_ref()
.map(|c| {
let args = c.resolve_args().join(" ");
if args.is_empty() { None } else { Some(args) }
})
.unwrap_or(None);
let cli_args_display = cli_args_source.as_deref().unwrap_or("<no arguments>");
if let Some(ref cli_config) = self.config.cli_config {
layers.cli = parse_cli(&self.config.schema, cli_config);
tracing::debug!(cli_value = ?layers.cli.value, "driver: parsed CLI layer");
all_diagnostics.extend(layers.cli.diagnostics.iter().cloned());
}
if let Some(ref cli_path) = layers.cli.config_file_path {
let file_config = self.config.file_config.get_or_insert_with(Default::default);
file_config.explicit_path = Some(cli_path.clone());
}
if let Some(ref file_config) = self.config.file_config {
let result = parse_file(&self.config.schema, file_config);
layers.file = result.output;
file_resolution = Some(result.resolution);
all_diagnostics.extend(layers.file.diagnostics.iter().cloned());
}
if let Some(ref env_config) = self.config.env_config {
layers.env = parse_env(&self.config.schema, env_config, env_config.source());
all_diagnostics.extend(layers.env.diagnostics.iter().cloned());
}
if let Some(cli_value) = &layers.cli.value {
let special = self.config.schema.special();
if let Some(ref help_path) = special.help
&& let Some(ConfigValue::Bool(b)) = cli_value.get_by_path(help_path)
&& b.value
{
let help_config = self
.config
.help_config
.as_ref()
.cloned()
.unwrap_or_default();
let subcommand_path = if let Some(subcommand_field) =
self.config.schema.args().subcommand_field_name()
{
cli_value.extract_subcommand_path(subcommand_field)
} else {
Vec::new()
};
let mut text = if let Some(mode) = layers.cli.help_list_mode {
generate_help_list_for_subcommand(
&self.config.schema,
&subcommand_path,
&help_config,
mode,
)
} else {
generate_help_for_subcommand(
&self.config.schema,
&subcommand_path,
&help_config,
)
};
maybe_append_implementation_source::<T>(&mut text, &help_config, &subcommand_path);
return DriverOutcome::err(DriverError::Help { text });
}
if let Some(ref version_path) = special.version
&& let Some(ConfigValue::Bool(b)) = cli_value.get_by_path(version_path)
&& b.value
{
let version = self
.config
.help_config
.as_ref()
.and_then(|h| h.version.clone())
.unwrap_or_else(|| "unknown".to_string());
let program_name = self
.config
.help_config
.as_ref()
.and_then(|h| h.program_name.clone())
.or_else(|| std::env::args().next())
.unwrap_or_else(|| "program".to_string());
let text = format!("{} {}", program_name, version);
return DriverOutcome::err(DriverError::Version { text });
}
if let Some(ref completions_path) = special.completions
&& let Some(value) = cli_value.get_by_path(completions_path)
{
if let Some(shell) = extract_shell_from_value(value) {
let program_name = self
.config
.help_config
.as_ref()
.and_then(|h| h.program_name.clone())
.or_else(|| std::env::args().next())
.unwrap_or_else(|| "program".to_string());
let script = generate_completions_for_shape(T::SHAPE, shell, &program_name);
return DriverOutcome::err(DriverError::Completions { script });
}
}
}
let has_errors = all_diagnostics
.iter()
.any(|d| d.severity == Severity::Error);
if has_errors {
return DriverOutcome::err(DriverError::Failed {
report: Box::new(DriverReport {
diagnostics: all_diagnostics,
layers,
file_resolution,
overrides: Vec::new(),
cli_args_source: cli_args_display.to_string(),
source_name: "<cli>".to_string(),
}),
});
}
let values_to_merge: Vec<ConfigValue> = [
layers.defaults.value.clone(),
layers.file.value.clone(),
layers.env.value.clone(),
layers.cli.value.clone(),
]
.into_iter()
.flatten()
.collect();
let merged = merge_layers(values_to_merge);
tracing::debug!(merged_value = ?merged.value, "driver: merged layers");
let overrides = merged.overrides;
let mut merged_value = merged.value;
if let Some(config_schema) = self.config.schema.config()
&& let ConfigValue::Object(ref mut sourced_fields) = merged_value
&& let Some(config_field_name) = config_schema.field_name()
&& let Some(config_value) = sourced_fields.value.get_mut(&config_field_name.to_string())
&& let Err(e) = substitute_env_vars(config_value, config_schema, &RealEnv)
{
return DriverOutcome::err(DriverError::EnvSubst { error: e });
}
tracing::debug!(merged_value = ?merged_value, "driver: after env_subst");
let enum_conflicts = detect_enum_conflicts(&merged_value, &self.config.schema);
if !enum_conflicts.is_empty() {
let messages: Vec<String> = enum_conflicts.iter().map(|c| c.format()).collect();
let message = messages.join("\n\n");
return DriverOutcome::err(DriverError::Failed {
report: Box::new(DriverReport {
diagnostics: vec![Diagnostic {
message,
label: None,
path: None,
span: None,
severity: Severity::Error,
}],
layers,
file_resolution,
overrides,
cli_args_source: cli_args_display.to_string(),
source_name: "<cli>".to_string(),
}),
});
}
let value_with_defaults = fill_defaults_from_schema(&merged_value, &self.config.schema);
tracing::debug!(value_with_defaults = ?value_with_defaults, "driver: after fill_defaults_from_schema");
let mut missing_fields = Vec::new();
collect_missing_fields(
&value_with_defaults,
&self.config.schema,
&mut missing_fields,
);
let cli_strict = self
.config
.cli_config
.as_ref()
.map(|c| c.strict())
.unwrap_or(false);
let env_strict = self
.config
.env_config
.as_ref()
.map(|c| c.strict)
.unwrap_or(false);
let file_strict = self
.config
.file_config
.as_ref()
.map(|c| c.strict)
.unwrap_or(false);
let mut unknown_keys: Vec<&UnusedKey> = Vec::new();
if cli_strict {
unknown_keys.extend(layers.cli.unused_keys.iter());
}
if env_strict {
unknown_keys.extend(layers.env.unused_keys.iter());
}
if file_strict {
unknown_keys.extend(layers.file.unused_keys.iter());
}
let has_missing = !missing_fields.is_empty();
let has_unknown = !unknown_keys.is_empty();
if has_missing || has_unknown {
let subcommand_field_name = self.config.schema.args().subcommand_field_name();
let only_missing_subcommand = !has_unknown
&& subcommand_field_name.is_some()
&& missing_fields.len() == 1
&& missing_fields[0].field_name == subcommand_field_name.unwrap();
if only_missing_subcommand {
let help_config = self
.config
.help_config
.as_ref()
.cloned()
.unwrap_or_default();
let mut help = generate_help_for_subcommand(&self.config.schema, &[], &help_config);
maybe_append_implementation_source::<T>(&mut help, &help_config, &[]);
return DriverOutcome::err(DriverError::Help { text: help });
}
let missing_subcommand_with_variants = !has_unknown
&& missing_fields.len() == 1
&& !missing_fields[0].available_subcommands.is_empty();
if missing_subcommand_with_variants {
let field = &missing_fields[0];
let items: Vec<(String, Option<&str>)> = field
.available_subcommands
.iter()
.map(|sub| (sub.name.clone(), sub.doc.as_deref()))
.collect();
let max_width = items.iter().map(|(name, _)| name.len()).max().unwrap_or(0);
let mut cmds = String::new();
for (name, doc) in &items {
use std::fmt::Write;
write!(cmds, " {name}").unwrap();
let padding = max_width.saturating_sub(name.len());
for _ in 0..padding {
cmds.push(' ');
}
if let Some(doc) = doc {
write!(cmds, " {}", doc.trim()).unwrap();
}
cmds.push('\n');
}
let mut diagnostics = vec![Diagnostic {
message: format!(
"expected a subcommand\n\navailable subcommands:\n{}",
cmds.trim_end()
),
label: None,
path: None,
span: None,
severity: Severity::Error,
}];
if self.config.schema.special().help.is_some() {
diagnostics.push(Diagnostic {
message: "Run with --help for usage information.".to_string(),
label: None,
path: None,
span: None,
severity: Severity::Note,
});
}
return DriverOutcome::err(DriverError::Failed {
report: Box::new(DriverReport {
diagnostics,
layers,
file_resolution,
overrides,
cli_args_source: cli_args_display.to_string(),
source_name: "<cli>".to_string(),
}),
});
}
let all_cli_missing = has_missing
&& !has_unknown
&& missing_fields
.iter()
.all(|f| matches!(f.kind, crate::missing::MissingFieldKind::CliArg));
if all_cli_missing {
let mut corrected = build_corrected_command_diagnostics(
&missing_fields,
cli_args_source.as_deref(),
);
if self.config.schema.special().help.is_some() {
corrected.diagnostics.push(Diagnostic {
message: "Run with --help for usage information.".to_string(),
label: None,
path: None,
span: None,
severity: Severity::Note,
});
}
return DriverOutcome::err(DriverError::Failed {
report: Box::new(DriverReport {
diagnostics: corrected.diagnostics,
layers,
file_resolution,
overrides,
cli_args_source: corrected.corrected_source,
source_name: "<suggestion>".to_string(),
}),
});
}
let message = {
let mut dump_buf = Vec::new();
let resolution = file_resolution.as_ref().cloned().unwrap_or_default();
dump_config_with_schema(
&mut dump_buf,
&value_with_defaults,
&resolution,
&self.config.schema,
);
let dump =
String::from_utf8(dump_buf).unwrap_or_else(|_| "error rendering dump".into());
let mut message_parts = Vec::new();
if has_unknown {
message_parts.push("Unknown configuration keys:".to_string());
}
if has_missing {
message_parts.push("Missing required fields:".to_string());
}
let header = message_parts.join(" / ");
let missing_summary = if has_missing {
format!(
"\nMissing:\n{}",
format_missing_fields_summary(&missing_fields)
)
} else {
String::new()
};
let unknown_summary = if has_unknown {
let config_schema = self.config.schema.config();
let unknown_list: Vec<String> = unknown_keys
.iter()
.map(|uk| {
let source = uk.provenance.source_description();
let suggestion = config_schema
.map(|cs| crate::suggest::suggest_config_path(cs, &uk.key))
.unwrap_or_default();
format!(" {} (from {}){}", uk.key.join("."), source, suggestion)
})
.collect();
format!("\nUnknown keys:\n{}", unknown_list.join("\n"))
} else {
String::new()
};
format!(
"{}\n\n{}{}{}\nRun with --help for usage information.",
header, dump, missing_summary, unknown_summary
)
};
return DriverOutcome::err(DriverError::Failed {
report: Box::new(DriverReport {
diagnostics: vec![Diagnostic {
message,
label: None,
path: None,
span: None,
severity: Severity::Error,
}],
layers,
file_resolution,
overrides,
cli_args_source: cli_args_display.to_string(),
source_name: "<cli>".to_string(),
}),
});
}
let mut value_with_virtual_spans = value_with_defaults;
let span_registry = assign_virtual_spans(&mut value_with_virtual_spans);
let value: T = match from_config_value(&value_with_virtual_spans) {
Ok(v) => v,
Err(e) => {
let (span, source_name, source_contents) = if let Some(virtual_span) = e.span() {
if let Some(entry) =
span_registry.lookup_by_offset(virtual_span.offset as usize)
{
let real_span = Span::new(
entry.real_span.offset as usize,
entry.real_span.len as usize,
);
let (name, contents) =
get_source_for_provenance(&entry.provenance, cli_args_display, &layers);
(Some(real_span), name, contents)
} else {
(None, "<unknown>".to_string(), cli_args_display.to_string())
}
} else {
(None, "<unknown>".to_string(), cli_args_display.to_string())
};
return DriverOutcome::err(DriverError::Failed {
report: Box::new(DriverReport {
diagnostics: vec![Diagnostic {
message: e.to_string(),
label: None,
path: None,
span,
severity: Severity::Error,
}],
layers,
file_resolution,
overrides,
cli_args_source: source_contents,
source_name,
}),
});
}
};
DriverOutcome::ok(DriverOutput {
value,
report: DriverReport {
diagnostics: all_diagnostics,
layers,
file_resolution,
overrides,
cli_args_source: cli_args_display.to_string(),
source_name: "<cli>".to_string(),
},
merged_config: value_with_virtual_spans,
schema: self.config.schema.clone(),
})
}
}
fn maybe_append_implementation_source<T: Facet<'static>>(
help_text: &mut String,
help_config: &crate::help::HelpConfig,
subcommand_path: &[String],
) {
let Some(source_file) = implementation_source_for_subcommand_path(T::SHAPE, subcommand_path)
else {
return;
};
let implementation_url = help_config
.implementation_url
.as_ref()
.map(|render_url| render_url(source_file));
if !help_config.include_implementation_source_file && implementation_url.is_none() {
return;
}
if !help_text.ends_with('\n') {
help_text.push('\n');
}
help_text.push('\n');
help_text.push_str("Implementation:\n");
if help_config.include_implementation_source_file {
help_text.push_str(" ");
help_text.push_str(source_file);
help_text.push('\n');
}
if let Some(implementation_url) = implementation_url {
help_text.push_str(" ");
help_text.push_str(&implementation_url);
help_text.push('\n');
}
}
fn get_source_for_provenance(
provenance: &Provenance,
cli_args_display: &str,
layers: &ConfigLayers,
) -> (String, String) {
match provenance {
Provenance::Cli { .. } => ("<cli>".to_string(), cli_args_display.to_string()),
Provenance::Env { .. } => {
let source_text = layers.env.source_text.as_ref().cloned().unwrap_or_default();
("<env>".to_string(), source_text)
}
Provenance::File { file, .. } => (file.path.to_string(), file.contents.clone()),
Provenance::Default => ("<default>".to_string(), String::new()),
}
}
#[must_use = "this `DriverOutcome` may contain a help/version request that should be handled"]
pub struct DriverOutcome<T>(Result<DriverOutput<T>, DriverError>);
impl<T: std::fmt::Debug> std::fmt::Debug for DriverOutcome<T> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match &self.0 {
Ok(output) => f
.debug_tuple("DriverOutcome::Ok")
.field(&output.value)
.finish(),
Err(e) => f.debug_tuple("DriverOutcome::Err").field(e).finish(),
}
}
}
impl<T> DriverOutcome<T> {
pub fn ok(output: DriverOutput<T>) -> Self {
Self(Ok(output))
}
pub fn err(error: DriverError) -> Self {
Self(Err(error))
}
pub fn into_result(self) -> Result<DriverOutput<T>, DriverError> {
self.0
}
pub fn is_ok(&self) -> bool {
self.0.is_ok()
}
pub fn is_err(&self) -> bool {
self.0.is_err()
}
pub fn unwrap(self) -> T {
match self.0 {
Ok(output) => output.get(),
Err(DriverError::Help { text }) => {
println!("{}", text);
std::process::exit(0);
}
Err(DriverError::Completions { script }) => {
println!("{}", script);
std::process::exit(0);
}
Err(DriverError::Version { text }) => {
println!("{}", text);
std::process::exit(0);
}
Err(DriverError::Failed { report }) => {
eprintln!("{}", report.render_pretty());
std::process::exit(1);
}
Err(DriverError::Builder { error }) => {
eprintln!("{}", error);
std::process::exit(1);
}
Err(DriverError::EnvSubst { error }) => {
eprintln!("error: {}", error);
std::process::exit(1);
}
}
}
pub fn unwrap_err(self) -> DriverError {
match self.0 {
Ok(_) => panic!("called `DriverOutcome::unwrap_err()` on a success"),
Err(e) => e,
}
}
}
pub struct DriverOutput<T> {
pub value: T,
pub report: DriverReport,
merged_config: ConfigValue,
schema: crate::schema::Schema,
}
impl<T: std::fmt::Debug> std::fmt::Debug for DriverOutput<T> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DriverOutput")
.field("value", &self.value)
.field("report", &self.report)
.finish_non_exhaustive()
}
}
impl<T> DriverOutput<T> {
pub fn get(self) -> T {
self.print_warnings();
self.value
}
pub fn get_silent(self) -> T {
self.value
}
pub fn into_parts(self) -> (T, DriverReport) {
(self.value, self.report)
}
pub fn print_warnings(&self) {
for diagnostic in &self.report.diagnostics {
if diagnostic.severity == Severity::Warning {
eprintln!("{}: {}", diagnostic.severity.as_str(), diagnostic.message);
}
}
}
pub fn extract<R: Facet<'static>>(&self) -> Result<R, crate::extract::ExtractError> {
crate::extract::extract_requirements::<R>(&self.merged_config, &self.schema)
}
}
#[derive(Default)]
pub struct DriverReport {
pub diagnostics: Vec<Diagnostic>,
pub layers: ConfigLayers,
pub file_resolution: Option<FileResolution>,
pub overrides: Vec<Override>,
pub cli_args_source: String,
pub source_name: String,
}
struct NamedSource {
name: String,
source: ariadne::Source<String>,
}
impl ariadne::Cache<()> for NamedSource {
type Storage = String;
fn fetch(&mut self, _: &()) -> Result<&ariadne::Source<Self::Storage>, impl std::fmt::Debug> {
Ok::<_, std::convert::Infallible>(&self.source)
}
fn display<'a>(&self, _: &'a ()) -> Option<impl std::fmt::Display + 'a> {
Some(self.name.clone())
}
}
impl DriverReport {
pub fn render_pretty(&self) -> String {
use ariadne::{Color, Config, Label, Report, ReportKind, Source};
if self.diagnostics.is_empty() {
return String::new();
}
let mut output = Vec::with_capacity(128);
let mut cache = NamedSource {
name: self.source_name.clone(),
source: Source::from(self.cli_args_source.clone()),
};
for diagnostic in &self.diagnostics {
if diagnostic.span.is_none() {
if diagnostic.message.starts_with("Error:")
|| diagnostic.message.starts_with("Warning:")
|| diagnostic.message.starts_with("Note:")
{
output.extend_from_slice(diagnostic.message.as_bytes());
output.push(b'\n');
continue;
}
let prefix = match diagnostic.severity {
Severity::Error => "Error: ",
Severity::Warning => "Warning: ",
Severity::Note => "Note: ",
};
output.extend_from_slice(prefix.as_bytes());
output.extend_from_slice(diagnostic.message.as_bytes());
output.push(b'\n');
continue;
}
let span = diagnostic
.span
.map(|s| s.start..(s.start + s.len))
.unwrap_or(0..0);
let report_kind = match diagnostic.severity {
Severity::Error => ReportKind::Error,
Severity::Warning => ReportKind::Warning,
Severity::Note => ReportKind::Advice,
};
let label_message = diagnostic.label.as_deref().unwrap_or(&diagnostic.message);
let mut label = Label::new(span.clone()).with_message(label_message);
if should_use_color() {
let color = match diagnostic.severity {
Severity::Error => Color::Red,
Severity::Warning => Color::Yellow,
Severity::Note => Color::Cyan,
};
label = label.with_color(color);
}
let report = Report::build(report_kind, span.clone())
.with_config(Config::default().with_color(should_use_color()))
.with_message(&diagnostic.message)
.with_label(label)
.finish();
report.write(&mut cache, &mut output).ok();
}
String::from_utf8(output).unwrap_or_else(|_| "error rendering diagnostics".to_string())
}
}
impl core::fmt::Display for DriverReport {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.write_str(&self.render_pretty())
}
}
impl core::fmt::Debug for DriverReport {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.write_str(&self.render_pretty())
}
}
#[derive(Debug, Clone)]
pub struct Diagnostic {
pub message: String,
pub label: Option<String>,
pub path: Option<Path>,
pub span: Option<Span>,
pub severity: Severity,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Severity {
Error,
Warning,
Note,
}
impl Severity {
fn as_str(self) -> &'static str {
match self {
Severity::Error => "error",
Severity::Warning => "warning",
Severity::Note => "note",
}
}
}
fn extract_shell_from_value(value: &ConfigValue) -> Option<Shell> {
match value {
ConfigValue::String(s) => match s.value.to_lowercase().as_str() {
"bash" => Some(Shell::Bash),
"zsh" => Some(Shell::Zsh),
"fish" => Some(Shell::Fish),
_ => None,
},
ConfigValue::Enum(e) => match e.value.variant.to_lowercase().as_str() {
"bash" => Some(Shell::Bash),
"zsh" => Some(Shell::Zsh),
"fish" => Some(Shell::Fish),
_ => None,
},
_ => None,
}
}
pub enum DriverError {
Builder {
error: crate::builder::BuilderError,
},
Failed {
report: Box<DriverReport>,
},
Help {
text: String,
},
Completions {
script: String,
},
Version {
text: String,
},
EnvSubst {
error: EnvSubstError,
},
}
impl DriverError {
pub fn exit_code(&self) -> i32 {
match self {
DriverError::Builder { .. } => 1,
DriverError::Failed { .. } => 1,
DriverError::Help { .. } => 0,
DriverError::Completions { .. } => 0,
DriverError::Version { .. } => 0,
DriverError::EnvSubst { .. } => 1,
}
}
pub fn is_success(&self) -> bool {
self.exit_code() == 0
}
pub fn is_help(&self) -> bool {
matches!(self, DriverError::Help { .. })
}
pub fn help_text(&self) -> Option<&str> {
match self {
DriverError::Help { text } => Some(text),
_ => None,
}
}
}
impl std::fmt::Display for DriverError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
DriverError::Builder { error } => write!(f, "{}", error),
DriverError::Failed { report } => write!(f, "{}", report),
DriverError::Help { text } => write!(f, "{}", text),
DriverError::Completions { script } => write!(f, "{}", script),
DriverError::Version { text } => write!(f, "{}", text),
DriverError::EnvSubst { error } => write!(f, "{}", error),
}
}
}
impl std::fmt::Debug for DriverError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
std::fmt::Display::fmt(self, f)
}
}
impl std::error::Error for DriverError {}
impl std::process::Termination for DriverError {
fn report(self) -> std::process::ExitCode {
match &self {
DriverError::Help { text } | DriverError::Version { text } => {
println!("{}", text);
}
DriverError::Completions { script } => {
println!("{}", script);
}
DriverError::Failed { report } => {
eprintln!("{}", report.render_pretty());
}
DriverError::Builder { error } => {
eprintln!("{}", error);
}
DriverError::EnvSubst { error } => {
eprintln!("error: {}", error);
}
}
std::process::ExitCode::from(self.exit_code() as u8)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate as figue;
use crate::FigueBuiltins;
use crate::builder::builder;
use facet::Facet;
use facet_testhelpers::test;
#[derive(Facet, Debug)]
struct ArgsWithBuiltins {
#[facet(figue::positional)]
input: Option<String>,
#[facet(flatten)]
builtins: FigueBuiltins,
}
#[test]
fn test_driver_help_flag() {
let config = builder::<ArgsWithBuiltins>()
.unwrap()
.cli(|cli| cli.args(["--help"]))
.help(|h| h.program_name("test-app").version("1.0.0"))
.build();
let driver = Driver::new(config);
let result = driver.run().into_result();
match result {
Err(DriverError::Help { text }) => {
assert!(
text.contains("test-app"),
"help should contain program name"
);
assert!(text.contains("--help"), "help should mention --help flag");
}
other => panic!("expected DriverError::Help, got {:?}", other),
}
}
#[test]
fn test_driver_help_short_flag() {
let config = builder::<ArgsWithBuiltins>()
.unwrap()
.cli(|cli| cli.args(["-h"]))
.help(|h| h.program_name("test-app"))
.build();
let driver = Driver::new(config);
let result = driver.run().into_result();
assert!(
matches!(result, Err(DriverError::Help { .. })),
"expected DriverError::Help"
);
}
#[test]
fn test_driver_version_flag() {
let config = builder::<ArgsWithBuiltins>()
.unwrap()
.cli(|cli| cli.args(["--version"]))
.help(|h| h.program_name("test-app").version("2.0.0"))
.build();
let driver = Driver::new(config);
let result = driver.run().into_result();
match result {
Err(DriverError::Version { text }) => {
assert!(
text.contains("test-app"),
"version should contain program name"
);
assert!(
text.contains("2.0.0"),
"version should contain version number"
);
}
other => panic!("expected DriverError::Version, got {:?}", other),
}
}
#[test]
fn test_driver_version_short_flag() {
let config = builder::<ArgsWithBuiltins>()
.unwrap()
.cli(|cli| cli.args(["-V"]))
.help(|h| h.program_name("test-app").version("3.0.0"))
.build();
let driver = Driver::new(config);
let result = driver.run().into_result();
match result {
Err(DriverError::Version { text }) => {
assert!(
text.contains("3.0.0"),
"version should contain version number"
);
}
other => panic!("expected DriverError::Version, got {:?}", other),
}
}
#[test]
fn test_driver_completions_bash() {
let config = builder::<ArgsWithBuiltins>()
.unwrap()
.cli(|cli| cli.args(["--completions", "bash"]))
.help(|h| h.program_name("test-app"))
.build();
let driver = Driver::new(config);
let result = driver.run().into_result();
match result {
Err(DriverError::Completions { script }) => {
assert!(
script.contains("_test-app"),
"bash completions should contain function name"
);
assert!(
script.contains("complete"),
"bash completions should contain 'complete'"
);
}
other => panic!("expected DriverError::Completions, got {:?}", other),
}
}
#[test]
fn test_driver_completions_zsh() {
let config = builder::<ArgsWithBuiltins>()
.unwrap()
.cli(|cli| cli.args(["--completions", "zsh"]))
.help(|h| h.program_name("myapp"))
.build();
let driver = Driver::new(config);
let result = driver.run().into_result();
match result {
Err(DriverError::Completions { script }) => {
assert!(
script.contains("#compdef myapp"),
"zsh completions should contain #compdef"
);
}
other => panic!("expected DriverError::Completions, got {:?}", other),
}
}
#[test]
fn test_driver_completions_fish() {
let config = builder::<ArgsWithBuiltins>()
.unwrap()
.cli(|cli| cli.args(["--completions", "fish"]))
.help(|h| h.program_name("myapp"))
.build();
let driver = Driver::new(config);
let result = driver.run().into_result();
match result {
Err(DriverError::Completions { script }) => {
assert!(
script.contains("complete -c myapp"),
"fish completions should contain 'complete -c myapp'"
);
}
other => panic!("expected DriverError::Completions, got {:?}", other),
}
}
#[test]
fn test_driver_normal_execution() {
let config = builder::<ArgsWithBuiltins>()
.unwrap()
.cli(|cli| cli.args(["myfile.txt"]))
.build();
let driver = Driver::new(config);
let result = driver.run().into_result();
match result {
Ok(output) => {
assert_eq!(output.value.input, Some("myfile.txt".to_string()));
assert!(!output.value.builtins.help);
assert!(!output.value.builtins.version);
assert!(output.value.builtins.completions.is_none());
}
Err(e) => panic!("expected success, got error: {:?}", e),
}
}
#[test]
fn test_driver_error_exit_codes() {
let help_err = DriverError::Help {
text: "help".to_string(),
};
let version_err = DriverError::Version {
text: "1.0".to_string(),
};
let completions_err = DriverError::Completions {
script: "script".to_string(),
};
let failed_err = DriverError::Failed {
report: Box::new(DriverReport::default()),
};
assert_eq!(help_err.exit_code(), 0);
assert_eq!(version_err.exit_code(), 0);
assert_eq!(completions_err.exit_code(), 0);
assert_eq!(failed_err.exit_code(), 1);
assert!(help_err.is_success());
assert!(version_err.is_success());
assert!(completions_err.is_success());
assert!(!failed_err.is_success());
}
#[derive(Facet, Debug, PartialEq)]
#[repr(u8)]
enum TestCommand {
Build {
#[facet(figue::named, figue::short = 'r')]
release: bool,
},
Run {
#[facet(figue::positional)]
args: Vec<String>,
},
}
#[derive(Facet, Debug)]
struct ArgsWithSubcommandOnly {
#[facet(figue::subcommand)]
command: TestCommand,
}
#[derive(Facet, Debug)]
struct ArgsWithSubcommandAndBuiltins {
#[facet(figue::subcommand)]
command: TestCommand,
#[facet(flatten)]
builtins: FigueBuiltins,
}
#[derive(Facet, Debug, PartialEq)]
#[repr(u8)]
enum TestCommandWithExplicitHelp {
Help,
Build {
#[facet(figue::named, figue::short = 'r')]
release: bool,
},
}
#[derive(Facet, Debug)]
struct ArgsWithExplicitHelpSubcommand {
#[facet(figue::subcommand)]
command: TestCommandWithExplicitHelp,
#[facet(flatten)]
builtins: FigueBuiltins,
}
#[test]
fn test_builder_api_with_subcommand_minimal() {
let config = builder::<ArgsWithSubcommandOnly>()
.expect("failed to build args schema")
.cli(|cli| cli.args(["build", "--release"]))
.build();
let result = Driver::new(config).run().into_result();
match result {
Ok(output) => match &output.value.command {
TestCommand::Build { release } => {
assert!(*release, "release flag should be true");
}
TestCommand::Run { .. } => {
panic!("expected Build subcommand, got Run");
}
},
Err(e) => panic!("expected success, got error: {:?}", e),
}
}
#[test]
fn test_builder_api_with_subcommand_and_builtins() {
let config = builder::<ArgsWithSubcommandAndBuiltins>()
.expect("failed to build args schema")
.cli(|cli| cli.args(["build", "--release"]))
.help(|h| h.program_name("test-app").version("1.0.0"))
.build();
let result = Driver::new(config).run().into_result();
match result {
Ok(output) => match &output.value.command {
TestCommand::Build { release } => {
assert!(*release, "release flag should be true");
}
TestCommand::Run { .. } => {
panic!("expected Build subcommand, got Run");
}
},
Err(e) => panic!("expected success, got error: {:?}", e),
}
}
#[test]
fn test_builder_help_word_alias_triggers_help_without_help_subcommand() {
let config = builder::<ArgsWithSubcommandAndBuiltins>()
.expect("failed to build args schema")
.cli(|cli| cli.args(["help"]))
.help(|h| h.program_name("test-app"))
.build();
let result = Driver::new(config).run().into_result();
match result {
Err(DriverError::Help { text }) => {
assert!(
text.contains("test-app"),
"help should contain configured program name"
);
assert!(
text.contains("--help"),
"help output should still mention --help"
);
}
other => panic!("expected DriverError::Help, got {:?}", other),
}
}
#[test]
fn test_builder_help_word_prefers_explicit_help_subcommand() {
let config = builder::<ArgsWithExplicitHelpSubcommand>()
.expect("failed to build args schema")
.cli(|cli| cli.args(["help"]))
.build();
let result = Driver::new(config).run().into_result();
match result {
Ok(output) => {
assert_eq!(output.value.command, TestCommandWithExplicitHelp::Help);
assert!(
!output.value.builtins.help,
"builtin help flag should remain false"
);
}
Err(e) => panic!("expected success, got error: {:?}", e),
}
}
#[test]
fn test_builder_api_with_subcommand_no_args() {
let config = builder::<ArgsWithSubcommandOnly>()
.expect("failed to build args schema")
.cli(|cli| cli.args(["build"]))
.build();
let result = Driver::new(config).run().into_result();
match result {
Ok(output) => match &output.value.command {
TestCommand::Build { release } => {
assert!(!*release, "release flag should be false by default");
}
TestCommand::Run { .. } => {
panic!("expected Build subcommand, got Run");
}
},
Err(e) => panic!("expected success, got error: {:?}", e),
}
}
#[test]
fn test_builder_api_with_run_subcommand() {
let config = builder::<ArgsWithSubcommandOnly>()
.expect("failed to build args schema")
.cli(|cli| cli.args(["run", "arg1", "arg2"]))
.build();
let result = Driver::new(config).run().into_result();
match result {
Ok(output) => match &output.value.command {
TestCommand::Run { args } => {
assert_eq!(args, &["arg1".to_string(), "arg2".to_string()]);
}
TestCommand::Build { .. } => {
panic!("expected Run subcommand, got Build");
}
},
Err(e) => panic!("expected success, got error: {:?}", e),
}
}
#[test]
fn test_from_slice_with_subcommand() {
let args: ArgsWithSubcommandOnly = crate::from_slice(&["build", "--release"]).unwrap();
match &args.command {
TestCommand::Build { release } => {
assert!(*release, "release flag should be true");
}
TestCommand::Run { .. } => {
panic!("expected Build subcommand, got Run");
}
}
}
#[test]
fn test_from_slice_with_subcommand_and_builtins() {
let args: ArgsWithSubcommandAndBuiltins =
crate::from_slice(&["build", "--release"]).unwrap();
match &args.command {
TestCommand::Build { release } => {
assert!(*release, "release flag should be true");
}
TestCommand::Run { .. } => {
panic!("expected Build subcommand, got Run");
}
}
}
#[test]
fn test_subcommand_with_builtins_parsing() {
use crate::config_value_parser::fill_defaults_from_shape;
use crate::layers::cli::CliConfigBuilder;
use crate::layers::cli::parse_cli;
use crate::missing::collect_missing_fields;
use crate::schema::Schema;
let schema =
Schema::from_shape(ArgsWithSubcommandAndBuiltins::SHAPE).expect("schema should build");
let cli_config = CliConfigBuilder::new().args(["build", "--release"]).build();
let output = parse_cli(&schema, &cli_config);
assert!(output.diagnostics.is_empty(), "should have no diagnostics");
let cli_value = output.value.unwrap();
let with_defaults =
fill_defaults_from_shape(&cli_value, ArgsWithSubcommandAndBuiltins::SHAPE);
let mut missing_fields = Vec::new();
collect_missing_fields(&with_defaults, &schema, &mut missing_fields);
assert!(
missing_fields.is_empty(),
"should have no missing fields, got: {:?}",
missing_fields
);
let result: Result<ArgsWithSubcommandAndBuiltins, _> =
crate::config_value_parser::from_config_value(&with_defaults);
assert!(
result.is_ok(),
"deserialization should succeed: {:?}",
result
);
}
#[derive(Facet, Debug, PartialEq)]
#[repr(u8)]
enum DatabaseAction {
Create {
#[facet(figue::positional)]
name: String,
},
Run {
#[facet(figue::named)]
dry_run: bool,
},
Rollback {
#[facet(figue::named, default)]
count: Option<u32>,
},
}
#[derive(Facet, Debug, PartialEq)]
#[repr(u8)]
enum TopLevelCommand {
Db {
#[facet(figue::subcommand)]
action: DatabaseAction,
},
Serve {
#[facet(figue::named, default)]
port: Option<u16>,
#[facet(figue::named, default)]
host: Option<String>,
},
Version,
}
#[derive(Facet, Debug)]
struct ArgsWithNestedSubcommands {
#[facet(figue::named, figue::short = 'v')]
verbose: bool,
#[facet(figue::subcommand)]
command: TopLevelCommand,
#[facet(flatten)]
builtins: FigueBuiltins,
}
#[test]
fn test_builder_nested_subcommand_db_create() {
let config = builder::<ArgsWithNestedSubcommands>()
.expect("failed to build schema")
.cli(|cli| cli.args(["db", "create", "add_users_table"]))
.help(|h| h.program_name("test-app"))
.build();
let result = Driver::new(config).run().into_result();
match result {
Ok(output) => {
assert!(!output.value.verbose);
match &output.value.command {
TopLevelCommand::Db { action } => match action {
DatabaseAction::Create { name } => {
assert_eq!(name, "add_users_table");
}
_ => panic!("expected Create action"),
},
_ => panic!("expected Db command"),
}
}
Err(e) => panic!("expected success: {:?}", e),
}
}
#[test]
fn test_builder_nested_subcommand_db_run_with_flag() {
let config = builder::<ArgsWithNestedSubcommands>()
.expect("failed to build schema")
.cli(|cli| cli.args(["-v", "db", "run", "--dry-run"]))
.help(|h| h.program_name("test-app"))
.build();
let result = Driver::new(config).run().into_result();
match result {
Ok(output) => {
assert!(output.value.verbose, "verbose should be true");
match &output.value.command {
TopLevelCommand::Db { action } => match action {
DatabaseAction::Run { dry_run } => {
assert!(*dry_run, "dry_run should be true");
}
_ => panic!("expected Run action"),
},
_ => panic!("expected Db command"),
}
}
Err(e) => panic!("expected success: {:?}", e),
}
}
#[test]
fn test_builder_nested_subcommand_db_rollback_default() {
let config = builder::<ArgsWithNestedSubcommands>()
.expect("failed to build schema")
.cli(|cli| cli.args(["db", "rollback"]))
.help(|h| h.program_name("test-app"))
.build();
let result = Driver::new(config).run().into_result();
match result {
Ok(output) => match &output.value.command {
TopLevelCommand::Db { action } => match action {
DatabaseAction::Rollback { count } => {
assert_eq!(*count, None, "count should default to None");
}
_ => panic!("expected Rollback action"),
},
_ => panic!("expected Db command"),
},
Err(e) => panic!("expected success: {:?}", e),
}
}
#[test]
fn test_builder_nested_subcommand_db_rollback_with_count() {
let config = builder::<ArgsWithNestedSubcommands>()
.expect("failed to build schema")
.cli(|cli| cli.args(["db", "rollback", "--count", "3"]))
.help(|h| h.program_name("test-app"))
.build();
let result = Driver::new(config).run().into_result();
match result {
Ok(output) => match &output.value.command {
TopLevelCommand::Db { action } => match action {
DatabaseAction::Rollback { count } => {
assert_eq!(*count, Some(3));
}
_ => panic!("expected Rollback action"),
},
_ => panic!("expected Db command"),
},
Err(e) => panic!("expected success: {:?}", e),
}
}
#[test]
fn test_builder_serve_with_defaults() {
let config = builder::<ArgsWithNestedSubcommands>()
.expect("failed to build schema")
.cli(|cli| cli.args(["serve"]))
.help(|h| h.program_name("test-app"))
.build();
let result = Driver::new(config).run().into_result();
match result {
Ok(output) => match &output.value.command {
TopLevelCommand::Serve { port, host } => {
assert_eq!(*port, None);
assert_eq!(*host, None);
}
_ => panic!("expected Serve command"),
},
Err(e) => panic!("expected success: {:?}", e),
}
}
#[test]
fn test_builder_serve_with_options() {
let config = builder::<ArgsWithNestedSubcommands>()
.expect("failed to build schema")
.cli(|cli| cli.args(["serve", "--port", "8080", "--host", "0.0.0.0"]))
.help(|h| h.program_name("test-app"))
.build();
let result = Driver::new(config).run().into_result();
match result {
Ok(output) => match &output.value.command {
TopLevelCommand::Serve { port, host } => {
assert_eq!(*port, Some(8080));
assert_eq!(host.as_deref(), Some("0.0.0.0"));
}
_ => panic!("expected Serve command"),
},
Err(e) => panic!("expected success: {:?}", e),
}
}
#[test]
fn test_builder_unit_variant_subcommand() {
let config = builder::<ArgsWithNestedSubcommands>()
.expect("failed to build schema")
.cli(|cli| cli.args(["version"]))
.help(|h| h.program_name("test-app"))
.build();
let result = Driver::new(config).run().into_result();
match result {
Ok(output) => match &output.value.command {
TopLevelCommand::Version => {
}
_ => panic!("expected Version command"),
},
Err(e) => panic!("expected success: {:?}", e),
}
}
#[derive(Facet, Debug, PartialEq, Default)]
struct InstallOptions {
#[facet(figue::named)]
global: bool,
#[facet(figue::named)]
force: bool,
}
#[derive(Facet, Debug, PartialEq)]
#[repr(u8)]
enum PackageCommand {
Install(#[facet(flatten)] InstallOptions),
Uninstall {
#[facet(figue::positional)]
name: String,
},
}
#[derive(Facet, Debug)]
struct ArgsWithTupleVariant {
#[facet(figue::subcommand)]
command: PackageCommand,
#[facet(flatten)]
builtins: FigueBuiltins,
}
#[test]
fn test_builder_tuple_variant_with_flatten() {
let config = builder::<ArgsWithTupleVariant>()
.expect("failed to build schema")
.cli(|cli| cli.args(["install", "--global", "--force"]))
.help(|h| h.program_name("pkg-manager"))
.build();
let result = Driver::new(config).run().into_result();
match result {
Ok(output) => match &output.value.command {
PackageCommand::Install(opts) => {
assert!(opts.global, "global should be true");
assert!(opts.force, "force should be true");
}
_ => panic!("expected Install command"),
},
Err(e) => panic!("expected success: {:?}", e),
}
}
#[test]
fn test_builder_tuple_variant_defaults() {
let config = builder::<ArgsWithTupleVariant>()
.expect("failed to build schema")
.cli(|cli| cli.args(["install"]))
.help(|h| h.program_name("pkg-manager"))
.build();
let result = Driver::new(config).run().into_result();
match result {
Ok(output) => match &output.value.command {
PackageCommand::Install(opts) => {
assert!(!opts.global, "global should default to false");
assert!(!opts.force, "force should default to false");
}
_ => panic!("expected Install command"),
},
Err(e) => panic!("expected success: {:?}", e),
}
}
#[derive(Facet, Debug, PartialEq)]
#[repr(u8)]
enum RenamedCommand {
#[facet(rename = "ls")]
List {
#[facet(figue::named, figue::short = 'a')]
all: bool,
},
#[facet(rename = "rm")]
Remove {
#[facet(figue::named, figue::short = 'f')]
force: bool,
#[facet(figue::positional)]
path: String,
},
}
#[derive(Facet, Debug)]
struct ArgsWithRenamedSubcommands {
#[facet(figue::subcommand)]
command: RenamedCommand,
#[facet(flatten)]
builtins: FigueBuiltins,
}
#[test]
fn test_builder_renamed_subcommand_ls() {
let config = builder::<ArgsWithRenamedSubcommands>()
.expect("failed to build schema")
.cli(|cli| cli.args(["ls", "-a"]))
.help(|h| h.program_name("file-tool"))
.build();
let result = Driver::new(config).run().into_result();
match result {
Ok(output) => match &output.value.command {
RenamedCommand::List { all } => {
assert!(*all, "all should be true");
}
_ => panic!("expected List command"),
},
Err(e) => panic!("expected success: {:?}", e),
}
}
#[test]
fn test_builder_renamed_subcommand_rm() {
let config = builder::<ArgsWithRenamedSubcommands>()
.expect("failed to build schema")
.cli(|cli| cli.args(["rm", "-f", "/tmp/file.txt"]))
.help(|h| h.program_name("file-tool"))
.build();
let result = Driver::new(config).run().into_result();
match result {
Ok(output) => match &output.value.command {
RenamedCommand::Remove { force, path } => {
assert!(*force, "force should be true");
assert_eq!(path, "/tmp/file.txt");
}
_ => panic!("expected Remove command"),
},
Err(e) => panic!("expected success: {:?}", e),
}
}
#[derive(Facet, Debug, PartialEq, Default)]
struct LoggingOpts {
#[facet(figue::named)]
debug: bool,
#[facet(figue::named, default)]
log_file: Option<String>,
}
#[derive(Facet, Debug, PartialEq, Default)]
struct CommonOpts {
#[facet(figue::named, figue::short = 'v')]
verbose: bool,
#[facet(figue::named, figue::short = 'q')]
quiet: bool,
#[facet(flatten)]
logging: LoggingOpts,
}
#[derive(Facet, Debug, PartialEq)]
#[repr(u8)]
enum DeepCommand {
Execute {
#[facet(flatten)]
common: CommonOpts,
#[facet(figue::positional)]
target: String,
},
}
#[derive(Facet, Debug)]
struct ArgsWithDeepFlatten {
#[facet(figue::subcommand)]
command: DeepCommand,
#[facet(flatten)]
builtins: FigueBuiltins,
}
#[test]
fn test_builder_deep_flatten_all_flags() {
let config = builder::<ArgsWithDeepFlatten>()
.expect("failed to build schema")
.cli(|cli| {
cli.args([
"execute",
"-v",
"--debug",
"--log-file",
"/var/log/app.log",
"my-target",
])
})
.help(|h| h.program_name("deep-app"))
.build();
let result = Driver::new(config).run().into_result();
match result {
Ok(output) => match &output.value.command {
DeepCommand::Execute { common, target } => {
assert!(common.verbose, "verbose should be true");
assert!(!common.quiet, "quiet should be false");
assert!(common.logging.debug, "debug should be true");
assert_eq!(common.logging.log_file.as_deref(), Some("/var/log/app.log"));
assert_eq!(target, "my-target");
}
},
Err(e) => panic!("expected success: {:?}", e),
}
}
#[test]
fn test_builder_deep_flatten_defaults() {
let config = builder::<ArgsWithDeepFlatten>()
.expect("failed to build schema")
.cli(|cli| cli.args(["execute", "simple-target"]))
.help(|h| h.program_name("deep-app"))
.build();
let result = Driver::new(config).run().into_result();
match result {
Ok(output) => match &output.value.command {
DeepCommand::Execute { common, target } => {
assert!(!common.verbose);
assert!(!common.quiet);
assert!(!common.logging.debug);
assert_eq!(common.logging.log_file, None);
assert_eq!(target, "simple-target");
}
},
Err(e) => panic!("expected success: {:?}", e),
}
}
#[derive(Facet, Debug)]
#[facet(rename_all = "kebab-case")]
#[repr(u8)]
#[allow(dead_code)]
enum StorageBackend {
S3 { bucket: String, region: String },
Gcp { project: String, zone: String },
Local { path: String },
}
#[derive(Facet, Debug)]
struct StorageConfig {
storage: StorageBackend,
}
#[derive(Facet, Debug)]
struct ArgsWithStorageConfig {
#[facet(figue::config)]
config: StorageConfig,
}
#[test]
#[ignore = "env parser doesn't support enum variant paths yet (issue #37)"]
fn test_enum_variant_conflict_from_env() {
use crate::layers::env::MockEnv;
let config = builder::<ArgsWithStorageConfig>()
.expect("failed to build schema")
.env(|env| {
env.prefix("MYAPP").source(MockEnv::from_pairs([
("MYAPP__STORAGE__S3__BUCKET", "my-bucket"),
("MYAPP__STORAGE__S3__REGION", "us-east-1"),
("MYAPP__STORAGE__GCP__PROJECT", "my-project"),
]))
})
.build();
let result = Driver::new(config).run().into_result();
match result {
Err(DriverError::Failed { report }) => {
let msg = format!("{}", report);
assert!(
msg.contains("Conflicting enum variants"),
"should report enum conflict: {msg}"
);
assert!(msg.contains("s3"), "should mention s3 variant: {msg}");
assert!(msg.contains("gcp"), "should mention gcp variant: {msg}");
}
Ok(_) => panic!("expected conflict error, got success"),
Err(e) => panic!("expected conflict error, got {:?}", e),
}
}
#[test]
#[ignore = "env parser doesn't support enum variant paths yet (issue #37)"]
fn test_enum_no_conflict_single_variant_from_env() {
use crate::layers::env::MockEnv;
let config = builder::<ArgsWithStorageConfig>()
.expect("failed to build schema")
.env(|env| {
env.prefix("MYAPP").source(MockEnv::from_pairs([
("MYAPP__STORAGE__S3__BUCKET", "my-bucket"),
("MYAPP__STORAGE__S3__REGION", "us-east-1"),
]))
})
.build();
let result = Driver::new(config).run().into_result();
match result {
Ok(output) => match &output.value.config.storage {
StorageBackend::S3 { bucket, region } => {
assert_eq!(bucket, "my-bucket");
assert_eq!(region, "us-east-1");
}
other => panic!("expected S3 variant, got {:?}", other),
},
Err(e) => panic!("expected success, got {:?}", e),
}
}
#[test]
#[ignore = "env parser doesn't support enum variant paths yet (issue #37)"]
fn test_enum_variant_conflict_cross_source() {
use crate::layers::env::MockEnv;
let config = builder::<ArgsWithStorageConfig>()
.expect("failed to build schema")
.env(|env| {
env.prefix("MYAPP").source(MockEnv::from_pairs([
("MYAPP__STORAGE__S3__BUCKET", "my-bucket"),
("MYAPP__STORAGE__S3__REGION", "us-east-1"),
]))
})
.cli(|cli| {
cli.args([
"--config.storage.gcp.project",
"my-project",
"--config.storage.gcp.zone",
"us-central1-a",
])
})
.build();
let result = Driver::new(config).run().into_result();
match result {
Err(DriverError::Failed { report }) => {
let msg = format!("{}", report);
assert!(
msg.contains("Conflicting enum variants"),
"should report enum conflict: {msg}"
);
assert!(msg.contains("s3"), "should mention s3: {msg}");
assert!(msg.contains("gcp"), "should mention gcp: {msg}");
}
Ok(_) => panic!("expected conflict error, got success"),
Err(e) => panic!("expected conflict error, got {:?}", e),
}
}
#[test]
fn test_enum_variant_conflict_from_cli() {
let config = builder::<ArgsWithStorageConfig>()
.expect("failed to build schema")
.cli(|cli| {
cli.args([
"--config.storage.s3.bucket",
"my-bucket",
"--config.storage.gcp.project",
"my-project",
])
})
.build();
let result = Driver::new(config).run().into_result();
match result {
Err(DriverError::Failed { report }) => {
let msg = format!("{}", report);
assert!(
msg.contains("Conflicting enum variants"),
"should report enum conflict: {msg}"
);
}
Ok(_) => panic!("expected conflict error, got success"),
Err(e) => panic!("expected conflict error, got {:?}", e),
}
}
#[test]
fn test_enum_no_conflict_single_variant_from_cli() {
let config = builder::<ArgsWithStorageConfig>()
.expect("failed to build schema")
.cli(|cli| {
cli.args([
"--config.storage.s3.bucket",
"my-bucket",
"--config.storage.s3.region",
"us-east-1",
])
})
.build();
let result = Driver::new(config).run().into_result();
match result {
Ok(output) => match &output.value.config.storage {
StorageBackend::S3 { bucket, region } => {
assert_eq!(bucket, "my-bucket");
assert_eq!(region, "us-east-1");
}
other => panic!("expected S3 variant, got {:?}", other),
},
Err(e) => panic!("expected success, got {:?}", e),
}
}
#[test]
fn test_enum_variant_conflict_three_variants() {
let config = builder::<ArgsWithStorageConfig>()
.expect("failed to build schema")
.cli(|cli| {
cli.args([
"--config.storage.s3.bucket",
"my-bucket",
"--config.storage.gcp.project",
"my-project",
"--config.storage.local.path",
"/data",
])
})
.build();
let result = Driver::new(config).run().into_result();
match result {
Err(DriverError::Failed { report }) => {
let msg = format!("{}", report);
assert!(
msg.contains("Conflicting enum variants"),
"should report enum conflict: {msg}"
);
assert!(msg.contains("s3"), "should mention s3: {msg}");
assert!(msg.contains("gcp"), "should mention gcp: {msg}");
assert!(msg.contains("local"), "should mention local: {msg}");
}
Ok(_) => panic!("expected conflict error, got success"),
Err(e) => panic!("expected conflict error, got {:?}", e),
}
}
#[derive(Facet, Debug, PartialEq, Default)]
struct SimpleOptions {
#[facet(figue::named)]
do_thing: bool,
}
#[derive(Facet, Debug, PartialEq)]
#[repr(u8)]
enum SimpleCommand {
DoSomething(SimpleOptions),
}
#[derive(Facet, Debug)]
struct ArgsWithSimpleTupleVariant {
#[facet(figue::subcommand)]
command: SimpleCommand,
#[facet(flatten)]
builtins: FigueBuiltins,
}
#[test]
fn test_builder_tuple_variant_no_explicit_flatten() {
let config = builder::<ArgsWithSimpleTupleVariant>()
.expect("failed to build schema")
.cli(|cli| cli.args(["do-something"]))
.help(|h| h.program_name("test-app"))
.build();
let result = Driver::new(config).run().into_result();
match result {
Ok(output) => match &output.value.command {
SimpleCommand::DoSomething(opts) => {
assert!(!opts.do_thing, "do_thing should default to false");
}
},
Err(e) => panic!("expected success: {:?}", e),
}
}
}