use std::boxed::Box;
use std::string::String;
use std::sync::Arc;
use std::vec::Vec;
use camino::{Utf8Path, Utf8PathBuf};
use crate::config_format::{ConfigFormat, ConfigFormatError, JsonFormat};
use crate::config_value::ConfigValue;
use crate::driver::{Diagnostic, LayerOutput, Severity};
use crate::provenance::{ConfigFile, FilePathStatus, FileResolution};
use crate::schema::Schema;
use crate::value_builder::ValueBuilder;
#[derive(Default)]
pub struct FormatRegistry {
formats: Vec<Box<dyn ConfigFormat>>,
}
impl FormatRegistry {
pub fn new() -> Self {
Self {
formats: Vec::new(),
}
}
pub fn with_defaults() -> Self {
let mut registry = Self::new();
registry.register(JsonFormat);
registry
}
pub fn register<F: ConfigFormat + 'static>(&mut self, format: F) {
self.formats.push(Box::new(format));
}
pub fn find_by_extension(&self, extension: &str) -> Option<&dyn ConfigFormat> {
let ext_lower = extension.to_lowercase();
self.formats
.iter()
.find(|f| {
f.extensions()
.iter()
.any(|e| e.eq_ignore_ascii_case(&ext_lower))
})
.map(|f| f.as_ref())
}
pub fn parse(&self, contents: &str, extension: &str) -> Result<ConfigValue, ConfigFormatError> {
let format = self.find_by_extension(extension).ok_or_else(|| {
ConfigFormatError::new(format!("unsupported file extension: .{extension}"))
})?;
format.parse(contents)
}
pub fn parse_file(
&self,
path: &Utf8Path,
contents: &str,
) -> Result<ConfigValue, ConfigFormatError> {
let extension = path.extension().unwrap_or("");
let mut value = self.parse(contents, extension)?;
let file = Arc::new(ConfigFile::new(path, contents));
value.set_file_provenance_recursive(&file, "");
Ok(value)
}
pub fn extensions(&self) -> Vec<&str> {
self.formats
.iter()
.flat_map(|f| f.extensions().iter().copied())
.collect()
}
}
pub struct FileConfig {
pub explicit_path: Option<Utf8PathBuf>,
pub default_paths: Vec<Utf8PathBuf>,
pub registry: FormatRegistry,
pub strict: bool,
pub inline_content: Option<(String, String)>,
}
impl Default for FileConfig {
fn default() -> Self {
Self {
explicit_path: None,
default_paths: Vec::new(),
registry: FormatRegistry::with_defaults(),
strict: false,
inline_content: None,
}
}
}
impl FileConfig {
pub fn new() -> Self {
Self::default()
}
pub fn path(mut self, path: impl Into<Utf8PathBuf>) -> Self {
self.explicit_path = Some(path.into());
self
}
pub fn default_paths<I, P>(mut self, paths: I) -> Self
where
I: IntoIterator<Item = P>,
P: Into<Utf8PathBuf>,
{
self.default_paths = paths.into_iter().map(|p| p.into()).collect();
self
}
pub fn registry(mut self, registry: FormatRegistry) -> Self {
self.registry = registry;
self
}
pub fn strict(mut self) -> Self {
self.strict = true;
self
}
pub fn content(mut self, content: impl Into<String>, filename: impl Into<String>) -> Self {
self.inline_content = Some((content.into(), filename.into()));
self
}
}
pub struct FileParseResult {
pub output: LayerOutput,
pub resolution: FileResolution,
}
pub fn parse_file(schema: &Schema, config: &FileConfig) -> FileParseResult {
let mut ctx = FileParseContext::new(schema, config);
ctx.parse();
ctx.into_result()
}
struct FileParseContext<'a> {
schema: &'a Schema,
config: &'a FileConfig,
value: Option<ConfigValue>,
early_diagnostics: Vec<Diagnostic>,
resolution: FileResolution,
}
impl<'a> FileParseContext<'a> {
fn new(schema: &'a Schema, config: &'a FileConfig) -> Self {
Self {
schema,
config,
value: None,
early_diagnostics: Vec::new(),
resolution: FileResolution::new(),
}
}
fn parse(&mut self) {
let (path, contents) = if let Some((content, filename)) = &self.config.inline_content {
let path = Utf8PathBuf::from(filename);
self.resolution.add_explicit(path.clone(), true);
(path, content.clone())
} else {
let path = match self.resolve_path() {
Some(p) => p,
None => return, };
let contents = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(e) => {
self.emit_error(format!("failed to read {}: {}", path, e));
return;
}
};
(path, contents)
};
let parsed = match self.config.registry.parse_file(&path, &contents) {
Ok(v) => v,
Err(e) => {
self.emit_error(format!("failed to parse {}: {}", path, e));
return;
}
};
self.value = Some(parsed);
}
fn resolve_path(&mut self) -> Option<Utf8PathBuf> {
if let Some(explicit) = &self.config.explicit_path {
let exists = explicit.exists();
self.resolution.add_explicit(explicit.clone(), exists);
if exists {
self.resolution
.mark_defaults_not_tried(&self.config.default_paths);
return Some(explicit.clone());
} else {
self.emit_error(format!("config file not found: {}", explicit));
return None;
}
}
for default_path in &self.config.default_paths {
if default_path.exists() {
self.resolution
.add_default(default_path.clone(), FilePathStatus::Picked);
return Some(default_path.clone());
} else {
self.resolution
.add_default(default_path.clone(), FilePathStatus::Absent);
}
}
None
}
fn emit_error(&mut self, message: String) {
self.early_diagnostics.push(Diagnostic {
message,
label: None,
path: None,
span: None,
severity: Severity::Error,
});
}
fn into_result(self) -> FileParseResult {
let output = if let Some(config_schema) = self.schema.config() {
if let Some(ref parsed) = self.value {
let mut builder = ValueBuilder::new(config_schema);
builder.import_tree(parsed);
let mut output =
builder.into_output_with_value(self.value.clone(), config_schema.field_name());
let mut all_diagnostics = self.early_diagnostics;
all_diagnostics.append(&mut output.diagnostics);
output.diagnostics = all_diagnostics;
output
} else {
LayerOutput {
value: None,
unused_keys: Vec::new(),
diagnostics: self.early_diagnostics,
source_text: None,
config_file_path: None,
help_list_mode: None,
}
}
} else {
LayerOutput {
value: self.value,
unused_keys: Vec::new(),
diagnostics: self.early_diagnostics,
source_text: None,
config_file_path: None,
help_list_mode: None,
}
};
FileParseResult {
output,
resolution: self.resolution,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate as figue;
use crate::provenance::Provenance;
use facet::Facet;
use std::io::Write;
use tempfile::NamedTempFile;
fn get_provenance(value: &ConfigValue) -> Option<&Provenance> {
match value {
ConfigValue::Null(s) => s.provenance.as_ref(),
ConfigValue::Bool(s) => s.provenance.as_ref(),
ConfigValue::Integer(s) => s.provenance.as_ref(),
ConfigValue::Float(s) => s.provenance.as_ref(),
ConfigValue::String(s) => s.provenance.as_ref(),
ConfigValue::Array(s) => s.provenance.as_ref(),
ConfigValue::Object(s) => s.provenance.as_ref(),
ConfigValue::Enum(s) => s.provenance.as_ref(),
}
}
#[derive(Facet)]
struct ArgsWithConfig {
#[facet(figue::named)]
verbose: bool,
#[facet(figue::config)]
config: ServerConfig,
}
#[derive(Facet)]
struct ServerConfig {
port: u16,
host: String,
}
#[derive(Facet)]
struct ArgsWithNestedConfig {
#[facet(figue::config)]
settings: AppSettings,
}
#[derive(Facet)]
struct AppSettings {
port: u16,
smtp: SmtpConfig,
}
#[derive(Facet)]
struct SmtpConfig {
host: String,
connection_timeout: u64,
}
fn create_temp_json(content: &str) -> NamedTempFile {
let mut file = NamedTempFile::with_suffix(".json").unwrap();
write!(file, "{}", content).unwrap();
file
}
fn get_nested<'a>(cv: &'a ConfigValue, path: &[&str]) -> Option<&'a ConfigValue> {
let mut current = cv;
for key in path {
match current {
ConfigValue::Object(obj) => {
current = obj.value.get(*key)?;
}
_ => return None,
}
}
Some(current)
}
fn get_integer(cv: &ConfigValue) -> Option<i64> {
match cv {
ConfigValue::Integer(i) => Some(i.value),
_ => None,
}
}
fn get_string(cv: &ConfigValue) -> Option<&str> {
match cv {
ConfigValue::String(s) => Some(&s.value),
_ => None,
}
}
#[test]
fn test_parse_simple_json() {
let file = create_temp_json(r#"{"port": 8080, "host": "localhost"}"#);
let path = Utf8PathBuf::from_path_buf(file.path().to_path_buf()).unwrap();
let schema = Schema::from_shape(ArgsWithConfig::SHAPE).unwrap();
let config = FileConfig::new().path(path);
let result = parse_file(&schema, &config);
assert!(result.output.diagnostics.is_empty());
assert!(result.output.unused_keys.is_empty());
let value = result.output.value.expect("should have value");
let port = get_nested(&value, &["config", "port"]).expect("config.port");
assert_eq!(get_integer(port), Some(8080));
let host = get_nested(&value, &["config", "host"]).expect("config.host");
assert_eq!(get_string(host), Some("localhost"));
}
#[test]
fn test_parse_nested_json() {
let file = create_temp_json(
r#"{"port": 8080, "smtp": {"host": "mail.example.com", "connection_timeout": 30}}"#,
);
let path = Utf8PathBuf::from_path_buf(file.path().to_path_buf()).unwrap();
let schema = Schema::from_shape(ArgsWithNestedConfig::SHAPE).unwrap();
let config = FileConfig::new().path(path);
let result = parse_file(&schema, &config);
assert!(result.output.diagnostics.is_empty());
let value = result.output.value.expect("should have value");
let port = get_nested(&value, &["settings", "port"]).expect("settings.port");
assert_eq!(get_integer(port), Some(8080));
let smtp_host =
get_nested(&value, &["settings", "smtp", "host"]).expect("settings.smtp.host");
assert_eq!(get_string(smtp_host), Some("mail.example.com"));
}
#[test]
fn test_explicit_path_not_found() {
let schema = Schema::from_shape(ArgsWithConfig::SHAPE).unwrap();
let config = FileConfig::new().path("/nonexistent/config.json");
let result = parse_file(&schema, &config);
assert!(!result.output.diagnostics.is_empty());
assert!(
result
.output
.diagnostics
.iter()
.any(|d| d.message.contains("not found"))
);
}
#[test]
fn test_no_file_configured() {
let schema = Schema::from_shape(ArgsWithConfig::SHAPE).unwrap();
let config = FileConfig::new();
let result = parse_file(&schema, &config);
assert!(result.output.diagnostics.is_empty());
assert!(result.output.value.is_none());
}
#[test]
fn test_default_paths_tried_in_order() {
let file = create_temp_json(r#"{"port": 9000, "host": "default"}"#);
let path = Utf8PathBuf::from_path_buf(file.path().to_path_buf()).unwrap();
let schema = Schema::from_shape(ArgsWithConfig::SHAPE).unwrap();
let config = FileConfig::new().default_paths([
Utf8PathBuf::from("/nonexistent/first.json"),
path.clone(),
Utf8PathBuf::from("/nonexistent/third.json"),
]);
let result = parse_file(&schema, &config);
assert!(result.output.diagnostics.is_empty());
assert!(result.output.value.is_some());
assert_eq!(result.resolution.paths.len(), 2); assert!(matches!(
result.resolution.paths[0].status,
FilePathStatus::Absent
));
assert!(matches!(
result.resolution.paths[1].status,
FilePathStatus::Picked
));
}
#[test]
fn test_unknown_key_tracked() {
let file = create_temp_json(r#"{"port": 8080, "host": "localhost", "unknown_field": 123}"#);
let path = Utf8PathBuf::from_path_buf(file.path().to_path_buf()).unwrap();
let schema = Schema::from_shape(ArgsWithConfig::SHAPE).unwrap();
let config = FileConfig::new().path(path);
let result = parse_file(&schema, &config);
assert!(!result.output.unused_keys.is_empty());
assert!(
result
.output
.unused_keys
.iter()
.any(|k| k.key.contains(&"unknown_field".to_string()))
);
assert!(result.output.diagnostics.is_empty());
}
#[test]
fn test_unknown_key_tracked_in_strict_mode() {
let file = create_temp_json(r#"{"port": 8080, "host": "localhost", "unknown_field": 123}"#);
let path = Utf8PathBuf::from_path_buf(file.path().to_path_buf()).unwrap();
let schema = Schema::from_shape(ArgsWithConfig::SHAPE).unwrap();
let config = FileConfig::new().path(path).strict();
let result = parse_file(&schema, &config);
assert!(
!result.output.unused_keys.is_empty(),
"should track unknown key in unused_keys"
);
assert!(
result
.output
.unused_keys
.iter()
.any(|uk| uk.key.join(".") == "unknown_field"),
"unused_keys should contain 'unknown_field': {:?}",
result.output.unused_keys
);
let errors: Vec<_> = result
.output
.diagnostics
.iter()
.filter(|d| d.severity == Severity::Error)
.collect();
assert!(
errors.is_empty(),
"should not have error diagnostics at parse time, got: {:?}",
errors
);
}
#[test]
fn test_file_provenance() {
let file = create_temp_json(r#"{"port": 8080, "host": "localhost"}"#);
let path = Utf8PathBuf::from_path_buf(file.path().to_path_buf()).unwrap();
let schema = Schema::from_shape(ArgsWithConfig::SHAPE).unwrap();
let config = FileConfig::new().path(path.clone());
let result = parse_file(&schema, &config);
let value = result.output.value.expect("should have value");
let port = get_nested(&value, &["config", "port"]).expect("config.port");
let prov = get_provenance(port).expect("should have provenance");
assert!(prov.is_file());
if let Provenance::File {
file: config_file, ..
} = prov
{
assert_eq!(config_file.path, path);
}
}
#[test]
fn test_format_registry_with_defaults() {
let registry = FormatRegistry::with_defaults();
assert!(registry.find_by_extension("json").is_some());
assert!(registry.find_by_extension("JSON").is_some()); assert!(registry.find_by_extension("toml").is_none());
}
#[test]
fn test_format_registry_extensions() {
let registry = FormatRegistry::with_defaults();
let extensions = registry.extensions();
assert!(extensions.contains(&"json"));
}
#[derive(Facet)]
struct CommonConfig {
log_level: Option<String>,
debug: bool,
}
#[derive(Facet)]
struct ConfigWithFlatten {
name: String,
#[facet(flatten)]
common: CommonConfig,
}
#[derive(Facet)]
struct ArgsWithFlattenedConfig {
#[facet(figue::config)]
config: ConfigWithFlatten,
}
#[test]
fn test_flatten_config_parses_flat_json() {
let file = create_temp_json(r#"{"name": "myapp", "log_level": "debug", "debug": true}"#);
let path = Utf8PathBuf::from_path_buf(file.path().to_path_buf()).unwrap();
let schema = Schema::from_shape(ArgsWithFlattenedConfig::SHAPE).unwrap();
let config = FileConfig::new().path(path);
let result = parse_file(&schema, &config);
assert!(
result.output.diagnostics.is_empty(),
"should have no errors: {:?}",
result.output.diagnostics
);
assert!(
result.output.unused_keys.is_empty(),
"should have no unused keys: {:?}",
result.output.unused_keys
);
let value = result.output.value.expect("should have value");
let name = get_nested(&value, &["config", "name"]).expect("config.name");
assert_eq!(get_string(name), Some("myapp"));
let log_level = get_nested(&value, &["config", "log_level"]).expect("config.log_level");
assert_eq!(get_string(log_level), Some("debug"));
let debug = get_nested(&value, &["config", "debug"]).expect("config.debug");
assert!(matches!(debug, ConfigValue::Bool(b) if b.value));
}
#[test]
fn test_flatten_config_rejects_nested_json() {
let file = create_temp_json(
r#"{"name": "myapp", "common": {"log_level": "debug", "debug": true}}"#,
);
let path = Utf8PathBuf::from_path_buf(file.path().to_path_buf()).unwrap();
let schema = Schema::from_shape(ArgsWithFlattenedConfig::SHAPE).unwrap();
let config = FileConfig::new().path(path);
let result = parse_file(&schema, &config);
assert!(
result
.output
.unused_keys
.iter()
.any(|k| k.key.contains(&"common".to_string())),
"should reject 'common' key: {:?}",
result.output.unused_keys
);
}
#[derive(Facet)]
struct DatabaseConfig {
host: String,
port: u16,
}
#[derive(Facet)]
struct ExtendedConfig {
#[facet(flatten)]
common: CommonConfig,
#[facet(flatten)]
database: DatabaseConfig,
}
#[derive(Facet)]
struct ConfigWithNestedFlatten {
app_name: String,
#[facet(flatten)]
extended: ExtendedConfig,
}
#[derive(Facet)]
struct ArgsWithNestedFlattenConfig {
#[facet(figue::config)]
config: ConfigWithNestedFlatten,
}
#[test]
fn test_two_level_flatten_config() {
let file = create_temp_json(
r#"{
"app_name": "super-app",
"log_level": "info",
"debug": false,
"host": "db.example.com",
"port": 5432
}"#,
);
let path = Utf8PathBuf::from_path_buf(file.path().to_path_buf()).unwrap();
let schema = Schema::from_shape(ArgsWithNestedFlattenConfig::SHAPE).unwrap();
let config = FileConfig::new().path(path);
let result = parse_file(&schema, &config);
assert!(
result.output.diagnostics.is_empty(),
"should have no errors: {:?}",
result.output.diagnostics
);
assert!(
result.output.unused_keys.is_empty(),
"should have no unused keys: {:?}",
result.output.unused_keys
);
let value = result.output.value.expect("should have value");
let app_name = get_nested(&value, &["config", "app_name"]).expect("config.app_name");
assert_eq!(get_string(app_name), Some("super-app"));
let log_level = get_nested(&value, &["config", "log_level"]).expect("config.log_level");
assert_eq!(get_string(log_level), Some("info"));
let debug = get_nested(&value, &["config", "debug"]).expect("config.debug");
assert!(matches!(debug, ConfigValue::Bool(b) if !b.value));
let host = get_nested(&value, &["config", "host"]).expect("config.host");
assert_eq!(get_string(host), Some("db.example.com"));
let port = get_nested(&value, &["config", "port"]).expect("config.port");
assert_eq!(get_integer(port), Some(5432));
}
#[test]
fn test_flatten_config_unknown_key_detection() {
let file = create_temp_json(
r#"{"name": "myapp", "log_level": "debug", "debug": true, "unknown_field": 123}"#,
);
let path = Utf8PathBuf::from_path_buf(file.path().to_path_buf()).unwrap();
let schema = Schema::from_shape(ArgsWithFlattenedConfig::SHAPE).unwrap();
let config = FileConfig::new().path(path);
let result = parse_file(&schema, &config);
assert!(
result
.output
.unused_keys
.iter()
.any(|k| k.key.contains(&"unknown_field".to_string())),
"should detect unknown key: {:?}",
result.output.unused_keys
);
}
#[derive(Facet)]
#[repr(u8)]
#[allow(dead_code)]
enum LogLevel {
Debug,
Info,
Warn,
Error,
}
#[derive(Facet)]
struct ConfigWithEnum {
log_level: LogLevel,
port: u16,
}
#[derive(Facet)]
struct ArgsWithEnumConfig {
#[facet(figue::config)]
config: ConfigWithEnum,
}
#[test]
fn test_enum_valid_variant_no_warning() {
let schema = Schema::from_shape(ArgsWithEnumConfig::SHAPE).unwrap();
let config =
FileConfig::new().content(r#"{"log_level": "Debug", "port": 8080}"#, "config.json");
let result = parse_file(&schema, &config);
assert!(
result.output.diagnostics.is_empty(),
"valid enum variant should not produce warnings: {:?}",
result.output.diagnostics
);
}
#[test]
fn test_enum_invalid_variant_produces_warning() {
let schema = Schema::from_shape(ArgsWithEnumConfig::SHAPE).unwrap();
let config =
FileConfig::new().content(r#"{"log_level": "Debugg", "port": 8080}"#, "config.json");
let result = parse_file(&schema, &config);
assert!(
!result.output.diagnostics.is_empty(),
"invalid enum variant should produce a warning"
);
let warning = &result.output.diagnostics[0];
assert!(
warning.message.contains("Debugg"),
"warning should mention the invalid value: {}",
warning.message
);
assert!(
warning.message.contains("Debug")
&& warning.message.contains("Info")
&& warning.message.contains("Warn")
&& warning.message.contains("Error"),
"warning should list valid variants: {}",
warning.message
);
}
#[derive(Facet)]
struct ConfigWithOptionalEnum {
log_level: Option<LogLevel>,
}
#[derive(Facet)]
struct ArgsWithOptionalEnumConfig {
#[facet(figue::config)]
config: ConfigWithOptionalEnum,
}
#[test]
fn test_optional_enum_validation() {
let schema = Schema::from_shape(ArgsWithOptionalEnumConfig::SHAPE).unwrap();
let config = FileConfig::new().content(r#"{"log_level": "invalid"}"#, "config.json");
let result = parse_file(&schema, &config);
assert!(
!result.output.diagnostics.is_empty(),
"invalid optional enum variant should produce a warning"
);
}
#[derive(Facet)]
struct NestedConfigWithEnum {
logging: LoggingConfig,
}
#[derive(Facet)]
struct LoggingConfig {
level: LogLevel,
}
#[derive(Facet)]
struct ArgsWithNestedEnumConfig {
#[facet(figue::config)]
config: NestedConfigWithEnum,
}
#[test]
fn test_nested_enum_validation() {
let schema = Schema::from_shape(ArgsWithNestedEnumConfig::SHAPE).unwrap();
let config =
FileConfig::new().content(r#"{"logging": {"level": "unknown"}}"#, "config.json");
let result = parse_file(&schema, &config);
assert!(
!result.output.diagnostics.is_empty(),
"invalid nested enum variant should produce a warning"
);
}
}