1use crate::{Error, Result};
4use serde::{Deserialize, Serialize};
5use std::path::{Path, PathBuf};
6
7pub const DEFAULT_ADR_DIR: &str = "doc/adr";
9
10pub const LEGACY_CONFIG_FILE: &str = ".adr-dir";
12
13pub const CONFIG_FILE: &str = "adrs.toml";
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
18#[serde(default)]
19pub struct Config {
20 pub adr_dir: PathBuf,
22
23 pub mode: ConfigMode,
25
26 #[serde(default)]
28 pub templates: TemplateConfig,
29}
30
31impl Default for Config {
32 fn default() -> Self {
33 Self {
34 adr_dir: PathBuf::from(DEFAULT_ADR_DIR),
35 mode: ConfigMode::Compatible,
36 templates: TemplateConfig::default(),
37 }
38 }
39}
40
41impl Config {
42 pub fn load(root: &Path) -> Result<Self> {
49 let config_path = root.join(CONFIG_FILE);
51 if config_path.exists() {
52 let content = std::fs::read_to_string(&config_path)?;
53 let config: Config = toml::from_str(&content)?;
54 return Ok(config);
55 }
56
57 let legacy_path = root.join(LEGACY_CONFIG_FILE);
59 if legacy_path.exists() {
60 let adr_dir = std::fs::read_to_string(&legacy_path)?.trim().to_string();
61 return Ok(Self {
62 adr_dir: PathBuf::from(adr_dir),
63 mode: ConfigMode::Compatible,
64 templates: TemplateConfig::default(),
65 });
66 }
67
68 let default_dir = root.join(DEFAULT_ADR_DIR);
70 if default_dir.exists() {
71 return Ok(Self::default());
72 }
73
74 Err(Error::AdrDirNotFound)
75 }
76
77 pub fn load_or_default(root: &Path) -> Self {
79 Self::load(root).unwrap_or_default()
80 }
81
82 pub fn save(&self, root: &Path) -> Result<()> {
84 match self.mode {
85 ConfigMode::Compatible => {
86 let path = root.join(LEGACY_CONFIG_FILE);
88 std::fs::write(&path, self.adr_dir.display().to_string())?;
89 }
90 ConfigMode::NextGen => {
91 let path = root.join(CONFIG_FILE);
93 let content =
94 toml::to_string_pretty(self).map_err(|e| Error::ConfigError(e.to_string()))?;
95 std::fs::write(&path, content)?;
96 }
97 }
98 Ok(())
99 }
100
101 pub fn adr_path(&self, root: &Path) -> PathBuf {
103 root.join(&self.adr_dir)
104 }
105
106 pub fn is_next_gen(&self) -> bool {
108 matches!(self.mode, ConfigMode::NextGen)
109 }
110}
111
112#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
114#[serde(rename_all = "lowercase")]
115pub enum ConfigMode {
116 #[default]
118 Compatible,
119
120 #[serde(rename = "ng")]
122 NextGen,
123}
124
125#[derive(Debug, Clone, Default, Serialize, Deserialize)]
127#[serde(default)]
128pub struct TemplateConfig {
129 pub format: Option<String>,
131
132 pub custom: Option<PathBuf>,
134}
135
136#[cfg(test)]
137mod tests {
138 use super::*;
139 use tempfile::TempDir;
140 use test_case::test_case;
141
142 #[test]
145 fn test_default_config() {
146 let config = Config::default();
147 assert_eq!(config.adr_dir, PathBuf::from("doc/adr"));
148 assert_eq!(config.mode, ConfigMode::Compatible);
149 assert!(config.templates.format.is_none());
150 assert!(config.templates.custom.is_none());
151 }
152
153 #[test]
154 fn test_constants() {
155 assert_eq!(DEFAULT_ADR_DIR, "doc/adr");
156 assert_eq!(LEGACY_CONFIG_FILE, ".adr-dir");
157 assert_eq!(CONFIG_FILE, "adrs.toml");
158 }
159
160 #[test]
161 fn test_config_mode_default() {
162 assert_eq!(ConfigMode::default(), ConfigMode::Compatible);
163 }
164
165 #[test]
168 fn test_load_legacy_config() {
169 let temp = TempDir::new().unwrap();
170 std::fs::write(temp.path().join(".adr-dir"), "decisions").unwrap();
171
172 let config = Config::load(temp.path()).unwrap();
173 assert_eq!(config.adr_dir, PathBuf::from("decisions"));
174 assert_eq!(config.mode, ConfigMode::Compatible);
175 }
176
177 #[test]
178 fn test_load_legacy_config_with_whitespace() {
179 let temp = TempDir::new().unwrap();
180 std::fs::write(temp.path().join(".adr-dir"), " decisions \n").unwrap();
181
182 let config = Config::load(temp.path()).unwrap();
183 assert_eq!(config.adr_dir, PathBuf::from("decisions"));
184 }
185
186 #[test]
187 fn test_load_legacy_config_nested_path() {
188 let temp = TempDir::new().unwrap();
189 std::fs::write(temp.path().join(".adr-dir"), "docs/architecture/decisions").unwrap();
190
191 let config = Config::load(temp.path()).unwrap();
192 assert_eq!(config.adr_dir, PathBuf::from("docs/architecture/decisions"));
193 }
194
195 #[test]
196 fn test_load_new_config() {
197 let temp = TempDir::new().unwrap();
198 std::fs::write(
199 temp.path().join("adrs.toml"),
200 r#"
201adr_dir = "docs/decisions"
202mode = "ng"
203"#,
204 )
205 .unwrap();
206
207 let config = Config::load(temp.path()).unwrap();
208 assert_eq!(config.adr_dir, PathBuf::from("docs/decisions"));
209 assert_eq!(config.mode, ConfigMode::NextGen);
210 }
211
212 #[test]
213 fn test_load_new_config_compatible_mode() {
214 let temp = TempDir::new().unwrap();
215 std::fs::write(
216 temp.path().join("adrs.toml"),
217 r#"
218adr_dir = "doc/adr"
219mode = "compatible"
220"#,
221 )
222 .unwrap();
223
224 let config = Config::load(temp.path()).unwrap();
225 assert_eq!(config.mode, ConfigMode::Compatible);
226 }
227
228 #[test]
229 fn test_load_new_config_with_templates() {
230 let temp = TempDir::new().unwrap();
231 std::fs::write(
232 temp.path().join("adrs.toml"),
233 r#"
234adr_dir = "decisions"
235mode = "ng"
236
237[templates]
238format = "markdown"
239custom = "templates/adr.md"
240"#,
241 )
242 .unwrap();
243
244 let config = Config::load(temp.path()).unwrap();
245 assert_eq!(config.templates.format, Some("markdown".to_string()));
246 assert_eq!(
247 config.templates.custom,
248 Some(PathBuf::from("templates/adr.md"))
249 );
250 }
251
252 #[test]
253 fn test_load_new_config_minimal() {
254 let temp = TempDir::new().unwrap();
255 std::fs::write(temp.path().join("adrs.toml"), r#"adr_dir = "adrs""#).unwrap();
256
257 let config = Config::load(temp.path()).unwrap();
258 assert_eq!(config.adr_dir, PathBuf::from("adrs"));
259 assert_eq!(config.mode, ConfigMode::Compatible);
261 }
262
263 #[test]
264 fn test_load_prefers_new_config_over_legacy() {
265 let temp = TempDir::new().unwrap();
266 std::fs::write(temp.path().join(".adr-dir"), "legacy-dir").unwrap();
268 std::fs::write(temp.path().join("adrs.toml"), r#"adr_dir = "new-dir""#).unwrap();
269
270 let config = Config::load(temp.path()).unwrap();
271 assert_eq!(config.adr_dir, PathBuf::from("new-dir"));
273 }
274
275 #[test]
276 fn test_load_default_dir_exists() {
277 let temp = TempDir::new().unwrap();
278 std::fs::create_dir_all(temp.path().join("doc/adr")).unwrap();
280
281 let config = Config::load(temp.path()).unwrap();
282 assert_eq!(config.adr_dir, PathBuf::from("doc/adr"));
283 }
284
285 #[test]
286 fn test_load_no_config_no_default_dir() {
287 let temp = TempDir::new().unwrap();
288 let result = Config::load(temp.path());
291 assert!(result.is_err());
292 }
293
294 #[test]
295 fn test_load_or_default_returns_default_on_error() {
296 let temp = TempDir::new().unwrap();
297 let config = Config::load_or_default(temp.path());
300 assert_eq!(config.adr_dir, PathBuf::from("doc/adr"));
301 assert_eq!(config.mode, ConfigMode::Compatible);
302 }
303
304 #[test]
305 fn test_load_or_default_returns_config_when_exists() {
306 let temp = TempDir::new().unwrap();
307 std::fs::write(temp.path().join(".adr-dir"), "custom-dir").unwrap();
308
309 let config = Config::load_or_default(temp.path());
310 assert_eq!(config.adr_dir, PathBuf::from("custom-dir"));
311 }
312
313 #[test]
316 fn test_save_legacy_config() {
317 let temp = TempDir::new().unwrap();
318 let config = Config {
319 adr_dir: PathBuf::from("my/adrs"),
320 mode: ConfigMode::Compatible,
321 templates: TemplateConfig::default(),
322 };
323
324 config.save(temp.path()).unwrap();
325
326 let content = std::fs::read_to_string(temp.path().join(".adr-dir")).unwrap();
327 assert_eq!(content, "my/adrs");
328 assert!(!temp.path().join("adrs.toml").exists());
330 }
331
332 #[test]
333 fn test_save_new_config() {
334 let temp = TempDir::new().unwrap();
335 let config = Config {
336 adr_dir: PathBuf::from("docs/decisions"),
337 mode: ConfigMode::NextGen,
338 templates: TemplateConfig::default(),
339 };
340
341 config.save(temp.path()).unwrap();
342
343 let content = std::fs::read_to_string(temp.path().join("adrs.toml")).unwrap();
344 assert!(content.contains("docs/decisions"));
345 assert!(content.contains("ng"));
346 assert!(!temp.path().join(".adr-dir").exists());
348 }
349
350 #[test]
351 fn test_save_new_config_with_templates() {
352 let temp = TempDir::new().unwrap();
353 let config = Config {
354 adr_dir: PathBuf::from("decisions"),
355 mode: ConfigMode::NextGen,
356 templates: TemplateConfig {
357 format: Some("custom".to_string()),
358 custom: Some(PathBuf::from("my-template.md")),
359 },
360 };
361
362 config.save(temp.path()).unwrap();
363
364 let content = std::fs::read_to_string(temp.path().join("adrs.toml")).unwrap();
365 assert!(content.contains("custom"));
366 assert!(content.contains("my-template.md"));
367 }
368
369 #[test]
370 fn test_save_and_load_roundtrip_compatible() {
371 let temp = TempDir::new().unwrap();
372 let original = Config {
373 adr_dir: PathBuf::from("architecture/decisions"),
374 mode: ConfigMode::Compatible,
375 templates: TemplateConfig::default(),
376 };
377
378 original.save(temp.path()).unwrap();
379 let loaded = Config::load(temp.path()).unwrap();
380
381 assert_eq!(loaded.adr_dir, original.adr_dir);
382 assert_eq!(loaded.mode, ConfigMode::Compatible);
383 }
384
385 #[test]
386 fn test_save_and_load_roundtrip_nextgen() {
387 let temp = TempDir::new().unwrap();
388 let original = Config {
389 adr_dir: PathBuf::from("docs/adr"),
390 mode: ConfigMode::NextGen,
391 templates: TemplateConfig {
392 format: Some("markdown".to_string()),
393 custom: None,
394 },
395 };
396
397 original.save(temp.path()).unwrap();
398 let loaded = Config::load(temp.path()).unwrap();
399
400 assert_eq!(loaded.adr_dir, original.adr_dir);
401 assert_eq!(loaded.mode, ConfigMode::NextGen);
402 assert_eq!(loaded.templates.format, Some("markdown".to_string()));
403 }
404
405 #[test_case("doc/adr", "/project" => PathBuf::from("/project/doc/adr"); "default path")]
408 #[test_case("decisions", "/home/user/repo" => PathBuf::from("/home/user/repo/decisions"); "simple path")]
409 #[test_case("docs/architecture/decisions", "/repo" => PathBuf::from("/repo/docs/architecture/decisions"); "nested path")]
410 fn test_adr_path(adr_dir: &str, root: &str) -> PathBuf {
411 let config = Config {
412 adr_dir: PathBuf::from(adr_dir),
413 ..Default::default()
414 };
415 config.adr_path(Path::new(root))
416 }
417
418 #[test]
419 fn test_is_next_gen() {
420 let compatible = Config {
421 mode: ConfigMode::Compatible,
422 ..Default::default()
423 };
424 assert!(!compatible.is_next_gen());
425
426 let nextgen = Config {
427 mode: ConfigMode::NextGen,
428 ..Default::default()
429 };
430 assert!(nextgen.is_next_gen());
431 }
432
433 #[test]
436 fn test_config_mode_equality() {
437 assert_eq!(ConfigMode::Compatible, ConfigMode::Compatible);
438 assert_eq!(ConfigMode::NextGen, ConfigMode::NextGen);
439 assert_ne!(ConfigMode::Compatible, ConfigMode::NextGen);
440 }
441
442 #[test]
443 fn test_config_mode_serialization_in_config() {
444 let config = Config {
446 mode: ConfigMode::Compatible,
447 ..Default::default()
448 };
449 let toml = toml::to_string(&config).unwrap();
450 assert!(toml.contains("mode = \"compatible\""));
451
452 let config = Config {
453 mode: ConfigMode::NextGen,
454 ..Default::default()
455 };
456 let toml = toml::to_string(&config).unwrap();
457 assert!(toml.contains("mode = \"ng\""));
458 }
459
460 #[test]
461 fn test_config_mode_deserialization_in_config() {
462 let config: Config = toml::from_str(r#"mode = "compatible""#).unwrap();
463 assert_eq!(config.mode, ConfigMode::Compatible);
464
465 let config: Config = toml::from_str(r#"mode = "ng""#).unwrap();
466 assert_eq!(config.mode, ConfigMode::NextGen);
467 }
468
469 #[test]
472 fn test_template_config_default() {
473 let config = TemplateConfig::default();
474 assert!(config.format.is_none());
475 assert!(config.custom.is_none());
476 }
477
478 #[test]
479 fn test_template_config_serialization() {
480 let config = TemplateConfig {
481 format: Some("nygard".to_string()),
482 custom: Some(PathBuf::from("templates/custom.md")),
483 };
484
485 let toml = toml::to_string(&config).unwrap();
486 assert!(toml.contains("nygard"));
487 assert!(toml.contains("templates/custom.md"));
488 }
489
490 #[test]
493 fn test_load_invalid_toml() {
494 let temp = TempDir::new().unwrap();
495 std::fs::write(temp.path().join("adrs.toml"), "this is not valid toml {{{").unwrap();
496
497 let result = Config::load(temp.path());
498 assert!(result.is_err());
499 }
500
501 #[test]
502 fn test_load_empty_toml() {
503 let temp = TempDir::new().unwrap();
504 std::fs::write(temp.path().join("adrs.toml"), "").unwrap();
505
506 let config = Config::load(temp.path()).unwrap();
508 assert_eq!(config.adr_dir, PathBuf::from("doc/adr"));
509 }
510
511 #[test]
512 fn test_load_empty_adr_dir_file() {
513 let temp = TempDir::new().unwrap();
514 std::fs::write(temp.path().join(".adr-dir"), "").unwrap();
515
516 let config = Config::load(temp.path()).unwrap();
517 assert_eq!(config.adr_dir, PathBuf::from(""));
519 }
520}