congen 0.1.0

congen helps you build configuration systems that support partial updates from structured changes and CLI input
Documentation
//! Read [CongenChange] from the environment

use std::{
    collections::VecDeque,
    ffi::{OsStr, OsString},
};

use crate::{
    Configuration,
    internal::{ChangeVerb, CongenChange, Description, ParseError, VerbError},
};

/// The error type returned by [load_from_env].
///
/// This includes the partial result that parsed correctly as well as all errors
pub struct EnvError<C: CongenChange> {
    /// A partial change that combines all successfully parsed changes
    pub partial_change: C,
    /// a list of changes that failed to parse
    pub errors: Vec<PartialVerbError>,
}

impl<C: CongenChange> std::fmt::Debug for EnvError<C> {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("EnvError")
            .field("errors", &self.errors)
            .finish_non_exhaustive()
    }
}

/// a partial error describing how a verb failed to parse
#[derive(Debug)]
pub struct PartialVerbError {
    /// The original environment variable
    pub var: OsString,
    /// The value of the environment variable
    pub value: OsString,
    /// The [VerbError] produced when parsing the variable
    pub error: VerbError,
}

/// Load [CongenChange] from environemnt variables passed as iterator.
///
/// does not support lists.
///
/// See also: [load_from_env]
pub fn load_from_env_iter<C: Configuration, E: IntoIterator<Item = (OsString, OsString)>>(
    env: E,
    prefix: &str,
) -> Result<C::CongenChange, EnvError<C::CongenChange>> {
    let mut combined_changes = C::CongenChange::empty();
    let mut errors = Vec::new();

    let prefix = if prefix.ends_with("_") {
        prefix.to_string()
    } else {
        format!("{prefix}_")
    };

    for (var, value) in env.into_iter() {
        let Some(var_str) = var.to_str() else {
            errors.push(PartialVerbError {
                var,
                value,
                error: VerbError::ParseError(ParseError(
                    "expected env varibale name to be valid utf-8".to_string(),
                )),
            });
            continue;
        };
        let Some(without_prefix) = var_str.strip_prefix(&prefix) else {
            continue;
        };

        let description = <C>::description("");
        let (path, verb) = match get_verb_from_var(without_prefix, &value, &description) {
            Ok(verb) => verb,
            Err(error) => {
                errors.push(PartialVerbError { var, value, error });
                continue;
            }
        };

        match C::CongenChange::from_path_and_verb(path.into_iter(), verb) {
            Ok(change) => combined_changes.apply_change(change),

            Err(error) => errors.push(PartialVerbError { var, value, error }),
        }
    }

    if !errors.is_empty() {
        return Err(EnvError {
            partial_change: combined_changes,
            errors,
        });
    }

    Ok(combined_changes)
}

/// Load [CongenChange] from environemnt variables
///
/// return `Ok(change)` if all environment variables starting with `prefix` parsed correctly.
/// Otherwise [`Err(partial_change)`](EnvError) is returned.
///
/// does not support lists.
pub fn load_from_env<C: Configuration>(
    prefix: &str,
) -> Result<C::CongenChange, EnvError<C::CongenChange>> {
    load_from_env_iter::<C, _>(std::env::vars_os(), prefix)
}

fn get_verb_from_var<'p>(
    var: &str,
    value: &OsStr,
    description: &Description,
) -> Result<(VecDeque<&'p str>, ChangeVerb), VerbError> {
    for actionable in description.actionable_fields() {
        let mut path: String = actionable
            .path
            .iter()
            .map(|field_name| field_name_to_scream_case(field_name))
            .collect::<Vec<_>>()
            .join("_");
        path.push('_');

        let Some(verb) = var.strip_prefix(&path) else {
            continue;
        };

        let verb = match verb {
            "UNSET" => ChangeVerb::Unset,
            "USE-DEFAULT" | "USE_DEFAULT" | "USEDEFAULT" => ChangeVerb::UseDefault,
            "SET" => ChangeVerb::Set(value.to_owned()),
            verb => return Err(VerbError::UnknownVerb(verb.to_owned())),
        };
        return Ok((actionable.path, verb));
    }

    Err(VerbError::InvalidPath)
}

fn field_name_to_scream_case(name: &str) -> String {
    let mut result = String::new();
    let chars: Vec<char> = name.chars().collect();

    for (i, ch) in chars.iter().copied().enumerate() {
        if ch == '_' {
            if !result.ends_with('_') {
                result.push('_');
            }
            continue;
        }

        if ch.is_uppercase() {
            let prev = i.checked_sub(1).and_then(|idx| chars.get(idx)).copied();
            let next = chars.get(i + 1).copied();
            let needs_separator = prev.is_some_and(|p| p != '_')
                && (prev.is_some_and(|p| p.is_lowercase() || p.is_numeric())
                    || (prev.is_some_and(char::is_uppercase)
                        && next.is_some_and(char::is_lowercase)));

            if needs_separator {
                result.push('_');
            }

            result.push(ch.to_ascii_uppercase());
            continue;
        }

        result.push(ch.to_ascii_uppercase());
    }

    result
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_field_name_to_scream_case_simple() {
        assert_eq!(field_name_to_scream_case("hello"), "HELLO");
    }

    #[test]
    fn test_field_name_to_scream_case_with_underscore() {
        assert_eq!(field_name_to_scream_case("hello_world"), "HELLO_WORLD");
    }

    #[test]
    fn test_field_name_to_scream_case_camel_case() {
        assert_eq!(field_name_to_scream_case("helloWorld"), "HELLO_WORLD");
    }

    #[test]
    fn test_field_name_to_scream_case_pascal_case() {
        assert_eq!(field_name_to_scream_case("HelloWorld"), "HELLO_WORLD");
    }

    #[test]
    fn test_field_name_to_scream_case_mixed() {
        assert_eq!(field_name_to_scream_case("myVarName"), "MY_VAR_NAME");
    }

    #[test]
    fn test_field_name_to_scream_case_already_scream() {
        assert_eq!(field_name_to_scream_case("HELLO_WORLD"), "HELLO_WORLD");
    }

    #[test]
    fn test_field_name_to_scream_case_empty() {
        assert_eq!(field_name_to_scream_case(""), "");
    }

    #[test]
    fn test_field_name_to_scream_case_single_char() {
        assert_eq!(field_name_to_scream_case("a"), "A");
        assert_eq!(field_name_to_scream_case("A"), "A");
    }
}