rustytag 1.0.20

A semantic version management tool built on Git tags.
Documentation
use anyhow::Result;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Config {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub github_token: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub version_prefix: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub version: Option<String>,
    #[serde(flatten)]
    extra: BTreeMap<String, Value>,
}

impl Config {
    fn user_path() -> PathBuf {
        dirs::home_dir()
            .unwrap_or_else(|| PathBuf::from("~"))
            .join(".rustytag.json")
    }

    fn repository_path() -> PathBuf {
        PathBuf::from(".rustytag.json")
    }

    fn load_file(path: &Path) -> Result<Self> {
        if !path.exists() {
            return Ok(Self::default());
        }
        let content = fs::read_to_string(path)?;
        Ok(serde_json::from_str(&content)?)
    }

    fn save_file(&self, path: &Path) -> Result<()> {
        if let Some(parent) = path
            .parent()
            .filter(|parent| !parent.as_os_str().is_empty())
        {
            fs::create_dir_all(parent)?;
        }
        fs::write(path, serde_json::to_string_pretty(self)?)?;
        Ok(())
    }

    fn overlay(&mut self, overrides: &Self) {
        if overrides.github_token.is_some() {
            self.github_token.clone_from(&overrides.github_token);
        }
        if overrides.version_prefix.is_some() {
            self.version_prefix.clone_from(&overrides.version_prefix);
        }
        if overrides.version.is_some() {
            self.version.clone_from(&overrides.version);
        }
        self.extra.extend(overrides.extra.clone());
    }

    fn load_from_paths(user: &Path, repository: &Path, cli: &Self) -> Result<Self> {
        let mut config = Self::load_file(user)?;
        config.overlay(&Self::load_file(repository)?);
        config.overlay(cli);
        Ok(config)
    }

    pub fn load() -> Result<Self> {
        Self::load_with_cli(&Self::default())
    }

    pub fn load_with_cli(cli: &Self) -> Result<Self> {
        Self::load_from_paths(&Self::user_path(), &Self::repository_path(), cli)
    }

    pub fn load_user() -> Result<Self> {
        Self::load_file(&Self::user_path())
    }

    pub fn load_repository() -> Result<Self> {
        Self::load_file(&Self::repository_path())
    }

    pub fn update_user(overrides: &Self) -> Result<()> {
        let path = Self::user_path();
        let mut config = Self::load_file(&path)?;
        config.overlay(overrides);
        config.save_file(&path)
    }

    pub fn update_repository(overrides: &Self) -> Result<()> {
        let path = Self::repository_path();
        let mut config = Self::load_file(&path)?;
        config.overlay(overrides);
        config.save_file(&path)
    }
}

pub fn handle_config_command(set: Option<String>, global: bool, local: bool) -> Result<()> {
    if let Some(set_str) = set {
        let (key, value) = set_str
            .split_once('=')
            .ok_or_else(|| anyhow::anyhow!("Invalid format. Use KEY=VALUE"))?;

        if global && local {
            return Err(anyhow::anyhow!(
                "--global and --local cannot be used together"
            ));
        }

        // 如果没有指定 local 或 global,根据配置类型自动选择
        let save_to_user = if !global && !local {
            match key {
                "GITHUB_TOKEN" => true,
                "VERSION_PREFIX" => false,
                _ => return Err(anyhow::anyhow!("Unknown configuration key")),
            }
        } else {
            global
        };

        let mut overrides = Config::default();
        match key {
            "GITHUB_TOKEN" => overrides.github_token = Some(value.to_string()),
            "VERSION_PREFIX" => overrides.version_prefix = Some(value.to_string()),
            _ => return Err(anyhow::anyhow!("Unknown configuration key")),
        }

        if save_to_user {
            Config::update_user(&overrides)?;
            println!("✔ User configuration saved");
        } else {
            Config::update_repository(&overrides)?;
            println!("✔ Repository configuration saved");
        }
    } else {
        println!("\n⚙️  Current Configuration");
        println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━");

        let user_config = Config::load_user()?;
        let repository_config = Config::load_repository()?;
        let effective_config = Config::load()?;
        print_config("🌍 User Configuration", &user_config);
        print_config("📍 Repository Configuration", &repository_config);
        print_config("✅ Effective Configuration", &effective_config);
        println!();
    }

    Ok(())
}

fn print_config(label: &str, config: &Config) {
    println!("\n{}:", label);
    println!(
        "  🔑 GITHUB_TOKEN: {}",
        config
            .github_token
            .as_ref()
            .map(|_| "Configured")
            .unwrap_or("Not set")
    );
    println!(
        "  📌 VERSION_PREFIX: {}",
        config.version_prefix.as_deref().unwrap_or("Not set")
    );
}

#[cfg(test)]
mod tests {
    use super::Config;
    use std::fs;
    use std::path::PathBuf;
    use std::sync::atomic::{AtomicU64, Ordering};

    static NEXT_TEST_ID: AtomicU64 = AtomicU64::new(0);

    struct TestDir(PathBuf);

    impl TestDir {
        fn new() -> Self {
            let id = NEXT_TEST_ID.fetch_add(1, Ordering::Relaxed);
            let path =
                std::env::temp_dir().join(format!("rustytag-config-{}-{id}", std::process::id()));
            fs::create_dir_all(&path).unwrap();
            Self(path)
        }
    }

    impl Drop for TestDir {
        fn drop(&mut self) {
            fs::remove_dir_all(&self.0).unwrap();
        }
    }

    #[test]
    fn config_layers_user_then_repository_then_cli() {
        let dir = TestDir::new();
        let user_path = dir.0.join("user.json");
        let repository_path = dir.0.join("repository.json");
        fs::write(
            &user_path,
            r#"{"github_token":"user-token","version_prefix":"user-"}"#,
        )
        .unwrap();
        fs::write(&repository_path, r#"{"version_prefix":"repo-"}"#).unwrap();

        let repository_config =
            Config::load_from_paths(&user_path, &repository_path, &Config::default()).unwrap();
        assert_eq!(repository_config.version_prefix.as_deref(), Some("repo-"));

        let cli = Config {
            version_prefix: Some("cli-".to_string()),
            ..Config::default()
        };
        let config = Config::load_from_paths(&user_path, &repository_path, &cli).unwrap();

        assert_eq!(config.github_token.as_deref(), Some("user-token"));
        assert_eq!(config.version_prefix.as_deref(), Some("cli-"));
    }
}