cinchdb 0.2.0

CLI for CinchDB - database and scope management
//! Config file management: ~/.cinch/config.toml and .cinch project overrides

use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};

pub const DEFAULT_API_URL: &str = "https://api.cinchdb.dev";

/// Full config file stored at ~/.cinch/config.toml
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct ConfigFile {
    #[serde(default)]
    pub auth: AuthConfig,
    #[serde(default)]
    pub context: ContextConfig,
    #[serde(default)]
    pub api: ApiConfig,
}

#[derive(Debug, Default, Serialize, Deserialize)]
pub struct AuthConfig {
    /// JWT session token from browser login
    pub token: Option<String>,
    /// API key (alternative to token)
    pub api_key: Option<String>,
}

#[derive(Debug, Default, Serialize, Deserialize, Clone)]
pub struct ContextConfig {
    pub org: Option<String>,
    pub project: Option<String>,
    pub environment: Option<String>,
    pub scope: Option<String>,
}

#[derive(Debug, Default, Serialize, Deserialize)]
pub struct ApiConfig {
    pub url: Option<String>,
}

/// Project-local .cinch file overrides (like .nvmrc)
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct ProjectConfig {
    pub project: Option<String>,
    pub environment: Option<String>,
    pub scope: Option<String>,
}

impl ConfigFile {
    /// Read config from ~/.cinch/config.toml, creating defaults if missing
    pub fn load() -> Result<Self> {
        let path = config_path()?;
        if !path.exists() {
            return Ok(Self::default());
        }
        let content = std::fs::read_to_string(&path)
            .with_context(|| format!("failed to read {}", path.display()))?;
        let config: ConfigFile = toml::from_str(&content)
            .with_context(|| format!("failed to parse {}", path.display()))?;
        Ok(config)
    }

    /// Write config to ~/.cinch/config.toml
    pub fn save(&self) -> Result<()> {
        let path = config_path()?;
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent)
                .with_context(|| format!("failed to create {}", parent.display()))?;
        }
        let content = toml::to_string_pretty(self)
            .context("failed to serialize config")?;
        std::fs::write(&path, content)
            .with_context(|| format!("failed to write {}", path.display()))?;
        Ok(())
    }

    /// Get the effective auth credential (API key takes precedence over token)
    pub fn auth_header_value(&self) -> Option<String> {
        // Env var override first
        if let Ok(key) = std::env::var("CINCH_API_KEY") {
            return Some(format!("Bearer {key}"));
        }
        if let Some(ref key) = self.auth.api_key {
            return Some(format!("Bearer {key}"));
        }
        if let Some(ref token) = self.auth.token {
            return Some(format!("Bearer {token}"));
        }
        None
    }
}

/// Resolve context by merging: CLI flags > project .cinch > global config
pub fn resolve_context(config: &ConfigFile) -> ContextConfig {
    let project_config = load_project_config();

    ContextConfig {
        org: config.context.org.clone(),
        project: project_config
            .as_ref()
            .and_then(|p| p.project.clone())
            .or_else(|| config.context.project.clone()),
        environment: project_config
            .as_ref()
            .and_then(|p| p.environment.clone())
            .or_else(|| config.context.environment.clone()),
        scope: project_config
            .as_ref()
            .and_then(|p| p.scope.clone())
            .or_else(|| config.context.scope.clone()),
    }
}

/// Resolve the API URL from config
pub fn resolve_api_url() -> Result<String> {
    let config = ConfigFile::load()?;
    Ok(config
        .api
        .url
        .unwrap_or_else(|| DEFAULT_API_URL.to_string()))
}

/// Path to ~/.cinch/config.toml
pub fn config_path() -> Result<PathBuf> {
    let home = dirs::home_dir().context("could not determine home directory")?;
    Ok(home.join(".cinch").join("config.toml"))
}

/// Load .cinch project file from current directory or parents
fn load_project_config() -> Option<ProjectConfig> {
    let cwd = std::env::current_dir().ok()?;
    find_project_config(&cwd)
}

fn find_project_config(start: &Path) -> Option<ProjectConfig> {
    let mut dir = start;
    loop {
        let candidate = dir.join(".cinch");
        if candidate.is_file() {
            let content = match std::fs::read_to_string(&candidate) {
                Ok(c) => c,
                Err(e) => {
                    eprintln!(
                        "warning: could not read {}: {e}",
                        candidate.display()
                    );
                    return None;
                }
            };
            match toml::from_str(&content) {
                Ok(cfg) => return Some(cfg),
                Err(e) => {
                    eprintln!(
                        "warning: could not parse {}: {e}",
                        candidate.display()
                    );
                    return None;
                }
            }
        }
        dir = dir.parent()?;
    }
}

/// Valid config keys that can be set with `cinch config set`
pub const SETTABLE_KEYS: &[&str] = &["org", "project", "environment", "scope", "api.url"];

/// Set a config key
pub fn set_config_value(key: &str, value: &str) -> Result<()> {
    let mut config = ConfigFile::load()?;

    match key {
        "org" => config.context.org = Some(value.to_string()),
        "project" => config.context.project = Some(value.to_string()),
        "environment" => config.context.environment = Some(value.to_string()),
        "scope" => config.context.scope = Some(value.to_string()),
        "api.url" => config.api.url = Some(value.to_string()),
        _ => anyhow::bail!(
            "unknown config key '{key}'. Valid keys: {}",
            SETTABLE_KEYS.join(", ")
        ),
    }

    config.save()?;
    Ok(())
}

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

    #[test]
    fn test_config_roundtrip() {
        let dir = TempDir::new().expect("tempdir");
        let path = dir.path().join("config.toml");

        let config = ConfigFile {
            auth: AuthConfig {
                token: Some("test-jwt".to_string()),
                api_key: None,
            },
            context: ContextConfig {
                org: Some("acme".to_string()),
                project: Some("api".to_string()),
                environment: Some("production".to_string()),
                scope: Some("default".to_string()),
            },
            api: ApiConfig {
                url: Some("https://custom.api.dev".to_string()),
            },
        };

        let content = toml::to_string_pretty(&config).expect("serialize");
        std::fs::write(&path, &content).expect("write");

        let loaded: ConfigFile =
            toml::from_str(&std::fs::read_to_string(&path).expect("read")).expect("parse");

        assert_eq!(loaded.auth.token.as_deref(), Some("test-jwt"));
        assert_eq!(loaded.context.org.as_deref(), Some("acme"));
        assert_eq!(loaded.context.project.as_deref(), Some("api"));
        assert_eq!(loaded.context.environment.as_deref(), Some("production"));
        assert_eq!(loaded.context.scope.as_deref(), Some("default"));
        assert_eq!(
            loaded.api.url.as_deref(),
            Some("https://custom.api.dev")
        );
    }

    #[test]
    fn test_default_config() {
        let config = ConfigFile::default();
        assert!(config.auth.token.is_none());
        assert!(config.auth.api_key.is_none());
        assert!(config.context.org.is_none());
        assert!(config.api.url.is_none());
    }

    #[test]
    fn test_auth_header_api_key_precedence() {
        // Clear env var for test isolation
        std::env::remove_var("CINCH_API_KEY");

        let config = ConfigFile {
            auth: AuthConfig {
                token: Some("jwt-token".to_string()),
                api_key: Some("ck_live_abc123".to_string()),
            },
            ..Default::default()
        };
        // API key takes precedence over JWT
        assert_eq!(
            config.auth_header_value().as_deref(),
            Some("Bearer ck_live_abc123")
        );
    }

    #[test]
    fn test_auth_header_jwt_fallback() {
        std::env::remove_var("CINCH_API_KEY");

        let config = ConfigFile {
            auth: AuthConfig {
                token: Some("jwt-token".to_string()),
                api_key: None,
            },
            ..Default::default()
        };
        assert_eq!(
            config.auth_header_value().as_deref(),
            Some("Bearer jwt-token")
        );
    }

    #[test]
    fn test_auth_header_none() {
        std::env::remove_var("CINCH_API_KEY");

        let config = ConfigFile::default();
        assert!(config.auth_header_value().is_none());
    }

    #[test]
    fn test_project_config_parse() {
        let toml_str = r#"
project = "api"
environment = "staging"
"#;
        let config: ProjectConfig = toml::from_str(toml_str).expect("parse");
        assert_eq!(config.project.as_deref(), Some("api"));
        assert_eq!(config.environment.as_deref(), Some("staging"));
        assert!(config.scope.is_none());
    }

    #[test]
    fn test_context_merge_project_overrides_global() {
        let global = ConfigFile {
            context: ContextConfig {
                org: Some("acme".to_string()),
                project: Some("global-proj".to_string()),
                environment: Some("global-env".to_string()),
                scope: Some("default".to_string()),
            },
            ..Default::default()
        };

        // Simulate project config override (without filesystem)
        let project = ProjectConfig {
            project: Some("local-proj".to_string()),
            environment: Some("staging".to_string()),
            scope: None,
        };

        // Manual merge (same logic as resolve_context)
        let resolved = ContextConfig {
            org: global.context.org.clone(),
            project: project
                .project
                .or(global.context.project.clone()),
            environment: project
                .environment
                .or(global.context.environment.clone()),
            scope: project.scope.or(global.context.scope.clone()),
        };

        assert_eq!(resolved.org.as_deref(), Some("acme"));
        assert_eq!(resolved.project.as_deref(), Some("local-proj"));
        assert_eq!(resolved.environment.as_deref(), Some("staging"));
        assert_eq!(resolved.scope.as_deref(), Some("default"));
    }

    #[test]
    fn test_find_project_config_walks_up() {
        let dir = TempDir::new().expect("tempdir");
        let nested = dir.path().join("a").join("b").join("c");
        std::fs::create_dir_all(&nested).expect("mkdir");

        // Put .cinch at root
        let cinch_file = dir.path().join(".cinch");
        std::fs::write(
            &cinch_file,
            "project = \"found\"\nenvironment = \"dev\"\n",
        )
        .expect("write");

        let found = find_project_config(&nested);
        assert!(found.is_some());
        let found = found.expect("should find");
        assert_eq!(found.project.as_deref(), Some("found"));
        assert_eq!(found.environment.as_deref(), Some("dev"));
    }

    #[test]
    fn test_settable_keys_list() {
        assert!(SETTABLE_KEYS.contains(&"org"));
        assert!(SETTABLE_KEYS.contains(&"project"));
        assert!(SETTABLE_KEYS.contains(&"environment"));
        assert!(SETTABLE_KEYS.contains(&"scope"));
        assert!(SETTABLE_KEYS.contains(&"api.url"));
    }
}