branch_party_core 0.1.1

Core library for branch-party CLI tool
Documentation
use crate::{Config, Error, Result};
use directories::ProjectDirs;
use std::env;
use std::path::PathBuf;

pub struct ConfigLoader {
    repo_path: PathBuf,
}

impl ConfigLoader {
    pub fn new(repo_path: PathBuf) -> Self {
        Self { repo_path }
    }

    /// Load configuration with full precedence chain
    /// CLI args > env vars > repo config > global config > defaults
    pub fn load_config(&self, cli_overrides: Option<CliOverrides>) -> Result<Config> {
        // Start with defaults
        let mut config = Config::default();

        // Apply global config if it exists
        if let Some(global_config) = self.load_global_config()? {
            self.merge_config(&mut config, global_config);
        }

        // Apply repo config if it exists
        if let Some(repo_config) = self.load_repo_config()? {
            self.merge_config(&mut config, repo_config);
        }

        // Apply environment variables
        self.apply_env_vars(&mut config);

        // Apply CLI overrides
        if let Some(overrides) = cli_overrides {
            self.apply_cli_overrides(&mut config, overrides);
        }

        Ok(config)
    }

    fn load_global_config(&self) -> Result<Option<Config>> {
        if let Some(proj_dirs) = ProjectDirs::from("", "", "branch-party") {
            let config_path = proj_dirs.config_dir().join("config.yaml");
            if config_path.exists() {
                let contents = std::fs::read_to_string(&config_path)?;
                let config: Config = serde_yaml::from_str(&contents)?;
                return Ok(Some(config));
            }
        }
        Ok(None)
    }

    fn load_repo_config(&self) -> Result<Option<Config>> {
        let config_path = self.repo_path.join(".git").join("branch-party").join("config.yaml");
        if config_path.exists() {
            let contents = std::fs::read_to_string(&config_path)?;
            let config: Config = serde_yaml::from_str(&contents)?;
            Ok(Some(config))
        } else {
            Ok(None)
        }
    }

    fn apply_env_vars(&self, config: &mut Config) {
        if let Ok(base_branch) = env::var("BP_BASE_BRANCH") {
            config.base_branch = base_branch;
        }
    }

    fn apply_cli_overrides(&self, config: &mut Config, overrides: CliOverrides) {
        if let Some(base_branch) = overrides.base_branch {
            config.base_branch = base_branch;
        }
    }

    fn merge_config(&self, target: &mut Config, source: Config) {
        // Merge parties - source takes precedence
        for (name, party) in source.parties {
            target.parties.insert(name, party);
        }

        // Only override base_branch if target has the default
        if target.base_branch == "main" && source.base_branch != "main" {
            target.base_branch = source.base_branch;
        }
    }

    /// Check if branch-party is already initialized
    pub fn is_initialized(&self) -> bool {
        self.repo_config_path().exists()
    }

    /// Write a starter config file to the repo
    pub fn init_repo_config(&self, with_sample: bool) -> Result<PathBuf> {
        let config_dir = self.repo_path.join(".git").join("branch-party");
        std::fs::create_dir_all(&config_dir)?;

        let config_path = config_dir.join("config.yaml");
        let config = if with_sample {
            Config::sample()
        } else {
            Config::default()
        };

        let yaml = serde_yaml::to_string(&config)?;
        
        // Add helpful comments
        let commented_yaml = self.add_comments_to_yaml(yaml, with_sample);
        
        std::fs::write(&config_path, commented_yaml)?;
        Ok(config_path)
    }

    fn add_comments_to_yaml(&self, yaml: String, with_sample: bool) -> String {
        let mut result = String::new();
        
        result.push_str("# Branch Party Configuration\n");
        result.push_str("# This config is stored in .git/branch-party/ so it's available on all branches\n");
        result.push_str("# See https://github.com/example/branch-party for documentation\n\n");
        
        if with_sample {
            result.push_str("# Base branch to merge into (usually 'main' or 'develop')\n");
        }
        
        for line in yaml.lines() {
            if line.trim_start().starts_with("base_branch:") && !with_sample {
                result.push_str("# Base branch to merge into (usually 'main' or 'develop')\n");
            } else if line.trim_start().starts_with("parties:") {
                result.push_str("# Party definitions\n");
                result.push_str("# Each party can contain branches or references to other parties (@party_name)\n");
            } else if line.trim_start().starts_with("merge_order:") {
                result.push_str("    # Options: listed, newest_first, oldest_first\n");
            } else if line.trim_start().starts_with("default:") && line.contains("ours") {
                result.push_str("      # Options: ours, theirs, union, manual\n");
            }
            
            result.push_str(line);
            result.push('\n');
        }
        
        result
    }

    /// Get the repo config path
    pub fn repo_config_path(&self) -> PathBuf {
        self.repo_path.join(".git").join("branch-party").join("config.yaml")
    }

    /// Get the global config path
    pub fn global_config_path(&self) -> Option<PathBuf> {
        ProjectDirs::from("", "", "branch-party")
            .map(|dirs| dirs.config_dir().join("config.yaml"))
    }

    /// Validate a configuration
    pub fn validate_config(&self, config: &Config) -> Result<()> {
        // Check for empty parties
        for (name, party) in &config.parties {
            if party.members.is_empty() {
                return Err(Error::config(format!("Party '{}' has no members", name)));
            }
        }

        // Check for party cycles (basic check - we'll do full cycle detection in party resolution)
        for (name, party) in &config.parties {
            for member in &party.members {
                if member == &format!("@{}", name) {
                    return Err(Error::party_cycle(name, format!("self-reference: {}", member)));
                }
            }
        }

        Ok(())
    }
}

#[derive(Debug, Default, Clone)]
pub struct CliOverrides {
    pub base_branch: Option<String>,
    pub config_path: Option<PathBuf>,
}

impl CliOverrides {
    pub fn new() -> Self {
        Self::default()
    }

    pub fn with_base_branch(mut self, base_branch: String) -> Self {
        self.base_branch = Some(base_branch);
        self
    }

    pub fn with_config_path(mut self, path: PathBuf) -> Self {
        self.config_path = Some(path);
        self
    }
}

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

    #[test]
    fn test_config_loading_defaults() {
        let temp_dir = TempDir::new().unwrap();
        let loader = ConfigLoader::new(temp_dir.path().to_path_buf());
        
        let config = loader.load_config(None).unwrap();
        assert_eq!(config.base_branch, "main");
        assert!(config.parties.is_empty());
    }

    #[test]
    fn test_env_var_override() {
        let temp_dir = TempDir::new().unwrap();
        let loader = ConfigLoader::new(temp_dir.path().to_path_buf());
        
        env::set_var("BP_BASE_BRANCH", "develop");
        let config = loader.load_config(None).unwrap();
        assert_eq!(config.base_branch, "develop");
        env::remove_var("BP_BASE_BRANCH");
    }

    #[test]
    fn test_cli_override() {
        let temp_dir = TempDir::new().unwrap();
        let loader = ConfigLoader::new(temp_dir.path().to_path_buf());
        
        let overrides = CliOverrides::new().with_base_branch("feature".to_string());
        let config = loader.load_config(Some(overrides)).unwrap();
        assert_eq!(config.base_branch, "feature");
    }

    #[test]
    fn test_init_repo_config() {
        let temp_dir = TempDir::new().unwrap();
        let loader = ConfigLoader::new(temp_dir.path().to_path_buf());
        
        let config_path = loader.init_repo_config(false).unwrap();
        assert!(config_path.exists());
        
        let contents = std::fs::read_to_string(config_path).unwrap();
        assert!(contents.contains("base_branch: main"));
        assert!(contents.contains("# Branch Party Configuration"));
    }

    #[test]
    fn test_validate_config() {
        let temp_dir = TempDir::new().unwrap();
        let loader = ConfigLoader::new(temp_dir.path().to_path_buf());
        
        let config = Config::default();
        assert!(loader.validate_config(&config).is_ok());
        
        // Test empty party validation
        let mut config = Config::default();
        config.parties.insert("empty".to_string(), crate::Party::default());
        assert!(loader.validate_config(&config).is_err());
    }
}