use crate::types::{Forge, Visibility};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use thiserror::Error;
pub const CONFIG_DIR: &str = ".hyperforge";
pub const CONFIG_FILE: &str = "config.toml";
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("Config not found at {path}")]
NotFound { path: PathBuf },
#[error("Failed to read config: {0}")]
ReadError(#[from] std::io::Error),
#[error("Failed to parse config: {0}")]
ParseError(#[from] toml::de::Error),
#[error("Failed to serialize config: {0}")]
SerializeError(#[from] toml::ser::Error),
#[error("Config already exists at {path}")]
AlreadyExists { path: PathBuf },
#[error("Invalid config: {message}")]
Invalid { message: String },
}
pub type ConfigResult<T> = Result<T, ConfigError>;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ForgeConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub org: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub remote: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HyperforgeConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub repo_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub org: Option<String>,
#[serde(default)]
pub forges: Vec<String>,
#[serde(default)]
pub visibility: Visibility,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub ssh: HashMap<String, String>,
#[serde(default, rename = "forge", skip_serializing_if = "HashMap::is_empty")]
pub forge_config: HashMap<String, ForgeConfig>,
}
impl Default for HyperforgeConfig {
fn default() -> Self {
Self {
repo_name: None,
org: None,
forges: vec!["github".to_string()],
visibility: Visibility::Public,
description: None,
ssh: HashMap::new(),
forge_config: HashMap::new(),
}
}
}
impl HyperforgeConfig {
pub fn new(forges: Vec<String>) -> Self {
Self {
forges,
..Default::default()
}
}
pub fn with_org(mut self, org: impl Into<String>) -> Self {
self.org = Some(org.into());
self
}
pub fn with_repo_name(mut self, name: impl Into<String>) -> Self {
self.repo_name = Some(name.into());
self
}
pub fn with_visibility(mut self, visibility: Visibility) -> Self {
self.visibility = visibility;
self
}
pub fn with_description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
self
}
pub fn with_ssh_key(mut self, forge: impl Into<String>, key_path: impl Into<String>) -> Self {
self.ssh.insert(forge.into(), key_path.into());
self
}
pub fn config_dir(repo_path: &Path) -> PathBuf {
repo_path.join(CONFIG_DIR)
}
pub fn config_path(repo_path: &Path) -> PathBuf {
Self::config_dir(repo_path).join(CONFIG_FILE)
}
pub fn exists(repo_path: &Path) -> bool {
Self::config_path(repo_path).exists()
}
pub fn load(repo_path: &Path) -> ConfigResult<Self> {
let config_path = Self::config_path(repo_path);
if !config_path.exists() {
return Err(ConfigError::NotFound { path: config_path });
}
let content = fs::read_to_string(&config_path)?;
let config: Self = toml::from_str(&content)?;
Ok(config)
}
pub fn save(&self, repo_path: &Path) -> ConfigResult<()> {
let config_dir = Self::config_dir(repo_path);
let config_path = Self::config_path(repo_path);
fs::create_dir_all(&config_dir)?;
let content = toml::to_string_pretty(self)?;
fs::write(&config_path, content)?;
Ok(())
}
pub fn org_for_forge(&self, forge: &str) -> Option<&str> {
if let Some(forge_config) = self.forge_config.get(forge) {
if let Some(ref org) = forge_config.org {
return Some(org);
}
}
self.org.as_deref()
}
pub fn remote_for_forge(&self, forge: &str) -> String {
if let Some(forge_config) = self.forge_config.get(forge) {
if let Some(ref remote) = forge_config.remote {
return remote.clone();
}
}
if self.forges.first().map(|f| f.as_str()) == Some(forge) {
"origin".to_string()
} else {
forge.to_string()
}
}
pub fn ssh_key_for_forge(&self, forge: &str) -> Option<&str> {
self.ssh.get(forge).map(|s| s.as_str())
}
pub fn get_repo_name(&self, repo_path: &Path) -> String {
self.repo_name
.clone()
.or_else(|| {
repo_path
.file_name()
.and_then(|n| n.to_str())
.map(|s| s.to_string())
})
.unwrap_or_else(|| "unknown".to_string())
}
pub fn parse_forge(forge: &str) -> Option<Forge> {
match forge.to_lowercase().as_str() {
"github" => Some(Forge::GitHub),
"codeberg" => Some(Forge::Codeberg),
"gitlab" => Some(Forge::GitLab),
_ => None,
}
}
pub fn validate(&self) -> ConfigResult<()> {
if self.forges.is_empty() {
return Err(ConfigError::Invalid {
message: "At least one forge must be specified".to_string(),
});
}
for forge in &self.forges {
if Self::parse_forge(forge).is_none() {
return Err(ConfigError::Invalid {
message: format!(
"Unknown forge: {}. Valid forges: github, codeberg, gitlab",
forge
),
});
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_config_default() {
let config = HyperforgeConfig::default();
assert_eq!(config.forges, vec!["github"]);
assert_eq!(config.visibility, Visibility::Public);
}
#[test]
fn test_config_builder() {
let config = HyperforgeConfig::new(vec!["github".to_string(), "codeberg".to_string()])
.with_org("alice")
.with_repo_name("my-tool")
.with_visibility(Visibility::Private)
.with_ssh_key("github", "~/.ssh/github_key");
assert_eq!(config.forges, vec!["github", "codeberg"]);
assert_eq!(config.org, Some("alice".to_string()));
assert_eq!(config.repo_name, Some("my-tool".to_string()));
assert_eq!(config.visibility, Visibility::Private);
assert_eq!(
config.ssh.get("github"),
Some(&"~/.ssh/github_key".to_string())
);
}
#[test]
fn test_config_save_load() {
let temp = TempDir::new().unwrap();
let config = HyperforgeConfig::new(vec!["github".to_string()])
.with_org("alice")
.with_repo_name("test-repo");
config.save(temp.path()).unwrap();
assert!(HyperforgeConfig::exists(temp.path()));
let loaded = HyperforgeConfig::load(temp.path()).unwrap();
assert_eq!(loaded.org, Some("alice".to_string()));
assert_eq!(loaded.repo_name, Some("test-repo".to_string()));
assert_eq!(loaded.forges, vec!["github"]);
}
#[test]
fn test_config_not_found() {
let temp = TempDir::new().unwrap();
let result = HyperforgeConfig::load(temp.path());
assert!(matches!(result, Err(ConfigError::NotFound { .. })));
}
#[test]
fn test_org_for_forge_default() {
let config = HyperforgeConfig::new(vec!["github".to_string()]).with_org("default-org");
assert_eq!(config.org_for_forge("github"), Some("default-org"));
assert_eq!(config.org_for_forge("codeberg"), Some("default-org"));
}
#[test]
fn test_org_for_forge_override() {
let mut config =
HyperforgeConfig::new(vec!["github".to_string(), "codeberg".to_string()])
.with_org("default-org");
config.forge_config.insert(
"codeberg".to_string(),
ForgeConfig {
org: Some("codeberg-org".to_string()),
remote: None,
},
);
assert_eq!(config.org_for_forge("github"), Some("default-org"));
assert_eq!(config.org_for_forge("codeberg"), Some("codeberg-org"));
}
#[test]
fn test_remote_for_forge() {
let config =
HyperforgeConfig::new(vec!["github".to_string(), "codeberg".to_string()]);
assert_eq!(config.remote_for_forge("github"), "origin");
assert_eq!(config.remote_for_forge("codeberg"), "codeberg");
}
#[test]
fn test_get_repo_name_explicit() {
let config = HyperforgeConfig::default().with_repo_name("explicit-name");
let temp = TempDir::new().unwrap();
assert_eq!(config.get_repo_name(temp.path()), "explicit-name");
}
#[test]
fn test_get_repo_name_from_path() {
let config = HyperforgeConfig::default();
let path = Path::new("/home/user/projects/my-project");
assert_eq!(config.get_repo_name(path), "my-project");
}
#[test]
fn test_validate_empty_forges() {
let config = HyperforgeConfig {
forges: vec![],
..Default::default()
};
let result = config.validate();
assert!(matches!(result, Err(ConfigError::Invalid { .. })));
}
#[test]
fn test_validate_unknown_forge() {
let config = HyperforgeConfig::new(vec!["unknown-forge".to_string()]);
let result = config.validate();
assert!(matches!(result, Err(ConfigError::Invalid { .. })));
}
#[test]
fn test_validate_valid() {
let config =
HyperforgeConfig::new(vec!["github".to_string(), "codeberg".to_string()]);
config.validate().unwrap();
}
#[test]
fn test_toml_roundtrip() {
let mut config =
HyperforgeConfig::new(vec!["github".to_string(), "codeberg".to_string()])
.with_org("alice")
.with_repo_name("my-tool")
.with_visibility(Visibility::Private)
.with_description("A cool tool")
.with_ssh_key("github", "~/.ssh/github_key");
config.forge_config.insert(
"codeberg".to_string(),
ForgeConfig {
org: Some("different-org".to_string()),
remote: Some("cb".to_string()),
},
);
let toml_str = toml::to_string_pretty(&config).unwrap();
let parsed: HyperforgeConfig = toml::from_str(&toml_str).unwrap();
assert_eq!(parsed.org, config.org);
assert_eq!(parsed.repo_name, config.repo_name);
assert_eq!(parsed.forges, config.forges);
assert_eq!(parsed.visibility, config.visibility);
}
}