tier 0.1.17

Rust configuration library for layered TOML, env, and CLI settings
Documentation
use std::collections::{BTreeMap, BTreeSet};

use serde::Serialize;
use serde_json::Value;

use crate::ConfigMetadata;
use crate::error::{ConfigError, UnknownField};
use crate::path::{collect_paths, path_is_at_or_below, path_matches_pattern};
use crate::report::ConfigReport;

use super::suggest::{best_path_suggestion, find_source_for_unknown_path};
use crate::loader::path::normalize_external_path;

pub(in crate::loader) fn collect_known_paths<T>(config: &T) -> Result<BTreeSet<String>, ConfigError>
where
    T: Serialize,
{
    let value = serde_json::to_value(config)?;
    Ok(collect_known_paths_from_value(&value))
}

pub(in crate::loader) fn collect_known_paths_from_value(value: &Value) -> BTreeSet<String> {
    let mut paths = Vec::new();
    collect_paths(value, "", &mut paths);
    paths.into_iter().collect()
}

pub(in crate::loader) fn collect_suggestion_paths(
    metadata: &ConfigMetadata,
    known_paths: &BTreeSet<String>,
) -> BTreeMap<String, String> {
    let mut candidates = BTreeMap::new();

    for field in metadata.fields() {
        candidates.insert(field.path.clone(), field.path.clone());
        for alias in &field.aliases {
            candidates.insert(alias.clone(), field.path.clone());
        }
    }

    for path in known_paths {
        candidates
            .entry(path.clone())
            .or_insert_with(|| path.clone());
    }

    candidates
}

pub(in crate::loader) fn collect_unknown_fields_from_metadata_scope(
    value: &Value,
    metadata: &ConfigMetadata,
    suggestion_paths: &BTreeMap<String, String>,
    report: &ConfigReport,
    scope: Option<String>,
) -> Vec<UnknownField> {
    let patterns = metadata_pattern_paths(metadata);
    let mut paths = Vec::new();
    collect_paths(value, "", &mut paths);
    paths.sort();
    paths.dedup();

    paths
        .into_iter()
        .filter(|path| path_is_in_scope(path, scope.as_deref()))
        .filter(|path| !path_is_covered_by_patterns(path, &patterns))
        .map(|path| {
            let source = find_source_for_unknown_path(report, &path);
            let suggestion = best_path_suggestion(&path, suggestion_paths);
            UnknownField::new(path)
                .with_source(source)
                .with_suggestion(suggestion)
        })
        .collect()
}

pub(in crate::loader) fn error_path_for_scope(error: &ConfigError) -> Option<&str> {
    match error {
        ConfigError::Deserialize { path, .. } => Some(path.as_str()),
        _ => None,
    }
}

pub(in crate::loader) fn deserialize_error_scope(path: Option<&str>) -> Option<String> {
    let normalized = path.map(normalize_external_path)?;
    if normalized.is_empty() {
        None
    } else {
        Some(normalized)
    }
}

fn metadata_pattern_paths(metadata: &ConfigMetadata) -> Vec<String> {
    let mut patterns = Vec::new();
    for field in metadata.fields() {
        patterns.push(field.path.clone());
        patterns.extend(field.aliases.iter().cloned());
    }
    patterns.sort();
    patterns.dedup();
    patterns
}

fn path_is_in_scope(path: &str, scope: Option<&str>) -> bool {
    scope.is_none_or(|scope| path_is_at_or_below(path, scope))
}

fn path_is_covered_by_patterns(path: &str, patterns: &[String]) -> bool {
    patterns.iter().any(|pattern| {
        path_matches_pattern(path, pattern) || path_is_prefix_of_pattern(path, pattern)
    })
}

fn path_is_prefix_of_pattern(prefix: &str, pattern: &str) -> bool {
    let prefix_segments = prefix
        .split('.')
        .filter(|segment| !segment.is_empty())
        .collect::<Vec<_>>();
    let pattern_segments = pattern
        .split('.')
        .filter(|segment| !segment.is_empty())
        .collect::<Vec<_>>();

    prefix_segments.len() <= pattern_segments.len()
        && prefix_segments
            .iter()
            .zip(pattern_segments.iter())
            .all(|(actual, expected)| *expected == "*" || actual == expected)
}