shelly-data 0.2.0

Data-layer primitives for Shelly LiveView (schemas, changesets, repo, migrations).
Documentation
use crate::error::{DataError, DataResult};
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AdapterKind {
    None,
    Postgres,
    MySql,
    Sqlite,
}

impl AdapterKind {
    pub fn as_str(self) -> &'static str {
        match self {
            Self::None => "none",
            Self::Postgres => "postgres",
            Self::MySql => "mysql",
            Self::Sqlite => "sqlite",
        }
    }

    pub fn parse(raw: &str) -> DataResult<Self> {
        match raw.trim().to_ascii_lowercase().as_str() {
            "none" => Ok(Self::None),
            "postgres" | "postgresql" | "pg" => Ok(Self::Postgres),
            "mysql" => Ok(Self::MySql),
            "sqlite" | "sqlite3" => Ok(Self::Sqlite),
            value => Err(DataError::Config(format!(
                "unsupported database adapter `{value}`; expected one of: none, postgres, mysql, sqlite"
            ))),
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DatabaseConfig {
    pub adapter: AdapterKind,
    pub url: Option<String>,
    pub url_env: Option<String>,
}

impl Default for DatabaseConfig {
    fn default() -> Self {
        Self {
            adapter: AdapterKind::None,
            url: None,
            url_env: Some("DATABASE_URL".to_string()),
        }
    }
}

impl DatabaseConfig {
    pub fn from_toml_like_str(content: &str) -> DataResult<Self> {
        let mut config = Self::default();
        let mut in_database_section = false;

        for raw_line in content.lines() {
            let line = raw_line.trim();
            if line.is_empty() || line.starts_with('#') {
                continue;
            }
            if line.starts_with('[') && line.ends_with(']') {
                in_database_section = line == "[database]";
                continue;
            }
            if !in_database_section {
                continue;
            }

            let Some((key, value)) = line.split_once('=') else {
                continue;
            };

            let key = key.trim();
            let value = strip_quotes(value.trim());
            match key {
                "adapter" => config.adapter = AdapterKind::parse(value)?,
                "url" => config.url = Some(value.to_string()),
                "url_env" => config.url_env = Some(value.to_string()),
                _ => {}
            }
        }

        Ok(config)
    }

    pub fn resolve_url(&self) -> Option<String> {
        if let Some(url) = &self.url {
            return Some(url.clone());
        }
        self.url_env
            .as_deref()
            .and_then(|env_name| std::env::var(env_name).ok())
    }
}

fn strip_quotes(value: &str) -> &str {
    value
        .strip_prefix('"')
        .and_then(|rest| rest.strip_suffix('"'))
        .unwrap_or(value)
}

#[cfg(test)]
mod tests {
    use super::{AdapterKind, DatabaseConfig};
    use proptest::prelude::*;

    #[test]
    fn parse_database_config() {
        let config = DatabaseConfig::from_toml_like_str(
            r#"
[database]
adapter = "postgres"
url_env = "APP_DB_URL"
"#,
        )
        .unwrap();

        assert_eq!(config.adapter, AdapterKind::Postgres);
        assert_eq!(config.url_env.as_deref(), Some("APP_DB_URL"));
    }

    proptest! {
        #[test]
        fn adapter_parse_accepts_aliases_case_and_whitespace(
            alias in prop_oneof![
                Just("none"),
                Just("postgres"),
                Just("postgresql"),
                Just("pg"),
                Just("mysql"),
                Just("sqlite"),
                Just("sqlite3"),
            ],
            left_ws in 0usize..3,
            right_ws in 0usize..3,
            uppercase in any::<bool>(),
        ) {
            let alias = if uppercase {
                alias.to_ascii_uppercase()
            } else {
                alias.to_string()
            };
            let input = format!("{}{}{}", " ".repeat(left_ws), alias, " ".repeat(right_ws));
            let kind = AdapterKind::parse(&input).unwrap();
            let expected = match alias.to_ascii_lowercase().as_str() {
                "none" => AdapterKind::None,
                "postgres" | "postgresql" | "pg" => AdapterKind::Postgres,
                "mysql" => AdapterKind::MySql,
                "sqlite" | "sqlite3" => AdapterKind::Sqlite,
                _ => unreachable!("input generated from known aliases"),
            };
            prop_assert_eq!(kind, expected);
        }

        #[test]
        fn adapter_parse_rejects_unknown_values(raw in "[a-zA-Z0-9_\\-]{1,24}") {
            let normalized = raw.trim().to_ascii_lowercase();
            prop_assume!(
                normalized != "none" &&
                normalized != "postgres" &&
                normalized != "postgresql" &&
                normalized != "pg" &&
                normalized != "mysql" &&
                normalized != "sqlite" &&
                normalized != "sqlite3"
            );
            prop_assert!(AdapterKind::parse(&raw).is_err());
        }
    }
}