Skip to main content

figue/layers/
file.rs

1//! Schema-driven config file parser that outputs ConfigValue with provenance.
2//!
3//! This module is under active development and not yet wired into the main API.
4//!
5//! This parser:
6//! - Uses the pre-built Schema to validate config structure
7//! - Outputs LayerOutput (ConfigValue + diagnostics), not a Partial
8//! - Supports multiple file formats via FormatRegistry
9//! - Reports unused keys (keys in file that don't match schema)
10//! - Tracks provenance for all values
11//!
12//! # Example
13//!
14//! ```rust,ignore
15//! use figue::layers::file::{parse_file, FileConfig, FormatRegistry};
16//! use figue::schema::Schema;
17//!
18//! let schema = Schema::from_shape(MyConfig::SHAPE)?;
19//! let config = FileConfig::new()
20//!     .default_paths(["config.json", "~/.config/app/config.json"])
21//!     .registry(FormatRegistry::with_defaults());
22//!
23//! let output = parse_file(&schema, &config)?;
24//! ```
25
26use 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// ============================================================================
41// Format Registry
42// ============================================================================
43
44/// A registry of config file formats.
45///
46/// This allows registering multiple formats and selecting the appropriate
47/// one based on file extension.
48#[derive(Default)]
49pub struct FormatRegistry {
50    formats: Vec<Box<dyn ConfigFormat>>,
51}
52
53impl FormatRegistry {
54    /// Create a new empty registry.
55    pub fn new() -> Self {
56        Self {
57            formats: Vec::new(),
58        }
59    }
60
61    /// Create a registry with the default JSON format.
62    pub fn with_defaults() -> Self {
63        let mut registry = Self::new();
64        registry.register(JsonFormat);
65        registry
66    }
67
68    /// Register a new format.
69    pub fn register<F: ConfigFormat + 'static>(&mut self, format: F) {
70        self.formats.push(Box::new(format));
71    }
72
73    /// Find a format that handles the given file extension.
74    ///
75    /// The extension should not include the leading dot.
76    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    /// Parse a config file, automatically selecting the format based on extension.
89    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    /// Parse a config file and set provenance on all values.
97    ///
98    /// This is the preferred method for loading config files, as it ensures
99    /// all values have proper provenance tracking for error messages.
100    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        // Create config file and set provenance recursively
109        let file = Arc::new(ConfigFile::new(path, contents));
110        value.set_file_provenance_recursive(&file, "");
111
112        Ok(value)
113    }
114
115    /// Get all registered extensions.
116    pub fn extensions(&self) -> Vec<&str> {
117        self.formats
118            .iter()
119            .flat_map(|f| f.extensions().iter().copied())
120            .collect()
121    }
122}
123
124// ============================================================================
125// File Configuration
126// ============================================================================
127
128/// Configuration for config file parsing.
129pub struct FileConfig {
130    /// Explicit path provided via CLI (e.g., --config path.json).
131    pub explicit_path: Option<Utf8PathBuf>,
132
133    /// Default paths to check if no explicit path is provided.
134    pub default_paths: Vec<Utf8PathBuf>,
135
136    /// Format registry for parsing different file types.
137    pub registry: FormatRegistry,
138
139    /// Whether to error on unknown keys in the config file.
140    pub strict: bool,
141
142    /// Inline content for testing (avoids disk I/O).
143    /// When set, this content is used instead of reading from disk.
144    /// The tuple is (content, filename_for_format_detection).
145    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    /// Create a new file config with defaults.
162    pub fn new() -> Self {
163        Self::default()
164    }
165
166    /// Set an explicit config file path.
167    pub fn path(mut self, path: impl Into<Utf8PathBuf>) -> Self {
168        self.explicit_path = Some(path.into());
169        self
170    }
171
172    /// Set default paths to check for config files.
173    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    /// Set the format registry.
183    pub fn registry(mut self, registry: FormatRegistry) -> Self {
184        self.registry = registry;
185        self
186    }
187
188    /// Enable strict mode - error on unknown keys.
189    pub fn strict(mut self) -> Self {
190        self.strict = true;
191        self
192    }
193
194    /// Set inline content for testing (avoids disk I/O).
195    ///
196    /// The filename is used for format detection (e.g., "config.toml" or "settings.json").
197    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
203// ============================================================================
204// File Parsing Result
205// ============================================================================
206
207/// Result of file parsing, including resolution info.
208pub struct FileParseResult {
209    /// The layer output with parsed values and diagnostics.
210    pub output: LayerOutput,
211    /// Information about which files were tried.
212    pub resolution: FileResolution,
213}
214
215// ============================================================================
216// Main Parse Function
217// ============================================================================
218
219/// Parse a config file using the schema, returning a LayerOutput.
220///
221/// This resolves the file path, reads the file, parses it using the appropriate
222/// format, validates against the schema, and tracks provenance.
223pub 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
229/// Context for parsing config files.
230struct FileParseContext<'a> {
231    schema: &'a Schema,
232    config: &'a FileConfig,
233    /// Parsed config value (if successful)
234    value: Option<ConfigValue>,
235    /// Diagnostics collected before ValueBuilder takes over
236    early_diagnostics: Vec<Diagnostic>,
237    /// File resolution tracking
238    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        // Check for inline content first (used for testing)
254        let (path, contents) = if let Some((content, filename)) = &self.config.inline_content {
255            let path = Utf8PathBuf::from(filename);
256            // Record this as a "picked" file in resolution for display purposes
257            self.resolution.add_explicit(path.clone(), true);
258            (path, content.clone())
259        } else {
260            // Resolve which file to load
261            let path = match self.resolve_path() {
262                Some(p) => p,
263                None => return, // No file to load (not an error if no explicit path)
264            };
265
266            // Read the file
267            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        // Parse the file
278        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    /// Resolve which file path to use.
290    ///
291    /// Returns Some(path) if a file should be loaded, None otherwise.
292    fn resolve_path(&mut self) -> Option<Utf8PathBuf> {
293        // If explicit path provided, use it (error if not found)
294        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                // Mark defaults as not tried
300                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        // Try default paths in order
310        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        // No file found - this is not an error (file layer is optional)
322        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        // If we have a config schema and a parsed value, use ValueBuilder
337        // to validate and collect unused keys
338        let output = if let Some(config_schema) = self.schema.config() {
339            if let Some(ref parsed) = self.value {
340                // Create a ValueBuilder and import the parsed tree
341                let mut builder = ValueBuilder::new(config_schema);
342                builder.import_tree(parsed);
343
344                // Unknown keys are tracked in unused_keys by the builder.
345                // In strict mode, they'll be reported by the driver alongside the config dump.
346
347                // Get the output from the builder
348                let mut output =
349                    builder.into_output_with_value(self.value.clone(), config_schema.field_name());
350
351                // Prepend any early diagnostics (file read errors, etc.)
352                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                // No parsed value - return early diagnostics only
359                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            // No config schema - just return the parsed value as-is
370            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    /// Extract provenance from a ConfigValue (test helper).
397    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    // ========================================================================
411    // Test schemas
412    // ========================================================================
413
414    #[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    // ========================================================================
448    // Helper functions
449    // ========================================================================
450
451    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    // ========================================================================
485    // Tests: Basic parsing
486    // ========================================================================
487
488    #[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    // ========================================================================
533    // Tests: File resolution
534    // ========================================================================
535
536    #[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(); // No path
557
558        let result = parse_file(&schema, &config);
559
560        // No file = no error, just no value
561        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        // Check resolution tracking
583        assert_eq!(result.resolution.paths.len(), 2); // first absent, second picked
584        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    // ========================================================================
595    // Tests: Unused keys
596    // ========================================================================
597
598    #[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        // Should track the unknown key
609        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        // But no error in non-strict mode
619        assert!(result.output.diagnostics.is_empty());
620    }
621
622    #[test]
623    fn test_unknown_key_tracked_in_strict_mode() {
624        // In strict mode, unknown keys are tracked in unused_keys.
625        // The driver will report them alongside the config dump (not as early errors).
626        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        // Unknown keys should be tracked in unused_keys
635        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        // No error diagnostics at parse time - driver handles reporting with dump
650        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    // ========================================================================
664    // Tests: Provenance
665    // ========================================================================
666
667    #[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        // Check provenance is set
681        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    // ========================================================================
692    // Tests: Format registry
693    // ========================================================================
694
695    #[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()); // case-insensitive
700        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    // ========================================================================
711    // Tests: Flatten support
712    // ========================================================================
713
714    /// Common config fields that can be flattened
715    #[derive(Facet)]
716    struct CommonConfig {
717        /// Log level
718        log_level: Option<String>,
719        /// Debug mode
720        debug: bool,
721    }
722
723    /// Config with flattened common fields
724    #[derive(Facet)]
725    struct ConfigWithFlatten {
726        /// Application name
727        name: String,
728        /// Common settings
729        #[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        // JSON file has FLAT structure - flattened fields appear at the current level
742        // NOT nested under "common"
743        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        // No errors - flatten should be handled
752        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        // All fields appear at the config level (flat)
766        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        // JSON with nested "common" should be rejected - "common" is not a valid key
779        // because it's flattened (its fields are hoisted to the parent level)
780        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        // Should have unused key for "common"
791        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    /// Database config for nested flatten test
803    #[derive(Facet)]
804    struct DatabaseConfig {
805        /// Database host
806        host: String,
807        /// Database port
808        port: u16,
809    }
810
811    /// Extended config with double flatten
812    #[derive(Facet)]
813    struct ExtendedConfig {
814        #[facet(flatten)]
815        common: CommonConfig,
816        #[facet(flatten)]
817        database: DatabaseConfig,
818    }
819
820    /// Config with deeply nested flatten
821    #[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        // Two levels of flattening: extended is flattened, and extended contains
837        // flattened common and database. So ALL fields appear at the top level.
838        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        // All fields appear at the config level (flat due to double flatten)
868        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        // JSON with an unknown key at the flattened level
887        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        // Should detect unknown key
898        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    // ========================================================================
910    // Tests: Schema-guided enum validation
911    // ========================================================================
912
913    /// Log level enum for testing enum validation
914    #[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        // Valid variant should not produce a warning
939        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        // Invalid variant should produce a warning with helpful message
955        let schema = Schema::from_shape(ArgsWithEnumConfig::SHAPE).unwrap();
956        let config =
957            FileConfig::new().content(r#"{"log_level": "Debugg", "port": 8080}"#, "config.json"); // typo
958
959        let result = parse_file(&schema, &config);
960
961        // Should have a warning
962        assert!(
963            !result.output.diagnostics.is_empty(),
964            "invalid enum variant should produce a warning"
965        );
966
967        // Warning should mention the invalid value and valid variants
968        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        // Optional enum should also be validated
998        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        // Should have a warning even for optional enum
1004        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        // Enum in nested struct should be validated
1029        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        // Should have a warning
1036        assert!(
1037            !result.output.diagnostics.is_empty(),
1038            "invalid nested enum variant should produce a warning"
1039        );
1040    }
1041}