1use crate::errors::SampoError;
2use rustc_hash::FxHashSet;
3use std::collections::BTreeSet;
4use std::path::Path;
5
6#[derive(Debug, Clone)]
8pub struct Config {
9 #[allow(dead_code)]
10 pub version: u64,
11 pub github_repository: Option<String>,
12 pub changelog_show_commit_hash: bool,
13 pub changelog_show_acknowledgments: bool,
14 pub changelog_show_release_date: bool,
15 pub changelog_release_date_format: String,
16 pub changelog_release_date_timezone: Option<String>,
17 pub fixed_dependencies: Vec<Vec<String>>,
18 pub linked_dependencies: Vec<Vec<String>>,
19 pub ignore_unpublished: bool,
20 pub ignore: Vec<String>,
21 pub git_default_branch: Option<String>,
22 pub git_release_branches: Vec<String>,
23}
24
25impl Default for Config {
26 fn default() -> Self {
27 Self {
28 version: 1,
29 github_repository: None,
30 changelog_show_commit_hash: true,
31 changelog_show_acknowledgments: true,
32 changelog_show_release_date: true,
33 changelog_release_date_format: "%Y-%m-%d".to_string(),
34 changelog_release_date_timezone: None,
35 fixed_dependencies: Vec::new(),
36 linked_dependencies: Vec::new(),
37 ignore_unpublished: false,
38 ignore: Vec::new(),
39 git_default_branch: None,
40 git_release_branches: Vec::new(),
41 }
42 }
43}
44
45impl Config {
46 pub fn load(root: &Path) -> Result<Self, SampoError> {
48 let base = root.join(".sampo");
49 let path = base.join("config.toml");
50 if !path.exists() {
51 return Ok(Self::default());
52 }
53
54 let text = std::fs::read_to_string(&path)
55 .map_err(|e| SampoError::Config(format!("failed to read {}: {e}", path.display())))?;
56 let value: toml::Value = text
57 .parse()
58 .map_err(|e| SampoError::Config(format!("invalid config.toml: {e}")))?;
59
60 let version = value
61 .get("version")
62 .and_then(toml::Value::as_integer)
63 .unwrap_or(1);
64
65 let version = u64::try_from(version).unwrap_or(1);
66
67 let github_repository = value
68 .get("github")
69 .and_then(|v| v.as_table())
70 .and_then(|t| t.get("repository"))
71 .and_then(|v| v.as_str())
72 .map(|s| s.to_string());
73
74 let changelog_table = value.get("changelog").and_then(|v| v.as_table());
75
76 let changelog_show_commit_hash = changelog_table
77 .and_then(|t| t.get("show_commit_hash"))
78 .and_then(|v| v.as_bool())
79 .unwrap_or(true);
80
81 let changelog_show_acknowledgments = changelog_table
82 .and_then(|t| t.get("show_acknowledgments"))
83 .and_then(|v| v.as_bool())
84 .unwrap_or(true);
85
86 let changelog_show_release_date = changelog_table
87 .and_then(|t| t.get("show_release_date"))
88 .and_then(|v| v.as_bool())
89 .unwrap_or(true);
90
91 let changelog_release_date_format = changelog_table
92 .and_then(|t| t.get("release_date_format"))
93 .and_then(|v| v.as_str())
94 .map(|s| s.to_string())
95 .unwrap_or_else(|| "%Y-%m-%d".to_string());
96
97 let changelog_release_date_timezone = changelog_table
98 .and_then(|t| t.get("release_date_timezone"))
99 .and_then(|v| v.as_str())
100 .map(|s| s.trim())
101 .filter(|s| !s.is_empty())
102 .map(|s| s.to_string());
103
104 let fixed_dependencies = value
105 .get("packages")
106 .and_then(|v| v.as_table())
107 .and_then(|t| t.get("fixed"))
108 .and_then(|v| v.as_array())
109 .map(|outer_arr| -> Result<Vec<Vec<String>>, String> {
110 let all_arrays = outer_arr.iter().all(|item| item.is_array());
112 let any_arrays = outer_arr.iter().any(|item| item.is_array());
113
114 if !all_arrays {
115 if any_arrays {
116 let non_array = outer_arr.iter().find(|item| !item.is_array()).unwrap();
118 return Err(format!(
119 "packages.fixed must be an array of arrays, found mixed format with: {}. Use [[\"a\", \"b\"]] instead of [\"a\", \"b\"]",
120 non_array
121 ));
122 } else {
123 return Err(
125 "packages.fixed must be an array of arrays. Use [[\"a\", \"b\"]] instead of [\"a\", \"b\"]".to_string()
126 );
127 }
128 }
129
130 let groups: Vec<Vec<String>> = outer_arr.iter()
131 .filter_map(|inner| inner.as_array())
132 .map(|inner_arr| {
133 inner_arr.iter()
134 .filter_map(|v| v.as_str())
135 .map(|s| s.to_string())
136 .collect()
137 })
138 .collect();
139
140 let mut seen_packages = FxHashSet::default();
142 for group in &groups {
143 for package in group {
144 if seen_packages.contains(package) {
145 return Err(format!(
146 "Package '{}' appears in multiple fixed dependency groups. Each package can only belong to one group.",
147 package
148 ));
149 }
150 seen_packages.insert(package.clone());
151 }
152 }
153
154 Ok(groups)
155 })
156 .transpose()
157 .map_err(SampoError::Config)?
158 .unwrap_or_default();
159
160 let ignore_unpublished = value
161 .get("packages")
162 .and_then(|v| v.as_table())
163 .and_then(|t| t.get("ignore_unpublished"))
164 .and_then(|v| v.as_bool())
165 .unwrap_or(false);
166
167 let ignore = value
168 .get("packages")
169 .and_then(|v| v.as_table())
170 .and_then(|t| t.get("ignore"))
171 .and_then(|v| v.as_array())
172 .map(|arr| {
173 arr.iter()
174 .filter_map(|v| v.as_str())
175 .map(|s| s.to_string())
176 .collect::<Vec<String>>()
177 })
178 .unwrap_or_default();
179
180 let linked_dependencies = value
181 .get("packages")
182 .and_then(|v| v.as_table())
183 .and_then(|t| t.get("linked"))
184 .and_then(|v| v.as_array())
185 .map(|outer_arr| -> Result<Vec<Vec<String>>, String> {
186 let all_arrays = outer_arr.iter().all(|item| item.is_array());
188 let any_arrays = outer_arr.iter().any(|item| item.is_array());
189
190 if !all_arrays {
191 if any_arrays {
192 let non_array = outer_arr.iter().find(|item| !item.is_array()).unwrap();
194 return Err(format!(
195 "packages.linked must be an array of arrays, found mixed format with: {}. Use [[\"a\", \"b\"]] instead of [\"a\", \"b\"]",
196 non_array
197 ));
198 } else {
199 return Err(
201 "packages.linked must be an array of arrays. Use [[\"a\", \"b\"]] instead of [\"a\", \"b\"]".to_string()
202 );
203 }
204 }
205
206 let groups: Vec<Vec<String>> = outer_arr.iter()
207 .filter_map(|inner| inner.as_array())
208 .map(|inner_arr| {
209 inner_arr.iter()
210 .filter_map(|v| v.as_str())
211 .map(|s| s.to_string())
212 .collect()
213 })
214 .collect();
215
216 let mut seen_packages = FxHashSet::default();
218 for group in &groups {
219 for package in group {
220 if seen_packages.contains(package) {
221 return Err(format!(
222 "Package '{}' appears in multiple linked dependency groups. Each package can only belong to one group.",
223 package
224 ));
225 }
226 seen_packages.insert(package.clone());
227 }
228 }
229
230 Ok(groups)
231 })
232 .transpose()
233 .map_err(SampoError::Config)?
234 .unwrap_or_default();
235
236 let mut all_fixed_packages = FxHashSet::default();
238 for group in &fixed_dependencies {
239 for package in group {
240 all_fixed_packages.insert(package.clone());
241 }
242 }
243
244 for group in &linked_dependencies {
245 for package in group {
246 if all_fixed_packages.contains(package) {
247 return Err(SampoError::Config(format!(
248 "Package '{}' cannot appear in both packages.fixed and packages.linked",
249 package
250 )));
251 }
252 }
253 }
254
255 let (git_default_branch, git_release_branches) = value
256 .get("git")
257 .and_then(|v| v.as_table())
258 .map(|git_table| {
259 let default_branch = git_table
260 .get("default_branch")
261 .and_then(|v| v.as_str())
262 .map(|s| s.trim())
263 .filter(|s| !s.is_empty())
264 .map(|s| s.to_string());
265
266 let release_branches = git_table
267 .get("release_branches")
268 .and_then(|v| v.as_array())
269 .map(|arr| {
270 arr.iter()
271 .filter_map(|item| item.as_str())
272 .map(|s| s.trim())
273 .filter(|s| !s.is_empty())
274 .map(|s| s.to_string())
275 .collect::<Vec<String>>()
276 })
277 .unwrap_or_default();
278
279 (default_branch, release_branches)
280 })
281 .unwrap_or((None, Vec::new()));
282
283 Ok(Self {
284 version,
285 github_repository,
286 changelog_show_commit_hash,
287 changelog_show_acknowledgments,
288 changelog_show_release_date,
289 changelog_release_date_format,
290 changelog_release_date_timezone,
291 fixed_dependencies,
292 linked_dependencies,
293 ignore_unpublished,
294 ignore,
295 git_default_branch,
296 git_release_branches,
297 })
298 }
299
300 pub fn default_branch(&self) -> &str {
301 self.git_default_branch.as_deref().unwrap_or("main")
302 }
303
304 pub fn release_branches(&self) -> BTreeSet<String> {
305 let mut branches: BTreeSet<String> = BTreeSet::new();
306 branches.insert(self.default_branch().to_string());
307 for name in &self.git_release_branches {
308 if !name.is_empty() {
309 branches.insert(name.clone());
310 }
311 }
312 branches
313 }
314
315 pub fn is_release_branch(&self, branch: &str) -> bool {
316 self.release_branches().contains(branch)
317 }
318}
319
320#[cfg(test)]
321mod tests {
322 use super::*;
323 use std::fs;
324
325 #[test]
326 fn defaults_when_missing() {
327 let temp = tempfile::tempdir().unwrap();
328 let config = Config::load(temp.path()).unwrap();
329 assert_eq!(config.version, 1);
330 assert!(config.github_repository.is_none());
331 assert!(config.changelog_show_commit_hash);
332 assert!(config.changelog_show_acknowledgments);
333 assert!(config.changelog_show_release_date);
334 assert_eq!(config.changelog_release_date_format, "%Y-%m-%d");
335 assert!(config.changelog_release_date_timezone.is_none());
336 assert_eq!(config.default_branch(), "main");
337 assert!(config.is_release_branch("main"));
338 assert_eq!(config.git_release_branches, Vec::<String>::new());
339 }
340
341 #[test]
342 fn reads_changelog_options() {
343 let temp = tempfile::tempdir().unwrap();
344 fs::create_dir_all(temp.path().join(".sampo")).unwrap();
345 fs::write(
346 temp.path().join(".sampo/config.toml"),
347 "[changelog]\nshow_commit_hash = false\nshow_acknowledgments = false\nshow_release_date = false\nrelease_date_format = \"%d/%m/%Y\"\nrelease_date_timezone = \"+02:30\"\n",
348 )
349 .unwrap();
350
351 let config = Config::load(temp.path()).unwrap();
352 assert!(!config.changelog_show_commit_hash);
353 assert!(!config.changelog_show_acknowledgments);
354 assert!(!config.changelog_show_release_date);
355 assert_eq!(config.changelog_release_date_format, "%d/%m/%Y");
356 assert_eq!(
357 config.changelog_release_date_timezone.as_deref(),
358 Some("+02:30")
359 );
360 }
361
362 #[test]
363 fn reads_github_repository() {
364 let temp = tempfile::tempdir().unwrap();
365 fs::create_dir_all(temp.path().join(".sampo")).unwrap();
366 fs::write(
367 temp.path().join(".sampo/config.toml"),
368 "[github]\nrepository = \"owner/repo\"\n",
369 )
370 .unwrap();
371
372 let config = Config::load(temp.path()).unwrap();
373 assert_eq!(config.github_repository.as_deref(), Some("owner/repo"));
374 }
375
376 #[test]
377 fn reads_both_changelog_and_github() {
378 let temp = tempfile::tempdir().unwrap();
379 fs::create_dir_all(temp.path().join(".sampo")).unwrap();
380 fs::write(
381 temp.path().join(".sampo/config.toml"),
382 "[changelog]\nshow_commit_hash = false\n[github]\nrepository = \"owner/repo\"\n",
383 )
384 .unwrap();
385
386 let config = Config::load(temp.path()).unwrap();
387 assert!(!config.changelog_show_commit_hash);
388 assert!(config.changelog_show_release_date);
389 assert_eq!(config.changelog_release_date_format, "%Y-%m-%d");
390 assert!(config.changelog_release_date_timezone.is_none());
391 assert_eq!(config.github_repository.as_deref(), Some("owner/repo"));
392 }
393
394 #[test]
395 fn reads_git_section() {
396 let temp = tempfile::tempdir().unwrap();
397 fs::create_dir_all(temp.path().join(".sampo")).unwrap();
398 fs::write(
399 temp.path().join(".sampo/config.toml"),
400 "[git]\ndefault_branch = \"release\"\nrelease_branches = [\"release\", \"3.x\"]\n",
401 )
402 .unwrap();
403
404 let config = Config::load(temp.path()).unwrap();
405 assert_eq!(config.default_branch(), "release");
406 assert!(config.is_release_branch("release"));
407 assert!(config.is_release_branch("3.x"));
408 assert!(!config.is_release_branch("main"));
409 }
410
411 #[test]
412 fn reads_fixed_dependencies() {
413 let temp = tempfile::tempdir().unwrap();
414 fs::create_dir_all(temp.path().join(".sampo")).unwrap();
415 fs::write(
416 temp.path().join(".sampo/config.toml"),
417 "[packages]\nfixed = [[\"pkg-a\", \"pkg-b\"]]\n",
418 )
419 .unwrap();
420
421 let config = Config::load(temp.path()).unwrap();
422 assert_eq!(
423 config.fixed_dependencies,
424 vec![vec!["pkg-a".to_string(), "pkg-b".to_string()]]
425 );
426 }
427
428 #[test]
429 fn reads_ignore_unpublished_and_ignore_list() {
430 let temp = tempfile::tempdir().unwrap();
431 fs::create_dir_all(temp.path().join(".sampo")).unwrap();
432 fs::write(
433 temp.path().join(".sampo/config.toml"),
434 "[packages]\nignore_unpublished = true\nignore = [\"internal-*\", \"examples/*\"]\n",
435 )
436 .unwrap();
437
438 let config = Config::load(temp.path()).unwrap();
439 assert!(config.ignore_unpublished);
440 assert_eq!(config.ignore, vec!["internal-*", "examples/*"]);
441 }
442
443 #[test]
444 fn defaults_ignore_options() {
445 let temp = tempfile::tempdir().unwrap();
446 let config = Config::load(temp.path()).unwrap();
447 assert!(!config.ignore_unpublished);
448 assert!(config.ignore.is_empty());
449 }
450
451 #[test]
452 fn reads_fixed_dependencies_groups() {
453 let temp = tempfile::tempdir().unwrap();
454 fs::create_dir_all(temp.path().join(".sampo")).unwrap();
455 fs::write(
456 temp.path().join(".sampo/config.toml"),
457 "[packages]\nfixed = [[\"pkg-a\", \"pkg-b\"], [\"pkg-c\", \"pkg-d\", \"pkg-e\"]]\n",
458 )
459 .unwrap();
460
461 let config = Config::load(temp.path()).unwrap();
462 assert_eq!(
463 config.fixed_dependencies,
464 vec![
465 vec!["pkg-a".to_string(), "pkg-b".to_string()],
466 vec![
467 "pkg-c".to_string(),
468 "pkg-d".to_string(),
469 "pkg-e".to_string()
470 ]
471 ]
472 );
473 }
474
475 #[test]
476 fn defaults_empty_fixed_dependencies() {
477 let temp = tempfile::tempdir().unwrap();
478 let config = Config::load(temp.path()).unwrap();
479 assert!(config.fixed_dependencies.is_empty());
480 }
481
482 #[test]
483 fn rejects_flat_array_format() {
484 let temp = tempfile::tempdir().unwrap();
485 fs::create_dir_all(temp.path().join(".sampo")).unwrap();
486 fs::write(
487 temp.path().join(".sampo/config.toml"),
488 "[packages]\nfixed = [\"pkg-a\", \"pkg-b\"]\n",
489 )
490 .unwrap();
491
492 let result = Config::load(temp.path());
493 assert!(result.is_err());
494 let error_msg = format!("{}", result.unwrap_err());
495 assert!(error_msg.contains("must be an array of arrays"));
496 assert!(error_msg.contains("Use [[\"a\", \"b\"]] instead of [\"a\", \"b\"]"));
497 }
498
499 #[test]
500 fn rejects_overlapping_groups() {
501 let temp = tempfile::tempdir().unwrap();
502 fs::create_dir_all(temp.path().join(".sampo")).unwrap();
503 fs::write(
504 temp.path().join(".sampo/config.toml"),
505 "[packages]\nfixed = [[\"pkg-a\", \"pkg-b\"], [\"pkg-b\", \"pkg-c\"]]\n",
506 )
507 .unwrap();
508
509 let result = Config::load(temp.path());
510 assert!(result.is_err());
511 let error_msg = format!("{}", result.unwrap_err());
512 assert!(error_msg.contains("Package 'pkg-b' appears in multiple fixed dependency groups"));
513 }
514
515 #[test]
516 fn reads_linked_dependencies() {
517 let temp = tempfile::tempdir().unwrap();
518 fs::create_dir_all(temp.path().join(".sampo")).unwrap();
519 fs::write(
520 temp.path().join(".sampo/config.toml"),
521 "[packages]\nlinked = [[\"pkg-a\", \"pkg-b\"]]\n",
522 )
523 .unwrap();
524
525 let config = Config::load(temp.path()).unwrap();
526 assert_eq!(
527 config.linked_dependencies,
528 vec![vec!["pkg-a".to_string(), "pkg-b".to_string()]]
529 );
530 }
531
532 #[test]
533 fn reads_linked_dependencies_groups() {
534 let temp = tempfile::tempdir().unwrap();
535 fs::create_dir_all(temp.path().join(".sampo")).unwrap();
536 fs::write(
537 temp.path().join(".sampo/config.toml"),
538 "[packages]\nlinked = [[\"pkg-a\", \"pkg-b\"], [\"pkg-c\", \"pkg-d\", \"pkg-e\"]]\n",
539 )
540 .unwrap();
541
542 let config = Config::load(temp.path()).unwrap();
543 assert_eq!(
544 config.linked_dependencies,
545 vec![
546 vec!["pkg-a".to_string(), "pkg-b".to_string()],
547 vec![
548 "pkg-c".to_string(),
549 "pkg-d".to_string(),
550 "pkg-e".to_string()
551 ]
552 ]
553 );
554 }
555
556 #[test]
557 fn defaults_empty_linked_dependencies() {
558 let temp = tempfile::tempdir().unwrap();
559 let config = Config::load(temp.path()).unwrap();
560 assert!(config.linked_dependencies.is_empty());
561 }
562
563 #[test]
564 fn rejects_overlapping_linked_groups() {
565 let temp = tempfile::tempdir().unwrap();
566 fs::create_dir_all(temp.path().join(".sampo")).unwrap();
567 fs::write(
568 temp.path().join(".sampo/config.toml"),
569 "[packages]\nlinked = [[\"pkg-a\", \"pkg-b\"], [\"pkg-b\", \"pkg-c\"]]\n",
570 )
571 .unwrap();
572
573 let result = Config::load(temp.path());
574 assert!(result.is_err());
575 let error_msg = format!("{}", result.unwrap_err());
576 assert!(error_msg.contains("Package 'pkg-b' appears in multiple linked dependency groups"));
577 }
578
579 #[test]
580 fn rejects_packages_in_both_fixed_and_linked() {
581 let temp = tempfile::tempdir().unwrap();
582 fs::create_dir_all(temp.path().join(".sampo")).unwrap();
583 fs::write(
584 temp.path().join(".sampo/config.toml"),
585 "[packages]\nfixed = [[\"pkg-a\", \"pkg-b\"]]\nlinked = [[\"pkg-b\", \"pkg-c\"]]\n",
586 )
587 .unwrap();
588
589 let result = Config::load(temp.path());
590 assert!(result.is_err());
591 let error_msg = format!("{}", result.unwrap_err());
592 assert!(
593 error_msg.contains(
594 "Package 'pkg-b' cannot appear in both packages.fixed and packages.linked"
595 )
596 );
597 }
598
599 #[test]
600 fn allows_separate_fixed_and_linked_groups() {
601 let temp = tempfile::tempdir().unwrap();
602 fs::create_dir_all(temp.path().join(".sampo")).unwrap();
603 fs::write(
604 temp.path().join(".sampo/config.toml"),
605 "[packages]\nfixed = [[\"pkg-a\", \"pkg-b\"]]\nlinked = [[\"pkg-c\", \"pkg-d\"]]\n",
606 )
607 .unwrap();
608
609 let config = Config::load(temp.path()).unwrap();
610 assert_eq!(
611 config.fixed_dependencies,
612 vec![vec!["pkg-a".to_string(), "pkg-b".to_string()]]
613 );
614 assert_eq!(
615 config.linked_dependencies,
616 vec![vec!["pkg-c".to_string(), "pkg-d".to_string()]]
617 );
618 }
619}