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