use std::{
collections::{BTreeSet, HashMap},
ffi::OsStr,
fs,
path::Component,
path::{Path, PathBuf},
sync::Arc,
};
use confique::{
Config, FileFormat, Layer,
meta::{Expr, FieldKind, LeafKind, MapKey, Meta},
};
use figment::{
Figment, Metadata, Profile, Provider, Source,
providers::{Env, Format, Json, Toml, Yaml},
value::{Dict, Map, Uncased},
};
use schemars::{JsonSchema, generate::SchemaSettings};
use serde_json::Value;
use tracing::trace;
use crate::{
ConfigError, ConfigSource, ConfigTree, ConfigTreeOptions, IncludeOrder, absolutize_lexical,
collect_template_targets, normalize_lexical, select_template_source,
};
pub type ConfigResult<T> = std::result::Result<T, ConfigError>;
pub trait ConfigSchema: Config + Sized {
fn include_paths(layer: &<Self as Config>::Layer) -> Vec<PathBuf>;
fn template_path_for_section(section_path: &[&str]) -> Option<PathBuf> {
let _ = section_path;
None
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConfigFormat {
Yaml,
Toml,
Json,
}
impl ConfigFormat {
pub fn from_path(path: impl AsRef<Path>) -> Self {
match path.as_ref().extension().and_then(OsStr::to_str) {
Some("toml") => Self::Toml,
Some("json" | "json5") => Self::Json,
Some("yaml" | "yml") | Some(_) | None => Self::Yaml,
}
}
pub fn as_file_format(self) -> FileFormat {
match self {
Self::Yaml => FileFormat::Yaml,
Self::Toml => FileFormat::Toml,
Self::Json => FileFormat::Json5,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfigTemplateTarget {
pub path: PathBuf,
pub content: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfigSchemaTarget {
pub path: PathBuf,
pub content: String,
}
#[derive(Clone)]
pub struct ConfiqueEnvProvider {
env: Env,
path_to_env: Arc<HashMap<String, String>>,
}
impl ConfiqueEnvProvider {
pub fn new<S>() -> Self
where
S: Config,
{
let mut env_to_path = HashMap::<String, String>::new();
let mut path_to_env = HashMap::<String, String>::new();
collect_env_mapping(&S::META, "", &mut env_to_path, &mut path_to_env);
let env_to_path = Arc::new(env_to_path);
let path_to_env = Arc::new(path_to_env);
let map_for_filter = Arc::clone(&env_to_path);
let env = Env::raw().filter_map(move |env_key| {
let lookup_key = env_key.as_str().to_ascii_uppercase();
map_for_filter
.get(&lookup_key)
.cloned()
.map(Uncased::from_owned)
});
Self { env, path_to_env }
}
}
impl Provider for ConfiqueEnvProvider {
fn metadata(&self) -> Metadata {
let path_to_env = Arc::clone(&self.path_to_env);
Metadata::named("environment variable").interpolater(move |_profile, keys| {
let path = keys.join(".");
path_to_env.get(&path).cloned().unwrap_or(path)
})
}
fn data(&self) -> Result<Map<Profile, Dict>, figment::Error> {
self.env.data()
}
}
pub fn load_config<S>(path: impl AsRef<Path>) -> ConfigResult<S>
where
S: ConfigSchema,
{
let (config, _) = load_config_with_figment::<S>(path)?;
Ok(config)
}
pub fn load_config_with_figment<S>(path: impl AsRef<Path>) -> ConfigResult<(S, Figment)>
where
S: ConfigSchema,
{
let figment = build_config_figment::<S>(path)?;
let config = load_config_from_figment::<S>(&figment)?;
Ok((config, figment))
}
pub fn build_config_figment<S>(path: impl AsRef<Path>) -> ConfigResult<Figment>
where
S: ConfigSchema,
{
let path = path.as_ref();
load_dotenv_for_path(path)?;
let tree = load_layer_tree::<S>(path)?;
let mut figment = Figment::new();
for node in tree.nodes().iter().rev() {
figment = merge_file_provider(figment, node.path());
}
Ok(figment.merge(ConfiqueEnvProvider::new::<S>()))
}
pub fn load_config_from_figment<S>(figment: &Figment) -> ConfigResult<S>
where
S: ConfigSchema,
{
let runtime_layer: <S as Config>::Layer = figment.extract()?;
let config = S::from_layer(runtime_layer.with_fallback(S::Layer::default_values()))?;
trace_config_sources::<S>(figment);
Ok(config)
}
pub fn load_layer<S>(path: &Path) -> ConfigResult<<S as Config>::Layer>
where
S: ConfigSchema,
{
Ok(figment_for_file(path).extract()?)
}
fn load_layer_tree<S>(path: &Path) -> ConfigResult<ConfigTree<<S as Config>::Layer>>
where
S: ConfigSchema,
{
Ok(ConfigTreeOptions::default()
.include_order(IncludeOrder::Reverse)
.load(
path,
|path| -> ConfigResult<ConfigSource<<S as Config>::Layer>> {
let layer = load_layer::<S>(path)?;
let include_paths = S::include_paths(&layer);
Ok(ConfigSource::new(layer, include_paths))
},
)?)
}
fn merge_file_provider(figment: Figment, path: &Path) -> Figment {
match ConfigFormat::from_path(path) {
ConfigFormat::Yaml => figment.merge(Yaml::file_exact(path)),
ConfigFormat::Toml => figment.merge(Toml::file_exact(path)),
ConfigFormat::Json => figment.merge(Json::file_exact(path)),
}
}
fn figment_for_file(path: &Path) -> Figment {
merge_file_provider(Figment::new(), path)
}
fn root_config_schema<S>() -> ConfigResult<Value>
where
S: JsonSchema,
{
let generator = SchemaSettings::draft07().into_generator();
let schema = generator.into_root_schema_for::<S>();
let mut schema = serde_json::to_value(schema)?;
remove_required_recursively(&mut schema);
Ok(schema)
}
fn schema_json(schema: &Value) -> ConfigResult<String> {
let mut json = serde_json::to_string_pretty(schema)?;
ensure_single_trailing_newline(&mut json);
Ok(json)
}
fn remove_required_recursively(value: &mut Value) {
match value {
Value::Object(object) => {
object.remove("required");
for (key, child) in object.iter_mut() {
if is_schema_map_key(key) {
remove_required_from_schema_map(child);
} else {
remove_required_recursively(child);
}
}
}
Value::Array(items) => {
for item in items {
remove_required_recursively(item);
}
}
Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {}
}
}
fn is_schema_map_key(key: &str) -> bool {
matches!(
key,
"$defs" | "definitions" | "properties" | "patternProperties"
)
}
fn remove_required_from_schema_map(value: &mut Value) {
match value {
Value::Object(object) => {
for schema in object.values_mut() {
remove_required_recursively(schema);
}
}
_ => remove_required_recursively(value),
}
}
fn section_schema_for_path(root_schema: &Value, section_path: &[&str]) -> Option<Value> {
let mut current = root_schema;
for section in section_path {
current = current.get("properties")?.get(*section)?;
current = resolve_schema_reference(root_schema, current).unwrap_or(current);
}
Some(standalone_section_schema(root_schema, current))
}
fn resolve_schema_reference<'a>(root_schema: &'a Value, schema: &'a Value) -> Option<&'a Value> {
if let Some(reference) = schema.get("$ref").and_then(Value::as_str) {
return resolve_json_pointer_ref(root_schema, reference);
}
schema
.get("allOf")
.and_then(Value::as_array)
.and_then(|schemas| schemas.first())
.and_then(|schema| schema.get("$ref"))
.and_then(Value::as_str)
.and_then(|reference| resolve_json_pointer_ref(root_schema, reference))
}
fn resolve_json_pointer_ref<'a>(root_schema: &'a Value, reference: &str) -> Option<&'a Value> {
let pointer = reference.strip_prefix('#')?;
root_schema.pointer(pointer)
}
fn standalone_section_schema(root_schema: &Value, section_schema: &Value) -> Value {
let mut section_schema = section_schema.clone();
let Some(object) = section_schema.as_object_mut() else {
return section_schema;
};
if let Some(schema_uri) = root_schema.get("$schema") {
object
.entry("$schema".to_owned())
.or_insert_with(|| schema_uri.clone());
}
if let Some(definitions) = root_schema.get("definitions") {
object
.entry("definitions".to_owned())
.or_insert_with(|| definitions.clone());
}
if let Some(defs) = root_schema.get("$defs") {
object
.entry("$defs".to_owned())
.or_insert_with(|| defs.clone());
}
section_schema
}
fn schema_path_for_section(root_schema_path: &Path, section_path: &[&str]) -> PathBuf {
let Some((last, parents)) = section_path.split_last() else {
return root_schema_path.to_path_buf();
};
let mut path = root_schema_path
.parent()
.unwrap_or_else(|| Path::new("."))
.to_path_buf();
for parent in parents {
path.push(*parent);
}
path.push(format!("{}.schema.json", *last));
path
}
fn schema_for_output_path<S>(
full_schema: &Value,
section_path: &[&'static str],
) -> ConfigResult<Value>
where
S: ConfigSchema,
{
let mut schema = if section_path.is_empty() {
full_schema.clone()
} else {
section_schema_for_path(full_schema, section_path).ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!(
"failed to extract JSON Schema for config section {}",
section_path.join(".")
),
)
})?
};
remove_child_section_properties::<S>(&mut schema, section_path);
prune_unused_schema_maps(&mut schema);
Ok(schema)
}
fn remove_child_section_properties<S>(schema: &mut Value, section_path: &[&'static str])
where
S: ConfigSchema,
{
let Some(properties) = schema.get_mut("properties").and_then(Value::as_object_mut) else {
return;
};
for child_section_path in immediate_child_section_paths(&S::META, section_path) {
if let Some(child_name) = child_section_path.last() {
properties.remove(*child_name);
}
}
}
fn prune_unused_schema_maps(schema: &mut Value) {
let mut definitions = BTreeSet::new();
let mut defs = BTreeSet::new();
collect_schema_refs(schema, false, &mut definitions, &mut defs);
loop {
let previous_len = definitions.len() + defs.len();
collect_transitive_schema_refs(schema, &mut definitions, &mut defs);
if definitions.len() + defs.len() == previous_len {
break;
}
}
retain_schema_map(schema, "definitions", &definitions);
retain_schema_map(schema, "$defs", &defs);
}
fn collect_transitive_schema_refs(
schema: &Value,
definitions: &mut BTreeSet<String>,
defs: &mut BTreeSet<String>,
) {
let current_definitions = definitions.clone();
let current_defs = defs.clone();
let mut referenced_definitions = BTreeSet::new();
let mut referenced_defs = BTreeSet::new();
if let Some(schema_map) = schema.get("definitions").and_then(Value::as_object) {
for name in ¤t_definitions {
if let Some(schema) = schema_map.get(name) {
collect_schema_refs(
schema,
true,
&mut referenced_definitions,
&mut referenced_defs,
);
}
}
}
if let Some(schema_map) = schema.get("$defs").and_then(Value::as_object) {
for name in ¤t_defs {
if let Some(schema) = schema_map.get(name) {
collect_schema_refs(
schema,
true,
&mut referenced_definitions,
&mut referenced_defs,
);
}
}
}
definitions.extend(referenced_definitions);
defs.extend(referenced_defs);
}
fn collect_schema_refs(
value: &Value,
include_schema_maps: bool,
definitions: &mut BTreeSet<String>,
defs: &mut BTreeSet<String>,
) {
match value {
Value::Object(object) => {
if let Some(reference) = object.get("$ref").and_then(Value::as_str) {
collect_schema_ref(reference, definitions, defs);
}
for (key, child) in object {
if !include_schema_maps && matches!(key.as_str(), "definitions" | "$defs") {
continue;
}
collect_schema_refs(child, include_schema_maps, definitions, defs);
}
}
Value::Array(items) => {
for item in items {
collect_schema_refs(item, include_schema_maps, definitions, defs);
}
}
Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {}
}
}
fn collect_schema_ref(
reference: &str,
definitions: &mut BTreeSet<String>,
defs: &mut BTreeSet<String>,
) {
if let Some(name) = schema_ref_name(reference, "#/definitions/") {
definitions.insert(name);
} else if let Some(name) = schema_ref_name(reference, "#/$defs/") {
defs.insert(name);
}
}
fn schema_ref_name(reference: &str, prefix: &str) -> Option<String> {
let name = reference.strip_prefix(prefix)?.split('/').next()?;
Some(decode_json_pointer_token(name))
}
fn decode_json_pointer_token(token: &str) -> String {
token.replace("~1", "/").replace("~0", "~")
}
fn retain_schema_map(schema: &mut Value, key: &str, used_names: &BTreeSet<String>) {
let Some(object) = schema.as_object_mut() else {
return;
};
let Some(schema_map) = object.get_mut(key).and_then(Value::as_object_mut) else {
return;
};
schema_map.retain(|name, _| used_names.contains(name));
if schema_map.is_empty() {
object.remove(key);
}
}
pub fn write_config_schema<S>(output_path: impl AsRef<Path>) -> ConfigResult<()>
where
S: JsonSchema,
{
let schema = root_config_schema::<S>()?;
let json = schema_json(&schema)?;
write_template(output_path.as_ref(), &json)
}
pub fn config_schema_targets_for_path<S>(
output_path: impl AsRef<Path>,
) -> ConfigResult<Vec<ConfigSchemaTarget>>
where
S: ConfigSchema + JsonSchema,
{
let output_path = output_path.as_ref();
let full_schema = root_config_schema::<S>()?;
let root_schema = schema_for_output_path::<S>(&full_schema, &[])?;
let mut targets = vec![ConfigSchemaTarget {
path: output_path.to_path_buf(),
content: schema_json(&root_schema)?,
}];
for section_path in nested_section_paths(&S::META) {
let schema_path = schema_path_for_section(output_path, §ion_path);
let section_schema = schema_for_output_path::<S>(&full_schema, §ion_path)?;
targets.push(ConfigSchemaTarget {
path: schema_path,
content: schema_json(§ion_schema)?,
});
}
Ok(targets)
}
pub fn write_config_schemas<S>(output_path: impl AsRef<Path>) -> ConfigResult<()>
where
S: ConfigSchema + JsonSchema,
{
for target in config_schema_targets_for_path::<S>(output_path)? {
write_template(&target.path, &target.content)?;
}
Ok(())
}
pub fn template_for_path<S>(path: impl AsRef<Path>) -> ConfigResult<String>
where
S: ConfigSchema,
{
let template = match ConfigFormat::from_path(path.as_ref()) {
ConfigFormat::Yaml => confique::yaml::template::<S>(yaml_options()),
ConfigFormat::Toml => confique::toml::template::<S>(toml_options()),
ConfigFormat::Json => confique::json5::template::<S>(json5_options()),
};
Ok(template)
}
pub fn template_targets_for_paths<S>(
config_path: impl AsRef<Path>,
output_path: impl AsRef<Path>,
) -> ConfigResult<Vec<ConfigTemplateTarget>>
where
S: ConfigSchema,
{
let output_path = output_path.as_ref();
let source_path = select_template_source(config_path, output_path);
let root_source_path = absolutize_lexical(source_path)?;
let output_base_dir = output_path.parent().unwrap_or_else(|| Path::new("."));
let template_targets = collect_template_targets(
&root_source_path,
output_path,
|node_source_path| -> ConfigResult<Vec<PathBuf>> {
let mut include_paths = template_source_include_paths::<S>(node_source_path)?;
if include_paths.is_empty() {
include_paths =
default_child_include_paths::<S>(&root_source_path, node_source_path);
}
Ok(include_paths)
},
)?;
let split_paths = template_targets
.iter()
.filter_map(|target| {
section_path_for_target::<S>(output_base_dir, target.target_path())
.filter(|section_path| !section_path.is_empty())
})
.collect::<Vec<_>>();
template_targets
.into_iter()
.map(|target| {
let (_, target_path, include_paths) = target.into_parts();
let section_path =
section_path_for_target::<S>(output_base_dir, &target_path).unwrap_or_default();
Ok(ConfigTemplateTarget {
content: template_for_target::<S>(
&target_path,
&include_paths,
§ion_path,
&split_paths,
)?,
path: target_path,
})
})
.collect()
}
pub fn template_targets_for_paths_with_schema<S>(
config_path: impl AsRef<Path>,
output_path: impl AsRef<Path>,
schema_path: impl AsRef<Path>,
) -> ConfigResult<Vec<ConfigTemplateTarget>>
where
S: ConfigSchema,
{
let output_path = output_path.as_ref();
let output_base_dir = output_path.parent().unwrap_or_else(|| Path::new("."));
let schema_path = schema_path.as_ref();
template_targets_for_paths::<S>(config_path, output_path)?
.into_iter()
.map(|mut target| {
let schema_path =
schema_path_for_template_target::<S>(output_base_dir, &target.path, schema_path);
target.content =
template_with_schema_directive(&target.path, &schema_path, &target.content)?;
Ok(target)
})
.collect()
}
pub fn write_config_templates<S>(
config_path: impl AsRef<Path>,
output_path: impl AsRef<Path>,
) -> ConfigResult<()>
where
S: ConfigSchema,
{
for target in template_targets_for_paths::<S>(config_path, output_path)? {
write_template(&target.path, &target.content)?;
}
Ok(())
}
pub fn write_config_templates_with_schema<S>(
config_path: impl AsRef<Path>,
output_path: impl AsRef<Path>,
schema_path: impl AsRef<Path>,
) -> ConfigResult<()>
where
S: ConfigSchema,
{
for target in
template_targets_for_paths_with_schema::<S>(config_path, output_path, schema_path)?
{
write_template(&target.path, &target.content)?;
}
Ok(())
}
pub(crate) fn write_template(path: &Path, content: &str) -> ConfigResult<()> {
if let Some(parent) = path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
{
fs::create_dir_all(parent)?;
}
fs::write(path, content)?;
Ok(())
}
pub(crate) fn resolve_config_template_output(output: Option<PathBuf>) -> ConfigResult<PathBuf> {
let current_dir = std::env::current_dir()?;
let output = output.unwrap_or_else(|| PathBuf::from("config.example.yaml"));
let output = if output.is_absolute() {
output
} else {
current_dir.join(output)
};
Ok(normalize_lexical(output))
}
fn template_source_include_paths<S>(path: &Path) -> ConfigResult<Vec<PathBuf>>
where
S: ConfigSchema,
{
if !path.exists() {
return Ok(Vec::new());
}
match load_layer::<S>(path) {
Ok(layer) => Ok(S::include_paths(&layer)),
Err(_) => load_include_paths_only(path),
}
}
fn load_include_paths_only(path: &Path) -> ConfigResult<Vec<PathBuf>> {
match figment_for_file(path).extract_inner::<Vec<PathBuf>>("include") {
Ok(paths) => Ok(paths),
Err(error) if error.missing() => Ok(Vec::new()),
Err(error) => Err(error.into()),
}
}
fn schema_path_for_template_target<S>(
root_base_dir: &Path,
target_path: &Path,
root_schema_path: &Path,
) -> PathBuf
where
S: ConfigSchema,
{
section_path_for_target::<S>(root_base_dir, target_path)
.filter(|section_path| !section_path.is_empty())
.map(|section_path| schema_path_for_section(root_schema_path, §ion_path))
.unwrap_or_else(|| root_schema_path.to_path_buf())
}
fn template_with_schema_directive(
template_path: &Path,
schema_path: &Path,
content: &str,
) -> ConfigResult<String> {
let schema_ref = schema_reference_for_path(template_path, schema_path)?;
let directive = match ConfigFormat::from_path(template_path) {
ConfigFormat::Yaml => Some(format!("# yaml-language-server: $schema={schema_ref}")),
ConfigFormat::Toml => Some(format!("#:schema {schema_ref}")),
ConfigFormat::Json => None,
};
let Some(directive) = directive else {
return Ok(content.to_owned());
};
Ok(format!("{directive}\n\n{content}"))
}
fn schema_reference_for_path(template_path: &Path, schema_path: &Path) -> ConfigResult<String> {
let template_path = absolutize_lexical(template_path)?;
let schema_path = absolutize_lexical(schema_path)?;
let template_dir = template_path.parent().unwrap_or_else(|| Path::new("."));
let relative_path = relative_path_from(&schema_path, template_dir);
Ok(render_schema_reference(&relative_path))
}
fn relative_path_from(path: &Path, base: &Path) -> PathBuf {
let path_components = path.components().collect::<Vec<_>>();
let base_components = base.components().collect::<Vec<_>>();
let mut common_len = 0;
while common_len < path_components.len()
&& common_len < base_components.len()
&& path_components[common_len] == base_components[common_len]
{
common_len += 1;
}
if common_len == 0 {
return path.to_path_buf();
}
let mut relative = PathBuf::new();
for component in &base_components[common_len..] {
if matches!(component, Component::Normal(_)) {
relative.push("..");
}
}
for component in &path_components[common_len..] {
relative.push(component.as_os_str());
}
if relative.as_os_str().is_empty() {
PathBuf::from(".")
} else {
relative
}
}
fn render_schema_reference(path: &Path) -> String {
let value = path.to_string_lossy().replace('\\', "/");
if path.is_absolute() || value.starts_with("../") || value.starts_with("./") {
value
} else {
format!("./{value}")
}
}
fn template_for_target<S>(
path: &Path,
include_paths: &[PathBuf],
section_path: &[&'static str],
split_paths: &[Vec<&'static str>],
) -> ConfigResult<String>
where
S: ConfigSchema,
{
if ConfigFormat::from_path(path) != ConfigFormat::Yaml || split_paths.is_empty() {
return template_for_path_with_includes::<S>(path, include_paths);
}
Ok(render_yaml_template(
&S::META,
include_paths,
section_path,
split_paths,
))
}
fn default_child_include_paths<S>(root_source_path: &Path, node_source_path: &Path) -> Vec<PathBuf>
where
S: ConfigSchema,
{
let root_base_dir = root_source_path.parent().unwrap_or_else(|| Path::new("."));
let section_path =
section_path_for_target::<S>(root_base_dir, node_source_path).unwrap_or_default();
let source_base_dir = node_source_path.parent().unwrap_or_else(|| Path::new("."));
immediate_child_section_paths(&S::META, §ion_path)
.into_iter()
.map(|child_section_path| {
let child_path =
root_base_dir.join(template_path_for_section::<S>(&child_section_path));
path_relative_to(&child_path, source_base_dir)
})
.collect()
}
fn collect_env_mapping(
meta: &'static Meta,
prefix: &str,
env_to_path: &mut HashMap<String, String>,
path_to_env: &mut HashMap<String, String>,
) {
for field in meta.fields {
let path = if prefix.is_empty() {
field.name.to_owned()
} else {
format!("{prefix}.{}", field.name)
};
match field.kind {
FieldKind::Leaf { env: Some(env), .. } => {
env_to_path.insert(env.to_ascii_uppercase(), path.clone());
path_to_env.insert(path, env.to_owned());
}
FieldKind::Leaf { env: None, .. } => {}
FieldKind::Nested { meta } => {
collect_env_mapping(meta, &path, env_to_path, path_to_env);
}
}
}
}
fn load_dotenv_for_path(path: &Path) -> ConfigResult<()> {
let path = absolutize_lexical(path)?;
let mut current_dir = path.parent();
while let Some(dir) = current_dir {
let dotenv_path = dir.join(".env");
if dotenv_path.try_exists()? {
dotenvy::from_path(&dotenv_path)?;
break;
}
current_dir = dir.parent();
}
Ok(())
}
fn section_path_for_target<S>(root_base_dir: &Path, target_path: &Path) -> Option<Vec<&'static str>>
where
S: ConfigSchema,
{
let normalized_target = normalize_lexical(target_path);
for section_path in nested_section_paths(&S::META) {
let section_target =
normalize_lexical(root_base_dir.join(template_path_for_section::<S>(§ion_path)));
if section_target == normalized_target {
return Some(section_path);
}
}
infer_section_path_from_path::<S>(target_path)
}
fn template_path_for_section<S>(section_path: &[&str]) -> PathBuf
where
S: ConfigSchema,
{
if let Some(path) = S::template_path_for_section(section_path) {
return path;
}
let Some((last, parent_path)) = section_path.split_last() else {
return PathBuf::new();
};
if parent_path.is_empty() {
return PathBuf::from("config").join(format!("{last}.yaml"));
}
let parent_template_path = template_path_for_section::<S>(parent_path);
parent_template_path
.with_extension("")
.join(format!("{last}.yaml"))
}
fn path_relative_to(path: &Path, base: &Path) -> PathBuf {
match path.strip_prefix(base) {
Ok(relative) if !relative.as_os_str().is_empty() => relative.to_path_buf(),
_ => path.to_path_buf(),
}
}
fn nested_section_paths(meta: &'static Meta) -> Vec<Vec<&'static str>> {
let mut paths = Vec::new();
collect_nested_section_paths(meta, &mut Vec::new(), &mut paths);
paths
}
fn collect_nested_section_paths(
meta: &'static Meta,
prefix: &mut Vec<&'static str>,
paths: &mut Vec<Vec<&'static str>>,
) {
for field in meta.fields {
if let FieldKind::Nested { meta } = field.kind {
prefix.push(field.name);
paths.push(prefix.clone());
collect_nested_section_paths(meta, prefix, paths);
prefix.pop();
}
}
}
fn immediate_child_section_paths(
meta: &'static Meta,
section_path: &[&'static str],
) -> Vec<Vec<&'static str>> {
let Some(section_meta) = meta_at_path(meta, section_path) else {
return Vec::new();
};
section_meta
.fields
.iter()
.filter_map(|field| match field.kind {
FieldKind::Nested { .. } => {
let mut path = section_path.to_vec();
path.push(field.name);
Some(path)
}
FieldKind::Leaf { .. } => None,
})
.collect()
}
pub fn trace_config_sources<S>(figment: &Figment)
where
S: ConfigSchema,
{
if !tracing::enabled!(tracing::Level::TRACE) {
return;
}
for path in leaf_config_paths(&S::META) {
let source = config_source_for_path(figment, &path);
trace!(target: "rust_config_tree::config", config_key = %path, source = %source, "config source");
}
}
fn config_source_for_path(figment: &Figment, path: &str) -> String {
match figment.find_metadata(path) {
Some(metadata) => render_metadata(metadata, path),
None => "confique default or unset optional field".to_owned(),
}
}
fn render_metadata(metadata: &Metadata, path: &str) -> String {
match &metadata.source {
Some(Source::File(path)) => format!("{} `{}`", metadata.name, path.display()),
Some(Source::Custom(value)) => format!("{} `{value}`", metadata.name),
Some(Source::Code(location)) => {
format!("{} {}:{}", metadata.name, location.file(), location.line())
}
Some(_) => metadata.name.to_string(),
None => {
let parts = path.split('.').collect::<Vec<_>>();
let native = metadata.interpolate(&Profile::Default, &parts);
format!("{} `{native}`", metadata.name)
}
}
}
fn leaf_config_paths(meta: &'static Meta) -> Vec<String> {
let mut paths = Vec::new();
collect_leaf_config_paths(meta, "", &mut paths);
paths
}
fn collect_leaf_config_paths(meta: &'static Meta, prefix: &str, paths: &mut Vec<String>) {
for field in meta.fields {
let path = if prefix.is_empty() {
field.name.to_owned()
} else {
format!("{prefix}.{}", field.name)
};
match field.kind {
FieldKind::Leaf { .. } => paths.push(path),
FieldKind::Nested { meta } => collect_leaf_config_paths(meta, &path, paths),
}
}
}
fn infer_section_path_from_path<S>(path: &Path) -> Option<Vec<&'static str>>
where
S: ConfigSchema,
{
let path_tokens = normalized_path_tokens(path);
let file_token = path
.file_stem()
.and_then(OsStr::to_str)
.map(normalize_token)
.unwrap_or_default();
nested_section_paths(&S::META)
.into_iter()
.filter_map(|section_path| {
let score = section_path_score(§ion_path, &path_tokens, &file_token);
(score > 0).then_some((score, section_path))
})
.max_by_key(|(score, section_path)| (*score, section_path.len()))
.map(|(_, section_path)| section_path)
}
fn normalized_path_tokens(path: &Path) -> Vec<String> {
path.components()
.filter_map(|component| component.as_os_str().to_str())
.map(|component| {
Path::new(component)
.file_stem()
.and_then(OsStr::to_str)
.unwrap_or(component)
})
.map(normalize_token)
.filter(|component| !component.is_empty())
.collect()
}
fn normalize_token(token: &str) -> String {
token
.chars()
.filter_map(|character| match character {
'-' | ' ' => Some('_'),
'_' => Some('_'),
character if character.is_ascii_alphanumeric() => Some(character.to_ascii_lowercase()),
_ => None,
})
.collect()
}
fn section_path_score(section_path: &[&str], path_tokens: &[String], file_token: &str) -> usize {
let section_tokens = section_path
.iter()
.map(|segment| normalize_token(segment))
.collect::<Vec<_>>();
if path_tokens.ends_with(§ion_tokens) {
return 1_000 + section_tokens.len();
}
let Some(last_section_token) = section_tokens.last() else {
return 0;
};
if file_token == last_section_token {
return 500 + section_tokens.len();
}
if file_token.starts_with(last_section_token) || last_section_token.starts_with(file_token) {
return 100 + last_section_token.len().min(file_token.len());
}
0
}
fn meta_at_path(meta: &'static Meta, section_path: &[&str]) -> Option<&'static Meta> {
let mut current_meta = meta;
for section in section_path {
current_meta = current_meta.fields.iter().find_map(|field| {
if field.name != *section {
return None;
}
match field.kind {
FieldKind::Nested { meta } => Some(meta),
FieldKind::Leaf { .. } => None,
}
})?;
}
Some(current_meta)
}
fn render_yaml_template(
meta: &'static Meta,
include_paths: &[PathBuf],
section_path: &[&'static str],
split_paths: &[Vec<&'static str>],
) -> String {
let mut output = String::new();
if !include_paths.is_empty() {
output.push_str(&render_yaml_include(include_paths));
output.push('\n');
}
if section_path.is_empty() {
render_yaml_fields(
meta,
&mut Vec::new(),
split_paths,
0,
!include_paths.is_empty(),
&mut output,
);
} else {
render_yaml_section(meta, section_path, split_paths, &mut output);
}
ensure_single_trailing_newline(&mut output);
output
}
fn render_yaml_section(
meta: &'static Meta,
section_path: &[&'static str],
split_paths: &[Vec<&'static str>],
output: &mut String,
) {
let mut current_meta = meta;
let mut current_path = Vec::new();
for (depth, section) in section_path.iter().enumerate() {
write_yaml_indent(output, depth);
output.push('#');
output.push_str(section);
output.push_str(":\n");
current_path.push(*section);
let Some(next_meta) = meta_at_path(current_meta, &[*section]) else {
return;
};
current_meta = next_meta;
}
render_yaml_fields(
current_meta,
&mut current_path,
split_paths,
section_path.len(),
false,
output,
);
}
fn render_yaml_fields(
meta: &'static Meta,
current_path: &mut Vec<&'static str>,
split_paths: &[Vec<&'static str>],
depth: usize,
skip_include_field: bool,
output: &mut String,
) {
let mut emitted_anything = false;
for field in meta.fields {
let FieldKind::Leaf { env, kind } = field.kind else {
continue;
};
if skip_include_field && current_path.is_empty() && field.name == "include" {
continue;
}
if emitted_anything {
output.push('\n');
}
emitted_anything = true;
render_yaml_leaf(field.name, field.doc, env, kind, depth, output);
}
for field in meta.fields {
let FieldKind::Nested { meta } = field.kind else {
continue;
};
current_path.push(field.name);
let split_exact = split_paths.iter().any(|path| path == current_path);
let split_descendant = split_paths
.iter()
.any(|path| path.starts_with(current_path) && path.len() > current_path.len());
if split_exact {
current_path.pop();
continue;
}
if emitted_anything {
output.push('\n');
}
emitted_anything = true;
for doc in field.doc {
write_yaml_indent(output, depth);
output.push('#');
output.push_str(doc);
output.push('\n');
}
write_yaml_indent(output, depth);
output.push_str(field.name);
output.push_str(":\n");
let child_split_paths = if split_descendant { split_paths } else { &[] };
render_yaml_fields(
meta,
current_path,
child_split_paths,
depth + 1,
false,
output,
);
current_path.pop();
}
}
fn render_yaml_leaf(
name: &str,
doc: &[&str],
env: Option<&str>,
kind: LeafKind,
depth: usize,
output: &mut String,
) {
let mut emitted_doc_comment = false;
for doc in doc {
write_yaml_indent(output, depth);
output.push('#');
output.push_str(doc);
output.push('\n');
emitted_doc_comment = true;
}
if let Some(env) = env {
if emitted_doc_comment {
write_yaml_indent(output, depth);
output.push_str("#\n");
}
write_yaml_indent(output, depth);
output.push_str("# Can also be specified via environment variable `");
output.push_str(env);
output.push_str("`.\n");
}
match kind {
LeafKind::Optional => {
write_yaml_indent(output, depth);
output.push('#');
output.push_str(name);
output.push_str(":\n");
}
LeafKind::Required { default } => {
write_yaml_indent(output, depth);
match default {
Some(default) => {
output.push_str("# Default value: ");
output.push_str(&render_yaml_expr(&default));
output.push('\n');
write_yaml_indent(output, depth);
output.push('#');
output.push_str(name);
output.push_str(": ");
output.push_str(&render_yaml_expr(&default));
output.push('\n');
}
None => {
output.push_str("# Required! This value must be specified.\n");
write_yaml_indent(output, depth);
output.push('#');
output.push_str(name);
output.push_str(":\n");
}
}
}
}
}
fn render_yaml_expr(expr: &Expr) -> String {
match expr {
Expr::Str(value) => render_plain_or_quoted_string(value),
Expr::Float(value) => value.to_string(),
Expr::Integer(value) => value.to_string(),
Expr::Bool(value) => value.to_string(),
Expr::Array(items) => {
let items = items
.iter()
.map(render_yaml_expr)
.collect::<Vec<_>>()
.join(", ");
format!("[{items}]")
}
Expr::Map(entries) => {
let entries = entries
.iter()
.map(|entry| {
format!(
"{}: {}",
render_yaml_map_key(&entry.key),
render_yaml_expr(&entry.value)
)
})
.collect::<Vec<_>>()
.join(", ");
format!("{{ {entries} }}")
}
_ => String::new(),
}
}
fn render_yaml_map_key(key: &MapKey) -> String {
match key {
MapKey::Str(value) => render_plain_or_quoted_string(value),
MapKey::Float(value) => value.to_string(),
MapKey::Integer(value) => value.to_string(),
MapKey::Bool(value) => value.to_string(),
_ => String::new(),
}
}
fn render_plain_or_quoted_string(value: &str) -> String {
let needs_quotes = value.is_empty()
|| value.starts_with([
' ', '#', '{', '}', '[', ']', ',', '&', '*', '!', '|', '>', '\'', '"',
])
|| value.contains([':', '\n', '\r', '\t']);
if needs_quotes {
quote_path(Path::new(value))
} else {
value.to_owned()
}
}
fn write_yaml_indent(output: &mut String, depth: usize) {
for _ in 0..depth {
output.push_str(" ");
}
}
fn ensure_single_trailing_newline(output: &mut String) {
if output.ends_with('\n') {
while output.ends_with("\n\n") {
output.pop();
}
} else {
output.push('\n');
}
}
fn template_for_path_with_includes<S>(
path: &Path,
include_paths: &[PathBuf],
) -> ConfigResult<String>
where
S: ConfigSchema,
{
let template = template_for_path::<S>(path)?;
if include_paths.is_empty() {
return Ok(template);
}
let template = match ConfigFormat::from_path(path) {
ConfigFormat::Yaml => {
let template = strip_prefix_once(&template, "# Default value: []\n#include: []\n\n");
format!("{}\n{template}", render_yaml_include(include_paths))
}
ConfigFormat::Toml => {
let template = strip_prefix_once(&template, "# Default value: []\n#include = []\n\n");
format!("{}\n{template}", render_toml_include(include_paths))
}
ConfigFormat::Json => {
let body = template.strip_prefix("{\n").unwrap_or(&template);
let body = strip_prefix_once(body, " // Default value: []\n //include: [],\n\n");
format!("{{\n{}\n{body}", render_json5_include(include_paths))
}
};
Ok(template)
}
fn render_yaml_include(paths: &[PathBuf]) -> String {
let mut out = String::from("include:\n");
for path in paths {
out.push_str(" - ");
out.push_str("e_path(path));
out.push('\n');
}
out
}
fn render_toml_include(paths: &[PathBuf]) -> String {
let entries = paths
.iter()
.map(|path| quote_path(path))
.collect::<Vec<_>>()
.join(", ");
format!("include = [{entries}]\n")
}
fn render_json5_include(paths: &[PathBuf]) -> String {
let mut out = String::from(" include: [\n");
for path in paths {
out.push_str(" ");
out.push_str("e_path(path));
out.push_str(",\n");
}
out.push_str(" ],\n");
out
}
fn quote_path(path: &Path) -> String {
serde_json::to_string(&path.to_string_lossy()).expect("path string serialization cannot fail")
}
fn strip_prefix_once<'a>(value: &'a str, prefix: &str) -> &'a str {
value.strip_prefix(prefix).unwrap_or(value)
}
fn yaml_options() -> confique::yaml::FormatOptions {
let mut options = confique::yaml::FormatOptions::default();
options.indent = 2;
options.general.comments = true;
options.general.env_keys = true;
options.general.nested_field_gap = 1;
options
}
fn toml_options() -> confique::toml::FormatOptions {
let mut options = confique::toml::FormatOptions::default();
options.general.comments = true;
options.general.env_keys = true;
options.general.nested_field_gap = 1;
options
}
fn json5_options() -> confique::json5::FormatOptions {
let mut options = confique::json5::FormatOptions::default();
options.indent = 2;
options.general.comments = true;
options.general.env_keys = true;
options.general.nested_field_gap = 1;
options
}
#[cfg(test)]
#[path = "unit_tests/config.rs"]
mod unit_tests;