1use crate::errors::SampoError;
2use rustc_hash::FxHashSet;
3use std::path::Path;
4
5#[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 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 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 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 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 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 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 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 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 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 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}