tier 0.1.17

Rust configuration library for layered TOML, env, and CLI settings
Documentation
use serde_json::Value;

use crate::error::ConfigError;
use crate::path::get_value_at_path;
use crate::report::{AppliedMigration, ConfigReport};

use super::canonical::canonicalize_runtime_path;
use super::de::insert_path;
use super::migration::{ConfigMigration, ConfigMigrationKind};
use super::path::try_normalize_external_path;

pub(super) fn normalize_version_registration_path(path: &str) -> Result<String, ConfigError> {
    let normalized =
        try_normalize_external_path(path).map_err(|message| ConfigError::MetadataInvalid {
            path: path.to_owned(),
            message: format!("invalid configuration version path: {message}"),
        })?;
    if normalized.is_empty() {
        return Err(ConfigError::MetadataInvalid {
            path: path.to_owned(),
            message: "configuration version path cannot be empty".to_owned(),
        });
    }
    Ok(normalized)
}

pub(super) fn validate_config_migrations(
    migrations: &[ConfigMigration],
) -> Result<(), ConfigError> {
    for migration in migrations {
        match &migration.kind {
            ConfigMigrationKind::Rename { from, to } => {
                let _ = normalize_migration_registration_path(from)?;
                let _ = normalize_migration_registration_path(to)?;
            }
            ConfigMigrationKind::Remove { path } => {
                let _ = normalize_migration_registration_path(path)?;
            }
        }
    }

    Ok(())
}

pub(super) fn apply_config_migrations(
    merged: &mut Value,
    version_path: &str,
    current_version: u32,
    migrations: &[ConfigMigration],
    report: &mut ConfigReport,
) -> Result<(), ConfigError> {
    let version_path = canonicalize_runtime_path(merged, version_path);
    let mut working_version = read_config_version(merged, &version_path)?;
    if working_version > current_version {
        return Err(ConfigError::UnsupportedConfigVersion {
            path: version_path,
            found: working_version,
            supported: current_version,
        });
    }

    let mut sorted = migrations.to_vec();
    sorted.sort_by_key(|migration| migration.since_version);

    for migration in sorted {
        if migration.since_version <= working_version || migration.since_version > current_version {
            continue;
        }

        match &migration.kind {
            ConfigMigrationKind::Rename { from, to } => {
                let from = canonicalize_runtime_path(
                    merged,
                    &normalize_migration_registration_path(from)?,
                );
                let to =
                    canonicalize_runtime_path(merged, &normalize_migration_registration_path(to)?);
                if let Some(value) = take_value_at_path(merged, &from) {
                    insert_normalized_path(merged, &to, value).map_err(|message| {
                        ConfigError::MetadataInvalid {
                            path: to.clone(),
                            message: format!("failed to apply migration: {message}"),
                        }
                    })?;
                    report.record_migration(AppliedMigration {
                        kind: "rename".to_owned(),
                        from_version: working_version,
                        to_version: migration.since_version,
                        from_path: from,
                        to_path: Some(to),
                        note: migration.note.clone(),
                    });
                }
            }
            ConfigMigrationKind::Remove { path } => {
                let path = canonicalize_runtime_path(
                    merged,
                    &normalize_migration_registration_path(path)?,
                );
                if take_value_at_path(merged, &path).is_some() {
                    report.record_migration(AppliedMigration {
                        kind: "remove".to_owned(),
                        from_version: working_version,
                        to_version: migration.since_version,
                        from_path: path,
                        to_path: None,
                        note: migration.note.clone(),
                    });
                }
            }
        }

        working_version = migration.since_version;
    }

    insert_normalized_path(
        merged,
        &version_path,
        Value::Number(serde_json::Number::from(current_version)),
    )
    .map_err(|message| ConfigError::InvalidConfigVersion {
        path: version_path,
        message,
    })?;

    Ok(())
}

fn normalize_migration_registration_path(path: &str) -> Result<String, ConfigError> {
    let normalized =
        try_normalize_external_path(path).map_err(|message| ConfigError::MetadataInvalid {
            path: path.to_owned(),
            message: format!("invalid migration path: {message}"),
        })?;
    if normalized.is_empty() {
        return Err(ConfigError::MetadataInvalid {
            path: path.to_owned(),
            message: "migration paths cannot target the configuration root".to_owned(),
        });
    }
    Ok(normalized)
}

fn read_config_version(value: &Value, path: &str) -> Result<u32, ConfigError> {
    let Some(found) = get_value_at_path(value, path) else {
        return Ok(0);
    };

    let Some(version) = found.as_u64() else {
        return Err(ConfigError::InvalidConfigVersion {
            path: path.to_owned(),
            message: "expected an unsigned integer".to_owned(),
        });
    };

    u32::try_from(version).map_err(|_| ConfigError::InvalidConfigVersion {
        path: path.to_owned(),
        message: "version must fit in u32".to_owned(),
    })
}

fn insert_normalized_path(root: &mut Value, path: &str, value: Value) -> Result<(), String> {
    let segments = path.split('.').collect::<Vec<_>>();
    insert_path(root, &segments, value)
}

fn take_value_at_path(root: &mut Value, path: &str) -> Option<Value> {
    let segments = path
        .split('.')
        .filter(|segment| !segment.is_empty())
        .collect::<Vec<_>>();
    take_value_at_segments(root, &segments)
}

fn take_value_at_segments(current: &mut Value, segments: &[&str]) -> Option<Value> {
    let segment = segments.first()?;
    if segments.len() == 1 {
        return match current {
            Value::Object(map) => map.remove(*segment),
            Value::Array(values) => {
                let index = segment.parse::<usize>().ok()?;
                (index < values.len()).then(|| values.remove(index))
            }
            _ => None,
        };
    }

    match current {
        Value::Object(map) => {
            let child = map.get_mut(*segment)?;
            take_value_at_segments(child, &segments[1..])
        }
        Value::Array(values) => {
            let index = segment.parse::<usize>().ok()?;
            let child = values.get_mut(index)?;
            take_value_at_segments(child, &segments[1..])
        }
        _ => None,
    }
}