1#![allow(clippy::result_large_err)]
20
21use std::marker::PhantomData;
22use std::string::String;
23use std::vec::Vec;
24
25use crate::builder::Config;
26use crate::color::should_use_color;
27use crate::completions::{Shell, generate_completions_for_shape};
28use crate::config_value::ConfigValue;
29use crate::config_value_parser::{fill_defaults_from_schema, from_config_value};
30use crate::dump::dump_config_with_schema;
31use crate::enum_conflicts::detect_enum_conflicts;
32use crate::env_subst::{EnvSubstError, RealEnv, substitute_env_vars};
33use crate::help::generate_help_for_subcommand;
34use crate::help::generate_help_list_for_subcommand;
35use crate::help::implementation_source_for_subcommand_path;
36use crate::layers::{cli::parse_cli, env::parse_env, file::parse_file};
37use crate::merge::merge_layers;
38use crate::missing::{
39 build_corrected_command_diagnostics, collect_missing_fields, format_missing_fields_summary,
40};
41use crate::path::Path;
42use crate::provenance::{FileResolution, Override, Provenance};
43use crate::span::Span;
44use crate::span_registry::assign_virtual_spans;
45use facet_core::Facet;
46
47#[derive(Debug, Default)]
49pub struct LayerOutput {
50 pub value: Option<ConfigValue>,
52 pub unused_keys: Vec<UnusedKey>,
54 pub diagnostics: Vec<Diagnostic>,
56 pub source_text: Option<String>,
61 pub config_file_path: Option<camino::Utf8PathBuf>,
64 pub help_list_mode: Option<HelpListMode>,
66}
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum HelpListMode {
71 Full,
73 Short,
75}
76
77#[derive(Debug)]
79pub struct UnusedKey {
80 pub key: Path,
82 pub provenance: Provenance,
84}
85
86#[derive(Debug, Default)]
88pub struct ConfigLayers {
89 pub defaults: LayerOutput,
91 pub file: LayerOutput,
93 pub env: LayerOutput,
95 pub cli: LayerOutput,
97}
98
99pub struct Driver<T> {
129 config: Config<T>,
130 core: DriverCore,
131 _phantom: PhantomData<T>,
132}
133
134#[derive(Debug, Default)]
136pub struct DriverCore;
137
138impl DriverCore {
139 fn new() -> Self {
140 Self
141 }
142}
143
144impl<T: Facet<'static>> Driver<T> {
145 pub fn new(config: Config<T>) -> Self {
149 Self {
150 config,
151 core: DriverCore::new(),
152 _phantom: PhantomData,
153 }
154 }
155
156 pub fn run(mut self) -> DriverOutcome<T> {
162 let _ = self.core;
163
164 let mut layers = ConfigLayers::default();
165 let mut all_diagnostics = Vec::new();
166 let mut file_resolution = None;
167
168 let cli_args_source = self
171 .config
172 .cli_config
173 .as_ref()
174 .map(|c| {
175 let args = c.resolve_args().join(" ");
176 if args.is_empty() { None } else { Some(args) }
177 })
178 .unwrap_or(None);
179
180 let cli_args_display = cli_args_source.as_deref().unwrap_or("<no arguments>");
182
183 if let Some(ref cli_config) = self.config.cli_config {
196 layers.cli = parse_cli(&self.config.schema, cli_config);
197 tracing::debug!(cli_value = ?layers.cli.value, "driver: parsed CLI layer");
198 all_diagnostics.extend(layers.cli.diagnostics.iter().cloned());
199 }
200
201 if let Some(ref cli_path) = layers.cli.config_file_path {
204 let file_config = self.config.file_config.get_or_insert_with(Default::default);
206 file_config.explicit_path = Some(cli_path.clone());
207 }
208
209 if let Some(ref file_config) = self.config.file_config {
210 let result = parse_file(&self.config.schema, file_config);
211 layers.file = result.output;
212 file_resolution = Some(result.resolution);
213 all_diagnostics.extend(layers.file.diagnostics.iter().cloned());
214 }
215
216 if let Some(ref env_config) = self.config.env_config {
218 layers.env = parse_env(&self.config.schema, env_config, env_config.source());
219 all_diagnostics.extend(layers.env.diagnostics.iter().cloned());
220 }
221
222 if let Some(cli_value) = &layers.cli.value {
225 let special = self.config.schema.special();
226
227 if let Some(ref help_path) = special.help
229 && let Some(ConfigValue::Bool(b)) = cli_value.get_by_path(help_path)
230 && b.value
231 {
232 let help_config = self
233 .config
234 .help_config
235 .as_ref()
236 .cloned()
237 .unwrap_or_default();
238
239 let subcommand_path = if let Some(subcommand_field) =
241 self.config.schema.args().subcommand_field_name()
242 {
243 cli_value.extract_subcommand_path(subcommand_field)
244 } else {
245 Vec::new()
246 };
247
248 let mut text = if let Some(mode) = layers.cli.help_list_mode {
249 generate_help_list_for_subcommand(
250 &self.config.schema,
251 &subcommand_path,
252 &help_config,
253 mode,
254 )
255 } else {
256 generate_help_for_subcommand(
257 &self.config.schema,
258 &subcommand_path,
259 &help_config,
260 )
261 };
262 maybe_append_implementation_source::<T>(&mut text, &help_config, &subcommand_path);
263 return DriverOutcome::err(DriverError::Help { text });
264 }
265
266 if let Some(ref version_path) = special.version
268 && let Some(ConfigValue::Bool(b)) = cli_value.get_by_path(version_path)
269 && b.value
270 {
271 let version = self
272 .config
273 .help_config
274 .as_ref()
275 .and_then(|h| h.version.clone())
276 .unwrap_or_else(|| "unknown".to_string());
277 let program_name = self
278 .config
279 .help_config
280 .as_ref()
281 .and_then(|h| h.program_name.clone())
282 .or_else(|| std::env::args().next())
283 .unwrap_or_else(|| "program".to_string());
284 let text = format!("{} {}", program_name, version);
285 return DriverOutcome::err(DriverError::Version { text });
286 }
287
288 if let Some(ref completions_path) = special.completions
290 && let Some(value) = cli_value.get_by_path(completions_path)
291 {
292 if let Some(shell) = extract_shell_from_value(value) {
294 let program_name = self
295 .config
296 .help_config
297 .as_ref()
298 .and_then(|h| h.program_name.clone())
299 .or_else(|| std::env::args().next())
300 .unwrap_or_else(|| "program".to_string());
301 let script = generate_completions_for_shape(T::SHAPE, shell, &program_name);
302 return DriverOutcome::err(DriverError::Completions { script });
303 }
304 }
305 }
306
307 let has_errors = all_diagnostics
309 .iter()
310 .any(|d| d.severity == Severity::Error);
311 if has_errors {
312 return DriverOutcome::err(DriverError::Failed {
313 report: Box::new(DriverReport {
314 diagnostics: all_diagnostics,
315 layers,
316 file_resolution,
317 overrides: Vec::new(),
318 cli_args_source: cli_args_display.to_string(),
319 source_name: "<cli>".to_string(),
320 }),
321 });
322 }
323
324 let values_to_merge: Vec<ConfigValue> = [
326 layers.defaults.value.clone(),
327 layers.file.value.clone(),
328 layers.env.value.clone(),
329 layers.cli.value.clone(),
330 ]
331 .into_iter()
332 .flatten()
333 .collect();
334
335 let merged = merge_layers(values_to_merge);
336 tracing::debug!(merged_value = ?merged.value, "driver: merged layers");
337 let overrides = merged.overrides;
338
339 let mut merged_value = merged.value;
342 if let Some(config_schema) = self.config.schema.config()
343 && let ConfigValue::Object(ref mut sourced_fields) = merged_value
344 && let Some(config_field_name) = config_schema.field_name()
345 && let Some(config_value) = sourced_fields.value.get_mut(&config_field_name.to_string())
346 && let Err(e) = substitute_env_vars(config_value, config_schema, &RealEnv)
347 {
348 return DriverOutcome::err(DriverError::EnvSubst { error: e });
349 }
350 tracing::debug!(merged_value = ?merged_value, "driver: after env_subst");
351
352 let enum_conflicts = detect_enum_conflicts(&merged_value, &self.config.schema);
355 if !enum_conflicts.is_empty() {
356 let messages: Vec<String> = enum_conflicts.iter().map(|c| c.format()).collect();
357 let message = messages.join("\n\n");
358
359 return DriverOutcome::err(DriverError::Failed {
360 report: Box::new(DriverReport {
361 diagnostics: vec![Diagnostic {
362 message,
363 label: None,
364 path: None,
365 span: None,
366 severity: Severity::Error,
367 }],
368 layers,
369 file_resolution,
370 overrides,
371 cli_args_source: cli_args_display.to_string(),
372 source_name: "<cli>".to_string(),
373 }),
374 });
375 }
376
377 let value_with_defaults = fill_defaults_from_schema(&merged_value, &self.config.schema);
380 tracing::debug!(value_with_defaults = ?value_with_defaults, "driver: after fill_defaults_from_schema");
381
382 let mut missing_fields = Vec::new();
384 collect_missing_fields(
385 &value_with_defaults,
386 &self.config.schema,
387 &mut missing_fields,
388 );
389
390 let cli_strict = self
392 .config
393 .cli_config
394 .as_ref()
395 .map(|c| c.strict())
396 .unwrap_or(false);
397 let env_strict = self
398 .config
399 .env_config
400 .as_ref()
401 .map(|c| c.strict)
402 .unwrap_or(false);
403 let file_strict = self
404 .config
405 .file_config
406 .as_ref()
407 .map(|c| c.strict)
408 .unwrap_or(false);
409
410 let mut unknown_keys: Vec<&UnusedKey> = Vec::new();
411 if cli_strict {
412 unknown_keys.extend(layers.cli.unused_keys.iter());
413 }
414 if env_strict {
415 unknown_keys.extend(layers.env.unused_keys.iter());
416 }
417 if file_strict {
418 unknown_keys.extend(layers.file.unused_keys.iter());
419 }
420
421 let has_missing = !missing_fields.is_empty();
422 let has_unknown = !unknown_keys.is_empty();
423
424 if has_missing || has_unknown {
425 let subcommand_field_name = self.config.schema.args().subcommand_field_name();
427 let only_missing_subcommand = !has_unknown
428 && subcommand_field_name.is_some()
429 && missing_fields.len() == 1
430 && missing_fields[0].field_name == subcommand_field_name.unwrap();
431
432 if only_missing_subcommand {
433 let help_config = self
435 .config
436 .help_config
437 .as_ref()
438 .cloned()
439 .unwrap_or_default();
440
441 let mut help = generate_help_for_subcommand(&self.config.schema, &[], &help_config);
442 maybe_append_implementation_source::<T>(&mut help, &help_config, &[]);
443 return DriverOutcome::err(DriverError::Help { text: help });
444 }
445
446 let missing_subcommand_with_variants = !has_unknown
449 && missing_fields.len() == 1
450 && !missing_fields[0].available_subcommands.is_empty();
451
452 if missing_subcommand_with_variants {
453 let field = &missing_fields[0];
454
455 let items: Vec<(String, Option<&str>)> = field
457 .available_subcommands
458 .iter()
459 .map(|sub| (sub.name.clone(), sub.doc.as_deref()))
460 .collect();
461
462 let max_width = items.iter().map(|(name, _)| name.len()).max().unwrap_or(0);
464 let mut cmds = String::new();
465 for (name, doc) in &items {
466 use std::fmt::Write;
467 write!(cmds, " {name}").unwrap();
468 let padding = max_width.saturating_sub(name.len());
469 for _ in 0..padding {
470 cmds.push(' ');
471 }
472 if let Some(doc) = doc {
473 write!(cmds, " {}", doc.trim()).unwrap();
474 }
475 cmds.push('\n');
476 }
477
478 let mut diagnostics = vec![Diagnostic {
479 message: format!(
480 "expected a subcommand\n\navailable subcommands:\n{}",
481 cmds.trim_end()
482 ),
483 label: None,
484 path: None,
485 span: None,
486 severity: Severity::Error,
487 }];
488
489 if self.config.schema.special().help.is_some() {
491 diagnostics.push(Diagnostic {
492 message: "Run with --help for usage information.".to_string(),
493 label: None,
494 path: None,
495 span: None,
496 severity: Severity::Note,
497 });
498 }
499
500 return DriverOutcome::err(DriverError::Failed {
501 report: Box::new(DriverReport {
502 diagnostics,
503 layers,
504 file_resolution,
505 overrides,
506 cli_args_source: cli_args_display.to_string(),
507 source_name: "<cli>".to_string(),
508 }),
509 });
510 }
511
512 let all_cli_missing = has_missing
515 && !has_unknown
516 && missing_fields
517 .iter()
518 .all(|f| matches!(f.kind, crate::missing::MissingFieldKind::CliArg));
519
520 if all_cli_missing {
521 let mut corrected = build_corrected_command_diagnostics(
523 &missing_fields,
524 cli_args_source.as_deref(),
525 );
526
527 if self.config.schema.special().help.is_some() {
529 corrected.diagnostics.push(Diagnostic {
530 message: "Run with --help for usage information.".to_string(),
531 label: None,
532 path: None,
533 span: None,
534 severity: Severity::Note,
535 });
536 }
537
538 return DriverOutcome::err(DriverError::Failed {
539 report: Box::new(DriverReport {
540 diagnostics: corrected.diagnostics,
541 layers,
542 file_resolution,
543 overrides,
544 cli_args_source: corrected.corrected_source,
545 source_name: "<suggestion>".to_string(),
546 }),
547 });
548 }
549
550 let message = {
551 let mut dump_buf = Vec::new();
553 let resolution = file_resolution.as_ref().cloned().unwrap_or_default();
554 dump_config_with_schema(
555 &mut dump_buf,
556 &value_with_defaults,
557 &resolution,
558 &self.config.schema,
559 );
560 let dump =
561 String::from_utf8(dump_buf).unwrap_or_else(|_| "error rendering dump".into());
562
563 let mut message_parts = Vec::new();
565
566 if has_unknown {
567 message_parts.push("Unknown configuration keys:".to_string());
568 }
569 if has_missing {
570 message_parts.push("Missing required fields:".to_string());
571 }
572
573 let header = message_parts.join(" / ");
574
575 let missing_summary = if has_missing {
577 format!(
578 "\nMissing:\n{}",
579 format_missing_fields_summary(&missing_fields)
580 )
581 } else {
582 String::new()
583 };
584
585 let unknown_summary = if has_unknown {
587 let config_schema = self.config.schema.config();
588 let unknown_list: Vec<String> = unknown_keys
589 .iter()
590 .map(|uk| {
591 let source = uk.provenance.source_description();
592 let suggestion = config_schema
593 .map(|cs| crate::suggest::suggest_config_path(cs, &uk.key))
594 .unwrap_or_default();
595 format!(" {} (from {}){}", uk.key.join("."), source, suggestion)
596 })
597 .collect();
598 format!("\nUnknown keys:\n{}", unknown_list.join("\n"))
599 } else {
600 String::new()
601 };
602
603 format!(
604 "{}\n\n{}{}{}\nRun with --help for usage information.",
605 header, dump, missing_summary, unknown_summary
606 )
607 };
608
609 return DriverOutcome::err(DriverError::Failed {
610 report: Box::new(DriverReport {
611 diagnostics: vec![Diagnostic {
612 message,
613 label: None,
614 path: None,
615 span: None,
616 severity: Severity::Error,
617 }],
618 layers,
619 file_resolution,
620 overrides,
621 cli_args_source: cli_args_display.to_string(),
622 source_name: "<cli>".to_string(),
623 }),
624 });
625 }
626
627 let mut value_with_virtual_spans = value_with_defaults;
630 let span_registry = assign_virtual_spans(&mut value_with_virtual_spans);
631
632 let value: T = match from_config_value(&value_with_virtual_spans) {
633 Ok(v) => v,
634 Err(e) => {
635 let (span, source_name, source_contents) = if let Some(virtual_span) = e.span() {
637 if let Some(entry) =
638 span_registry.lookup_by_offset(virtual_span.offset as usize)
639 {
640 let real_span = Span::new(
641 entry.real_span.offset as usize,
642 entry.real_span.len as usize,
643 );
644 let (name, contents) =
645 get_source_for_provenance(&entry.provenance, cli_args_display, &layers);
646 (Some(real_span), name, contents)
647 } else {
648 (None, "<unknown>".to_string(), cli_args_display.to_string())
649 }
650 } else {
651 (None, "<unknown>".to_string(), cli_args_display.to_string())
652 };
653
654 return DriverOutcome::err(DriverError::Failed {
655 report: Box::new(DriverReport {
656 diagnostics: vec![Diagnostic {
657 message: e.to_string(),
658 label: None,
659 path: None,
660 span,
661 severity: Severity::Error,
662 }],
663 layers,
664 file_resolution,
665 overrides,
666 cli_args_source: source_contents,
667 source_name,
668 }),
669 });
670 }
671 };
672
673 DriverOutcome::ok(DriverOutput {
674 value,
675 report: DriverReport {
676 diagnostics: all_diagnostics,
677 layers,
678 file_resolution,
679 overrides,
680 cli_args_source: cli_args_display.to_string(),
681 source_name: "<cli>".to_string(),
682 },
683 merged_config: value_with_virtual_spans,
684 schema: self.config.schema.clone(),
685 })
686 }
687}
688
689fn maybe_append_implementation_source<T: Facet<'static>>(
690 help_text: &mut String,
691 help_config: &crate::help::HelpConfig,
692 subcommand_path: &[String],
693) {
694 let Some(source_file) = implementation_source_for_subcommand_path(T::SHAPE, subcommand_path)
695 else {
696 return;
697 };
698
699 let implementation_url = help_config
700 .implementation_url
701 .as_ref()
702 .map(|render_url| render_url(source_file));
703
704 if !help_config.include_implementation_source_file && implementation_url.is_none() {
705 return;
706 }
707
708 if !help_text.ends_with('\n') {
709 help_text.push('\n');
710 }
711 help_text.push('\n');
712 help_text.push_str("Implementation:\n");
713 if help_config.include_implementation_source_file {
714 help_text.push_str(" ");
715 help_text.push_str(source_file);
716 help_text.push('\n');
717 }
718 if let Some(implementation_url) = implementation_url {
719 help_text.push_str(" ");
720 help_text.push_str(&implementation_url);
721 help_text.push('\n');
722 }
723}
724
725fn get_source_for_provenance(
727 provenance: &Provenance,
728 cli_args_display: &str,
729 layers: &ConfigLayers,
730) -> (String, String) {
731 match provenance {
732 Provenance::Cli { .. } => ("<cli>".to_string(), cli_args_display.to_string()),
733 Provenance::Env { .. } => {
734 let source_text = layers.env.source_text.as_ref().cloned().unwrap_or_default();
736 ("<env>".to_string(), source_text)
737 }
738 Provenance::File { file, .. } => (file.path.to_string(), file.contents.clone()),
739 Provenance::Default => ("<default>".to_string(), String::new()),
740 }
741}
742
743#[must_use = "this `DriverOutcome` may contain a help/version request that should be handled"]
815pub struct DriverOutcome<T>(Result<DriverOutput<T>, DriverError>);
816
817impl<T: std::fmt::Debug> std::fmt::Debug for DriverOutcome<T> {
818 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
819 match &self.0 {
820 Ok(output) => f
821 .debug_tuple("DriverOutcome::Ok")
822 .field(&output.value)
823 .finish(),
824 Err(e) => f.debug_tuple("DriverOutcome::Err").field(e).finish(),
825 }
826 }
827}
828
829impl<T> DriverOutcome<T> {
830 pub fn ok(output: DriverOutput<T>) -> Self {
832 Self(Ok(output))
833 }
834
835 pub fn err(error: DriverError) -> Self {
837 Self(Err(error))
838 }
839
840 pub fn into_result(self) -> Result<DriverOutput<T>, DriverError> {
849 self.0
850 }
851
852 pub fn is_ok(&self) -> bool {
854 self.0.is_ok()
855 }
856
857 pub fn is_err(&self) -> bool {
859 self.0.is_err()
860 }
861
862 pub fn unwrap(self) -> T {
897 match self.0 {
898 Ok(output) => output.get(),
899 Err(DriverError::Help { text }) => {
900 println!("{}", text);
901 std::process::exit(0);
902 }
903 Err(DriverError::Completions { script }) => {
904 println!("{}", script);
905 std::process::exit(0);
906 }
907 Err(DriverError::Version { text }) => {
908 println!("{}", text);
909 std::process::exit(0);
910 }
911 Err(DriverError::Failed { report }) => {
912 eprintln!("{}", report.render_pretty());
913 std::process::exit(1);
914 }
915 Err(DriverError::Builder { error }) => {
916 eprintln!("{}", error);
917 std::process::exit(1);
918 }
919 Err(DriverError::EnvSubst { error }) => {
920 eprintln!("error: {}", error);
921 std::process::exit(1);
922 }
923 }
924 }
925
926 pub fn unwrap_err(self) -> DriverError {
934 match self.0 {
935 Ok(_) => panic!("called `DriverOutcome::unwrap_err()` on a success"),
936 Err(e) => e,
937 }
938 }
939}
940
941pub struct DriverOutput<T> {
972 pub value: T,
974 pub report: DriverReport,
976 merged_config: ConfigValue,
978 schema: crate::schema::Schema,
980}
981
982impl<T: std::fmt::Debug> std::fmt::Debug for DriverOutput<T> {
983 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
984 f.debug_struct("DriverOutput")
985 .field("value", &self.value)
986 .field("report", &self.report)
987 .finish_non_exhaustive()
988 }
989}
990
991impl<T> DriverOutput<T> {
992 pub fn get(self) -> T {
994 self.print_warnings();
995 self.value
996 }
997
998 pub fn get_silent(self) -> T {
1000 self.value
1001 }
1002
1003 pub fn into_parts(self) -> (T, DriverReport) {
1005 (self.value, self.report)
1006 }
1007
1008 pub fn print_warnings(&self) {
1010 for diagnostic in &self.report.diagnostics {
1011 if diagnostic.severity == Severity::Warning {
1012 eprintln!("{}: {}", diagnostic.severity.as_str(), diagnostic.message);
1013 }
1014 }
1015 }
1016
1017 pub fn extract<R: Facet<'static>>(&self) -> Result<R, crate::extract::ExtractError> {
1052 crate::extract::extract_requirements::<R>(&self.merged_config, &self.schema)
1053 }
1054}
1055
1056#[derive(Default)]
1061pub struct DriverReport {
1062 pub diagnostics: Vec<Diagnostic>,
1064 pub layers: ConfigLayers,
1066 pub file_resolution: Option<FileResolution>,
1068 pub overrides: Vec<Override>,
1070 pub cli_args_source: String,
1072 pub source_name: String,
1074}
1075
1076struct NamedSource {
1078 name: String,
1079 source: ariadne::Source<String>,
1080}
1081
1082impl ariadne::Cache<()> for NamedSource {
1083 type Storage = String;
1084
1085 fn fetch(&mut self, _: &()) -> Result<&ariadne::Source<Self::Storage>, impl std::fmt::Debug> {
1086 Ok::<_, std::convert::Infallible>(&self.source)
1087 }
1088
1089 fn display<'a>(&self, _: &'a ()) -> Option<impl std::fmt::Display + 'a> {
1090 Some(self.name.clone())
1091 }
1092}
1093
1094impl DriverReport {
1095 pub fn render_pretty(&self) -> String {
1097 use ariadne::{Color, Config, Label, Report, ReportKind, Source};
1098
1099 if self.diagnostics.is_empty() {
1100 return String::new();
1101 }
1102
1103 let mut output = Vec::with_capacity(128);
1104 let mut cache = NamedSource {
1105 name: self.source_name.clone(),
1106 source: Source::from(self.cli_args_source.clone()),
1107 };
1108
1109 for diagnostic in &self.diagnostics {
1110 if diagnostic.span.is_none() {
1113 if diagnostic.message.starts_with("Error:")
1116 || diagnostic.message.starts_with("Warning:")
1117 || diagnostic.message.starts_with("Note:")
1118 {
1119 output.extend_from_slice(diagnostic.message.as_bytes());
1120 output.push(b'\n');
1121 continue;
1122 }
1123
1124 let prefix = match diagnostic.severity {
1126 Severity::Error => "Error: ",
1127 Severity::Warning => "Warning: ",
1128 Severity::Note => "Note: ",
1129 };
1130 output.extend_from_slice(prefix.as_bytes());
1131 output.extend_from_slice(diagnostic.message.as_bytes());
1132 output.push(b'\n');
1133 continue;
1134 }
1135
1136 let span = diagnostic
1137 .span
1138 .map(|s| s.start..(s.start + s.len))
1139 .unwrap_or(0..0);
1140
1141 let report_kind = match diagnostic.severity {
1142 Severity::Error => ReportKind::Error,
1143 Severity::Warning => ReportKind::Warning,
1144 Severity::Note => ReportKind::Advice,
1145 };
1146
1147 let label_message = diagnostic.label.as_deref().unwrap_or(&diagnostic.message);
1148 let mut label = Label::new(span.clone()).with_message(label_message);
1149 if should_use_color() {
1150 let color = match diagnostic.severity {
1151 Severity::Error => Color::Red,
1152 Severity::Warning => Color::Yellow,
1153 Severity::Note => Color::Cyan,
1154 };
1155 label = label.with_color(color);
1156 }
1157
1158 let report = Report::build(report_kind, span.clone())
1159 .with_config(Config::default().with_color(should_use_color()))
1160 .with_message(&diagnostic.message)
1161 .with_label(label)
1162 .finish();
1163
1164 report.write(&mut cache, &mut output).ok();
1165 }
1166
1167 String::from_utf8(output).unwrap_or_else(|_| "error rendering diagnostics".to_string())
1168 }
1169}
1170
1171impl core::fmt::Display for DriverReport {
1172 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
1173 f.write_str(&self.render_pretty())
1174 }
1175}
1176
1177impl core::fmt::Debug for DriverReport {
1178 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
1179 f.write_str(&self.render_pretty())
1180 }
1181}
1182
1183#[derive(Debug, Clone)]
1188pub struct Diagnostic {
1189 pub message: String,
1191 pub label: Option<String>,
1193 pub path: Option<Path>,
1195 pub span: Option<Span>,
1197 pub severity: Severity,
1199}
1200
1201#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1203pub enum Severity {
1204 Error,
1206 Warning,
1208 Note,
1210}
1211
1212impl Severity {
1213 fn as_str(self) -> &'static str {
1214 match self {
1215 Severity::Error => "error",
1216 Severity::Warning => "warning",
1217 Severity::Note => "note",
1218 }
1219 }
1220}
1221
1222fn extract_shell_from_value(value: &ConfigValue) -> Option<Shell> {
1227 match value {
1228 ConfigValue::String(s) => match s.value.to_lowercase().as_str() {
1229 "bash" => Some(Shell::Bash),
1230 "zsh" => Some(Shell::Zsh),
1231 "fish" => Some(Shell::Fish),
1232 _ => None,
1233 },
1234 ConfigValue::Enum(e) => match e.value.variant.to_lowercase().as_str() {
1236 "bash" => Some(Shell::Bash),
1237 "zsh" => Some(Shell::Zsh),
1238 "fish" => Some(Shell::Fish),
1239 _ => None,
1240 },
1241 _ => None,
1242 }
1243}
1244
1245pub enum DriverError {
1314 Builder {
1318 error: crate::builder::BuilderError,
1320 },
1321
1322 Failed {
1329 report: Box<DriverReport>,
1331 },
1332
1333 Help {
1339 text: String,
1341 },
1342
1343 Completions {
1349 script: String,
1351 },
1352
1353 Version {
1359 text: String,
1361 },
1362
1363 EnvSubst {
1370 error: EnvSubstError,
1372 },
1373}
1374
1375impl DriverError {
1376 pub fn exit_code(&self) -> i32 {
1378 match self {
1379 DriverError::Builder { .. } => 1,
1380 DriverError::Failed { .. } => 1,
1381 DriverError::Help { .. } => 0,
1382 DriverError::Completions { .. } => 0,
1383 DriverError::Version { .. } => 0,
1384 DriverError::EnvSubst { .. } => 1,
1385 }
1386 }
1387
1388 pub fn is_success(&self) -> bool {
1390 self.exit_code() == 0
1391 }
1392
1393 pub fn is_help(&self) -> bool {
1395 matches!(self, DriverError::Help { .. })
1396 }
1397
1398 pub fn help_text(&self) -> Option<&str> {
1400 match self {
1401 DriverError::Help { text } => Some(text),
1402 _ => None,
1403 }
1404 }
1405}
1406
1407impl std::fmt::Display for DriverError {
1408 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1409 match self {
1410 DriverError::Builder { error } => write!(f, "{}", error),
1411 DriverError::Failed { report } => write!(f, "{}", report),
1412 DriverError::Help { text } => write!(f, "{}", text),
1413 DriverError::Completions { script } => write!(f, "{}", script),
1414 DriverError::Version { text } => write!(f, "{}", text),
1415 DriverError::EnvSubst { error } => write!(f, "{}", error),
1416 }
1417 }
1418}
1419
1420impl std::fmt::Debug for DriverError {
1421 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1422 std::fmt::Display::fmt(self, f)
1423 }
1424}
1425
1426impl std::error::Error for DriverError {}
1427
1428impl std::process::Termination for DriverError {
1429 fn report(self) -> std::process::ExitCode {
1430 match &self {
1432 DriverError::Help { text } | DriverError::Version { text } => {
1433 println!("{}", text);
1434 }
1435 DriverError::Completions { script } => {
1436 println!("{}", script);
1437 }
1438 DriverError::Failed { report } => {
1439 eprintln!("{}", report.render_pretty());
1440 }
1441 DriverError::Builder { error } => {
1442 eprintln!("{}", error);
1443 }
1444 DriverError::EnvSubst { error } => {
1445 eprintln!("error: {}", error);
1446 }
1447 }
1448 std::process::ExitCode::from(self.exit_code() as u8)
1449 }
1450}
1451
1452#[cfg(test)]
1453mod tests {
1454 use super::*;
1455 use crate as figue;
1456 use crate::FigueBuiltins;
1457 use crate::builder::builder;
1458 use facet::Facet;
1459 use facet_testhelpers::test;
1460
1461 #[derive(Facet, Debug)]
1463 struct ArgsWithBuiltins {
1464 #[facet(figue::positional)]
1466 input: Option<String>,
1467
1468 #[facet(flatten)]
1470 builtins: FigueBuiltins,
1471 }
1472
1473 #[test]
1474 fn test_driver_help_flag() {
1475 let config = builder::<ArgsWithBuiltins>()
1476 .unwrap()
1477 .cli(|cli| cli.args(["--help"]))
1478 .help(|h| h.program_name("test-app").version("1.0.0"))
1479 .build();
1480
1481 let driver = Driver::new(config);
1482 let result = driver.run().into_result();
1483
1484 match result {
1485 Err(DriverError::Help { text }) => {
1486 assert!(
1487 text.contains("test-app"),
1488 "help should contain program name"
1489 );
1490 assert!(text.contains("--help"), "help should mention --help flag");
1491 }
1492 other => panic!("expected DriverError::Help, got {:?}", other),
1493 }
1494 }
1495
1496 #[test]
1497 fn test_driver_help_short_flag() {
1498 let config = builder::<ArgsWithBuiltins>()
1499 .unwrap()
1500 .cli(|cli| cli.args(["-h"]))
1501 .help(|h| h.program_name("test-app"))
1502 .build();
1503
1504 let driver = Driver::new(config);
1505 let result = driver.run().into_result();
1506
1507 assert!(
1508 matches!(result, Err(DriverError::Help { .. })),
1509 "expected DriverError::Help"
1510 );
1511 }
1512
1513 #[test]
1514 fn test_driver_version_flag() {
1515 let config = builder::<ArgsWithBuiltins>()
1516 .unwrap()
1517 .cli(|cli| cli.args(["--version"]))
1518 .help(|h| h.program_name("test-app").version("2.0.0"))
1519 .build();
1520
1521 let driver = Driver::new(config);
1522 let result = driver.run().into_result();
1523
1524 match result {
1525 Err(DriverError::Version { text }) => {
1526 assert!(
1527 text.contains("test-app"),
1528 "version should contain program name"
1529 );
1530 assert!(
1531 text.contains("2.0.0"),
1532 "version should contain version number"
1533 );
1534 }
1535 other => panic!("expected DriverError::Version, got {:?}", other),
1536 }
1537 }
1538
1539 #[test]
1540 fn test_driver_version_short_flag() {
1541 let config = builder::<ArgsWithBuiltins>()
1542 .unwrap()
1543 .cli(|cli| cli.args(["-V"]))
1544 .help(|h| h.program_name("test-app").version("3.0.0"))
1545 .build();
1546
1547 let driver = Driver::new(config);
1548 let result = driver.run().into_result();
1549
1550 match result {
1551 Err(DriverError::Version { text }) => {
1552 assert!(
1553 text.contains("3.0.0"),
1554 "version should contain version number"
1555 );
1556 }
1557 other => panic!("expected DriverError::Version, got {:?}", other),
1558 }
1559 }
1560
1561 #[test]
1562 fn test_driver_completions_bash() {
1563 let config = builder::<ArgsWithBuiltins>()
1564 .unwrap()
1565 .cli(|cli| cli.args(["--completions", "bash"]))
1566 .help(|h| h.program_name("test-app"))
1567 .build();
1568
1569 let driver = Driver::new(config);
1570 let result = driver.run().into_result();
1571
1572 match result {
1573 Err(DriverError::Completions { script }) => {
1574 assert!(
1575 script.contains("_test-app"),
1576 "bash completions should contain function name"
1577 );
1578 assert!(
1579 script.contains("complete"),
1580 "bash completions should contain 'complete'"
1581 );
1582 }
1583 other => panic!("expected DriverError::Completions, got {:?}", other),
1584 }
1585 }
1586
1587 #[test]
1588 fn test_driver_completions_zsh() {
1589 let config = builder::<ArgsWithBuiltins>()
1590 .unwrap()
1591 .cli(|cli| cli.args(["--completions", "zsh"]))
1592 .help(|h| h.program_name("myapp"))
1593 .build();
1594
1595 let driver = Driver::new(config);
1596 let result = driver.run().into_result();
1597
1598 match result {
1599 Err(DriverError::Completions { script }) => {
1600 assert!(
1601 script.contains("#compdef myapp"),
1602 "zsh completions should contain #compdef"
1603 );
1604 }
1605 other => panic!("expected DriverError::Completions, got {:?}", other),
1606 }
1607 }
1608
1609 #[test]
1610 fn test_driver_completions_fish() {
1611 let config = builder::<ArgsWithBuiltins>()
1612 .unwrap()
1613 .cli(|cli| cli.args(["--completions", "fish"]))
1614 .help(|h| h.program_name("myapp"))
1615 .build();
1616
1617 let driver = Driver::new(config);
1618 let result = driver.run().into_result();
1619
1620 match result {
1621 Err(DriverError::Completions { script }) => {
1622 assert!(
1623 script.contains("complete -c myapp"),
1624 "fish completions should contain 'complete -c myapp'"
1625 );
1626 }
1627 other => panic!("expected DriverError::Completions, got {:?}", other),
1628 }
1629 }
1630
1631 #[test]
1632 fn test_driver_normal_execution() {
1633 let config = builder::<ArgsWithBuiltins>()
1634 .unwrap()
1635 .cli(|cli| cli.args(["myfile.txt"]))
1636 .build();
1637
1638 let driver = Driver::new(config);
1639 let result = driver.run().into_result();
1640
1641 match result {
1642 Ok(output) => {
1643 assert_eq!(output.value.input, Some("myfile.txt".to_string()));
1644 assert!(!output.value.builtins.help);
1645 assert!(!output.value.builtins.version);
1646 assert!(output.value.builtins.completions.is_none());
1647 }
1648 Err(e) => panic!("expected success, got error: {:?}", e),
1649 }
1650 }
1651
1652 #[test]
1653 fn test_driver_error_exit_codes() {
1654 let help_err = DriverError::Help {
1655 text: "help".to_string(),
1656 };
1657 let version_err = DriverError::Version {
1658 text: "1.0".to_string(),
1659 };
1660 let completions_err = DriverError::Completions {
1661 script: "script".to_string(),
1662 };
1663 let failed_err = DriverError::Failed {
1664 report: Box::new(DriverReport::default()),
1665 };
1666
1667 assert_eq!(help_err.exit_code(), 0);
1668 assert_eq!(version_err.exit_code(), 0);
1669 assert_eq!(completions_err.exit_code(), 0);
1670 assert_eq!(failed_err.exit_code(), 1);
1671
1672 assert!(help_err.is_success());
1673 assert!(version_err.is_success());
1674 assert!(completions_err.is_success());
1675 assert!(!failed_err.is_success());
1676 }
1677
1678 #[derive(Facet, Debug, PartialEq)]
1684 #[repr(u8)]
1685 enum TestCommand {
1686 Build {
1688 #[facet(figue::named, figue::short = 'r')]
1690 release: bool,
1691 },
1692 Run {
1694 #[facet(figue::positional)]
1696 args: Vec<String>,
1697 },
1698 }
1699
1700 #[derive(Facet, Debug)]
1702 struct ArgsWithSubcommandOnly {
1703 #[facet(figue::subcommand)]
1704 command: TestCommand,
1705 }
1706
1707 #[derive(Facet, Debug)]
1709 struct ArgsWithSubcommandAndBuiltins {
1710 #[facet(figue::subcommand)]
1711 command: TestCommand,
1712
1713 #[facet(flatten)]
1714 builtins: FigueBuiltins,
1715 }
1716
1717 #[derive(Facet, Debug, PartialEq)]
1718 #[repr(u8)]
1719 enum TestCommandWithExplicitHelp {
1720 Help,
1721 Build {
1722 #[facet(figue::named, figue::short = 'r')]
1723 release: bool,
1724 },
1725 }
1726
1727 #[derive(Facet, Debug)]
1728 struct ArgsWithExplicitHelpSubcommand {
1729 #[facet(figue::subcommand)]
1730 command: TestCommandWithExplicitHelp,
1731
1732 #[facet(flatten)]
1733 builtins: FigueBuiltins,
1734 }
1735
1736 #[test]
1738 fn test_builder_api_with_subcommand_minimal() {
1739 let config = builder::<ArgsWithSubcommandOnly>()
1740 .expect("failed to build args schema")
1741 .cli(|cli| cli.args(["build", "--release"]))
1742 .build();
1743
1744 let result = Driver::new(config).run().into_result();
1745
1746 match result {
1747 Ok(output) => match &output.value.command {
1748 TestCommand::Build { release } => {
1749 assert!(*release, "release flag should be true");
1750 }
1751 TestCommand::Run { .. } => {
1752 panic!("expected Build subcommand, got Run");
1753 }
1754 },
1755 Err(e) => panic!("expected success, got error: {:?}", e),
1756 }
1757 }
1758
1759 #[test]
1761 fn test_builder_api_with_subcommand_and_builtins() {
1762 let config = builder::<ArgsWithSubcommandAndBuiltins>()
1763 .expect("failed to build args schema")
1764 .cli(|cli| cli.args(["build", "--release"]))
1765 .help(|h| h.program_name("test-app").version("1.0.0"))
1766 .build();
1767
1768 let result = Driver::new(config).run().into_result();
1769
1770 match result {
1771 Ok(output) => match &output.value.command {
1772 TestCommand::Build { release } => {
1773 assert!(*release, "release flag should be true");
1774 }
1775 TestCommand::Run { .. } => {
1776 panic!("expected Build subcommand, got Run");
1777 }
1778 },
1779 Err(e) => panic!("expected success, got error: {:?}", e),
1780 }
1781 }
1782
1783 #[test]
1784 fn test_builder_help_word_alias_triggers_help_without_help_subcommand() {
1785 let config = builder::<ArgsWithSubcommandAndBuiltins>()
1786 .expect("failed to build args schema")
1787 .cli(|cli| cli.args(["help"]))
1788 .help(|h| h.program_name("test-app"))
1789 .build();
1790
1791 let result = Driver::new(config).run().into_result();
1792
1793 match result {
1794 Err(DriverError::Help { text }) => {
1795 assert!(
1796 text.contains("test-app"),
1797 "help should contain configured program name"
1798 );
1799 assert!(
1800 text.contains("--help"),
1801 "help output should still mention --help"
1802 );
1803 }
1804 other => panic!("expected DriverError::Help, got {:?}", other),
1805 }
1806 }
1807
1808 #[test]
1809 fn test_builder_help_word_prefers_explicit_help_subcommand() {
1810 let config = builder::<ArgsWithExplicitHelpSubcommand>()
1811 .expect("failed to build args schema")
1812 .cli(|cli| cli.args(["help"]))
1813 .build();
1814
1815 let result = Driver::new(config).run().into_result();
1816
1817 match result {
1818 Ok(output) => {
1819 assert_eq!(output.value.command, TestCommandWithExplicitHelp::Help);
1820 assert!(
1821 !output.value.builtins.help,
1822 "builtin help flag should remain false"
1823 );
1824 }
1825 Err(e) => panic!("expected success, got error: {:?}", e),
1826 }
1827 }
1828
1829 #[test]
1830 fn test_builder_api_with_subcommand_no_args() {
1831 let config = builder::<ArgsWithSubcommandOnly>()
1832 .expect("failed to build args schema")
1833 .cli(|cli| cli.args(["build"]))
1834 .build();
1835
1836 let result = Driver::new(config).run().into_result();
1837
1838 match result {
1839 Ok(output) => match &output.value.command {
1840 TestCommand::Build { release } => {
1841 assert!(!*release, "release flag should be false by default");
1842 }
1843 TestCommand::Run { .. } => {
1844 panic!("expected Build subcommand, got Run");
1845 }
1846 },
1847 Err(e) => panic!("expected success, got error: {:?}", e),
1848 }
1849 }
1850
1851 #[test]
1852 fn test_builder_api_with_run_subcommand() {
1853 let config = builder::<ArgsWithSubcommandOnly>()
1854 .expect("failed to build args schema")
1855 .cli(|cli| cli.args(["run", "arg1", "arg2"]))
1856 .build();
1857
1858 let result = Driver::new(config).run().into_result();
1859
1860 match result {
1861 Ok(output) => match &output.value.command {
1862 TestCommand::Run { args } => {
1863 assert_eq!(args, &["arg1".to_string(), "arg2".to_string()]);
1864 }
1865 TestCommand::Build { .. } => {
1866 panic!("expected Run subcommand, got Build");
1867 }
1868 },
1869 Err(e) => panic!("expected success, got error: {:?}", e),
1870 }
1871 }
1872
1873 #[test]
1874 fn test_from_slice_with_subcommand() {
1875 let args: ArgsWithSubcommandOnly = crate::from_slice(&["build", "--release"]).unwrap();
1877
1878 match &args.command {
1879 TestCommand::Build { release } => {
1880 assert!(*release, "release flag should be true");
1881 }
1882 TestCommand::Run { .. } => {
1883 panic!("expected Build subcommand, got Run");
1884 }
1885 }
1886 }
1887
1888 #[test]
1889 fn test_from_slice_with_subcommand_and_builtins() {
1890 let args: ArgsWithSubcommandAndBuiltins =
1892 crate::from_slice(&["build", "--release"]).unwrap();
1893
1894 match &args.command {
1895 TestCommand::Build { release } => {
1896 assert!(*release, "release flag should be true");
1897 }
1898 TestCommand::Run { .. } => {
1899 panic!("expected Build subcommand, got Run");
1900 }
1901 }
1902 }
1903
1904 #[test]
1905 fn test_subcommand_with_builtins_parsing() {
1906 use crate::config_value_parser::fill_defaults_from_shape;
1907 use crate::layers::cli::CliConfigBuilder;
1908 use crate::layers::cli::parse_cli;
1909 use crate::missing::collect_missing_fields;
1910 use crate::schema::Schema;
1911
1912 let schema =
1914 Schema::from_shape(ArgsWithSubcommandAndBuiltins::SHAPE).expect("schema should build");
1915
1916 let cli_config = CliConfigBuilder::new().args(["build", "--release"]).build();
1918 let output = parse_cli(&schema, &cli_config);
1919
1920 assert!(output.diagnostics.is_empty(), "should have no diagnostics");
1921
1922 let cli_value = output.value.unwrap();
1924 let with_defaults =
1925 fill_defaults_from_shape(&cli_value, ArgsWithSubcommandAndBuiltins::SHAPE);
1926
1927 let mut missing_fields = Vec::new();
1929 collect_missing_fields(&with_defaults, &schema, &mut missing_fields);
1930
1931 assert!(
1932 missing_fields.is_empty(),
1933 "should have no missing fields, got: {:?}",
1934 missing_fields
1935 );
1936
1937 let result: Result<ArgsWithSubcommandAndBuiltins, _> =
1939 crate::config_value_parser::from_config_value(&with_defaults);
1940
1941 assert!(
1942 result.is_ok(),
1943 "deserialization should succeed: {:?}",
1944 result
1945 );
1946 }
1947
1948 #[derive(Facet, Debug, PartialEq)]
1954 #[repr(u8)]
1955 enum DatabaseAction {
1956 Create {
1958 #[facet(figue::positional)]
1960 name: String,
1961 },
1962 Run {
1964 #[facet(figue::named)]
1966 dry_run: bool,
1967 },
1968 Rollback {
1970 #[facet(figue::named, default)]
1972 count: Option<u32>,
1973 },
1974 }
1975
1976 #[derive(Facet, Debug, PartialEq)]
1978 #[repr(u8)]
1979 enum TopLevelCommand {
1980 Db {
1982 #[facet(figue::subcommand)]
1983 action: DatabaseAction,
1984 },
1985 Serve {
1987 #[facet(figue::named, default)]
1989 port: Option<u16>,
1990 #[facet(figue::named, default)]
1992 host: Option<String>,
1993 },
1994 Version,
1996 }
1997
1998 #[derive(Facet, Debug)]
2000 struct ArgsWithNestedSubcommands {
2001 #[facet(figue::named, figue::short = 'v')]
2003 verbose: bool,
2004
2005 #[facet(figue::subcommand)]
2006 command: TopLevelCommand,
2007
2008 #[facet(flatten)]
2009 builtins: FigueBuiltins,
2010 }
2011
2012 #[test]
2013 fn test_builder_nested_subcommand_db_create() {
2014 let config = builder::<ArgsWithNestedSubcommands>()
2015 .expect("failed to build schema")
2016 .cli(|cli| cli.args(["db", "create", "add_users_table"]))
2017 .help(|h| h.program_name("test-app"))
2018 .build();
2019
2020 let result = Driver::new(config).run().into_result();
2021 match result {
2022 Ok(output) => {
2023 assert!(!output.value.verbose);
2024 match &output.value.command {
2025 TopLevelCommand::Db { action } => match action {
2026 DatabaseAction::Create { name } => {
2027 assert_eq!(name, "add_users_table");
2028 }
2029 _ => panic!("expected Create action"),
2030 },
2031 _ => panic!("expected Db command"),
2032 }
2033 }
2034 Err(e) => panic!("expected success: {:?}", e),
2035 }
2036 }
2037
2038 #[test]
2039 fn test_builder_nested_subcommand_db_run_with_flag() {
2040 let config = builder::<ArgsWithNestedSubcommands>()
2041 .expect("failed to build schema")
2042 .cli(|cli| cli.args(["-v", "db", "run", "--dry-run"]))
2043 .help(|h| h.program_name("test-app"))
2044 .build();
2045
2046 let result = Driver::new(config).run().into_result();
2047 match result {
2048 Ok(output) => {
2049 assert!(output.value.verbose, "verbose should be true");
2050 match &output.value.command {
2051 TopLevelCommand::Db { action } => match action {
2052 DatabaseAction::Run { dry_run } => {
2053 assert!(*dry_run, "dry_run should be true");
2054 }
2055 _ => panic!("expected Run action"),
2056 },
2057 _ => panic!("expected Db command"),
2058 }
2059 }
2060 Err(e) => panic!("expected success: {:?}", e),
2061 }
2062 }
2063
2064 #[test]
2065 fn test_builder_nested_subcommand_db_rollback_default() {
2066 let config = builder::<ArgsWithNestedSubcommands>()
2067 .expect("failed to build schema")
2068 .cli(|cli| cli.args(["db", "rollback"]))
2069 .help(|h| h.program_name("test-app"))
2070 .build();
2071
2072 let result = Driver::new(config).run().into_result();
2073 match result {
2074 Ok(output) => match &output.value.command {
2075 TopLevelCommand::Db { action } => match action {
2076 DatabaseAction::Rollback { count } => {
2077 assert_eq!(*count, None, "count should default to None");
2078 }
2079 _ => panic!("expected Rollback action"),
2080 },
2081 _ => panic!("expected Db command"),
2082 },
2083 Err(e) => panic!("expected success: {:?}", e),
2084 }
2085 }
2086
2087 #[test]
2088 fn test_builder_nested_subcommand_db_rollback_with_count() {
2089 let config = builder::<ArgsWithNestedSubcommands>()
2090 .expect("failed to build schema")
2091 .cli(|cli| cli.args(["db", "rollback", "--count", "3"]))
2092 .help(|h| h.program_name("test-app"))
2093 .build();
2094
2095 let result = Driver::new(config).run().into_result();
2096 match result {
2097 Ok(output) => match &output.value.command {
2098 TopLevelCommand::Db { action } => match action {
2099 DatabaseAction::Rollback { count } => {
2100 assert_eq!(*count, Some(3));
2101 }
2102 _ => panic!("expected Rollback action"),
2103 },
2104 _ => panic!("expected Db command"),
2105 },
2106 Err(e) => panic!("expected success: {:?}", e),
2107 }
2108 }
2109
2110 #[test]
2111 fn test_builder_serve_with_defaults() {
2112 let config = builder::<ArgsWithNestedSubcommands>()
2113 .expect("failed to build schema")
2114 .cli(|cli| cli.args(["serve"]))
2115 .help(|h| h.program_name("test-app"))
2116 .build();
2117
2118 let result = Driver::new(config).run().into_result();
2119 match result {
2120 Ok(output) => match &output.value.command {
2121 TopLevelCommand::Serve { port, host } => {
2122 assert_eq!(*port, None);
2123 assert_eq!(*host, None);
2124 }
2125 _ => panic!("expected Serve command"),
2126 },
2127 Err(e) => panic!("expected success: {:?}", e),
2128 }
2129 }
2130
2131 #[test]
2132 fn test_builder_serve_with_options() {
2133 let config = builder::<ArgsWithNestedSubcommands>()
2134 .expect("failed to build schema")
2135 .cli(|cli| cli.args(["serve", "--port", "8080", "--host", "0.0.0.0"]))
2136 .help(|h| h.program_name("test-app"))
2137 .build();
2138
2139 let result = Driver::new(config).run().into_result();
2140 match result {
2141 Ok(output) => match &output.value.command {
2142 TopLevelCommand::Serve { port, host } => {
2143 assert_eq!(*port, Some(8080));
2144 assert_eq!(host.as_deref(), Some("0.0.0.0"));
2145 }
2146 _ => panic!("expected Serve command"),
2147 },
2148 Err(e) => panic!("expected success: {:?}", e),
2149 }
2150 }
2151
2152 #[test]
2153 fn test_builder_unit_variant_subcommand() {
2154 let config = builder::<ArgsWithNestedSubcommands>()
2155 .expect("failed to build schema")
2156 .cli(|cli| cli.args(["version"]))
2157 .help(|h| h.program_name("test-app"))
2158 .build();
2159
2160 let result = Driver::new(config).run().into_result();
2161 match result {
2162 Ok(output) => match &output.value.command {
2163 TopLevelCommand::Version => {
2164 }
2166 _ => panic!("expected Version command"),
2167 },
2168 Err(e) => panic!("expected success: {:?}", e),
2169 }
2170 }
2171
2172 #[derive(Facet, Debug, PartialEq, Default)]
2174 struct InstallOptions {
2175 #[facet(figue::named)]
2177 global: bool,
2178 #[facet(figue::named)]
2180 force: bool,
2181 }
2182
2183 #[derive(Facet, Debug, PartialEq)]
2184 #[repr(u8)]
2185 enum PackageCommand {
2186 Install(#[facet(flatten)] InstallOptions),
2188 Uninstall {
2190 #[facet(figue::positional)]
2192 name: String,
2193 },
2194 }
2195
2196 #[derive(Facet, Debug)]
2197 struct ArgsWithTupleVariant {
2198 #[facet(figue::subcommand)]
2199 command: PackageCommand,
2200
2201 #[facet(flatten)]
2202 builtins: FigueBuiltins,
2203 }
2204
2205 #[test]
2206 fn test_builder_tuple_variant_with_flatten() {
2207 let config = builder::<ArgsWithTupleVariant>()
2208 .expect("failed to build schema")
2209 .cli(|cli| cli.args(["install", "--global", "--force"]))
2210 .help(|h| h.program_name("pkg-manager"))
2211 .build();
2212
2213 let result = Driver::new(config).run().into_result();
2214 match result {
2215 Ok(output) => match &output.value.command {
2216 PackageCommand::Install(opts) => {
2217 assert!(opts.global, "global should be true");
2218 assert!(opts.force, "force should be true");
2219 }
2220 _ => panic!("expected Install command"),
2221 },
2222 Err(e) => panic!("expected success: {:?}", e),
2223 }
2224 }
2225
2226 #[test]
2227 fn test_builder_tuple_variant_defaults() {
2228 let config = builder::<ArgsWithTupleVariant>()
2229 .expect("failed to build schema")
2230 .cli(|cli| cli.args(["install"]))
2231 .help(|h| h.program_name("pkg-manager"))
2232 .build();
2233
2234 let result = Driver::new(config).run().into_result();
2235 match result {
2236 Ok(output) => match &output.value.command {
2237 PackageCommand::Install(opts) => {
2238 assert!(!opts.global, "global should default to false");
2239 assert!(!opts.force, "force should default to false");
2240 }
2241 _ => panic!("expected Install command"),
2242 },
2243 Err(e) => panic!("expected success: {:?}", e),
2244 }
2245 }
2246
2247 #[derive(Facet, Debug, PartialEq)]
2249 #[repr(u8)]
2250 enum RenamedCommand {
2251 #[facet(rename = "ls")]
2253 List {
2254 #[facet(figue::named, figue::short = 'a')]
2256 all: bool,
2257 },
2258 #[facet(rename = "rm")]
2260 Remove {
2261 #[facet(figue::named, figue::short = 'f')]
2263 force: bool,
2264 #[facet(figue::positional)]
2266 path: String,
2267 },
2268 }
2269
2270 #[derive(Facet, Debug)]
2271 struct ArgsWithRenamedSubcommands {
2272 #[facet(figue::subcommand)]
2273 command: RenamedCommand,
2274
2275 #[facet(flatten)]
2276 builtins: FigueBuiltins,
2277 }
2278
2279 #[test]
2280 fn test_builder_renamed_subcommand_ls() {
2281 let config = builder::<ArgsWithRenamedSubcommands>()
2282 .expect("failed to build schema")
2283 .cli(|cli| cli.args(["ls", "-a"]))
2284 .help(|h| h.program_name("file-tool"))
2285 .build();
2286
2287 let result = Driver::new(config).run().into_result();
2288 match result {
2289 Ok(output) => match &output.value.command {
2290 RenamedCommand::List { all } => {
2291 assert!(*all, "all should be true");
2292 }
2293 _ => panic!("expected List command"),
2294 },
2295 Err(e) => panic!("expected success: {:?}", e),
2296 }
2297 }
2298
2299 #[test]
2300 fn test_builder_renamed_subcommand_rm() {
2301 let config = builder::<ArgsWithRenamedSubcommands>()
2302 .expect("failed to build schema")
2303 .cli(|cli| cli.args(["rm", "-f", "/tmp/file.txt"]))
2304 .help(|h| h.program_name("file-tool"))
2305 .build();
2306
2307 let result = Driver::new(config).run().into_result();
2308 match result {
2309 Ok(output) => match &output.value.command {
2310 RenamedCommand::Remove { force, path } => {
2311 assert!(*force, "force should be true");
2312 assert_eq!(path, "/tmp/file.txt");
2313 }
2314 _ => panic!("expected Remove command"),
2315 },
2316 Err(e) => panic!("expected success: {:?}", e),
2317 }
2318 }
2319
2320 #[derive(Facet, Debug, PartialEq, Default)]
2322 struct LoggingOpts {
2323 #[facet(figue::named)]
2325 debug: bool,
2326 #[facet(figue::named, default)]
2328 log_file: Option<String>,
2329 }
2330
2331 #[derive(Facet, Debug, PartialEq, Default)]
2332 struct CommonOpts {
2333 #[facet(figue::named, figue::short = 'v')]
2335 verbose: bool,
2336 #[facet(figue::named, figue::short = 'q')]
2338 quiet: bool,
2339 #[facet(flatten)]
2340 logging: LoggingOpts,
2341 }
2342
2343 #[derive(Facet, Debug, PartialEq)]
2344 #[repr(u8)]
2345 enum DeepCommand {
2346 Execute {
2348 #[facet(flatten)]
2349 common: CommonOpts,
2350 #[facet(figue::positional)]
2352 target: String,
2353 },
2354 }
2355
2356 #[derive(Facet, Debug)]
2357 struct ArgsWithDeepFlatten {
2358 #[facet(figue::subcommand)]
2359 command: DeepCommand,
2360
2361 #[facet(flatten)]
2362 builtins: FigueBuiltins,
2363 }
2364
2365 #[test]
2366 fn test_builder_deep_flatten_all_flags() {
2367 let config = builder::<ArgsWithDeepFlatten>()
2368 .expect("failed to build schema")
2369 .cli(|cli| {
2370 cli.args([
2371 "execute",
2372 "-v",
2373 "--debug",
2374 "--log-file",
2375 "/var/log/app.log",
2376 "my-target",
2377 ])
2378 })
2379 .help(|h| h.program_name("deep-app"))
2380 .build();
2381
2382 let result = Driver::new(config).run().into_result();
2383 match result {
2384 Ok(output) => match &output.value.command {
2385 DeepCommand::Execute { common, target } => {
2386 assert!(common.verbose, "verbose should be true");
2387 assert!(!common.quiet, "quiet should be false");
2388 assert!(common.logging.debug, "debug should be true");
2389 assert_eq!(common.logging.log_file.as_deref(), Some("/var/log/app.log"));
2390 assert_eq!(target, "my-target");
2391 }
2392 },
2393 Err(e) => panic!("expected success: {:?}", e),
2394 }
2395 }
2396
2397 #[test]
2398 fn test_builder_deep_flatten_defaults() {
2399 let config = builder::<ArgsWithDeepFlatten>()
2400 .expect("failed to build schema")
2401 .cli(|cli| cli.args(["execute", "simple-target"]))
2402 .help(|h| h.program_name("deep-app"))
2403 .build();
2404
2405 let result = Driver::new(config).run().into_result();
2406 match result {
2407 Ok(output) => match &output.value.command {
2408 DeepCommand::Execute { common, target } => {
2409 assert!(!common.verbose);
2410 assert!(!common.quiet);
2411 assert!(!common.logging.debug);
2412 assert_eq!(common.logging.log_file, None);
2413 assert_eq!(target, "simple-target");
2414 }
2415 },
2416 Err(e) => panic!("expected success: {:?}", e),
2417 }
2418 }
2419
2420 #[derive(Facet, Debug)]
2425 #[facet(rename_all = "kebab-case")]
2426 #[repr(u8)]
2427 #[allow(dead_code)]
2428 enum StorageBackend {
2429 S3 { bucket: String, region: String },
2430 Gcp { project: String, zone: String },
2431 Local { path: String },
2432 }
2433
2434 #[derive(Facet, Debug)]
2435 struct StorageConfig {
2436 storage: StorageBackend,
2437 }
2438
2439 #[derive(Facet, Debug)]
2440 struct ArgsWithStorageConfig {
2441 #[facet(figue::config)]
2442 config: StorageConfig,
2443 }
2444
2445 #[test]
2446 #[ignore = "env parser doesn't support enum variant paths yet (issue #37)"]
2447 fn test_enum_variant_conflict_from_env() {
2448 use crate::layers::env::MockEnv;
2449
2450 let config = builder::<ArgsWithStorageConfig>()
2452 .expect("failed to build schema")
2453 .env(|env| {
2454 env.prefix("MYAPP").source(MockEnv::from_pairs([
2455 ("MYAPP__STORAGE__S3__BUCKET", "my-bucket"),
2456 ("MYAPP__STORAGE__S3__REGION", "us-east-1"),
2457 ("MYAPP__STORAGE__GCP__PROJECT", "my-project"),
2458 ]))
2459 })
2460 .build();
2461
2462 let result = Driver::new(config).run().into_result();
2463 match result {
2464 Err(DriverError::Failed { report }) => {
2465 let msg = format!("{}", report);
2466 assert!(
2467 msg.contains("Conflicting enum variants"),
2468 "should report enum conflict: {msg}"
2469 );
2470 assert!(msg.contains("s3"), "should mention s3 variant: {msg}");
2471 assert!(msg.contains("gcp"), "should mention gcp variant: {msg}");
2472 }
2473 Ok(_) => panic!("expected conflict error, got success"),
2474 Err(e) => panic!("expected conflict error, got {:?}", e),
2475 }
2476 }
2477
2478 #[test]
2479 #[ignore = "env parser doesn't support enum variant paths yet (issue #37)"]
2480 fn test_enum_no_conflict_single_variant_from_env() {
2481 use crate::layers::env::MockEnv;
2482
2483 let config = builder::<ArgsWithStorageConfig>()
2485 .expect("failed to build schema")
2486 .env(|env| {
2487 env.prefix("MYAPP").source(MockEnv::from_pairs([
2488 ("MYAPP__STORAGE__S3__BUCKET", "my-bucket"),
2489 ("MYAPP__STORAGE__S3__REGION", "us-east-1"),
2490 ]))
2491 })
2492 .build();
2493
2494 let result = Driver::new(config).run().into_result();
2495 match result {
2496 Ok(output) => match &output.value.config.storage {
2497 StorageBackend::S3 { bucket, region } => {
2498 assert_eq!(bucket, "my-bucket");
2499 assert_eq!(region, "us-east-1");
2500 }
2501 other => panic!("expected S3 variant, got {:?}", other),
2502 },
2503 Err(e) => panic!("expected success, got {:?}", e),
2504 }
2505 }
2506
2507 #[test]
2508 #[ignore = "env parser doesn't support enum variant paths yet (issue #37)"]
2509 fn test_enum_variant_conflict_cross_source() {
2510 use crate::layers::env::MockEnv;
2511
2512 let config = builder::<ArgsWithStorageConfig>()
2514 .expect("failed to build schema")
2515 .env(|env| {
2516 env.prefix("MYAPP").source(MockEnv::from_pairs([
2517 ("MYAPP__STORAGE__S3__BUCKET", "my-bucket"),
2518 ("MYAPP__STORAGE__S3__REGION", "us-east-1"),
2519 ]))
2520 })
2521 .cli(|cli| {
2522 cli.args([
2523 "--config.storage.gcp.project",
2524 "my-project",
2525 "--config.storage.gcp.zone",
2526 "us-central1-a",
2527 ])
2528 })
2529 .build();
2530
2531 let result = Driver::new(config).run().into_result();
2532 match result {
2533 Err(DriverError::Failed { report }) => {
2534 let msg = format!("{}", report);
2535 assert!(
2536 msg.contains("Conflicting enum variants"),
2537 "should report enum conflict: {msg}"
2538 );
2539 assert!(msg.contains("s3"), "should mention s3: {msg}");
2541 assert!(msg.contains("gcp"), "should mention gcp: {msg}");
2542 }
2543 Ok(_) => panic!("expected conflict error, got success"),
2544 Err(e) => panic!("expected conflict error, got {:?}", e),
2545 }
2546 }
2547
2548 #[test]
2549 fn test_enum_variant_conflict_from_cli() {
2550 let config = builder::<ArgsWithStorageConfig>()
2552 .expect("failed to build schema")
2553 .cli(|cli| {
2554 cli.args([
2555 "--config.storage.s3.bucket",
2556 "my-bucket",
2557 "--config.storage.gcp.project",
2558 "my-project",
2559 ])
2560 })
2561 .build();
2562
2563 let result = Driver::new(config).run().into_result();
2564 match result {
2565 Err(DriverError::Failed { report }) => {
2566 let msg = format!("{}", report);
2567 assert!(
2568 msg.contains("Conflicting enum variants"),
2569 "should report enum conflict: {msg}"
2570 );
2571 }
2572 Ok(_) => panic!("expected conflict error, got success"),
2573 Err(e) => panic!("expected conflict error, got {:?}", e),
2574 }
2575 }
2576
2577 #[test]
2578 fn test_enum_no_conflict_single_variant_from_cli() {
2579 let config = builder::<ArgsWithStorageConfig>()
2581 .expect("failed to build schema")
2582 .cli(|cli| {
2583 cli.args([
2584 "--config.storage.s3.bucket",
2585 "my-bucket",
2586 "--config.storage.s3.region",
2587 "us-east-1",
2588 ])
2589 })
2590 .build();
2591
2592 let result = Driver::new(config).run().into_result();
2593 match result {
2594 Ok(output) => match &output.value.config.storage {
2595 StorageBackend::S3 { bucket, region } => {
2596 assert_eq!(bucket, "my-bucket");
2597 assert_eq!(region, "us-east-1");
2598 }
2599 other => panic!("expected S3 variant, got {:?}", other),
2600 },
2601 Err(e) => panic!("expected success, got {:?}", e),
2602 }
2603 }
2604
2605 #[test]
2606 fn test_enum_variant_conflict_three_variants() {
2607 let config = builder::<ArgsWithStorageConfig>()
2609 .expect("failed to build schema")
2610 .cli(|cli| {
2611 cli.args([
2612 "--config.storage.s3.bucket",
2613 "my-bucket",
2614 "--config.storage.gcp.project",
2615 "my-project",
2616 "--config.storage.local.path",
2617 "/data",
2618 ])
2619 })
2620 .build();
2621
2622 let result = Driver::new(config).run().into_result();
2623 match result {
2624 Err(DriverError::Failed { report }) => {
2625 let msg = format!("{}", report);
2626 assert!(
2627 msg.contains("Conflicting enum variants"),
2628 "should report enum conflict: {msg}"
2629 );
2630 assert!(msg.contains("s3"), "should mention s3: {msg}");
2632 assert!(msg.contains("gcp"), "should mention gcp: {msg}");
2633 assert!(msg.contains("local"), "should mention local: {msg}");
2634 }
2635 Ok(_) => panic!("expected conflict error, got success"),
2636 Err(e) => panic!("expected conflict error, got {:?}", e),
2637 }
2638 }
2639
2640 #[derive(Facet, Debug, PartialEq, Default)]
2643 struct SimpleOptions {
2644 #[facet(figue::named)]
2646 do_thing: bool,
2647 }
2648
2649 #[derive(Facet, Debug, PartialEq)]
2650 #[repr(u8)]
2651 enum SimpleCommand {
2652 DoSomething(SimpleOptions),
2654 }
2655
2656 #[derive(Facet, Debug)]
2657 struct ArgsWithSimpleTupleVariant {
2658 #[facet(figue::subcommand)]
2659 command: SimpleCommand,
2660
2661 #[facet(flatten)]
2662 builtins: FigueBuiltins,
2663 }
2664
2665 #[test]
2669 fn test_builder_tuple_variant_no_explicit_flatten() {
2670 let config = builder::<ArgsWithSimpleTupleVariant>()
2671 .expect("failed to build schema")
2672 .cli(|cli| cli.args(["do-something"]))
2673 .help(|h| h.program_name("test-app"))
2674 .build();
2675
2676 let result = Driver::new(config).run().into_result();
2677 match result {
2678 Ok(output) => match &output.value.command {
2679 SimpleCommand::DoSomething(opts) => {
2680 assert!(!opts.do_thing, "do_thing should default to false");
2681 }
2682 },
2683 Err(e) => panic!("expected success: {:?}", e),
2684 }
2685 }
2686}