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,
#[serde(default)]
pub templates: TemplateConfig,
}
impl Default for Config {
fn default() -> Self {
Self {
adr_dir: PathBuf::from(DEFAULT_ADR_DIR),
mode: ConfigMode::Compatible,
templates: TemplateConfig::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,
templates: TemplateConfig::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();
}
}
}
#[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,
templates: TemplateConfig::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>,
}
#[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.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_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,
templates: TemplateConfig::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,
templates: TemplateConfig::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,
templates: TemplateConfig {
format: Some("custom".to_string()),
variant: None,
custom: Some(PathBuf::from("my-template.md")),
},
};
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,
templates: TemplateConfig::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,
templates: TemplateConfig {
format: Some("markdown".to_string()),
variant: None,
custom: None,
},
};
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"));
}
#[test]
fn test_apply_env_overrides() {
let mut config = Config::default();
apply_env_overrides(&mut config);
assert_eq!(config.adr_dir, PathBuf::from(DEFAULT_ADR_DIR));
}
#[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,
templates: TemplateConfig {
format: Some("madr".to_string()),
variant: None,
custom: None,
},
};
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_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,
templates: TemplateConfig {
format: Some("madr".to_string()),
variant: Some("minimal".to_string()),
custom: Some(PathBuf::from("templates/custom.md")),
},
};
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")));
}
}