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