tier 0.1.14

Rust configuration library for layered TOML, env, and CLI settings
Documentation
use super::*;

pub(super) fn normalize_checks<I>(checks: I) -> Vec<ValidationCheck>
where
    I: IntoIterator<Item = ValidationCheck>,
{
    let mut normalized = Vec::new();
    for check in checks {
        let Some(check) = check.normalize() else {
            continue;
        };
        if !normalized.contains(&check) {
            normalized.push(check);
        }
    }
    normalized
}

pub(super) fn normalize_check_path_group<I>(paths: I) -> Option<Vec<String>>
where
    I: IntoIterator<Item = String>,
{
    let mut normalized = Vec::new();
    for path in paths {
        let path = normalize_metadata_path(&path);
        if normalized.contains(&path) {
            continue;
        }
        normalized.push(path);
    }
    (!normalized.is_empty()).then_some(normalized)
}

pub(super) fn normalize_metadata_path(path: &str) -> String {
    try_normalize_metadata_path(path).unwrap_or_else(|_| path.to_owned())
}

pub(super) fn validate_metadata_path(path: &str) -> Result<(), ConfigError> {
    try_normalize_metadata_path(path)
        .map(|_| ())
        .map_err(|message| ConfigError::MetadataInvalid {
            path: path.to_owned(),
            message: format!("invalid metadata path: {message}"),
        })
}

pub(super) fn validate_check_path(path: &str) -> Result<(), ConfigError> {
    validate_metadata_path(path)?;
    if normalize_metadata_path(path).is_empty() {
        return Err(ConfigError::MetadataInvalid {
            path: path.to_owned(),
            message: "invalid metadata path: cross-field checks cannot use the root path"
                .to_owned(),
        });
    }
    Ok(())
}

pub(super) fn try_normalize_metadata_path(path: &str) -> Result<String, String> {
    if path.is_empty() {
        return Ok(String::new());
    }
    if path == "." {
        return Ok(String::new());
    }

    let mut segments = Vec::new();
    let mut current = String::new();
    let mut chars = path.chars().peekable();
    let mut after_index = false;
    let mut expecting_segment = true;

    while let Some(ch) = chars.next() {
        if after_index {
            match ch {
                '.' => {
                    if chars.peek().is_none() {
                        return Err("configuration path cannot end with `.`".to_owned());
                    }
                    after_index = false;
                    expecting_segment = true;
                }
                '[' => {
                    let index = parse_metadata_array_index(&mut chars)?;
                    segments.push(index);
                    after_index = true;
                    expecting_segment = false;
                }
                _ => {
                    return Err(
                        "expected `.` or `[` after an array index in configuration path".to_owned(),
                    );
                }
            }
            continue;
        }

        match ch {
            '.' => {
                if current.is_empty() {
                    return Err("empty path segment in configuration path".to_owned());
                }
                segments.push(std::mem::take(&mut current));
                expecting_segment = true;
            }
            '[' => {
                if current.is_empty() {
                    return Err("array indices must follow a field name".to_owned());
                }
                segments.push(std::mem::take(&mut current));
                let index = parse_metadata_array_index(&mut chars)?;
                segments.push(index);
                after_index = true;
                expecting_segment = false;
            }
            ']' => return Err("unexpected `]` in configuration path".to_owned()),
            _ => {
                current.push(ch);
                expecting_segment = false;
            }
        }
    }

    if expecting_segment && !segments.is_empty() && current.is_empty() && !after_index {
        return Err("configuration path cannot end with `.`".to_owned());
    }

    if !current.is_empty() {
        segments.push(current);
    }

    for segment in &segments {
        if segment.contains('*') && segment != "*" {
            return Err("wildcard path segments must be exactly `*`".to_owned());
        }
    }

    Ok(segments.join("."))
}

fn parse_metadata_array_index<I>(chars: &mut std::iter::Peekable<I>) -> Result<String, String>
where
    I: Iterator<Item = char>,
{
    let mut index = String::new();
    let mut closed = false;
    for next in chars.by_ref() {
        if next == ']' {
            closed = true;
            break;
        }
        index.push(next);
    }

    if !closed {
        return Err("unclosed `[` in configuration path".to_owned());
    }
    if index.is_empty() {
        return Err("empty array index in configuration path".to_owned());
    }
    if !index.chars().all(|ch| ch.is_ascii_digit()) {
        return Err("array indices in configuration paths must be numeric".to_owned());
    }

    Ok(index
        .parse::<usize>()
        .expect("checked numeric array indices")
        .to_string())
}

pub(super) fn metadata_match_score(path: &str, candidate: &str) -> Option<MetadataMatchScore> {
    if candidate != path && !path_matches_pattern(path, candidate) {
        return None;
    }

    let segments = candidate
        .split('.')
        .filter(|segment| !segment.is_empty())
        .collect::<Vec<_>>();
    let positional_specificity = segments
        .iter()
        .map(|segment| *segment != "*")
        .collect::<Vec<_>>();
    let specificity = positional_specificity
        .iter()
        .filter(|segment| **segment)
        .count();
    Some(MetadataMatchScore {
        segment_count: segments.len(),
        specificity,
        positional_specificity,
    })
}

pub(super) fn alias_mapping_is_lossless(alias: &str, canonical: &str) -> bool {
    let alias_segments = path_segments(alias);
    let canonical_segments = path_segments(canonical);
    if canonical_segments.len() < alias_segments.len() {
        return false;
    }

    for index in 0..alias_segments.len() {
        let alias_wildcard = alias_segments[index] == "*";
        let canonical_wildcard = canonical_segments[index] == "*";
        if alias_wildcard != canonical_wildcard {
            return false;
        }
    }

    !canonical_segments[alias_segments.len()..].contains(&"*")
}

pub(super) fn alias_patterns_are_ambiguous(
    left_alias: &str,
    left_canonical: &str,
    right_alias: &str,
    right_canonical: &str,
) -> bool {
    if alias_rank(left_alias) != alias_rank(right_alias) {
        return false;
    }

    let left_segments = path_segments(left_alias);
    let right_segments = path_segments(right_alias);
    if left_segments.len() != right_segments.len() {
        return false;
    }

    if !left_segments
        .iter()
        .zip(right_segments.iter())
        .all(|(left, right)| *left == "*" || *right == "*" || left == right)
    {
        return false;
    }

    let sample_path = alias_overlap_sample_path(left_alias, right_alias);
    rewrite_alias_sample(&sample_path, left_alias, left_canonical)
        != rewrite_alias_sample(&sample_path, right_alias, right_canonical)
}

fn alias_rank(alias: &str) -> (usize, usize) {
    let segments = path_segments(alias);
    let specificity = segments.iter().filter(|segment| **segment != "*").count();
    (segments.len(), specificity)
}

pub(super) fn alias_overlap_sample_path(left: &str, right: &str) -> String {
    path_segments(left)
        .into_iter()
        .zip(path_segments(right))
        .map(|(left, right)| {
            if left == "*" && right == "*" {
                "item".to_owned()
            } else if left == "*" {
                right.to_owned()
            } else {
                left.to_owned()
            }
        })
        .collect::<Vec<_>>()
        .join(".")
}

fn rewrite_alias_sample(path: &str, alias: &str, canonical: &str) -> String {
    let concrete_segments = path_segments(path);
    let alias_segments = path_segments(alias);
    let canonical_segments = path_segments(canonical);

    let mut rewritten = canonical_segments
        .iter()
        .enumerate()
        .map(|(index, segment)| {
            if *segment == "*" && alias_segments.get(index) == Some(&"*") {
                concrete_segments[index].to_owned()
            } else {
                (*segment).to_owned()
            }
        })
        .collect::<Vec<_>>();
    rewritten.extend(
        concrete_segments[alias_segments.len()..]
            .iter()
            .map(|segment| (*segment).to_owned()),
    );
    normalize_path(&rewritten.join("."))
}

fn path_segments(path: &str) -> Vec<&str> {
    path.split('.')
        .filter(|segment| !segment.is_empty())
        .collect()
}