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 }
}
pub fn load_config(&self, cli_overrides: Option<CliOverrides>) -> Result<Config> {
let mut config = Config::default();
if let Some(global_config) = self.load_global_config()? {
self.merge_config(&mut config, global_config);
}
if let Some(repo_config) = self.load_repo_config()? {
self.merge_config(&mut config, repo_config);
}
self.apply_env_vars(&mut config);
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) {
for (name, party) in source.parties {
target.parties.insert(name, party);
}
if target.base_branch == "main" && source.base_branch != "main" {
target.base_branch = source.base_branch;
}
}
pub fn is_initialized(&self) -> bool {
self.repo_config_path().exists()
}
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)?;
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
}
pub fn repo_config_path(&self) -> PathBuf {
self.repo_path.join(".git").join("branch-party").join("config.yaml")
}
pub fn global_config_path(&self) -> Option<PathBuf> {
ProjectDirs::from("", "", "branch-party")
.map(|dirs| dirs.config_dir().join("config.yaml"))
}
pub fn validate_config(&self, config: &Config) -> Result<()> {
for (name, party) in &config.parties {
if party.members.is_empty() {
return Err(Error::config(format!("Party '{}' has no members", name)));
}
}
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());
let mut config = Config::default();
config.parties.insert("empty".to_string(), crate::Party::default());
assert!(loader.validate_config(&config).is_err());
}
}