sampo_core/
config.rs

1use crate::errors::SampoError;
2use rustc_hash::FxHashSet;
3use std::collections::BTreeSet;
4use std::path::Path;
5
6/// Configuration for Sampo
7#[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    /// Load configuration from .sampo/config.toml
47    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                // Check if all elements are arrays (groups format)
111                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                        // Mixed format
117                        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                        // All strings (flat format)
124                        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                // Check for overlapping groups
141                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                // Check if all elements are arrays (groups format)
187                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                        // Mixed format
193                        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                        // All strings (flat format)
200                        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                // Check for overlapping groups within linked_dependencies
217                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        // Check for overlapping packages between fixed and linked dependencies
237        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}