BareGit
//! TOML configuration parsing, secret resolution, and validation.

use std::{
    collections::HashSet,
    env, fmt, fs,
    path::{Path, PathBuf},
};

use serde::Deserialize;
use url::Url;

use crate::errors::ConfigurationError;

/// Placeholder in `persona.generic_error` replaced with a safe error code.
pub const ERROR_CODE_PLACEHOLDER: &str = "{error_code}";

const MAX_SYNC_TIMELINE_LIMIT: u32 = 1_000;
const MAX_GAP_PAGE_LIMIT: u32 = 100;
const MAX_CONCURRENT_ROOMS: u32 = 64;
const MAX_CONTEXT_EVENTS: u32 = 200;
const MAX_CONTEXT_CHARACTERS: u32 = 200_000;
const MAX_LLM_ITERATIONS: u32 = 16;
const MAX_DEVICE_DISPLAY_NAME_CHARACTERS: usize = 256;

/// Holds a resolved secret while preventing accidental debug output.
#[derive(Clone, Eq, PartialEq)]
pub struct SecretString(String);

impl SecretString {
    /// Returns the secret only for an authenticated client request.
    #[must_use]
    pub fn expose(&self) -> &str {
        &self.0
    }
}

impl fmt::Debug for SecretString {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str("[REDACTED]")
    }
}

/// Holds the Matrix settings needed for Lisa's dedicated client session.
#[derive(Clone, Debug)]
pub struct MatrixConfig {
    /// Absolute Matrix homeserver URL.
    pub homeserver_url: Url,
    /// Matrix user ID for Lisa.
    pub user_id: String,
    /// Stable Matrix device ID used for transaction idempotency.
    pub device_id: String,
    /// Human-readable label assigned to Lisa's Matrix device.
    pub device_display_name: String,
    /// Password resolved from the environment for automatic session renewal.
    pub password: SecretString,
    /// Exact room IDs in which Lisa may operate.
    pub allowed_room_ids: HashSet<String>,
    /// Human-facing aliases accepted as Lisa mentions.
    pub mention_aliases: Vec<String>,
    /// Long-poll duration for controlled Matrix sync requests.
    pub sync_timeout_ms: u64,
    /// Timeline event limit requested from Matrix.
    pub sync_timeline_limit: u32,
    /// Maximum pages used to recover a limited sync gap.
    pub gap_page_limit: u32,
}

/// Holds local-process settings needed during the foundation phase.
#[derive(Clone, Debug)]
pub struct RuntimeConfig {
    /// SQLite database path for Lisa's authoritative state.
    pub database_path: PathBuf,
    /// Separate persistent state-store path for the Matrix SDK.
    pub matrix_store_path: PathBuf,
    /// Maximum simultaneous room workers for later phases.
    pub max_concurrent_rooms: u32,
    /// Maximum time an invocation may remain leased.
    pub invocation_timeout_seconds: u64,
    /// Grace period used during process shutdown.
    pub shutdown_grace_seconds: u64,
    /// Default tracing level when RUST_LOG is unset.
    pub log_level: String,
}

/// Holds the self-hosted OpenAI-compatible LLM settings.
#[derive(Clone, Debug)]
pub struct LlmConfig {
    /// Base URL of the operator's OpenAI-compatible service.
    pub base_url: Url,
    /// Bearer credential resolved from the environment.
    pub api_key: SecretString,
    /// Model identifier sent to the inference service.
    pub model: String,
    /// Total request timeout for an LLM completion.
    pub request_timeout_seconds: u64,
    /// Maximum generated tokens requested from the LLM.
    pub max_output_tokens: u32,
    /// Maximum character count for the complete model request.
    pub max_request_characters: u32,
    /// Maximum character count for the model's JSON response.
    pub max_response_characters: u32,
    /// Maximum LLM turns permitted for an invocation.
    pub max_iterations: u32,
    /// Maximum fixed tool calls permitted for an invocation.
    pub max_tool_calls: u32,
    /// Model sampling temperature.
    pub temperature: f64,
}

/// Holds the operator-owned persona and fallbacks.
#[derive(Clone, Debug)]
pub struct PersonaConfig {
    /// Regular file containing the operator's persona prose.
    pub path: PathBuf,
    /// In-character response used after an internal invocation failure.
    pub generic_error: String,
    /// In-character response used when a configured limit is exceeded.
    pub overload_error: String,
}

/// Holds bounded conversation-context settings.
#[derive(Clone, Debug)]
pub struct ContextConfig {
    /// Maximum event count exposed to the LLM.
    pub max_events: u32,
    /// Maximum total normalized context characters.
    pub max_characters: u32,
    /// Maximum normalized characters from one event.
    pub max_event_characters: u32,
    /// Maximum followed reply references, reserved for a later context expansion.
    pub max_reply_depth: u32,
}

/// Holds the fixed SearXNG search-service configuration.
#[derive(Clone, Debug)]
pub struct SearchConfig {
    /// Fixed SearXNG JSON API endpoint.
    pub endpoint: Url,
    /// Optional API credential resolved from the environment.
    pub api_key: Option<SecretString>,
    /// Maximum returned results visible to the model.
    pub max_results: u32,
    /// Search request timeout.
    pub timeout_seconds: u64,
}

/// Holds resource limits for model-selected web requests and Pandoc.
#[derive(Clone, Debug)]
pub struct WebConfig {
    /// HTTP connection timeout.
    pub connect_timeout_seconds: u64,
    /// HTTP read timeout.
    pub read_timeout_seconds: u64,
    /// Total request deadline.
    pub total_timeout_seconds: u64,
    /// Maximum followed HTTP redirects.
    pub max_redirects: u32,
    /// Maximum downloaded page bytes.
    pub max_download_bytes: usize,
    /// Maximum model-visible tool output characters.
    pub max_tool_output_characters: u32,
    /// Maximum bytes retained in one invocation's artifacts.
    pub max_total_artifact_bytes: usize,
    /// Pandoc conversion deadline.
    pub pandoc_timeout_seconds: u64,
    /// Fixed page-download user agent.
    pub user_agent: String,
}

/// Contains Lisa's validated application configuration.
#[derive(Clone, Debug)]
pub struct AppConfig {
    /// Matrix connectivity and invocation policy.
    pub matrix: MatrixConfig,
    /// Local state and runtime configuration.
    pub runtime: RuntimeConfig,
    /// Inference-service configuration.
    pub llm: LlmConfig,
    /// Persona and failure-text configuration.
    pub persona: PersonaConfig,
    /// Conversation context limits.
    pub context: ContextConfig,
    /// Fixed search-provider configuration.
    pub search: SearchConfig,
    /// Model-selected web resource limits.
    pub web: WebConfig,
}

#[derive(Debug, Deserialize)]
struct RawConfig {
    matrix: RawMatrixConfig,
    runtime: RawRuntimeConfig,
    llm: RawLlmConfig,
    persona: RawPersonaConfig,
    context: RawContextConfig,
    search: RawSearchConfig,
    web: RawWebConfig,
}

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawMatrixConfig {
    homeserver_url: String,
    user_id: String,
    device_id: String,
    device_display_name: String,
    password: String,
    allowed_room_ids: Vec<String>,
    mention_aliases: Vec<String>,
    sync_timeout_ms: u64,
    sync_timeline_limit: u32,
    gap_page_limit: u32,
}

#[derive(Debug, Deserialize)]
struct RawRuntimeConfig {
    database_path: PathBuf,
    matrix_store_path: PathBuf,
    max_concurrent_rooms: u32,
    invocation_timeout_seconds: u64,
    shutdown_grace_seconds: u64,
    log_level: String,
}

#[derive(Debug, Deserialize)]
struct RawLlmConfig {
    base_url: String,
    api_key: String,
    model: String,
    request_timeout_seconds: u64,
    max_output_tokens: u32,
    max_request_characters: u32,
    max_response_characters: u32,
    max_iterations: u32,
    max_tool_calls: u32,
    temperature: f64,
}

#[derive(Debug, Deserialize)]
struct RawPersonaConfig {
    path: PathBuf,
    generic_error: String,
    overload_error: String,
}

#[derive(Debug, Deserialize)]
#[allow(clippy::struct_field_names)]
struct RawContextConfig {
    max_events: u32,
    max_characters: u32,
    max_event_characters: u32,
    max_reply_depth: u32,
}

#[derive(Debug, Deserialize)]
struct RawSearchConfig {
    endpoint: String,
    api_key: Option<String>,
    max_results: u32,
    timeout_seconds: u64,
}

#[derive(Debug, Deserialize)]
struct RawWebConfig {
    connect_timeout_seconds: u64,
    read_timeout_seconds: u64,
    total_timeout_seconds: u64,
    max_redirects: u32,
    max_download_bytes: usize,
    max_tool_output_characters: u32,
    max_total_artifact_bytes: usize,
    pandoc_timeout_seconds: u64,
    user_agent: String,
}

impl AppConfig {
    /// Loads, resolves, and validates configuration at the supplied path.
    pub fn load(path: &Path) -> Result<Self, ConfigurationError> {
        let input = fs::read_to_string(path)?;
        Self::from_toml_with_env(&input, |name| env::var(name).ok())
    }

    pub(crate) fn from_toml_with_env<F>(input: &str, get_env: F) -> Result<Self, ConfigurationError>
    where
        F: Fn(&str) -> Option<String>,
    {
        let raw: RawConfig = toml::from_str(input)?;
        let homeserver_url = parse_http_url("matrix.homeserver_url", &raw.matrix.homeserver_url)?;
        validate_user_id(&raw.matrix.user_id)?;
        validate_device_id(&raw.matrix.device_id)?;
        validate_device_display_name(&raw.matrix.device_display_name)?;
        let password = resolve_secret("matrix.password", &raw.matrix.password, &get_env)?;
        validate_limits(&raw.matrix, &raw.runtime)?;
        validate_llm(&raw.llm)?;
        validate_persona(&raw.persona)?;
        validate_context(&raw.context)?;
        validate_search(&raw.search)?;
        validate_web(&raw.web)?;
        let mention_aliases = validate_aliases(raw.matrix.mention_aliases, &raw.matrix.user_id)?;
        let allowed_room_ids = validate_rooms(raw.matrix.allowed_room_ids)?;

        Ok(Self {
            matrix: MatrixConfig {
                homeserver_url,
                user_id: raw.matrix.user_id,
                device_id: raw.matrix.device_id,
                device_display_name: raw.matrix.device_display_name,
                password,
                allowed_room_ids,
                mention_aliases,
                sync_timeout_ms: raw.matrix.sync_timeout_ms,
                sync_timeline_limit: raw.matrix.sync_timeline_limit,
                gap_page_limit: raw.matrix.gap_page_limit,
            },
            runtime: RuntimeConfig {
                database_path: raw.runtime.database_path,
                matrix_store_path: raw.runtime.matrix_store_path,
                max_concurrent_rooms: raw.runtime.max_concurrent_rooms,
                invocation_timeout_seconds: raw.runtime.invocation_timeout_seconds,
                shutdown_grace_seconds: raw.runtime.shutdown_grace_seconds,
                log_level: raw.runtime.log_level,
            },
            llm: LlmConfig {
                base_url: parse_http_url("llm.base_url", &raw.llm.base_url)?,
                api_key: resolve_secret("llm.api_key", &raw.llm.api_key, &get_env)?,
                model: raw.llm.model,
                request_timeout_seconds: raw.llm.request_timeout_seconds,
                max_output_tokens: raw.llm.max_output_tokens,
                max_request_characters: raw.llm.max_request_characters,
                max_response_characters: raw.llm.max_response_characters,
                max_iterations: raw.llm.max_iterations,
                max_tool_calls: raw.llm.max_tool_calls,
                temperature: raw.llm.temperature,
            },
            persona: PersonaConfig {
                path: raw.persona.path,
                generic_error: raw.persona.generic_error,
                overload_error: raw.persona.overload_error,
            },
            context: ContextConfig {
                max_events: raw.context.max_events,
                max_characters: raw.context.max_characters,
                max_event_characters: raw.context.max_event_characters,
                max_reply_depth: raw.context.max_reply_depth,
            },
            search: SearchConfig {
                endpoint: parse_http_url("search.endpoint", &raw.search.endpoint)?,
                api_key: raw.search.api_key.as_deref()
                    .map(|value| resolve_secret("search.api_key", value, &get_env))
                    .transpose()?,
                max_results: raw.search.max_results,
                timeout_seconds: raw.search.timeout_seconds,
            },
            web: WebConfig {
                connect_timeout_seconds: raw.web.connect_timeout_seconds,
                read_timeout_seconds: raw.web.read_timeout_seconds,
                total_timeout_seconds: raw.web.total_timeout_seconds,
                max_redirects: raw.web.max_redirects,
                max_download_bytes: raw.web.max_download_bytes,
                max_tool_output_characters: raw.web.max_tool_output_characters,
                max_total_artifact_bytes: raw.web.max_total_artifact_bytes,
                pandoc_timeout_seconds: raw.web.pandoc_timeout_seconds,
                user_agent: raw.web.user_agent,
            },
        })
    }
}

fn parse_http_url(key: &'static str, value: &str) -> Result<Url, ConfigurationError> {
    let url = Url::parse(value).map_err(|error| invalid(key, error.to_string()))?;
    if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() {
        return Err(invalid(key, "must be an absolute HTTP or HTTPS URL"));
    }
    if !url.username().is_empty() || url.password().is_some() {
        return Err(invalid(key, "must not contain user information"));
    }
    Ok(url)
}

fn resolve_secret<F>(
    key: &'static str,
    value: &str,
    get_env: &F,
) -> Result<SecretString, ConfigurationError>
where
    F: Fn(&str) -> Option<String>,
{
    let Some(name) = value.strip_prefix("env:") else {
        return Err(invalid(key, "must be an env: reference"));
    };
    if name.is_empty() {
        return Err(invalid(key, "environment variable name is empty"));
    }
    let Some(secret) = get_env(name) else {
        return Err(invalid(key, "referenced environment variable is missing"));
    };
    if secret.is_empty() {
        return Err(invalid(key, "referenced environment variable is empty"));
    }
    Ok(SecretString(secret))
}

fn validate_user_id(value: &str) -> Result<(), ConfigurationError> {
    if !value.starts_with('@') || !value.contains(':') || value.chars().any(char::is_whitespace) {
        return Err(invalid("matrix.user_id", "must be a Matrix user ID"));
    }
    Ok(())
}

fn validate_device_id(value: &str) -> Result<(), ConfigurationError> {
    if value.is_empty() || value.chars().any(char::is_whitespace) {
        return Err(invalid("matrix.device_id", "must be a non-empty device ID"));
    }
    Ok(())
}

fn validate_device_display_name(value: &str) -> Result<(), ConfigurationError> {
    if value.trim().is_empty() || value.chars().count() > MAX_DEVICE_DISPLAY_NAME_CHARACTERS {
        return Err(invalid(
            "matrix.device_display_name",
            "must be non-empty and no more than 256 characters",
        ));
    }
    Ok(())
}

fn validate_rooms(values: Vec<String>) -> Result<HashSet<String>, ConfigurationError> {
    if values.is_empty() {
        return Err(invalid("matrix.allowed_room_ids", "must not be empty"));
    }
    let mut rooms = HashSet::with_capacity(values.len());
    for room_id in values {
        if !room_id.starts_with('!')
            || room_id.len() == 1
            || room_id.chars().any(char::is_whitespace)
        {
            return Err(invalid(
                "matrix.allowed_room_ids",
                "must contain only Matrix room IDs",
            ));
        }
        if !rooms.insert(room_id) {
            return Err(invalid(
                "matrix.allowed_room_ids",
                "must not contain duplicate room IDs",
            ));
        }
    }
    Ok(rooms)
}

fn validate_aliases(
    mut values: Vec<String>,
    user_id: &str,
) -> Result<Vec<String>, ConfigurationError> {
    if values.is_empty() {
        return Err(invalid("matrix.mention_aliases", "must not be empty"));
    }
    values.push(user_id.to_owned());
    let mut seen = HashSet::new();
    values.retain(|alias| !alias.trim().is_empty() && seen.insert(alias.to_lowercase()));
    if values.iter().any(|alias| alias.chars().count() > 128) {
        return Err(invalid(
            "matrix.mention_aliases",
            "aliases may not exceed 128 characters",
        ));
    }
    Ok(values)
}

fn validate_limits(
    matrix: &RawMatrixConfig,
    runtime: &RawRuntimeConfig,
) -> Result<(), ConfigurationError> {
    if matrix.sync_timeout_ms == 0 || matrix.sync_timeout_ms > 120_000 {
        return Err(invalid(
            "matrix.sync_timeout_ms",
            "must be between 1 and 120000",
        ));
    }
    if matrix.sync_timeline_limit == 0 || matrix.sync_timeline_limit > MAX_SYNC_TIMELINE_LIMIT {
        return Err(invalid(
            "matrix.sync_timeline_limit",
            "is outside the safety ceiling",
        ));
    }
    if matrix.gap_page_limit > MAX_GAP_PAGE_LIMIT {
        return Err(invalid(
            "matrix.gap_page_limit",
            "is outside the safety ceiling",
        ));
    }
    if runtime.max_concurrent_rooms == 0 || runtime.max_concurrent_rooms > MAX_CONCURRENT_ROOMS {
        return Err(invalid(
            "runtime.max_concurrent_rooms",
            "is outside the safety ceiling",
        ));
    }
    if runtime.invocation_timeout_seconds == 0 || runtime.invocation_timeout_seconds > 3_600 {
        return Err(invalid(
            "runtime.invocation_timeout_seconds",
            "is outside the safety ceiling",
        ));
    }
    if runtime.shutdown_grace_seconds == 0 || runtime.shutdown_grace_seconds > 300 {
        return Err(invalid(
            "runtime.shutdown_grace_seconds",
            "is outside the safety ceiling",
        ));
    }
    if runtime.database_path.as_os_str().is_empty()
        || runtime.matrix_store_path.as_os_str().is_empty()
    {
        return Err(invalid("runtime", "state paths must not be empty"));
    }
    Ok(())
}

fn validate_llm(value: &RawLlmConfig) -> Result<(), ConfigurationError> {
    parse_http_url("llm.base_url", &value.base_url)?;
    if value.model.trim().is_empty() || value.model.chars().count() > 256 {
        return Err(invalid("llm.model", "must be a non-empty model identifier"));
    }
    if value.request_timeout_seconds == 0 || value.request_timeout_seconds > 600 {
        return Err(invalid(
            "llm.request_timeout_seconds",
            "is outside the safety ceiling",
        ));
    }
    if value.max_output_tokens == 0
        || value.max_output_tokens > 16_000
        || value.max_request_characters == 0
        || value.max_request_characters > 500_000
        || value.max_response_characters == 0
        || value.max_response_characters > 100_000
        || value.max_response_characters > value.max_request_characters
        || value.max_iterations == 0
        || value.max_iterations > MAX_LLM_ITERATIONS
        || value.max_tool_calls > 32
        || !value.temperature.is_finite()
        || !(0.0..=2.0).contains(&value.temperature)
    {
        return Err(invalid("llm", "contains unsafe generation limits"));
    }
    Ok(())
}

fn validate_persona(value: &RawPersonaConfig) -> Result<(), ConfigurationError> {
    if value.path.as_os_str().is_empty()
        || value.generic_error.trim().is_empty()
        || value.overload_error.trim().is_empty()
        || value.generic_error.chars().count() > 4_000
        || value.overload_error.chars().count() > 4_000
        || value
            .generic_error
            .matches(ERROR_CODE_PLACEHOLDER)
            .count()
            != 1
    {
        return Err(invalid(
            "persona",
            "must contain non-empty bounded values and exactly one {error_code} placeholder",
        ));
    }
    Ok(())
}

fn validate_context(value: &RawContextConfig) -> Result<(), ConfigurationError> {
    if value.max_events == 0
        || value.max_events > MAX_CONTEXT_EVENTS
        || value.max_characters == 0
        || value.max_characters > MAX_CONTEXT_CHARACTERS
        || value.max_event_characters == 0
        || value.max_event_characters > value.max_characters
        || value.max_reply_depth > 8
    {
        return Err(invalid("context", "contains unsafe context limits"));
    }
    Ok(())
}

fn validate_search(value: &RawSearchConfig) -> Result<(), ConfigurationError> {
    parse_http_url("search.endpoint", &value.endpoint)?;
    if value.max_results == 0 || value.max_results > 20
        || value.timeout_seconds == 0 || value.timeout_seconds > 120
    {
        return Err(invalid("search", "contains unsafe search limits"));
    }
    Ok(())
}

fn validate_web(value: &RawWebConfig) -> Result<(), ConfigurationError> {
    if value.connect_timeout_seconds == 0 || value.connect_timeout_seconds > 60
        || value.read_timeout_seconds == 0 || value.read_timeout_seconds > 120
        || value.total_timeout_seconds == 0 || value.total_timeout_seconds > 300
        || value.max_redirects > 10
        || value.max_download_bytes == 0 || value.max_download_bytes > 10_000_000
        || value.max_tool_output_characters == 0 || value.max_tool_output_characters > 100_000
        || value.max_total_artifact_bytes < value.max_download_bytes
        || value.max_total_artifact_bytes > 20_000_000
        || value.pandoc_timeout_seconds == 0 || value.pandoc_timeout_seconds > 120
        || value.user_agent.trim().is_empty() || value.user_agent.chars().count() > 256
    {
        return Err(invalid("web", "contains unsafe web limits"));
    }
    Ok(())
}

fn invalid(key: &'static str, reason: impl Into<String>) -> ConfigurationError {
    ConfigurationError::InvalidValue {
        key,
        reason: reason.into(),
    }
}

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

    const CONFIG: &str = r#"
[matrix]
homeserver_url = "https://matrix.example.test"
user_id = "@lisa:example.test"
device_id = "LISABOT"
device_display_name = "Lisa bot"
password = "env:LISA_MATRIX_PASSWORD"
allowed_room_ids = ["!private:example.test"]
mention_aliases = ["Lisa", "@Lisa"]
sync_timeout_ms = 30000
sync_timeline_limit = 100
gap_page_limit = 20

[runtime]
database_path = "state.sqlite3"
matrix_store_path = "matrix_sdk"
max_concurrent_rooms = 3
invocation_timeout_seconds = 180
shutdown_grace_seconds = 20
log_level = "INFO"

[llm]
base_url = "http://llm.example.test/v1/"
api_key = "env:LISA_LLM_API_KEY"
model = "operator-model"
request_timeout_seconds = 90
max_output_tokens = 700
max_request_characters = 120000
max_response_characters = 12000
max_iterations = 8
max_tool_calls = 6
temperature = 0.7

[persona]
path = "persona.md"
generic_error = "Sorry—my thoughts got tangled. Please try me again. [Error: {error_code}]"
overload_error = "I have too much to untangle at once. Try a narrower question."

[context]
max_events = 40
max_characters = 24000
max_event_characters = 4000
max_reply_depth = 2

[search]
endpoint = "https://search.example.test/search"
max_results = 8
timeout_seconds = 15

[web]
connect_timeout_seconds = 5
read_timeout_seconds = 15
total_timeout_seconds = 20
max_redirects = 5
max_download_bytes = 2000000
max_tool_output_characters = 30000
max_total_artifact_bytes = 4000000
pandoc_timeout_seconds = 10
user_agent = "LisaBot/1.0"
"#;

    #[test]
    fn resolves_password_without_exposing_it() {
        let config = AppConfig::from_toml_with_env(CONFIG, |name| {
            matches!(name, "LISA_MATRIX_PASSWORD" | "LISA_LLM_API_KEY")
                .then(|| "secret-canary".to_owned())
        })
        .unwrap();

        assert_eq!(config.matrix.password.expose(), "secret-canary");
        assert_eq!(format!("{:?}", config.matrix.password), "[REDACTED]");
    }

    #[test]
    fn accepts_room_id_without_server_name() {
        let config = CONFIG.replace("!private:example.test", "!private");
        let config = AppConfig::from_toml_with_env(&config, |name| {
            matches!(name, "LISA_MATRIX_PASSWORD" | "LISA_LLM_API_KEY")
                .then(|| "secret-canary".to_owned())
        })
        .unwrap();

        assert!(config.matrix.allowed_room_ids.contains("!private"));
    }

    #[test]
    fn rejects_error_template_without_placeholder() {
        let config = CONFIG.replace(" [Error: {error_code}]", "");
        assert!(AppConfig::from_toml_with_env(&config, |name| {
            matches!(name, "LISA_MATRIX_PASSWORD" | "LISA_LLM_API_KEY")
                .then(|| "secret-canary".to_owned())
        })
        .is_err());
    }

    #[test]
    fn rejects_literal_secret_and_duplicate_room() {
        let literal = CONFIG.replace("env:LISA_MATRIX_PASSWORD", "secret");
        assert!(AppConfig::from_toml_with_env(&literal, |_| None).is_err());

        let legacy = CONFIG.replace(
            "password = \"env:LISA_MATRIX_PASSWORD\"",
            "password = \"env:LISA_MATRIX_PASSWORD\"\naccess_token = \"env:LISA_MATRIX_ACCESS_TOKEN\"",
        );
        assert!(AppConfig::from_toml_with_env(&legacy, |_| Some("token".to_owned())).is_err());

        let duplicate = CONFIG.replace(
            "[\"!private:example.test\"]",
            "[\"!private:example.test\", \"!private:example.test\"]",
        );
        assert!(AppConfig::from_toml_with_env(&duplicate, |_| Some("token".to_owned())).is_err());
    }
}