1use std::boxed::Box;
27use std::string::String;
28use std::sync::Arc;
29use std::vec::Vec;
30
31use camino::{Utf8Path, Utf8PathBuf};
32
33use crate::config_format::{ConfigFormat, ConfigFormatError, JsonFormat};
34use crate::config_value::ConfigValue;
35use crate::driver::{Diagnostic, LayerOutput, Severity};
36use crate::provenance::{ConfigFile, FilePathStatus, FileResolution};
37use crate::schema::Schema;
38use crate::value_builder::ValueBuilder;
39
40#[derive(Default)]
49pub struct FormatRegistry {
50 formats: Vec<Box<dyn ConfigFormat>>,
51}
52
53impl FormatRegistry {
54 pub fn new() -> Self {
56 Self {
57 formats: Vec::new(),
58 }
59 }
60
61 pub fn with_defaults() -> Self {
63 let mut registry = Self::new();
64 registry.register(JsonFormat);
65 registry
66 }
67
68 pub fn register<F: ConfigFormat + 'static>(&mut self, format: F) {
70 self.formats.push(Box::new(format));
71 }
72
73 pub fn find_by_extension(&self, extension: &str) -> Option<&dyn ConfigFormat> {
77 let ext_lower = extension.to_lowercase();
78 self.formats
79 .iter()
80 .find(|f| {
81 f.extensions()
82 .iter()
83 .any(|e| e.eq_ignore_ascii_case(&ext_lower))
84 })
85 .map(|f| f.as_ref())
86 }
87
88 pub fn parse(&self, contents: &str, extension: &str) -> Result<ConfigValue, ConfigFormatError> {
90 let format = self.find_by_extension(extension).ok_or_else(|| {
91 ConfigFormatError::new(format!("unsupported file extension: .{extension}"))
92 })?;
93 format.parse(contents)
94 }
95
96 pub fn parse_file(
101 &self,
102 path: &Utf8Path,
103 contents: &str,
104 ) -> Result<ConfigValue, ConfigFormatError> {
105 let extension = path.extension().unwrap_or("");
106 let mut value = self.parse(contents, extension)?;
107
108 let file = Arc::new(ConfigFile::new(path, contents));
110 value.set_file_provenance_recursive(&file, "");
111
112 Ok(value)
113 }
114
115 pub fn extensions(&self) -> Vec<&str> {
117 self.formats
118 .iter()
119 .flat_map(|f| f.extensions().iter().copied())
120 .collect()
121 }
122}
123
124pub struct FileConfig {
130 pub explicit_path: Option<Utf8PathBuf>,
132
133 pub default_paths: Vec<Utf8PathBuf>,
135
136 pub registry: FormatRegistry,
138
139 pub strict: bool,
141
142 pub inline_content: Option<(String, String)>,
146}
147
148impl Default for FileConfig {
149 fn default() -> Self {
150 Self {
151 explicit_path: None,
152 default_paths: Vec::new(),
153 registry: FormatRegistry::with_defaults(),
154 strict: false,
155 inline_content: None,
156 }
157 }
158}
159
160impl FileConfig {
161 pub fn new() -> Self {
163 Self::default()
164 }
165
166 pub fn path(mut self, path: impl Into<Utf8PathBuf>) -> Self {
168 self.explicit_path = Some(path.into());
169 self
170 }
171
172 pub fn default_paths<I, P>(mut self, paths: I) -> Self
174 where
175 I: IntoIterator<Item = P>,
176 P: Into<Utf8PathBuf>,
177 {
178 self.default_paths = paths.into_iter().map(|p| p.into()).collect();
179 self
180 }
181
182 pub fn registry(mut self, registry: FormatRegistry) -> Self {
184 self.registry = registry;
185 self
186 }
187
188 pub fn strict(mut self) -> Self {
190 self.strict = true;
191 self
192 }
193
194 pub fn content(mut self, content: impl Into<String>, filename: impl Into<String>) -> Self {
198 self.inline_content = Some((content.into(), filename.into()));
199 self
200 }
201}
202
203pub struct FileParseResult {
209 pub output: LayerOutput,
211 pub resolution: FileResolution,
213}
214
215pub fn parse_file(schema: &Schema, config: &FileConfig) -> FileParseResult {
224 let mut ctx = FileParseContext::new(schema, config);
225 ctx.parse();
226 ctx.into_result()
227}
228
229struct FileParseContext<'a> {
231 schema: &'a Schema,
232 config: &'a FileConfig,
233 value: Option<ConfigValue>,
235 early_diagnostics: Vec<Diagnostic>,
237 resolution: FileResolution,
239}
240
241impl<'a> FileParseContext<'a> {
242 fn new(schema: &'a Schema, config: &'a FileConfig) -> Self {
243 Self {
244 schema,
245 config,
246 value: None,
247 early_diagnostics: Vec::new(),
248 resolution: FileResolution::new(),
249 }
250 }
251
252 fn parse(&mut self) {
253 let (path, contents) = if let Some((content, filename)) = &self.config.inline_content {
255 let path = Utf8PathBuf::from(filename);
256 self.resolution.add_explicit(path.clone(), true);
258 (path, content.clone())
259 } else {
260 let path = match self.resolve_path() {
262 Some(p) => p,
263 None => return, };
265
266 let contents = match std::fs::read_to_string(&path) {
268 Ok(c) => c,
269 Err(e) => {
270 self.emit_error(format!("failed to read {}: {}", path, e));
271 return;
272 }
273 };
274 (path, contents)
275 };
276
277 let parsed = match self.config.registry.parse_file(&path, &contents) {
279 Ok(v) => v,
280 Err(e) => {
281 self.emit_error(format!("failed to parse {}: {}", path, e));
282 return;
283 }
284 };
285
286 self.value = Some(parsed);
287 }
288
289 fn resolve_path(&mut self) -> Option<Utf8PathBuf> {
293 if let Some(explicit) = &self.config.explicit_path {
295 let exists = explicit.exists();
296 self.resolution.add_explicit(explicit.clone(), exists);
297
298 if exists {
299 self.resolution
301 .mark_defaults_not_tried(&self.config.default_paths);
302 return Some(explicit.clone());
303 } else {
304 self.emit_error(format!("config file not found: {}", explicit));
305 return None;
306 }
307 }
308
309 for default_path in &self.config.default_paths {
311 if default_path.exists() {
312 self.resolution
313 .add_default(default_path.clone(), FilePathStatus::Picked);
314 return Some(default_path.clone());
315 } else {
316 self.resolution
317 .add_default(default_path.clone(), FilePathStatus::Absent);
318 }
319 }
320
321 None
323 }
324
325 fn emit_error(&mut self, message: String) {
326 self.early_diagnostics.push(Diagnostic {
327 message,
328 label: None,
329 path: None,
330 span: None,
331 severity: Severity::Error,
332 });
333 }
334
335 fn into_result(self) -> FileParseResult {
336 let output = if let Some(config_schema) = self.schema.config() {
339 if let Some(ref parsed) = self.value {
340 let mut builder = ValueBuilder::new(config_schema);
342 builder.import_tree(parsed);
343
344 let mut output =
349 builder.into_output_with_value(self.value.clone(), config_schema.field_name());
350
351 let mut all_diagnostics = self.early_diagnostics;
353 all_diagnostics.append(&mut output.diagnostics);
354 output.diagnostics = all_diagnostics;
355
356 output
357 } else {
358 LayerOutput {
360 value: None,
361 unused_keys: Vec::new(),
362 diagnostics: self.early_diagnostics,
363 source_text: None,
364 config_file_path: None,
365 help_list_mode: None,
366 }
367 }
368 } else {
369 LayerOutput {
371 value: self.value,
372 unused_keys: Vec::new(),
373 diagnostics: self.early_diagnostics,
374 source_text: None,
375 config_file_path: None,
376 help_list_mode: None,
377 }
378 };
379
380 FileParseResult {
381 output,
382 resolution: self.resolution,
383 }
384 }
385}
386
387#[cfg(test)]
388mod tests {
389 use super::*;
390 use crate as figue;
391 use crate::provenance::Provenance;
392 use facet::Facet;
393 use std::io::Write;
394 use tempfile::NamedTempFile;
395
396 fn get_provenance(value: &ConfigValue) -> Option<&Provenance> {
398 match value {
399 ConfigValue::Null(s) => s.provenance.as_ref(),
400 ConfigValue::Bool(s) => s.provenance.as_ref(),
401 ConfigValue::Integer(s) => s.provenance.as_ref(),
402 ConfigValue::Float(s) => s.provenance.as_ref(),
403 ConfigValue::String(s) => s.provenance.as_ref(),
404 ConfigValue::Array(s) => s.provenance.as_ref(),
405 ConfigValue::Object(s) => s.provenance.as_ref(),
406 ConfigValue::Enum(s) => s.provenance.as_ref(),
407 }
408 }
409
410 #[derive(Facet)]
415 struct ArgsWithConfig {
416 #[facet(figue::named)]
417 verbose: bool,
418
419 #[facet(figue::config)]
420 config: ServerConfig,
421 }
422
423 #[derive(Facet)]
424 struct ServerConfig {
425 port: u16,
426 host: String,
427 }
428
429 #[derive(Facet)]
430 struct ArgsWithNestedConfig {
431 #[facet(figue::config)]
432 settings: AppSettings,
433 }
434
435 #[derive(Facet)]
436 struct AppSettings {
437 port: u16,
438 smtp: SmtpConfig,
439 }
440
441 #[derive(Facet)]
442 struct SmtpConfig {
443 host: String,
444 connection_timeout: u64,
445 }
446
447 fn create_temp_json(content: &str) -> NamedTempFile {
452 let mut file = NamedTempFile::with_suffix(".json").unwrap();
453 write!(file, "{}", content).unwrap();
454 file
455 }
456
457 fn get_nested<'a>(cv: &'a ConfigValue, path: &[&str]) -> Option<&'a ConfigValue> {
458 let mut current = cv;
459 for key in path {
460 match current {
461 ConfigValue::Object(obj) => {
462 current = obj.value.get(*key)?;
463 }
464 _ => return None,
465 }
466 }
467 Some(current)
468 }
469
470 fn get_integer(cv: &ConfigValue) -> Option<i64> {
471 match cv {
472 ConfigValue::Integer(i) => Some(i.value),
473 _ => None,
474 }
475 }
476
477 fn get_string(cv: &ConfigValue) -> Option<&str> {
478 match cv {
479 ConfigValue::String(s) => Some(&s.value),
480 _ => None,
481 }
482 }
483
484 #[test]
489 fn test_parse_simple_json() {
490 let file = create_temp_json(r#"{"port": 8080, "host": "localhost"}"#);
491 let path = Utf8PathBuf::from_path_buf(file.path().to_path_buf()).unwrap();
492
493 let schema = Schema::from_shape(ArgsWithConfig::SHAPE).unwrap();
494 let config = FileConfig::new().path(path);
495
496 let result = parse_file(&schema, &config);
497
498 assert!(result.output.diagnostics.is_empty());
499 assert!(result.output.unused_keys.is_empty());
500
501 let value = result.output.value.expect("should have value");
502 let port = get_nested(&value, &["config", "port"]).expect("config.port");
503 assert_eq!(get_integer(port), Some(8080));
504
505 let host = get_nested(&value, &["config", "host"]).expect("config.host");
506 assert_eq!(get_string(host), Some("localhost"));
507 }
508
509 #[test]
510 fn test_parse_nested_json() {
511 let file = create_temp_json(
512 r#"{"port": 8080, "smtp": {"host": "mail.example.com", "connection_timeout": 30}}"#,
513 );
514 let path = Utf8PathBuf::from_path_buf(file.path().to_path_buf()).unwrap();
515
516 let schema = Schema::from_shape(ArgsWithNestedConfig::SHAPE).unwrap();
517 let config = FileConfig::new().path(path);
518
519 let result = parse_file(&schema, &config);
520
521 assert!(result.output.diagnostics.is_empty());
522 let value = result.output.value.expect("should have value");
523
524 let port = get_nested(&value, &["settings", "port"]).expect("settings.port");
525 assert_eq!(get_integer(port), Some(8080));
526
527 let smtp_host =
528 get_nested(&value, &["settings", "smtp", "host"]).expect("settings.smtp.host");
529 assert_eq!(get_string(smtp_host), Some("mail.example.com"));
530 }
531
532 #[test]
537 fn test_explicit_path_not_found() {
538 let schema = Schema::from_shape(ArgsWithConfig::SHAPE).unwrap();
539 let config = FileConfig::new().path("/nonexistent/config.json");
540
541 let result = parse_file(&schema, &config);
542
543 assert!(!result.output.diagnostics.is_empty());
544 assert!(
545 result
546 .output
547 .diagnostics
548 .iter()
549 .any(|d| d.message.contains("not found"))
550 );
551 }
552
553 #[test]
554 fn test_no_file_configured() {
555 let schema = Schema::from_shape(ArgsWithConfig::SHAPE).unwrap();
556 let config = FileConfig::new(); let result = parse_file(&schema, &config);
559
560 assert!(result.output.diagnostics.is_empty());
562 assert!(result.output.value.is_none());
563 }
564
565 #[test]
566 fn test_default_paths_tried_in_order() {
567 let file = create_temp_json(r#"{"port": 9000, "host": "default"}"#);
568 let path = Utf8PathBuf::from_path_buf(file.path().to_path_buf()).unwrap();
569
570 let schema = Schema::from_shape(ArgsWithConfig::SHAPE).unwrap();
571 let config = FileConfig::new().default_paths([
572 Utf8PathBuf::from("/nonexistent/first.json"),
573 path.clone(),
574 Utf8PathBuf::from("/nonexistent/third.json"),
575 ]);
576
577 let result = parse_file(&schema, &config);
578
579 assert!(result.output.diagnostics.is_empty());
580 assert!(result.output.value.is_some());
581
582 assert_eq!(result.resolution.paths.len(), 2); assert!(matches!(
585 result.resolution.paths[0].status,
586 FilePathStatus::Absent
587 ));
588 assert!(matches!(
589 result.resolution.paths[1].status,
590 FilePathStatus::Picked
591 ));
592 }
593
594 #[test]
599 fn test_unknown_key_tracked() {
600 let file = create_temp_json(r#"{"port": 8080, "host": "localhost", "unknown_field": 123}"#);
601 let path = Utf8PathBuf::from_path_buf(file.path().to_path_buf()).unwrap();
602
603 let schema = Schema::from_shape(ArgsWithConfig::SHAPE).unwrap();
604 let config = FileConfig::new().path(path);
605
606 let result = parse_file(&schema, &config);
607
608 assert!(!result.output.unused_keys.is_empty());
610 assert!(
611 result
612 .output
613 .unused_keys
614 .iter()
615 .any(|k| k.key.contains(&"unknown_field".to_string()))
616 );
617
618 assert!(result.output.diagnostics.is_empty());
620 }
621
622 #[test]
623 fn test_unknown_key_tracked_in_strict_mode() {
624 let file = create_temp_json(r#"{"port": 8080, "host": "localhost", "unknown_field": 123}"#);
627 let path = Utf8PathBuf::from_path_buf(file.path().to_path_buf()).unwrap();
628
629 let schema = Schema::from_shape(ArgsWithConfig::SHAPE).unwrap();
630 let config = FileConfig::new().path(path).strict();
631
632 let result = parse_file(&schema, &config);
633
634 assert!(
636 !result.output.unused_keys.is_empty(),
637 "should track unknown key in unused_keys"
638 );
639 assert!(
640 result
641 .output
642 .unused_keys
643 .iter()
644 .any(|uk| uk.key.join(".") == "unknown_field"),
645 "unused_keys should contain 'unknown_field': {:?}",
646 result.output.unused_keys
647 );
648
649 let errors: Vec<_> = result
651 .output
652 .diagnostics
653 .iter()
654 .filter(|d| d.severity == Severity::Error)
655 .collect();
656 assert!(
657 errors.is_empty(),
658 "should not have error diagnostics at parse time, got: {:?}",
659 errors
660 );
661 }
662
663 #[test]
668 fn test_file_provenance() {
669 let file = create_temp_json(r#"{"port": 8080, "host": "localhost"}"#);
670 let path = Utf8PathBuf::from_path_buf(file.path().to_path_buf()).unwrap();
671
672 let schema = Schema::from_shape(ArgsWithConfig::SHAPE).unwrap();
673 let config = FileConfig::new().path(path.clone());
674
675 let result = parse_file(&schema, &config);
676
677 let value = result.output.value.expect("should have value");
678 let port = get_nested(&value, &["config", "port"]).expect("config.port");
679
680 let prov = get_provenance(port).expect("should have provenance");
682 assert!(prov.is_file());
683 if let Provenance::File {
684 file: config_file, ..
685 } = prov
686 {
687 assert_eq!(config_file.path, path);
688 }
689 }
690
691 #[test]
696 fn test_format_registry_with_defaults() {
697 let registry = FormatRegistry::with_defaults();
698 assert!(registry.find_by_extension("json").is_some());
699 assert!(registry.find_by_extension("JSON").is_some()); assert!(registry.find_by_extension("toml").is_none());
701 }
702
703 #[test]
704 fn test_format_registry_extensions() {
705 let registry = FormatRegistry::with_defaults();
706 let extensions = registry.extensions();
707 assert!(extensions.contains(&"json"));
708 }
709
710 #[derive(Facet)]
716 struct CommonConfig {
717 log_level: Option<String>,
719 debug: bool,
721 }
722
723 #[derive(Facet)]
725 struct ConfigWithFlatten {
726 name: String,
728 #[facet(flatten)]
730 common: CommonConfig,
731 }
732
733 #[derive(Facet)]
734 struct ArgsWithFlattenedConfig {
735 #[facet(figue::config)]
736 config: ConfigWithFlatten,
737 }
738
739 #[test]
740 fn test_flatten_config_parses_flat_json() {
741 let file = create_temp_json(r#"{"name": "myapp", "log_level": "debug", "debug": true}"#);
744 let path = Utf8PathBuf::from_path_buf(file.path().to_path_buf()).unwrap();
745
746 let schema = Schema::from_shape(ArgsWithFlattenedConfig::SHAPE).unwrap();
747 let config = FileConfig::new().path(path);
748
749 let result = parse_file(&schema, &config);
750
751 assert!(
753 result.output.diagnostics.is_empty(),
754 "should have no errors: {:?}",
755 result.output.diagnostics
756 );
757 assert!(
758 result.output.unused_keys.is_empty(),
759 "should have no unused keys: {:?}",
760 result.output.unused_keys
761 );
762
763 let value = result.output.value.expect("should have value");
764
765 let name = get_nested(&value, &["config", "name"]).expect("config.name");
767 assert_eq!(get_string(name), Some("myapp"));
768
769 let log_level = get_nested(&value, &["config", "log_level"]).expect("config.log_level");
770 assert_eq!(get_string(log_level), Some("debug"));
771
772 let debug = get_nested(&value, &["config", "debug"]).expect("config.debug");
773 assert!(matches!(debug, ConfigValue::Bool(b) if b.value));
774 }
775
776 #[test]
777 fn test_flatten_config_rejects_nested_json() {
778 let file = create_temp_json(
781 r#"{"name": "myapp", "common": {"log_level": "debug", "debug": true}}"#,
782 );
783 let path = Utf8PathBuf::from_path_buf(file.path().to_path_buf()).unwrap();
784
785 let schema = Schema::from_shape(ArgsWithFlattenedConfig::SHAPE).unwrap();
786 let config = FileConfig::new().path(path);
787
788 let result = parse_file(&schema, &config);
789
790 assert!(
792 result
793 .output
794 .unused_keys
795 .iter()
796 .any(|k| k.key.contains(&"common".to_string())),
797 "should reject 'common' key: {:?}",
798 result.output.unused_keys
799 );
800 }
801
802 #[derive(Facet)]
804 struct DatabaseConfig {
805 host: String,
807 port: u16,
809 }
810
811 #[derive(Facet)]
813 struct ExtendedConfig {
814 #[facet(flatten)]
815 common: CommonConfig,
816 #[facet(flatten)]
817 database: DatabaseConfig,
818 }
819
820 #[derive(Facet)]
822 struct ConfigWithNestedFlatten {
823 app_name: String,
824 #[facet(flatten)]
825 extended: ExtendedConfig,
826 }
827
828 #[derive(Facet)]
829 struct ArgsWithNestedFlattenConfig {
830 #[facet(figue::config)]
831 config: ConfigWithNestedFlatten,
832 }
833
834 #[test]
835 fn test_two_level_flatten_config() {
836 let file = create_temp_json(
839 r#"{
840 "app_name": "super-app",
841 "log_level": "info",
842 "debug": false,
843 "host": "db.example.com",
844 "port": 5432
845 }"#,
846 );
847 let path = Utf8PathBuf::from_path_buf(file.path().to_path_buf()).unwrap();
848
849 let schema = Schema::from_shape(ArgsWithNestedFlattenConfig::SHAPE).unwrap();
850 let config = FileConfig::new().path(path);
851
852 let result = parse_file(&schema, &config);
853
854 assert!(
855 result.output.diagnostics.is_empty(),
856 "should have no errors: {:?}",
857 result.output.diagnostics
858 );
859 assert!(
860 result.output.unused_keys.is_empty(),
861 "should have no unused keys: {:?}",
862 result.output.unused_keys
863 );
864
865 let value = result.output.value.expect("should have value");
866
867 let app_name = get_nested(&value, &["config", "app_name"]).expect("config.app_name");
869 assert_eq!(get_string(app_name), Some("super-app"));
870
871 let log_level = get_nested(&value, &["config", "log_level"]).expect("config.log_level");
872 assert_eq!(get_string(log_level), Some("info"));
873
874 let debug = get_nested(&value, &["config", "debug"]).expect("config.debug");
875 assert!(matches!(debug, ConfigValue::Bool(b) if !b.value));
876
877 let host = get_nested(&value, &["config", "host"]).expect("config.host");
878 assert_eq!(get_string(host), Some("db.example.com"));
879
880 let port = get_nested(&value, &["config", "port"]).expect("config.port");
881 assert_eq!(get_integer(port), Some(5432));
882 }
883
884 #[test]
885 fn test_flatten_config_unknown_key_detection() {
886 let file = create_temp_json(
888 r#"{"name": "myapp", "log_level": "debug", "debug": true, "unknown_field": 123}"#,
889 );
890 let path = Utf8PathBuf::from_path_buf(file.path().to_path_buf()).unwrap();
891
892 let schema = Schema::from_shape(ArgsWithFlattenedConfig::SHAPE).unwrap();
893 let config = FileConfig::new().path(path);
894
895 let result = parse_file(&schema, &config);
896
897 assert!(
899 result
900 .output
901 .unused_keys
902 .iter()
903 .any(|k| k.key.contains(&"unknown_field".to_string())),
904 "should detect unknown key: {:?}",
905 result.output.unused_keys
906 );
907 }
908
909 #[derive(Facet)]
915 #[repr(u8)]
916 #[allow(dead_code)]
917 enum LogLevel {
918 Debug,
919 Info,
920 Warn,
921 Error,
922 }
923
924 #[derive(Facet)]
925 struct ConfigWithEnum {
926 log_level: LogLevel,
927 port: u16,
928 }
929
930 #[derive(Facet)]
931 struct ArgsWithEnumConfig {
932 #[facet(figue::config)]
933 config: ConfigWithEnum,
934 }
935
936 #[test]
937 fn test_enum_valid_variant_no_warning() {
938 let schema = Schema::from_shape(ArgsWithEnumConfig::SHAPE).unwrap();
940 let config =
941 FileConfig::new().content(r#"{"log_level": "Debug", "port": 8080}"#, "config.json");
942
943 let result = parse_file(&schema, &config);
944
945 assert!(
946 result.output.diagnostics.is_empty(),
947 "valid enum variant should not produce warnings: {:?}",
948 result.output.diagnostics
949 );
950 }
951
952 #[test]
953 fn test_enum_invalid_variant_produces_warning() {
954 let schema = Schema::from_shape(ArgsWithEnumConfig::SHAPE).unwrap();
956 let config =
957 FileConfig::new().content(r#"{"log_level": "Debugg", "port": 8080}"#, "config.json"); let result = parse_file(&schema, &config);
960
961 assert!(
963 !result.output.diagnostics.is_empty(),
964 "invalid enum variant should produce a warning"
965 );
966
967 let warning = &result.output.diagnostics[0];
969 assert!(
970 warning.message.contains("Debugg"),
971 "warning should mention the invalid value: {}",
972 warning.message
973 );
974 assert!(
975 warning.message.contains("Debug")
976 && warning.message.contains("Info")
977 && warning.message.contains("Warn")
978 && warning.message.contains("Error"),
979 "warning should list valid variants: {}",
980 warning.message
981 );
982 }
983
984 #[derive(Facet)]
985 struct ConfigWithOptionalEnum {
986 log_level: Option<LogLevel>,
987 }
988
989 #[derive(Facet)]
990 struct ArgsWithOptionalEnumConfig {
991 #[facet(figue::config)]
992 config: ConfigWithOptionalEnum,
993 }
994
995 #[test]
996 fn test_optional_enum_validation() {
997 let schema = Schema::from_shape(ArgsWithOptionalEnumConfig::SHAPE).unwrap();
999 let config = FileConfig::new().content(r#"{"log_level": "invalid"}"#, "config.json");
1000
1001 let result = parse_file(&schema, &config);
1002
1003 assert!(
1005 !result.output.diagnostics.is_empty(),
1006 "invalid optional enum variant should produce a warning"
1007 );
1008 }
1009
1010 #[derive(Facet)]
1011 struct NestedConfigWithEnum {
1012 logging: LoggingConfig,
1013 }
1014
1015 #[derive(Facet)]
1016 struct LoggingConfig {
1017 level: LogLevel,
1018 }
1019
1020 #[derive(Facet)]
1021 struct ArgsWithNestedEnumConfig {
1022 #[facet(figue::config)]
1023 config: NestedConfigWithEnum,
1024 }
1025
1026 #[test]
1027 fn test_nested_enum_validation() {
1028 let schema = Schema::from_shape(ArgsWithNestedEnumConfig::SHAPE).unwrap();
1030 let config =
1031 FileConfig::new().content(r#"{"logging": {"level": "unknown"}}"#, "config.json");
1032
1033 let result = parse_file(&schema, &config);
1034
1035 assert!(
1037 !result.output.diagnostics.is_empty(),
1038 "invalid nested enum variant should produce a warning"
1039 );
1040 }
1041}