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