bzr 0.1.0

A CLI for Bugzilla, inspired by gh
Documentation
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;

use serde::{Deserialize, Serialize};

use crate::error::{BzrError, Result};
use crate::types::{ApiMode, AuthMethod, BugTemplate, SavedQuery};

#[derive(Debug, Serialize, Deserialize, Default)]
#[non_exhaustive]
pub struct Config {
    pub default_server: Option<String>,
    #[serde(default)]
    pub servers: HashMap<String, ServerConfig>,
    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
    pub templates: HashMap<String, BugTemplate>,
    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
    pub queries: HashMap<String, SavedQuery>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ServerConfig {
    pub url: String,
    pub api_key: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub email: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub auth_method: Option<AuthMethod>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub api_mode: Option<ApiMode>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub server_version: Option<String>,
    /// Accept invalid TLS certificates (self-signed, expired, etc.).
    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
    pub tls_insecure: bool,
}

impl Config {
    pub fn path() -> Result<PathBuf> {
        let config_dir = std::env::var_os("XDG_CONFIG_HOME")
            .map(PathBuf::from)
            .filter(|p| p.is_absolute())
            .or_else(dirs::config_dir)
            .ok_or_else(|| BzrError::config("cannot determine config directory"))?;
        Ok(config_dir.join("bzr").join("config.toml"))
    }

    pub fn load() -> Result<Config> {
        let path = Self::path()?;
        match fs::read_to_string(&path) {
            Ok(content) => Ok(toml::from_str(&content)?),
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Config::default()),
            Err(e) => Err(e.into()),
        }
    }

    pub fn save(&self) -> Result<()> {
        let path = Self::path()?;
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent)?;
        }
        let content = toml::to_string_pretty(self)?;
        fs::write(&path, content)?;
        Ok(())
    }

    pub fn resolve_server<'a>(
        &'a self,
        server_name: Option<&'a str>,
    ) -> Result<(&'a str, &'a ServerConfig)> {
        let name = self.resolve_server_name_only(server_name)?;
        let srv = self
            .servers
            .get(name)
            .ok_or_else(|| BzrError::config(format!("server '{name}' not found in config")))?;
        Ok((name, srv))
    }

    pub fn resolve_server_name_only<'a>(&'a self, server_name: Option<&'a str>) -> Result<&'a str> {
        server_name
            .or(self.default_server.as_deref())
            .ok_or_else(|| {
                BzrError::config(
                    "no server configured. Run `bzr config set-server <name> --url <url> --api-key <key>` first",
                )
            })
    }
}

#[cfg(test)]
#[expect(clippy::unwrap_used)]
mod tests {
    use super::*;
    use std::env;
    use std::str::FromStr;

    fn make_server_config(url: &str) -> ServerConfig {
        ServerConfig {
            url: url.to_string(),
            api_key: "test-key".to_string(),
            email: None,
            auth_method: None,
            api_mode: None,
            server_version: None,
            tls_insecure: false,
        }
    }

    fn make_config_with_server() -> Config {
        let mut servers = HashMap::new();
        servers.insert(
            "myserver".to_string(),
            make_server_config("https://bugzilla.example.com"),
        );
        Config {
            default_server: Some("myserver".to_string()),
            servers,
            templates: HashMap::new(),
            queries: HashMap::new(),
        }
    }

    /// Combined test for operations that require `env::set_var`.
    /// Grouped in a single test to avoid env var race conditions with
    /// parallel test execution.
    #[test]
    fn config_file_io_operations() {
        let _lock = crate::ENV_LOCK.blocking_lock();
        let tmp = tempfile::tempdir().unwrap();
        // SAFETY: Tests are serialized via ENV_LOCK; no other threads read this var concurrently.
        unsafe { env::set_var("XDG_CONFIG_HOME", tmp.path()) };

        // 1. Load returns default when no file exists
        let config = Config::load().unwrap();
        assert!(config.default_server.is_none());
        assert!(config.servers.is_empty());

        // 2. Save and load roundtrip
        let original = make_config_with_server();
        original.save().unwrap();

        let loaded = Config::load().unwrap();
        assert_eq!(loaded.default_server.as_deref(), Some("myserver"));
        assert!(loaded.servers.contains_key("myserver"));
        let srv = loaded.servers.get("myserver").unwrap();
        assert_eq!(srv.url, "https://bugzilla.example.com");
        assert_eq!(srv.api_key, "test-key");
    }

    #[test]
    fn resolve_server_name_only_returns_default_when_none() {
        let config = make_config_with_server();
        let name = config.resolve_server_name_only(None).unwrap();
        assert_eq!(name, "myserver");
    }

    #[test]
    fn resolve_server_name_only_returns_explicit_name() {
        let config = make_config_with_server();
        let name = config.resolve_server_name_only(Some("other")).unwrap();
        assert_eq!(name, "other");
    }

    #[test]
    fn resolve_server_name_only_errors_when_no_default_and_no_name() {
        let config = Config::default();
        let result = config.resolve_server_name_only(None);
        assert!(result.is_err());
    }

    #[test]
    fn resolve_server_returns_correct_server() {
        let config = make_config_with_server();
        let (name, srv) = config.resolve_server(Some("myserver")).unwrap();
        assert_eq!(name, "myserver");
        assert_eq!(srv.url, "https://bugzilla.example.com");
    }

    #[test]
    fn resolve_server_errors_for_unknown_server() {
        let config = make_config_with_server();
        let result = config.resolve_server(Some("nonexistent"));
        assert!(result.is_err());
    }

    #[test]
    fn api_mode_from_str_valid() {
        assert_eq!(ApiMode::from_str("rest").unwrap(), ApiMode::Rest);
        assert_eq!(ApiMode::from_str("xmlrpc").unwrap(), ApiMode::XmlRpc);
        assert_eq!(ApiMode::from_str("hybrid").unwrap(), ApiMode::Hybrid);
    }

    #[test]
    fn api_mode_from_str_invalid() {
        let result = ApiMode::from_str("invalid");
        assert!(result.is_err());
        assert!(result.unwrap_err().contains("invalid API mode"));
    }

    #[test]
    fn auth_method_from_str_valid() {
        assert_eq!(AuthMethod::from_str("header").unwrap(), AuthMethod::Header);
        assert_eq!(
            AuthMethod::from_str("query_param").unwrap(),
            AuthMethod::QueryParam
        );
    }

    #[test]
    fn auth_method_from_str_invalid() {
        let result = AuthMethod::from_str("invalid");
        assert!(result.is_err());
        assert!(result.unwrap_err().contains("invalid auth method"));
    }

    #[test]
    fn api_mode_display_formatting() {
        assert_eq!(ApiMode::Rest.to_string(), "rest");
        assert_eq!(ApiMode::XmlRpc.to_string(), "xmlrpc");
        assert_eq!(ApiMode::Hybrid.to_string(), "hybrid");
    }

    #[test]
    fn config_roundtrips_saved_queries() {
        let _lock = crate::ENV_LOCK.blocking_lock();
        let tmp = tempfile::tempdir().unwrap();
        // SAFETY: Tests are serialized via ENV_LOCK; no other threads read this var concurrently.
        unsafe { env::set_var("XDG_CONFIG_HOME", tmp.path()) };

        let mut config = make_config_with_server();
        let query = crate::types::SavedQuery {
            kind: crate::types::QueryKind::List,
            product: vec!["Firefox".into()],
            status: vec!["NEW".into()],
            limit: Some(25),
            ..Default::default()
        };
        config.queries.insert("firefox-new".into(), query);
        config.save().unwrap();

        let loaded = Config::load().unwrap();
        assert!(loaded.queries.contains_key("firefox-new"));
        let q = loaded.queries.get("firefox-new").unwrap();
        assert_eq!(q.product, vec!["Firefox"]);
        assert_eq!(q.status, vec!["NEW"]);
        assert_eq!(q.limit, Some(25));
    }

    #[test]
    fn config_empty_queries_not_serialized() {
        let config = make_config_with_server();
        let toml_str = toml::to_string_pretty(&config).unwrap();
        assert!(
            !toml_str.contains("[queries"),
            "empty queries should not appear in TOML"
        );
    }
}