use crate::{Error, Result};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
pub const DEFAULT_ADR_DIR: &str = "doc/adr";
pub const LEGACY_CONFIG_FILE: &str = ".adr-dir";
pub const CONFIG_FILE: &str = "adrs.toml";
pub const GLOBAL_CONFIG_FILE: &str = "config.toml";
pub const ENV_ADR_DIRECTORY: &str = "ADR_DIRECTORY";
pub const ENV_ADRS_CONFIG: &str = "ADRS_CONFIG";
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct Config {
pub adr_dir: PathBuf,
pub mode: ConfigMode,
pub default_status: Option<String>,
pub no_edit: bool,
#[serde(default)]
pub templates: TemplateConfig,
#[serde(default)]
pub generate: GenerateConfig,
#[serde(default)]
pub export: ExportConfig,
#[serde(default)]
pub doctor: DoctorConfig,
}
impl Default for Config {
fn default() -> Self {
Self {
adr_dir: PathBuf::from(DEFAULT_ADR_DIR),
mode: ConfigMode::Compatible,
default_status: None,
no_edit: false,
templates: TemplateConfig::default(),
generate: GenerateConfig::default(),
export: ExportConfig::default(),
doctor: DoctorConfig::default(),
}
}
}
impl Config {
pub fn load(root: &Path) -> Result<Self> {
let config_path = root.join(CONFIG_FILE);
if config_path.exists() {
let content = std::fs::read_to_string(&config_path)?;
let config: Config = toml::from_str(&content)?;
if config.adr_dir.as_os_str().is_empty() {
return Err(Error::ConfigError(
"adr_dir cannot be empty in adrs.toml".into(),
));
}
return Ok(config);
}
let legacy_path = root.join(LEGACY_CONFIG_FILE);
if legacy_path.exists() {
let adr_dir = std::fs::read_to_string(&legacy_path)?.trim().to_string();
if adr_dir.is_empty() {
return Err(Error::ConfigError(
"ADR directory path is empty in .adr-dir file".into(),
));
}
return Ok(Self {
adr_dir: PathBuf::from(adr_dir),
mode: ConfigMode::Compatible,
default_status: None,
no_edit: false,
templates: TemplateConfig::default(),
generate: GenerateConfig::default(),
export: ExportConfig::default(),
doctor: DoctorConfig::default(),
});
}
let default_dir = root.join(DEFAULT_ADR_DIR);
if default_dir.exists() {
return Ok(Self::default());
}
Err(Error::AdrDirNotFound)
}
pub fn load_or_default(root: &Path) -> Self {
Self::load(root).unwrap_or_default()
}
pub fn save(&self, root: &Path) -> Result<()> {
match self.mode {
ConfigMode::Compatible => {
let path = root.join(LEGACY_CONFIG_FILE);
std::fs::write(&path, self.adr_dir.display().to_string())?;
}
ConfigMode::NextGen => {
let path = root.join(CONFIG_FILE);
let content =
toml::to_string_pretty(self).map_err(|e| Error::ConfigError(e.to_string()))?;
std::fs::write(&path, content)?;
}
}
Ok(())
}
pub fn adr_path(&self, root: &Path) -> PathBuf {
root.join(&self.adr_dir)
}
pub fn is_next_gen(&self) -> bool {
matches!(self.mode, ConfigMode::NextGen)
}
pub fn merge(&mut self, other: &Config) {
if other.adr_dir.as_os_str() != DEFAULT_ADR_DIR {
self.adr_dir = other.adr_dir.clone();
}
self.mode = other.mode;
if other.templates.format.is_some() {
self.templates.format = other.templates.format.clone();
}
if other.templates.variant.is_some() {
self.templates.variant = other.templates.variant.clone();
}
if other.templates.custom.is_some() {
self.templates.custom = other.templates.custom.clone();
}
if other.default_status.is_some() {
self.default_status = other.default_status.clone();
}
if other.no_edit {
self.no_edit = other.no_edit;
}
if other.generate.toc_prefix.is_some() {
self.generate.toc_prefix = other.generate.toc_prefix.clone();
}
if other.export.base_url.is_some() {
self.export.base_url = other.export.base_url.clone();
}
if !other.doctor.ignore.is_empty() {
self.doctor.ignore = other.doctor.ignore.clone();
}
if other.doctor.warnings_as_errors {
self.doctor.warnings_as_errors = other.doctor.warnings_as_errors;
}
}
}
#[derive(Debug, Clone)]
pub struct DiscoveredConfig {
pub config: Config,
pub root: PathBuf,
pub source: ConfigSource,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ConfigSource {
Project(PathBuf),
Global(PathBuf),
Environment,
Default,
}
pub fn discover(start_dir: &Path) -> Result<DiscoveredConfig> {
if let Ok(config_path) = std::env::var(ENV_ADRS_CONFIG) {
let path = PathBuf::from(&config_path);
if path.exists() {
let content = std::fs::read_to_string(&path)?;
let mut config: Config = toml::from_str(&content)?;
apply_env_overrides(&mut config);
return Ok(DiscoveredConfig {
config,
root: path
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| start_dir.to_path_buf()),
source: ConfigSource::Environment,
});
}
}
if let Some((root, config, source)) = search_upward(start_dir)? {
let mut config = config;
apply_env_overrides(&mut config);
return Ok(DiscoveredConfig {
config,
root,
source,
});
}
if let Some((config, path)) = load_global_config()? {
let mut config = config;
apply_env_overrides(&mut config);
return Ok(DiscoveredConfig {
config,
root: start_dir.to_path_buf(),
source: ConfigSource::Global(path),
});
}
let mut config = Config::default();
apply_env_overrides(&mut config);
Ok(DiscoveredConfig {
config,
root: start_dir.to_path_buf(),
source: ConfigSource::Default,
})
}
fn search_upward(start_dir: &Path) -> Result<Option<(PathBuf, Config, ConfigSource)>> {
let mut current = start_dir.to_path_buf();
loop {
let config_path = current.join(CONFIG_FILE);
if config_path.exists() {
let content = std::fs::read_to_string(&config_path)?;
let config: Config = toml::from_str(&content)?;
return Ok(Some((current, config, ConfigSource::Project(config_path))));
}
let legacy_path = current.join(LEGACY_CONFIG_FILE);
if legacy_path.exists() {
let adr_dir = std::fs::read_to_string(&legacy_path)?.trim().to_string();
let config = Config {
adr_dir: PathBuf::from(adr_dir),
mode: ConfigMode::Compatible,
default_status: None,
no_edit: false,
templates: TemplateConfig::default(),
generate: GenerateConfig::default(),
export: ExportConfig::default(),
doctor: DoctorConfig::default(),
};
return Ok(Some((current, config, ConfigSource::Project(legacy_path))));
}
let default_dir = current.join(DEFAULT_ADR_DIR);
if default_dir.exists() {
return Ok(Some((current, Config::default(), ConfigSource::Default)));
}
if current.join(".git").exists() {
break;
}
match current.parent() {
Some(parent) => current = parent.to_path_buf(),
None => break,
}
}
Ok(None)
}
fn load_global_config() -> Result<Option<(Config, PathBuf)>> {
let config_dir = dirs_config_dir()?;
let global_path = config_dir.join("adrs").join(GLOBAL_CONFIG_FILE);
if global_path.exists() {
let content = std::fs::read_to_string(&global_path)?;
let config: Config = toml::from_str(&content)?;
return Ok(Some((config, global_path)));
}
Ok(None)
}
fn dirs_config_dir() -> Result<PathBuf> {
if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
return Ok(PathBuf::from(xdg));
}
if let Ok(home) = std::env::var("HOME") {
return Ok(PathBuf::from(home).join(".config"));
}
if let Ok(appdata) = std::env::var("APPDATA") {
return Ok(PathBuf::from(appdata));
}
Err(Error::ConfigError(
"Could not determine config directory".into(),
))
}
fn apply_env_overrides(config: &mut Config) {
if let Ok(adr_dir) = std::env::var(ENV_ADR_DIRECTORY) {
config.adr_dir = PathBuf::from(adr_dir);
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ConfigMode {
#[default]
Compatible,
#[serde(rename = "ng", alias = "nextgen")]
NextGen,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct TemplateConfig {
pub format: Option<String>,
pub variant: Option<String>,
pub custom: Option<PathBuf>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct GenerateConfig {
pub toc_prefix: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct ExportConfig {
pub base_url: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct DoctorConfig {
pub ignore: Vec<String>,
pub warnings_as_errors: bool,
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
use test_case::test_case;
#[test]
fn test_default_config() {
let config = Config::default();
assert_eq!(config.adr_dir, PathBuf::from("doc/adr"));
assert_eq!(config.mode, ConfigMode::Compatible);
assert!(config.default_status.is_none());
assert!(config.templates.format.is_none());
assert!(config.templates.custom.is_none());
}
#[test]
fn test_constants() {
assert_eq!(DEFAULT_ADR_DIR, "doc/adr");
assert_eq!(LEGACY_CONFIG_FILE, ".adr-dir");
assert_eq!(CONFIG_FILE, "adrs.toml");
}
#[test]
fn test_config_mode_default() {
assert_eq!(ConfigMode::default(), ConfigMode::Compatible);
}
#[test]
fn test_load_legacy_config() {
let temp = TempDir::new().unwrap();
std::fs::write(temp.path().join(".adr-dir"), "decisions").unwrap();
let config = Config::load(temp.path()).unwrap();
assert_eq!(config.adr_dir, PathBuf::from("decisions"));
assert_eq!(config.mode, ConfigMode::Compatible);
}
#[test]
fn test_load_legacy_config_with_whitespace() {
let temp = TempDir::new().unwrap();
std::fs::write(temp.path().join(".adr-dir"), " decisions \n").unwrap();
let config = Config::load(temp.path()).unwrap();
assert_eq!(config.adr_dir, PathBuf::from("decisions"));
}
#[test]
fn test_load_legacy_config_nested_path() {
let temp = TempDir::new().unwrap();
std::fs::write(temp.path().join(".adr-dir"), "docs/architecture/decisions").unwrap();
let config = Config::load(temp.path()).unwrap();
assert_eq!(config.adr_dir, PathBuf::from("docs/architecture/decisions"));
}
#[test]
fn test_load_new_config() {
let temp = TempDir::new().unwrap();
std::fs::write(
temp.path().join("adrs.toml"),
r#"
adr_dir = "docs/decisions"
mode = "ng"
"#,
)
.unwrap();
let config = Config::load(temp.path()).unwrap();
assert_eq!(config.adr_dir, PathBuf::from("docs/decisions"));
assert_eq!(config.mode, ConfigMode::NextGen);
}
#[test]
fn test_load_new_config_compatible_mode() {
let temp = TempDir::new().unwrap();
std::fs::write(
temp.path().join("adrs.toml"),
r#"
adr_dir = "doc/adr"
mode = "compatible"
"#,
)
.unwrap();
let config = Config::load(temp.path()).unwrap();
assert_eq!(config.mode, ConfigMode::Compatible);
}
#[test]
fn test_load_new_config_with_default_status() {
let temp = TempDir::new().unwrap();
std::fs::write(
temp.path().join("adrs.toml"),
r#"
adr_dir = "doc/adr"
default_status = "accepted"
"#,
)
.unwrap();
let config = Config::load(temp.path()).unwrap();
assert_eq!(config.default_status, Some("accepted".to_string()));
}
#[test]
fn test_load_new_config_with_templates() {
let temp = TempDir::new().unwrap();
std::fs::write(
temp.path().join("adrs.toml"),
r#"
adr_dir = "decisions"
mode = "ng"
[templates]
format = "markdown"
custom = "templates/adr.md"
"#,
)
.unwrap();
let config = Config::load(temp.path()).unwrap();
assert_eq!(config.templates.format, Some("markdown".to_string()));
assert_eq!(
config.templates.custom,
Some(PathBuf::from("templates/adr.md"))
);
}
#[test]
fn test_load_new_config_with_template_variant() {
let temp = TempDir::new().unwrap();
std::fs::write(
temp.path().join("adrs.toml"),
r#"
adr_dir = "decisions"
mode = "ng"
[templates]
format = "madr"
variant = "minimal"
"#,
)
.unwrap();
let config = Config::load(temp.path()).unwrap();
assert_eq!(config.templates.format, Some("madr".to_string()));
assert_eq!(config.templates.variant, Some("minimal".to_string()));
}
#[test]
fn test_load_new_config_with_nextgen_alias() {
let temp = TempDir::new().unwrap();
std::fs::write(
temp.path().join("adrs.toml"),
r#"
adr_dir = "decisions"
mode = "nextgen"
"#,
)
.unwrap();
let config = Config::load(temp.path()).unwrap();
assert_eq!(config.mode, ConfigMode::NextGen);
}
#[test]
fn test_load_new_config_minimal() {
let temp = TempDir::new().unwrap();
std::fs::write(temp.path().join("adrs.toml"), r#"adr_dir = "adrs""#).unwrap();
let config = Config::load(temp.path()).unwrap();
assert_eq!(config.adr_dir, PathBuf::from("adrs"));
assert_eq!(config.mode, ConfigMode::Compatible);
}
#[test]
fn test_load_prefers_new_config_over_legacy() {
let temp = TempDir::new().unwrap();
std::fs::write(temp.path().join(".adr-dir"), "legacy-dir").unwrap();
std::fs::write(temp.path().join("adrs.toml"), r#"adr_dir = "new-dir""#).unwrap();
let config = Config::load(temp.path()).unwrap();
assert_eq!(config.adr_dir, PathBuf::from("new-dir"));
}
#[test]
fn test_load_default_dir_exists() {
let temp = TempDir::new().unwrap();
std::fs::create_dir_all(temp.path().join("doc/adr")).unwrap();
let config = Config::load(temp.path()).unwrap();
assert_eq!(config.adr_dir, PathBuf::from("doc/adr"));
}
#[test]
fn test_load_no_config_no_default_dir() {
let temp = TempDir::new().unwrap();
let result = Config::load(temp.path());
assert!(result.is_err());
}
#[test]
fn test_load_or_default_returns_default_on_error() {
let temp = TempDir::new().unwrap();
let config = Config::load_or_default(temp.path());
assert_eq!(config.adr_dir, PathBuf::from("doc/adr"));
assert_eq!(config.mode, ConfigMode::Compatible);
}
#[test]
fn test_load_or_default_returns_config_when_exists() {
let temp = TempDir::new().unwrap();
std::fs::write(temp.path().join(".adr-dir"), "custom-dir").unwrap();
let config = Config::load_or_default(temp.path());
assert_eq!(config.adr_dir, PathBuf::from("custom-dir"));
}
#[test]
fn test_save_legacy_config() {
let temp = TempDir::new().unwrap();
let config = Config {
adr_dir: PathBuf::from("my/adrs"),
mode: ConfigMode::Compatible,
default_status: None,
no_edit: false,
templates: TemplateConfig::default(),
generate: GenerateConfig::default(),
export: ExportConfig::default(),
doctor: DoctorConfig::default(),
};
config.save(temp.path()).unwrap();
let content = std::fs::read_to_string(temp.path().join(".adr-dir")).unwrap();
assert_eq!(content, "my/adrs");
assert!(!temp.path().join("adrs.toml").exists());
}
#[test]
fn test_save_new_config() {
let temp = TempDir::new().unwrap();
let config = Config {
adr_dir: PathBuf::from("docs/decisions"),
mode: ConfigMode::NextGen,
default_status: None,
no_edit: false,
templates: TemplateConfig::default(),
generate: GenerateConfig::default(),
export: ExportConfig::default(),
doctor: DoctorConfig::default(),
};
config.save(temp.path()).unwrap();
let content = std::fs::read_to_string(temp.path().join("adrs.toml")).unwrap();
assert!(content.contains("docs/decisions"));
assert!(content.contains("ng"));
assert!(!temp.path().join(".adr-dir").exists());
}
#[test]
fn test_save_new_config_with_templates() {
let temp = TempDir::new().unwrap();
let config = Config {
adr_dir: PathBuf::from("decisions"),
mode: ConfigMode::NextGen,
default_status: None,
no_edit: false,
templates: TemplateConfig {
format: Some("custom".to_string()),
variant: None,
custom: Some(PathBuf::from("my-template.md")),
},
generate: GenerateConfig::default(),
export: ExportConfig::default(),
doctor: DoctorConfig::default(),
};
config.save(temp.path()).unwrap();
let content = std::fs::read_to_string(temp.path().join("adrs.toml")).unwrap();
assert!(content.contains("custom"));
assert!(content.contains("my-template.md"));
}
#[test]
fn test_save_and_load_roundtrip_compatible() {
let temp = TempDir::new().unwrap();
let original = Config {
adr_dir: PathBuf::from("architecture/decisions"),
mode: ConfigMode::Compatible,
default_status: None,
no_edit: false,
templates: TemplateConfig::default(),
generate: GenerateConfig::default(),
export: ExportConfig::default(),
doctor: DoctorConfig::default(),
};
original.save(temp.path()).unwrap();
let loaded = Config::load(temp.path()).unwrap();
assert_eq!(loaded.adr_dir, original.adr_dir);
assert_eq!(loaded.mode, ConfigMode::Compatible);
}
#[test]
fn test_save_and_load_roundtrip_nextgen() {
let temp = TempDir::new().unwrap();
let original = Config {
adr_dir: PathBuf::from("docs/adr"),
mode: ConfigMode::NextGen,
default_status: None,
no_edit: false,
templates: TemplateConfig {
format: Some("markdown".to_string()),
variant: None,
custom: None,
},
generate: GenerateConfig::default(),
export: ExportConfig::default(),
doctor: DoctorConfig::default(),
};
original.save(temp.path()).unwrap();
let loaded = Config::load(temp.path()).unwrap();
assert_eq!(loaded.adr_dir, original.adr_dir);
assert_eq!(loaded.mode, ConfigMode::NextGen);
assert_eq!(loaded.templates.format, Some("markdown".to_string()));
}
#[test_case("doc/adr", "/project" => PathBuf::from("/project/doc/adr"); "default path")]
#[test_case("decisions", "/home/user/repo" => PathBuf::from("/home/user/repo/decisions"); "simple path")]
#[test_case("docs/architecture/decisions", "/repo" => PathBuf::from("/repo/docs/architecture/decisions"); "nested path")]
fn test_adr_path(adr_dir: &str, root: &str) -> PathBuf {
let config = Config {
adr_dir: PathBuf::from(adr_dir),
..Default::default()
};
config.adr_path(Path::new(root))
}
#[test]
fn test_is_next_gen() {
let compatible = Config {
mode: ConfigMode::Compatible,
..Default::default()
};
assert!(!compatible.is_next_gen());
let nextgen = Config {
mode: ConfigMode::NextGen,
..Default::default()
};
assert!(nextgen.is_next_gen());
}
#[test]
fn test_config_mode_equality() {
assert_eq!(ConfigMode::Compatible, ConfigMode::Compatible);
assert_eq!(ConfigMode::NextGen, ConfigMode::NextGen);
assert_ne!(ConfigMode::Compatible, ConfigMode::NextGen);
}
#[test]
fn test_config_mode_serialization_in_config() {
let config = Config {
mode: ConfigMode::Compatible,
..Default::default()
};
let toml = toml::to_string(&config).unwrap();
assert!(toml.contains("mode = \"compatible\""));
let config = Config {
mode: ConfigMode::NextGen,
..Default::default()
};
let toml = toml::to_string(&config).unwrap();
assert!(toml.contains("mode = \"ng\""));
}
#[test]
fn test_config_mode_deserialization_in_config() {
let config: Config = toml::from_str(r#"mode = "compatible""#).unwrap();
assert_eq!(config.mode, ConfigMode::Compatible);
let config: Config = toml::from_str(r#"mode = "ng""#).unwrap();
assert_eq!(config.mode, ConfigMode::NextGen);
}
#[test]
fn test_config_mode_deserialization_nextgen_alias() {
let config: Config = toml::from_str(r#"mode = "nextgen""#).unwrap();
assert_eq!(config.mode, ConfigMode::NextGen);
}
#[test]
fn test_template_config_default() {
let config = TemplateConfig::default();
assert!(config.format.is_none());
assert!(config.variant.is_none());
assert!(config.custom.is_none());
}
#[test]
fn test_template_config_serialization() {
let config = TemplateConfig {
format: Some("nygard".to_string()),
variant: None,
custom: Some(PathBuf::from("templates/custom.md")),
};
let toml = toml::to_string(&config).unwrap();
assert!(toml.contains("nygard"));
assert!(toml.contains("templates/custom.md"));
}
#[test]
fn test_load_invalid_toml() {
let temp = TempDir::new().unwrap();
std::fs::write(temp.path().join("adrs.toml"), "this is not valid toml {{{").unwrap();
let result = Config::load(temp.path());
assert!(result.is_err());
}
#[test]
fn test_load_empty_toml() {
let temp = TempDir::new().unwrap();
std::fs::write(temp.path().join("adrs.toml"), "").unwrap();
let config = Config::load(temp.path()).unwrap();
assert_eq!(config.adr_dir, PathBuf::from("doc/adr"));
}
#[test]
fn test_load_empty_adr_dir_file() {
let temp = TempDir::new().unwrap();
std::fs::write(temp.path().join(".adr-dir"), "").unwrap();
let result = Config::load(temp.path());
assert!(result.is_err(), "Empty .adr-dir should produce an error");
}
#[test]
fn test_discover_finds_config_in_current_dir() {
let temp = TempDir::new().unwrap();
std::fs::write(temp.path().join(".adr-dir"), "decisions").unwrap();
let discovered = discover(temp.path()).unwrap();
assert_eq!(discovered.root, temp.path());
assert_eq!(discovered.config.adr_dir, PathBuf::from("decisions"));
assert!(matches!(discovered.source, ConfigSource::Project(_)));
}
#[test]
fn test_discover_finds_config_in_parent_dir() {
let temp = TempDir::new().unwrap();
let subdir = temp.path().join("src").join("lib");
std::fs::create_dir_all(&subdir).unwrap();
std::fs::write(temp.path().join("adrs.toml"), r#"adr_dir = "docs/adr""#).unwrap();
let discovered = discover(&subdir).unwrap();
assert_eq!(discovered.root, temp.path());
assert_eq!(discovered.config.adr_dir, PathBuf::from("docs/adr"));
}
#[test]
fn test_discover_stops_at_git_root() {
let temp = TempDir::new().unwrap();
std::fs::create_dir(temp.path().join(".git")).unwrap();
let subdir = temp.path().join("src");
std::fs::create_dir(&subdir).unwrap();
let result = discover(&subdir);
assert!(result.is_ok());
let discovered = result.unwrap();
assert!(matches!(discovered.source, ConfigSource::Default));
}
#[test]
fn test_discover_prefers_adrs_toml_over_adr_dir() {
let temp = TempDir::new().unwrap();
std::fs::write(temp.path().join(".adr-dir"), "legacy").unwrap();
std::fs::write(temp.path().join("adrs.toml"), r#"adr_dir = "modern""#).unwrap();
let discovered = discover(temp.path()).unwrap();
assert_eq!(discovered.config.adr_dir, PathBuf::from("modern"));
}
#[test]
fn test_discover_finds_default_adr_dir() {
let temp = TempDir::new().unwrap();
std::fs::create_dir_all(temp.path().join("doc/adr")).unwrap();
let discovered = discover(temp.path()).unwrap();
assert_eq!(discovered.root, temp.path());
assert_eq!(discovered.config.adr_dir, PathBuf::from("doc/adr"));
}
#[test]
fn test_discover_returns_defaults_when_nothing_found() {
let temp = TempDir::new().unwrap();
std::fs::create_dir(temp.path().join(".git")).unwrap();
let discovered = discover(temp.path()).unwrap();
assert!(matches!(discovered.source, ConfigSource::Default));
assert_eq!(discovered.config.adr_dir, PathBuf::from("doc/adr"));
}
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[test]
fn test_apply_env_overrides() {
let _env = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let old = std::env::var(ENV_ADR_DIRECTORY).ok();
unsafe { std::env::remove_var(ENV_ADR_DIRECTORY) };
let mut config = Config::default();
apply_env_overrides(&mut config);
unsafe {
if let Some(v) = old {
std::env::set_var(ENV_ADR_DIRECTORY, v);
}
}
assert_eq!(config.adr_dir, PathBuf::from(DEFAULT_ADR_DIR));
}
#[test]
fn test_apply_env_overrides_sets_adr_dir() {
let _env = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let old = std::env::var(ENV_ADR_DIRECTORY).ok();
unsafe { std::env::set_var(ENV_ADR_DIRECTORY, "my/custom/adr/dir") };
let mut config = Config::default();
apply_env_overrides(&mut config);
unsafe {
match old {
Some(v) => std::env::set_var(ENV_ADR_DIRECTORY, v),
None => std::env::remove_var(ENV_ADR_DIRECTORY),
}
}
assert_eq!(config.adr_dir, PathBuf::from("my/custom/adr/dir"));
}
#[test]
fn test_apply_env_overrides_overrides_non_default_adr_dir() {
let _env = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let old = std::env::var(ENV_ADR_DIRECTORY).ok();
unsafe { std::env::set_var(ENV_ADR_DIRECTORY, "env_override") };
let mut config = Config {
adr_dir: PathBuf::from("config_dir"),
..Default::default()
};
apply_env_overrides(&mut config);
unsafe {
match old {
Some(v) => std::env::set_var(ENV_ADR_DIRECTORY, v),
None => std::env::remove_var(ENV_ADR_DIRECTORY),
}
}
assert_eq!(config.adr_dir, PathBuf::from("env_override"));
}
#[test]
fn test_apply_env_overrides_no_adr_dir_var_leaves_config_unchanged() {
let _env = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let old = std::env::var(ENV_ADR_DIRECTORY).ok();
unsafe { std::env::remove_var(ENV_ADR_DIRECTORY) };
let mut config = Config {
adr_dir: PathBuf::from("original/path"),
..Default::default()
};
apply_env_overrides(&mut config);
unsafe {
match old {
Some(v) => std::env::set_var(ENV_ADR_DIRECTORY, v),
None => std::env::remove_var(ENV_ADR_DIRECTORY),
}
}
assert_eq!(config.adr_dir, PathBuf::from("original/path"));
}
#[test]
fn test_config_source_variants() {
let project = ConfigSource::Project(PathBuf::from("test"));
let global = ConfigSource::Global(PathBuf::from("test"));
let env = ConfigSource::Environment;
let default = ConfigSource::Default;
assert_ne!(project, global);
assert_ne!(env, default);
assert_eq!(default, ConfigSource::Default);
}
#[test]
fn test_config_merge() {
let mut base = Config::default();
let other = Config {
adr_dir: PathBuf::from("custom"),
mode: ConfigMode::NextGen,
default_status: None,
no_edit: false,
templates: TemplateConfig {
format: Some("madr".to_string()),
variant: None,
custom: None,
},
generate: GenerateConfig::default(),
export: ExportConfig::default(),
doctor: DoctorConfig::default(),
};
base.merge(&other);
assert_eq!(base.adr_dir, PathBuf::from("custom"));
assert_eq!(base.mode, ConfigMode::NextGen);
assert_eq!(base.templates.format, Some("madr".to_string()));
}
#[test]
fn test_config_merge_preserves_default_adr_dir() {
let mut base = Config {
adr_dir: PathBuf::from("original"),
..Default::default()
};
let other = Config::default();
base.merge(&other);
assert_eq!(base.adr_dir, PathBuf::from("original"));
}
#[test]
fn test_config_merge_default_status() {
let mut base = Config::default();
let other = Config {
default_status: Some("accepted".to_string()),
..Default::default()
};
base.merge(&other);
assert_eq!(base.default_status, Some("accepted".to_string()));
}
#[test]
fn test_no_edit_defaults_to_false() {
let config = Config::default();
assert!(!config.no_edit);
}
#[test]
fn test_no_edit_true_from_toml() {
let temp = TempDir::new().unwrap();
std::fs::write(
temp.path().join("adrs.toml"),
"adr_dir = \"doc/adr\"\nno_edit = true\n",
)
.unwrap();
let config = Config::load(temp.path()).unwrap();
assert!(config.no_edit);
}
#[test]
fn test_no_edit_false_from_toml() {
let temp = TempDir::new().unwrap();
std::fs::write(
temp.path().join("adrs.toml"),
"adr_dir = \"doc/adr\"\nno_edit = false\n",
)
.unwrap();
let config = Config::load(temp.path()).unwrap();
assert!(!config.no_edit);
}
#[test]
fn test_no_edit_absent_from_toml_defaults_false() {
let temp = TempDir::new().unwrap();
std::fs::write(temp.path().join("adrs.toml"), "adr_dir = \"doc/adr\"\n").unwrap();
let config = Config::load(temp.path()).unwrap();
assert!(!config.no_edit);
}
#[test]
fn test_config_merge_no_edit() {
let mut base = Config::default();
let other = Config {
no_edit: true,
..Default::default()
};
base.merge(&other);
assert!(base.no_edit);
}
#[test]
fn test_config_merge_no_edit_false_does_not_overwrite_true() {
let mut base = Config {
no_edit: true,
..Default::default()
};
let other = Config::default(); base.merge(&other);
assert!(
base.no_edit,
"merge with no_edit=false should not overwrite true"
);
}
#[test]
fn test_no_edit_save_load_roundtrip() {
let temp = TempDir::new().unwrap();
let original = Config {
adr_dir: PathBuf::from("doc/adr"),
mode: ConfigMode::NextGen,
no_edit: true,
..Default::default()
};
original.save(temp.path()).unwrap();
let loaded = Config::load(temp.path()).unwrap();
assert!(loaded.no_edit);
}
#[test]
fn test_load_empty_adr_dir_in_toml() {
let temp = TempDir::new().unwrap();
std::fs::write(temp.path().join("adrs.toml"), r#"adr_dir = """#).unwrap();
let result = Config::load(temp.path());
assert!(
result.is_err(),
"Empty adr_dir in TOML should produce an error"
);
}
#[test]
fn test_load_whitespace_only_adr_dir_file() {
let temp = TempDir::new().unwrap();
std::fs::write(temp.path().join(".adr-dir"), " \n ").unwrap();
let result = Config::load(temp.path());
assert!(
result.is_err(),
"Whitespace-only .adr-dir should produce an error"
);
}
#[test]
fn test_invalid_format_string_accepted_in_toml() {
let temp = TempDir::new().unwrap();
std::fs::write(
temp.path().join("adrs.toml"),
r#"
adr_dir = "doc/adr"
[templates]
format = "nonexistent"
"#,
)
.unwrap();
let config = Config::load(temp.path()).unwrap();
assert_eq!(config.templates.format, Some("nonexistent".to_string()));
}
#[test]
fn test_invalid_variant_string_accepted_in_toml() {
let temp = TempDir::new().unwrap();
std::fs::write(
temp.path().join("adrs.toml"),
r#"
adr_dir = "doc/adr"
[templates]
variant = "bogus"
"#,
)
.unwrap();
let config = Config::load(temp.path()).unwrap();
assert_eq!(config.templates.variant, Some("bogus".to_string()));
}
#[test]
fn test_invalid_mode_string_rejected() {
let temp = TempDir::new().unwrap();
std::fs::write(temp.path().join("adrs.toml"), r#"mode = "invalid_mode""#).unwrap();
let result = Config::load(temp.path());
assert!(
result.is_err(),
"Invalid mode should produce a TOML parse error"
);
}
#[test]
fn test_unknown_toml_fields_accepted() {
let temp = TempDir::new().unwrap();
std::fs::write(
temp.path().join("adrs.toml"),
r#"
adr_dir = "doc/adr"
unknown_field = "hello"
[templates]
also_unknown = true
"#,
)
.unwrap();
let config = Config::load(temp.path()).unwrap();
assert_eq!(config.adr_dir, PathBuf::from("doc/adr"));
}
#[test]
fn test_custom_template_path_in_config() {
let temp = TempDir::new().unwrap();
std::fs::write(
temp.path().join("adrs.toml"),
r#"
adr_dir = "doc/adr"
mode = "ng"
[templates]
custom = "templates/my-adr.md"
"#,
)
.unwrap();
let config = Config::load(temp.path()).unwrap();
assert_eq!(
config.templates.custom,
Some(PathBuf::from("templates/my-adr.md"))
);
}
#[test]
fn test_save_and_load_roundtrip_nextgen_with_templates() {
let temp = TempDir::new().unwrap();
let original = Config {
adr_dir: PathBuf::from("docs/decisions"),
mode: ConfigMode::NextGen,
default_status: None,
no_edit: false,
templates: TemplateConfig {
format: Some("madr".to_string()),
variant: Some("minimal".to_string()),
custom: Some(PathBuf::from("templates/custom.md")),
},
generate: GenerateConfig::default(),
export: ExportConfig::default(),
doctor: DoctorConfig::default(),
};
original.save(temp.path()).unwrap();
let loaded = Config::load(temp.path()).unwrap();
assert_eq!(loaded.adr_dir, PathBuf::from("docs/decisions"));
assert_eq!(loaded.mode, ConfigMode::NextGen);
assert_eq!(loaded.templates.format, Some("madr".to_string()));
assert_eq!(loaded.templates.variant, Some("minimal".to_string()));
assert_eq!(
loaded.templates.custom,
Some(PathBuf::from("templates/custom.md"))
);
}
#[test]
fn test_save_and_load_roundtrip_nextgen_mode_serializes_as_ng() {
let temp = TempDir::new().unwrap();
let original = Config {
mode: ConfigMode::NextGen,
..Default::default()
};
original.save(temp.path()).unwrap();
let content = std::fs::read_to_string(temp.path().join("adrs.toml")).unwrap();
assert!(content.contains(r#"mode = "ng""#));
let loaded = Config::load(temp.path()).unwrap();
assert_eq!(loaded.mode, ConfigMode::NextGen);
}
#[test]
fn test_config_merge_variant_field() {
let mut base = Config::default();
let other = Config {
templates: TemplateConfig {
format: None,
variant: Some("minimal".to_string()),
custom: None,
},
..Default::default()
};
base.merge(&other);
assert_eq!(base.templates.variant, Some("minimal".to_string()));
}
#[test]
fn test_config_merge_custom_field() {
let mut base = Config::default();
let other = Config {
templates: TemplateConfig {
format: None,
variant: None,
custom: Some(PathBuf::from("my-template.md")),
},
..Default::default()
};
base.merge(&other);
assert_eq!(base.templates.custom, Some(PathBuf::from("my-template.md")));
}
#[test]
fn test_config_merge_does_not_overwrite_with_none() {
let mut base = Config {
templates: TemplateConfig {
format: Some("madr".to_string()),
variant: Some("minimal".to_string()),
custom: Some(PathBuf::from("template.md")),
},
..Default::default()
};
let other = Config::default();
base.merge(&other);
assert_eq!(base.templates.format, Some("madr".to_string()));
assert_eq!(base.templates.variant, Some("minimal".to_string()));
assert_eq!(base.templates.custom, Some(PathBuf::from("template.md")));
}
#[test]
fn test_generate_config_default() {
let config = GenerateConfig::default();
assert!(config.toc_prefix.is_none());
}
#[test]
fn test_generate_toc_prefix_from_toml() {
let temp = TempDir::new().unwrap();
std::fs::write(
temp.path().join("adrs.toml"),
r#"
adr_dir = "doc/adr"
[generate]
toc_prefix = "./"
"#,
)
.unwrap();
let config = Config::load(temp.path()).unwrap();
assert_eq!(config.generate.toc_prefix, Some("./".to_string()));
}
#[test]
fn test_generate_toc_prefix_absent_defaults_none() {
let temp = TempDir::new().unwrap();
std::fs::write(temp.path().join("adrs.toml"), "adr_dir = \"doc/adr\"\n").unwrap();
let config = Config::load(temp.path()).unwrap();
assert!(config.generate.toc_prefix.is_none());
}
#[test]
fn test_config_merge_generate_toc_prefix() {
let mut base = Config::default();
let other = Config {
generate: GenerateConfig {
toc_prefix: Some("./".to_string()),
},
..Default::default()
};
base.merge(&other);
assert_eq!(base.generate.toc_prefix, Some("./".to_string()));
}
#[test]
fn test_config_merge_generate_toc_prefix_none_does_not_overwrite() {
let mut base = Config {
generate: GenerateConfig {
toc_prefix: Some("docs/".to_string()),
},
..Default::default()
};
let other = Config::default();
base.merge(&other);
assert_eq!(
base.generate.toc_prefix,
Some("docs/".to_string()),
"merge with toc_prefix=None should not overwrite existing value"
);
}
#[test]
fn test_generate_toc_prefix_save_load_roundtrip() {
let temp = TempDir::new().unwrap();
let original = Config {
mode: ConfigMode::NextGen,
generate: GenerateConfig {
toc_prefix: Some("wiki/adr/".to_string()),
},
..Default::default()
};
original.save(temp.path()).unwrap();
let loaded = Config::load(temp.path()).unwrap();
assert_eq!(
loaded.generate.toc_prefix,
Some("wiki/adr/".to_string()),
"toc_prefix should survive a save/load round-trip"
);
}
#[test]
fn test_export_config_default() {
let config = ExportConfig::default();
assert!(config.base_url.is_none());
}
#[test]
fn test_export_base_url_from_toml() {
let temp = TempDir::new().unwrap();
std::fs::write(
temp.path().join("adrs.toml"),
r#"
adr_dir = "doc/adr"
[export]
base_url = "https://github.com/org/repo/blob/main/doc/adr"
"#,
)
.unwrap();
let config = Config::load(temp.path()).unwrap();
assert_eq!(
config.export.base_url,
Some("https://github.com/org/repo/blob/main/doc/adr".to_string())
);
}
#[test]
fn test_export_base_url_absent_defaults_none() {
let temp = TempDir::new().unwrap();
std::fs::write(temp.path().join("adrs.toml"), "adr_dir = \"doc/adr\"\n").unwrap();
let config = Config::load(temp.path()).unwrap();
assert!(config.export.base_url.is_none());
}
#[test]
fn test_config_merge_export_base_url() {
let mut base = Config::default();
let other = Config {
export: ExportConfig {
base_url: Some("https://example.com/adr".to_string()),
},
..Default::default()
};
base.merge(&other);
assert_eq!(
base.export.base_url,
Some("https://example.com/adr".to_string())
);
}
#[test]
fn test_config_merge_export_base_url_none_does_not_overwrite() {
let mut base = Config {
export: ExportConfig {
base_url: Some("https://existing.example.com/adr".to_string()),
},
..Default::default()
};
let other = Config::default();
base.merge(&other);
assert_eq!(
base.export.base_url,
Some("https://existing.example.com/adr".to_string()),
"merge with export.base_url=None should not overwrite existing value"
);
}
#[test]
fn test_export_base_url_save_load_roundtrip() {
let temp = TempDir::new().unwrap();
let original = Config {
mode: ConfigMode::NextGen,
export: ExportConfig {
base_url: Some("https://github.com/org/repo/blob/main/doc/adr".to_string()),
},
..Default::default()
};
original.save(temp.path()).unwrap();
let loaded = Config::load(temp.path()).unwrap();
assert_eq!(
loaded.export.base_url,
Some("https://github.com/org/repo/blob/main/doc/adr".to_string()),
"export.base_url should survive a save/load round-trip"
);
}
#[test]
fn test_doctor_config_default() {
let config = DoctorConfig::default();
assert!(config.ignore.is_empty());
assert!(!config.warnings_as_errors);
}
#[test]
fn test_doctor_ignore_from_toml() {
let temp = TempDir::new().unwrap();
std::fs::write(
temp.path().join("adrs.toml"),
r#"
adr_dir = "doc/adr"
[doctor]
ignore = ["ADR011", "MD013"]
"#,
)
.unwrap();
let config = Config::load(temp.path()).unwrap();
assert_eq!(
config.doctor.ignore,
vec!["ADR011".to_string(), "MD013".to_string()]
);
}
#[test]
fn test_doctor_warnings_as_errors_from_toml() {
let temp = TempDir::new().unwrap();
std::fs::write(
temp.path().join("adrs.toml"),
r#"
adr_dir = "doc/adr"
[doctor]
warnings_as_errors = true
"#,
)
.unwrap();
let config = Config::load(temp.path()).unwrap();
assert!(config.doctor.warnings_as_errors);
}
#[test]
fn test_doctor_config_absent_defaults() {
let temp = TempDir::new().unwrap();
std::fs::write(temp.path().join("adrs.toml"), "adr_dir = \"doc/adr\"\n").unwrap();
let config = Config::load(temp.path()).unwrap();
assert!(config.doctor.ignore.is_empty());
assert!(!config.doctor.warnings_as_errors);
}
#[test]
fn test_config_merge_doctor_ignore() {
let mut base = Config::default();
let other = Config {
doctor: DoctorConfig {
ignore: vec!["ADR011".to_string()],
warnings_as_errors: false,
},
..Default::default()
};
base.merge(&other);
assert_eq!(base.doctor.ignore, vec!["ADR011".to_string()]);
let mut base = Config {
doctor: DoctorConfig {
ignore: vec!["ADR011".to_string()],
warnings_as_errors: false,
},
..Default::default()
};
let other = Config::default();
base.merge(&other);
assert_eq!(
base.doctor.ignore,
vec!["ADR011".to_string()],
"merge with empty doctor.ignore should not overwrite existing value"
);
}
#[test]
fn test_config_merge_doctor_warnings_as_errors() {
let mut base = Config {
doctor: DoctorConfig {
ignore: vec![],
warnings_as_errors: true,
},
..Default::default()
};
let other = Config::default(); base.merge(&other);
assert!(
base.doctor.warnings_as_errors,
"merge with warnings_as_errors=false should not overwrite true"
);
}
#[test]
fn test_doctor_config_save_load_roundtrip() {
let temp = TempDir::new().unwrap();
let original = Config {
mode: ConfigMode::NextGen,
doctor: DoctorConfig {
ignore: vec!["ADR011".to_string()],
warnings_as_errors: true,
},
..Default::default()
};
original.save(temp.path()).unwrap();
let loaded = Config::load(temp.path()).unwrap();
assert_eq!(loaded.doctor.ignore, vec!["ADR011".to_string()]);
assert!(loaded.doctor.warnings_as_errors);
}
}