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"
));
}
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-"));
}
}