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 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 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 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 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 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 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 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 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 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 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 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}