sampo_core/
config.rs

1use crate::errors::SampoError;
2use rustc_hash::FxHashSet;
3use std::path::Path;
4
5/// Configuration for Sampo
6#[derive(Debug, Clone)]
7pub struct Config {
8    #[allow(dead_code)]
9    pub version: u64,
10    pub github_repository: Option<String>,
11    pub changelog_show_commit_hash: bool,
12    pub changelog_show_acknowledgments: bool,
13    pub fixed_dependencies: Vec<Vec<String>>,
14    pub linked_dependencies: Vec<Vec<String>>,
15    pub ignore_unpublished: bool,
16    pub ignore: Vec<String>,
17}
18
19impl Default for Config {
20    fn default() -> Self {
21        Self {
22            version: 1,
23            github_repository: None,
24            changelog_show_commit_hash: true,
25            changelog_show_acknowledgments: true,
26            fixed_dependencies: Vec::new(),
27            linked_dependencies: Vec::new(),
28            ignore_unpublished: false,
29            ignore: Vec::new(),
30        }
31    }
32}
33
34impl Config {
35    /// Load configuration from .sampo/config.toml
36    pub fn load(root: &Path) -> Result<Self, SampoError> {
37        let base = root.join(".sampo");
38        let path = base.join("config.toml");
39        if !path.exists() {
40            return Ok(Self::default());
41        }
42
43        let text = std::fs::read_to_string(&path)?;
44        let value: toml::Value = text
45            .parse()
46            .map_err(|e| SampoError::Config(format!("invalid config.toml: {e}")))?;
47
48        let version = value
49            .get("version")
50            .and_then(toml::Value::as_integer)
51            .unwrap_or(1);
52
53        let version = u64::try_from(version).unwrap_or(1);
54
55        let github_repository = value
56            .get("github")
57            .and_then(|v| v.as_table())
58            .and_then(|t| t.get("repository"))
59            .and_then(|v| v.as_str())
60            .map(|s| s.to_string());
61
62        let changelog_show_commit_hash = value
63            .get("changelog")
64            .and_then(|v| v.as_table())
65            .and_then(|t| t.get("show_commit_hash"))
66            .and_then(|v| v.as_bool())
67            .unwrap_or(true);
68
69        let changelog_show_acknowledgments = value
70            .get("changelog")
71            .and_then(|v| v.as_table())
72            .and_then(|t| t.get("show_acknowledgments"))
73            .and_then(|v| v.as_bool())
74            .unwrap_or(true);
75
76        let fixed_dependencies = value
77            .get("packages")
78            .and_then(|v| v.as_table())
79            .and_then(|t| t.get("fixed"))
80            .and_then(|v| v.as_array())
81            .map(|outer_arr| -> Result<Vec<Vec<String>>, String> {
82                // Check if all elements are arrays (groups format)
83                let all_arrays = outer_arr.iter().all(|item| item.is_array());
84                let any_arrays = outer_arr.iter().any(|item| item.is_array());
85
86                if !all_arrays {
87                    if any_arrays {
88                        // Mixed format
89                        let non_array = outer_arr.iter().find(|item| !item.is_array()).unwrap();
90                        return Err(format!(
91                            "packages.fixed must be an array of arrays, found mixed format with: {}. Use [[\"a\", \"b\"]] instead of [\"a\", \"b\"]",
92                            non_array
93                        ));
94                    } else {
95                        // All strings (flat format)
96                        return Err(
97                            "packages.fixed must be an array of arrays. Use [[\"a\", \"b\"]] instead of [\"a\", \"b\"]".to_string()
98                        );
99                    }
100                }
101
102                let groups: Vec<Vec<String>> = outer_arr.iter()
103                    .filter_map(|inner| inner.as_array())
104                    .map(|inner_arr| {
105                        inner_arr.iter()
106                            .filter_map(|v| v.as_str())
107                            .map(|s| s.to_string())
108                            .collect()
109                    })
110                    .collect();
111
112                // Check for overlapping groups
113                let mut seen_packages = FxHashSet::default();
114                for group in &groups {
115                    for package in group {
116                        if seen_packages.contains(package) {
117                            return Err(format!(
118                                "Package '{}' appears in multiple fixed dependency groups. Each package can only belong to one group.",
119                                package
120                            ));
121                        }
122                        seen_packages.insert(package.clone());
123                    }
124                }
125
126                Ok(groups)
127            })
128            .transpose()
129            .map_err(SampoError::Config)?
130            .unwrap_or_default();
131
132        let ignore_unpublished = value
133            .get("packages")
134            .and_then(|v| v.as_table())
135            .and_then(|t| t.get("ignore_unpublished"))
136            .and_then(|v| v.as_bool())
137            .unwrap_or(false);
138
139        let ignore = value
140            .get("packages")
141            .and_then(|v| v.as_table())
142            .and_then(|t| t.get("ignore"))
143            .and_then(|v| v.as_array())
144            .map(|arr| {
145                arr.iter()
146                    .filter_map(|v| v.as_str())
147                    .map(|s| s.to_string())
148                    .collect::<Vec<String>>()
149            })
150            .unwrap_or_default();
151
152        let linked_dependencies = value
153            .get("packages")
154            .and_then(|v| v.as_table())
155            .and_then(|t| t.get("linked"))
156            .and_then(|v| v.as_array())
157            .map(|outer_arr| -> Result<Vec<Vec<String>>, String> {
158                // Check if all elements are arrays (groups format)
159                let all_arrays = outer_arr.iter().all(|item| item.is_array());
160                let any_arrays = outer_arr.iter().any(|item| item.is_array());
161
162                if !all_arrays {
163                    if any_arrays {
164                        // Mixed format
165                        let non_array = outer_arr.iter().find(|item| !item.is_array()).unwrap();
166                        return Err(format!(
167                            "packages.linked must be an array of arrays, found mixed format with: {}. Use [[\"a\", \"b\"]] instead of [\"a\", \"b\"]",
168                            non_array
169                        ));
170                    } else {
171                        // All strings (flat format)
172                        return Err(
173                            "packages.linked must be an array of arrays. Use [[\"a\", \"b\"]] instead of [\"a\", \"b\"]".to_string()
174                        );
175                    }
176                }
177
178                let groups: Vec<Vec<String>> = outer_arr.iter()
179                    .filter_map(|inner| inner.as_array())
180                    .map(|inner_arr| {
181                        inner_arr.iter()
182                            .filter_map(|v| v.as_str())
183                            .map(|s| s.to_string())
184                            .collect()
185                    })
186                    .collect();
187
188                // Check for overlapping groups within linked_dependencies
189                let mut seen_packages = FxHashSet::default();
190                for group in &groups {
191                    for package in group {
192                        if seen_packages.contains(package) {
193                            return Err(format!(
194                                "Package '{}' appears in multiple linked dependency groups. Each package can only belong to one group.",
195                                package
196                            ));
197                        }
198                        seen_packages.insert(package.clone());
199                    }
200                }
201
202                Ok(groups)
203            })
204            .transpose()
205            .map_err(SampoError::Config)?
206            .unwrap_or_default();
207
208        // Check for overlapping packages between fixed and linked dependencies
209        let mut all_fixed_packages = FxHashSet::default();
210        for group in &fixed_dependencies {
211            for package in group {
212                all_fixed_packages.insert(package.clone());
213            }
214        }
215
216        for group in &linked_dependencies {
217            for package in group {
218                if all_fixed_packages.contains(package) {
219                    return Err(SampoError::Config(format!(
220                        "Package '{}' cannot appear in both packages.fixed and packages.linked",
221                        package
222                    )));
223                }
224            }
225        }
226
227        Ok(Self {
228            version,
229            github_repository,
230            changelog_show_commit_hash,
231            changelog_show_acknowledgments,
232            fixed_dependencies,
233            linked_dependencies,
234            ignore_unpublished,
235            ignore,
236        })
237    }
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243    use std::fs;
244
245    #[test]
246    fn defaults_when_missing() {
247        let temp = tempfile::tempdir().unwrap();
248        let config = Config::load(temp.path()).unwrap();
249        assert_eq!(config.version, 1);
250        assert!(config.github_repository.is_none());
251        assert!(config.changelog_show_commit_hash);
252        assert!(config.changelog_show_acknowledgments);
253    }
254
255    #[test]
256    fn reads_changelog_options() {
257        let temp = tempfile::tempdir().unwrap();
258        fs::create_dir_all(temp.path().join(".sampo")).unwrap();
259        fs::write(
260            temp.path().join(".sampo/config.toml"),
261            "[changelog]\nshow_commit_hash = false\nshow_acknowledgments = false\n",
262        )
263        .unwrap();
264
265        let config = Config::load(temp.path()).unwrap();
266        assert!(!config.changelog_show_commit_hash);
267        assert!(!config.changelog_show_acknowledgments);
268    }
269
270    #[test]
271    fn reads_github_repository() {
272        let temp = tempfile::tempdir().unwrap();
273        fs::create_dir_all(temp.path().join(".sampo")).unwrap();
274        fs::write(
275            temp.path().join(".sampo/config.toml"),
276            "[github]\nrepository = \"owner/repo\"\n",
277        )
278        .unwrap();
279
280        let config = Config::load(temp.path()).unwrap();
281        assert_eq!(config.github_repository.as_deref(), Some("owner/repo"));
282    }
283
284    #[test]
285    fn reads_both_changelog_and_github() {
286        let temp = tempfile::tempdir().unwrap();
287        fs::create_dir_all(temp.path().join(".sampo")).unwrap();
288        fs::write(
289            temp.path().join(".sampo/config.toml"),
290            "[changelog]\nshow_commit_hash = false\n[github]\nrepository = \"owner/repo\"\n",
291        )
292        .unwrap();
293
294        let config = Config::load(temp.path()).unwrap();
295        assert!(!config.changelog_show_commit_hash);
296        assert_eq!(config.github_repository.as_deref(), Some("owner/repo"));
297    }
298
299    #[test]
300    fn reads_fixed_dependencies() {
301        let temp = tempfile::tempdir().unwrap();
302        fs::create_dir_all(temp.path().join(".sampo")).unwrap();
303        fs::write(
304            temp.path().join(".sampo/config.toml"),
305            "[packages]\nfixed = [[\"pkg-a\", \"pkg-b\"]]\n",
306        )
307        .unwrap();
308
309        let config = Config::load(temp.path()).unwrap();
310        assert_eq!(
311            config.fixed_dependencies,
312            vec![vec!["pkg-a".to_string(), "pkg-b".to_string()]]
313        );
314    }
315
316    #[test]
317    fn reads_ignore_unpublished_and_ignore_list() {
318        let temp = tempfile::tempdir().unwrap();
319        fs::create_dir_all(temp.path().join(".sampo")).unwrap();
320        fs::write(
321            temp.path().join(".sampo/config.toml"),
322            "[packages]\nignore_unpublished = true\nignore = [\"internal-*\", \"examples/*\"]\n",
323        )
324        .unwrap();
325
326        let config = Config::load(temp.path()).unwrap();
327        assert!(config.ignore_unpublished);
328        assert_eq!(config.ignore, vec!["internal-*", "examples/*"]);
329    }
330
331    #[test]
332    fn defaults_ignore_options() {
333        let temp = tempfile::tempdir().unwrap();
334        let config = Config::load(temp.path()).unwrap();
335        assert!(!config.ignore_unpublished);
336        assert!(config.ignore.is_empty());
337    }
338
339    #[test]
340    fn reads_fixed_dependencies_groups() {
341        let temp = tempfile::tempdir().unwrap();
342        fs::create_dir_all(temp.path().join(".sampo")).unwrap();
343        fs::write(
344            temp.path().join(".sampo/config.toml"),
345            "[packages]\nfixed = [[\"pkg-a\", \"pkg-b\"], [\"pkg-c\", \"pkg-d\", \"pkg-e\"]]\n",
346        )
347        .unwrap();
348
349        let config = Config::load(temp.path()).unwrap();
350        assert_eq!(
351            config.fixed_dependencies,
352            vec![
353                vec!["pkg-a".to_string(), "pkg-b".to_string()],
354                vec![
355                    "pkg-c".to_string(),
356                    "pkg-d".to_string(),
357                    "pkg-e".to_string()
358                ]
359            ]
360        );
361    }
362
363    #[test]
364    fn defaults_empty_fixed_dependencies() {
365        let temp = tempfile::tempdir().unwrap();
366        let config = Config::load(temp.path()).unwrap();
367        assert!(config.fixed_dependencies.is_empty());
368    }
369
370    #[test]
371    fn rejects_flat_array_format() {
372        let temp = tempfile::tempdir().unwrap();
373        fs::create_dir_all(temp.path().join(".sampo")).unwrap();
374        fs::write(
375            temp.path().join(".sampo/config.toml"),
376            "[packages]\nfixed = [\"pkg-a\", \"pkg-b\"]\n",
377        )
378        .unwrap();
379
380        let result = Config::load(temp.path());
381        assert!(result.is_err());
382        let error_msg = format!("{}", result.unwrap_err());
383        assert!(error_msg.contains("must be an array of arrays"));
384        assert!(error_msg.contains("Use [[\"a\", \"b\"]] instead of [\"a\", \"b\"]"));
385    }
386
387    #[test]
388    fn rejects_overlapping_groups() {
389        let temp = tempfile::tempdir().unwrap();
390        fs::create_dir_all(temp.path().join(".sampo")).unwrap();
391        fs::write(
392            temp.path().join(".sampo/config.toml"),
393            "[packages]\nfixed = [[\"pkg-a\", \"pkg-b\"], [\"pkg-b\", \"pkg-c\"]]\n",
394        )
395        .unwrap();
396
397        let result = Config::load(temp.path());
398        assert!(result.is_err());
399        let error_msg = format!("{}", result.unwrap_err());
400        assert!(error_msg.contains("Package 'pkg-b' appears in multiple fixed dependency groups"));
401    }
402
403    #[test]
404    fn reads_linked_dependencies() {
405        let temp = tempfile::tempdir().unwrap();
406        fs::create_dir_all(temp.path().join(".sampo")).unwrap();
407        fs::write(
408            temp.path().join(".sampo/config.toml"),
409            "[packages]\nlinked = [[\"pkg-a\", \"pkg-b\"]]\n",
410        )
411        .unwrap();
412
413        let config = Config::load(temp.path()).unwrap();
414        assert_eq!(
415            config.linked_dependencies,
416            vec![vec!["pkg-a".to_string(), "pkg-b".to_string()]]
417        );
418    }
419
420    #[test]
421    fn reads_linked_dependencies_groups() {
422        let temp = tempfile::tempdir().unwrap();
423        fs::create_dir_all(temp.path().join(".sampo")).unwrap();
424        fs::write(
425            temp.path().join(".sampo/config.toml"),
426            "[packages]\nlinked = [[\"pkg-a\", \"pkg-b\"], [\"pkg-c\", \"pkg-d\", \"pkg-e\"]]\n",
427        )
428        .unwrap();
429
430        let config = Config::load(temp.path()).unwrap();
431        assert_eq!(
432            config.linked_dependencies,
433            vec![
434                vec!["pkg-a".to_string(), "pkg-b".to_string()],
435                vec![
436                    "pkg-c".to_string(),
437                    "pkg-d".to_string(),
438                    "pkg-e".to_string()
439                ]
440            ]
441        );
442    }
443
444    #[test]
445    fn defaults_empty_linked_dependencies() {
446        let temp = tempfile::tempdir().unwrap();
447        let config = Config::load(temp.path()).unwrap();
448        assert!(config.linked_dependencies.is_empty());
449    }
450
451    #[test]
452    fn rejects_overlapping_linked_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]\nlinked = [[\"pkg-a\", \"pkg-b\"], [\"pkg-b\", \"pkg-c\"]]\n",
458        )
459        .unwrap();
460
461        let result = Config::load(temp.path());
462        assert!(result.is_err());
463        let error_msg = format!("{}", result.unwrap_err());
464        assert!(error_msg.contains("Package 'pkg-b' appears in multiple linked dependency groups"));
465    }
466
467    #[test]
468    fn rejects_packages_in_both_fixed_and_linked() {
469        let temp = tempfile::tempdir().unwrap();
470        fs::create_dir_all(temp.path().join(".sampo")).unwrap();
471        fs::write(
472            temp.path().join(".sampo/config.toml"),
473            "[packages]\nfixed = [[\"pkg-a\", \"pkg-b\"]]\nlinked = [[\"pkg-b\", \"pkg-c\"]]\n",
474        )
475        .unwrap();
476
477        let result = Config::load(temp.path());
478        assert!(result.is_err());
479        let error_msg = format!("{}", result.unwrap_err());
480        assert!(
481            error_msg.contains(
482                "Package 'pkg-b' cannot appear in both packages.fixed and packages.linked"
483            )
484        );
485    }
486
487    #[test]
488    fn allows_separate_fixed_and_linked_groups() {
489        let temp = tempfile::tempdir().unwrap();
490        fs::create_dir_all(temp.path().join(".sampo")).unwrap();
491        fs::write(
492            temp.path().join(".sampo/config.toml"),
493            "[packages]\nfixed = [[\"pkg-a\", \"pkg-b\"]]\nlinked = [[\"pkg-c\", \"pkg-d\"]]\n",
494        )
495        .unwrap();
496
497        let config = Config::load(temp.path()).unwrap();
498        assert_eq!(
499            config.fixed_dependencies,
500            vec![vec!["pkg-a".to_string(), "pkg-b".to_string()]]
501        );
502        assert_eq!(
503            config.linked_dependencies,
504            vec![vec!["pkg-c".to_string(), "pkg-d".to_string()]]
505        );
506    }
507}