1use std::collections::{BTreeMap, BTreeSet};
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use callisto_model::{
6 ConfigKey, Ecosystem, PackageId, PublishTarget, RegistryKey, ReleaseTrigger, Severity, TagTemplate,
7};
8
9use crate::config::groups::{GroupTable, RawGroupTable};
10use crate::config::pattern::PackagePattern;
11use crate::config::raw::RawConfig;
12use crate::error::{ConfigError, GraphError};
13
14#[derive(Clone, Debug)]
15pub struct ResolvedConfig {
16 pub root: PathBuf,
17 pub changesets_dir: PathBuf,
18 pub cascade: CascadeConfig,
19 pub validation: ValidationConfig,
20 pub registries: BTreeMap<RegistryKey, RegistryConfig>,
21 pub packages: Vec<(PackageId, PackageConfig)>,
30 pub package_sets: Vec<(PackagePattern, PackageConfig)>,
35 pub groups: GroupTable,
36 pub(crate) raw_groups: RawGroupTable,
40 pub promoted_siblings: BTreeMap<String, Vec<(PackageId, BTreeSet<Ecosystem>)>>,
41 provenance: BTreeMap<ConfigKey, ConfigProvenance>,
42}
43
44#[derive(Clone, Copy, Debug, PartialEq, Eq)]
45pub enum ConfigProvenance {
46 Default,
47 Explicit,
48}
49
50impl ResolvedConfig {
51 pub(crate) fn with_promoted_siblings(
52 &self,
53 promoted_siblings: BTreeMap<String, Vec<(PackageId, BTreeSet<Ecosystem>)>>,
54 ) -> ResolvedConfig {
55 let mut overlay = self.clone();
56 overlay.promoted_siblings = promoted_siblings;
57 overlay
58 }
59
60 pub fn provenance(&self, key: &ConfigKey) -> ConfigProvenance {
61 self.provenance.get(key).copied().unwrap_or(ConfigProvenance::Default)
62 }
63
64 pub fn rendered_value(&self, key: &ConfigKey) -> Option<String> {
65 if key == &ConfigKey::CASCADE_MODE {
66 Some(match self.cascade.mode {
67 CascadeMode::OutOfRange => "out-of-range".to_string(),
68 CascadeMode::Always => "always".to_string(),
69 })
70 } else if key == &ConfigKey::CASCADE_BUMP_SEVERITY {
71 Some(match self.cascade.bump_severity {
72 CascadeBumpSeverity::Patch => "patch".to_string(),
73 CascadeBumpSeverity::Minor => "minor".to_string(),
74 })
75 } else if key == &ConfigKey::CASCADE_PEER_ESCALATION {
76 Some(self.cascade.peer_escalation.to_string())
77 } else if key == &ConfigKey::CASCADE_PRESERVE_NPM_RANGES {
78 Some(self.cascade.preserve_npm_ranges.to_string())
79 } else if key == &ConfigKey::VALIDATION_ALLOW_EMPTY_CHANGESETS {
80 Some(self.validation.allow_empty_changesets.to_string())
81 } else {
82 None
83 }
84 }
85}
86
87#[derive(Clone, Copy, Debug, PartialEq, Eq)]
88pub struct CascadeConfig {
89 pub mode: CascadeMode,
90 pub bump_severity: CascadeBumpSeverity,
91 pub peer_escalation: bool,
92 pub preserve_npm_ranges: bool,
93}
94
95#[derive(Clone, Copy, Debug, PartialEq, Eq)]
96pub enum CascadeMode {
97 OutOfRange,
98 Always,
99}
100
101#[derive(Clone, Copy, Debug, PartialEq, Eq)]
102pub enum CascadeBumpSeverity {
103 Patch,
104 Minor,
105}
106
107impl CascadeBumpSeverity {
108 pub fn as_severity(self) -> Severity {
109 match self {
110 CascadeBumpSeverity::Patch => Severity::Patch,
111 CascadeBumpSeverity::Minor => Severity::Minor,
112 }
113 }
114}
115
116#[derive(Clone, Copy, Debug, PartialEq, Eq)]
117pub struct ValidationConfig {
118 pub allow_empty_changesets: bool,
119}
120
121#[derive(Clone, Debug)]
122pub struct RegistryConfig {
123 pub kind: Ecosystem,
124 pub url: Option<String>,
125}
126
127#[derive(Clone, Debug)]
132pub struct PackageConfig {
133 pub release_trigger: Option<ReleaseTrigger>,
134 pub publish_to: Option<Vec<PublishTarget>>,
135 pub tag_template: Option<TagTemplate>,
136 pub changelog: Option<PathBuf>,
138 pub pre_major_inference: Option<PreMajorInferencePolicy>,
139}
140
141#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
147pub enum PreMajorInferencePolicy {
148 #[default]
149 Off,
150 Conservative,
152 ConservativeFeat,
154}
155
156pub fn parse_publish_target(s: &str) -> Result<PublishTarget, ConfigError> {
157 match s {
158 "crates-io" => Ok(PublishTarget::CratesIo),
159 "npm" => Ok(PublishTarget::Npm {
160 registry: None,
161 access: None,
162 }),
163 "pypi" => Ok(PublishTarget::Pypi { index: None }),
164 "nuget" => Ok(PublishTarget::NuGet { source: None }),
165 "github-release" => Ok(PublishTarget::GitHubRelease),
166 "none" => Ok(PublishTarget::None),
167 other => Err(ConfigError::UnknownKey {
168 path: PathBuf::new(),
169 key: format!("publish-to = {other:?}"),
170 }),
171 }
172}
173
174pub fn parse_release_trigger(s: &str) -> Result<ReleaseTrigger, ConfigError> {
175 match s {
176 "changeset" => Ok(ReleaseTrigger::Changeset),
177 "auto" => Ok(ReleaseTrigger::Auto),
178 other => Err(ConfigError::UnknownKey {
179 path: PathBuf::new(),
180 key: format!("release-trigger = {other}"),
181 }),
182 }
183}
184
185pub(crate) fn resolve_package_config<'a>(
200 id: &PackageId,
201 cfg: &'a ResolvedConfig,
202) -> Result<Option<&'a PackageConfig>, GraphError> {
203 if let Some((_, pcfg)) = cfg
204 .packages
205 .iter()
206 .find(|(rule_id, _)| rule_id.ecosystem().is_some() && rule_id.matches(id))
207 {
208 return Ok(Some(pcfg));
209 }
210 if let Some((_, pcfg)) = cfg.packages.iter().find(|(rule_id, _)| rule_id.matches(id)) {
211 if let Some(siblings) = cfg.promoted_siblings.get(id.name()) {
212 return Err(GraphError::AmbiguousName {
213 name: id.name().to_string(),
214 candidates: siblings.iter().map(|(pid, _)| pid.clone()).collect(),
215 });
216 }
217 return Ok(Some(pcfg));
218 }
219 Ok(None)
220}
221
222pub fn parse_pre_major_policy(s: &str) -> Result<PreMajorInferencePolicy, ConfigError> {
223 match s {
224 "off" | "false" => Ok(PreMajorInferencePolicy::Off),
225 "conservative" => Ok(PreMajorInferencePolicy::Conservative),
226 "conservative-feat" => Ok(PreMajorInferencePolicy::ConservativeFeat),
227 _ => Err(ConfigError::InvalidPreMajorInference { found: s.to_string() }),
228 }
229}
230
231pub fn load(root: &Path) -> Result<ResolvedConfig, ConfigError> {
232 let callisto_toml = root.join("callisto.toml");
233 let raw = if callisto_toml.exists() {
234 let content = fs::read_to_string(&callisto_toml).map_err(|e| ConfigError::Read {
235 path: callisto_toml.clone(),
236 message: e.to_string(),
237 })?;
238 toml::from_str::<RawConfig>(&content).map_err(|e| ConfigError::ParseToml {
239 path: callisto_toml.clone(),
240 message: e.to_string(),
241 })?
242 } else {
243 RawConfig::default()
244 };
245
246 let mut provenance = BTreeMap::new();
247
248 let changesets_dir_str = raw
249 .changesets
250 .as_ref()
251 .and_then(|c| c.dir.as_deref())
252 .unwrap_or(".changeset");
253
254 let changesets_dir =
260 callisto_model::workspace_relative(changesets_dir_str).map_err(|_err| ConfigError::InvalidChangesetsDir {
261 dir: changesets_dir_str.to_string(),
262 })?;
263
264 let cascade_raw = raw.cascade.unwrap_or_default();
265 let mode = match cascade_raw.mode.as_deref() {
266 Some("always") => {
267 provenance.insert(ConfigKey::CASCADE_MODE, ConfigProvenance::Explicit);
268 CascadeMode::Always
269 }
270 Some("out-of-range") | None => CascadeMode::OutOfRange,
271 Some(other) => {
272 return Err(ConfigError::UnknownKey {
273 path: callisto_toml,
274 key: format!("cascade.mode = {other}"),
275 })
276 }
277 };
278
279 let bump_severity = match cascade_raw.bump_severity.as_deref() {
280 Some("minor") => {
281 provenance.insert(ConfigKey::CASCADE_BUMP_SEVERITY, ConfigProvenance::Explicit);
282 CascadeBumpSeverity::Minor
283 }
284 Some("patch") | None => CascadeBumpSeverity::Patch,
285 Some(other) => {
286 return Err(ConfigError::InvalidBumpSeverity {
287 found: other.to_string(),
288 })
289 }
290 };
291
292 let peer_escalation = cascade_raw.peer_escalation.unwrap_or(true);
293 if cascade_raw.peer_escalation.is_some() {
294 provenance.insert(ConfigKey::CASCADE_PEER_ESCALATION, ConfigProvenance::Explicit);
295 }
296
297 let preserve_npm_ranges = cascade_raw.preserve_npm_ranges.unwrap_or(true);
298 if cascade_raw.preserve_npm_ranges.is_some() {
299 provenance.insert(ConfigKey::CASCADE_PRESERVE_NPM_RANGES, ConfigProvenance::Explicit);
300 }
301
302 let validation_raw = raw.validation.unwrap_or_default();
303 let allow_empty_changesets = validation_raw.allow_empty_changesets.unwrap_or(false);
304 if validation_raw.allow_empty_changesets.is_some() {
305 provenance.insert(ConfigKey::VALIDATION_ALLOW_EMPTY_CHANGESETS, ConfigProvenance::Explicit);
306 }
307
308 let mut registries = BTreeMap::new();
309 registries.insert(
310 RegistryKey(RegistryKey::CRATES_IO.to_string()),
311 RegistryConfig {
312 kind: Ecosystem::Cargo,
313 url: None,
314 },
315 );
316 registries.insert(
317 RegistryKey(RegistryKey::NPM.to_string()),
318 RegistryConfig {
319 kind: Ecosystem::Npm,
320 url: None,
321 },
322 );
323
324 if let Some(raw_regs) = raw.registries {
325 for (k_str, reg) in raw_regs {
326 let key = RegistryKey(k_str);
327 let kind = match reg.kind.as_deref() {
328 Some("cargo") => Ecosystem::Cargo,
329 Some("npm") | None => Ecosystem::Npm,
330 Some("pypi") => Ecosystem::Pypi,
331 Some(other) => {
332 return Err(ConfigError::UnknownKey {
333 path: callisto_toml.clone(),
334 key: format!("[registries] kind = {other:?}"),
335 })
336 }
337 };
338 registries.insert(key, RegistryConfig { kind, url: reg.url });
339 }
340 }
341
342 let raw_groups = RawGroupTable {
343 fixed: raw.fixed_group.unwrap_or_default(),
344 linked: raw.linked_group.unwrap_or_default(),
345 };
346 GroupTable::validate_syntactic(&raw_groups)?;
347
348 let mut packages: Vec<(PackageId, PackageConfig)> = Vec::new();
351 for raw_pkg in raw.package.unwrap_or_default() {
352 let pattern = PackageId::parse(&raw_pkg.pattern).map_err(|e| ConfigError::UnknownKey {
353 path: callisto_toml.clone(),
354 key: format!("[[package]] match = {:?}: {e}", raw_pkg.pattern),
355 })?;
356
357 let release_trigger = raw_pkg
358 .release_trigger
359 .as_deref()
360 .map(parse_release_trigger)
361 .transpose()?;
362
363 let tag_template = raw_pkg
364 .tag_template
365 .as_deref()
366 .map(TagTemplate::parse)
367 .transpose()
368 .map_err(ConfigError::Tag)?;
369
370 let changelog = raw_pkg
371 .changelog
372 .as_deref()
373 .map(|s| {
374 callisto_model::workspace_relative(s).map_err(|_err| ConfigError::InvalidChangelogPath {
375 pattern: raw_pkg.pattern.clone(),
376 value: s.to_string(),
377 })
378 })
379 .transpose()?;
380
381 let pre_major_inference = raw_pkg
382 .pre_major_inference
383 .as_deref()
384 .map(parse_pre_major_policy)
385 .transpose()?;
386
387 let publish_to = raw_pkg
388 .publish_to
389 .as_deref()
390 .map(|targets| {
391 targets
392 .iter()
393 .map(|s| parse_publish_target(s))
394 .collect::<Result<Vec<_>, _>>()
395 })
396 .transpose()
397 .map_err(|e| match e {
398 ConfigError::UnknownKey { key, .. } => ConfigError::UnknownKey {
399 path: callisto_toml.clone(),
400 key: format!("[[package]] publish-to: {key}"),
401 },
402 other => other,
403 })?;
404
405 packages.push((
406 pattern,
407 PackageConfig {
408 release_trigger,
409 publish_to,
410 tag_template,
411 changelog,
412 pre_major_inference,
413 },
414 ));
415 }
416
417 let mut package_sets: Vec<(PackagePattern, PackageConfig)> = Vec::new();
421 for raw_pkg in raw.package_set.unwrap_or_default() {
422 let pattern = PackagePattern::parse(&raw_pkg.pattern).map_err(|e| ConfigError::UnknownKey {
423 path: callisto_toml.clone(),
424 key: format!("[[package-set]] match = {:?}: {e}", raw_pkg.pattern),
425 })?;
426
427 let release_trigger = raw_pkg
428 .release_trigger
429 .as_deref()
430 .map(parse_release_trigger)
431 .transpose()?;
432
433 let tag_template = raw_pkg
434 .tag_template
435 .as_deref()
436 .map(TagTemplate::parse)
437 .transpose()
438 .map_err(ConfigError::Tag)?;
439
440 let changelog = raw_pkg
441 .changelog
442 .as_deref()
443 .map(|s| {
444 callisto_model::workspace_relative(s).map_err(|_err| ConfigError::InvalidChangelogPath {
445 pattern: raw_pkg.pattern.clone(),
446 value: s.to_string(),
447 })
448 })
449 .transpose()?;
450
451 let pre_major_inference = raw_pkg
452 .pre_major_inference
453 .as_deref()
454 .map(parse_pre_major_policy)
455 .transpose()?;
456
457 let publish_to = raw_pkg
458 .publish_to
459 .as_deref()
460 .map(|targets| {
461 targets
462 .iter()
463 .map(|s| parse_publish_target(s))
464 .collect::<Result<Vec<_>, _>>()
465 })
466 .transpose()
467 .map_err(|e| match e {
468 ConfigError::UnknownKey { key, .. } => ConfigError::UnknownKey {
469 path: callisto_toml.clone(),
470 key: format!("[[package-set]] publish-to: {key}"),
471 },
472 other => other,
473 })?;
474
475 package_sets.push((
476 pattern,
477 PackageConfig {
478 release_trigger,
479 publish_to,
480 tag_template,
481 changelog,
482 pre_major_inference,
483 },
484 ));
485 }
486
487 Ok(ResolvedConfig {
488 root: root.to_path_buf(),
489 changesets_dir,
490 cascade: CascadeConfig {
491 mode,
492 bump_severity,
493 peer_escalation,
494 preserve_npm_ranges,
495 },
496 validation: ValidationConfig { allow_empty_changesets },
497 registries,
498 packages,
499 package_sets,
500 groups: GroupTable::default(),
501 raw_groups,
502 promoted_siblings: BTreeMap::new(),
503 provenance,
504 })
505}
506
507#[cfg(test)]
508mod tests {
509 use super::*;
510 use std::fs;
511
512 #[test]
513 fn with_promoted_siblings_overlays_without_mutating_original() {
514 let tmp = tempfile::tempdir().expect("tempdir");
515 let root = tmp.path();
516 fs::write(root.join("callisto.toml"), "").expect("write callisto.toml");
517 let cfg = load(root).expect("load should succeed");
518 assert!(
519 cfg.promoted_siblings.is_empty(),
520 "promoted_siblings must default to empty at config-load time"
521 );
522
523 let mut local: BTreeMap<String, Vec<(PackageId, std::collections::BTreeSet<Ecosystem>)>> = BTreeMap::new();
524 let mut ecos = std::collections::BTreeSet::new();
525 ecos.insert(Ecosystem::Cargo);
526 local.insert(
527 "native-core".to_string(),
528 vec![(
529 PackageId::Prefixed {
530 ecosystem: Ecosystem::Cargo,
531 name: "native-core".to_string(),
532 },
533 ecos,
534 )],
535 );
536 let overlay = cfg.with_promoted_siblings(local);
537 assert_eq!(overlay.promoted_siblings.len(), 1);
538 assert!(
539 cfg.promoted_siblings.is_empty(),
540 "original cfg must remain untouched by with_promoted_siblings"
541 );
542 }
543
544 #[test]
545 fn promoted_siblings_field_is_pub_not_pub_crate() {
546 let tmp = tempfile::tempdir().expect("tempdir");
547 let root = tmp.path();
548 fs::write(root.join("callisto.toml"), "").expect("write callisto.toml");
549 let cfg = load(root).expect("load should succeed");
550 let _: &BTreeMap<String, Vec<(PackageId, std::collections::BTreeSet<Ecosystem>)>> = &cfg.promoted_siblings;
551 }
552
553 #[test]
554 fn test_config_resolve_rejects_traversal_in_changesets_dir() {
555 let tmp = tempfile::tempdir().expect("tempdir");
556 let root = tmp.path();
557 fs::write(root.join("callisto.toml"), "[changesets]\ndir = \"../../tmp\"\n").expect("write callisto.toml");
558
559 let result = load(root);
560 assert!(
561 result.is_err(),
562 "expected load() to fail for traversal changesets dir, got Ok"
563 );
564 let err = result.unwrap_err();
565 assert!(
566 matches!(err, ConfigError::InvalidChangesetsDir { .. }),
567 "expected InvalidChangesetsDir error, got: {err:?}"
568 );
569 }
570
571 #[test]
572 fn test_config_resolve_rejects_absolute_changesets_dir() {
573 let tmp = tempfile::tempdir().expect("tempdir");
574 let root = tmp.path();
575 fs::write(root.join("callisto.toml"), "[changesets]\ndir = \"/etc\"\n").expect("write callisto.toml");
576
577 let result = load(root);
578 assert!(
579 result.is_err(),
580 "expected load() to fail for absolute changesets dir '/etc', got Ok \
581 (the traversal guard only checks '..' components, not absolute paths — \
582 this is the bug under test)"
583 );
584 let err = result.unwrap_err();
585 assert!(
586 matches!(err, ConfigError::InvalidChangesetsDir { .. }),
587 "expected InvalidChangesetsDir error, got: {err:?}"
588 );
589 }
590
591 #[test]
592 fn test_config_resolve_rejects_absolute_changelog_in_package_block() {
593 let tmp = tempfile::tempdir().expect("tempdir");
594 let root = tmp.path();
595 fs::write(
596 root.join("callisto.toml"),
597 "[[package]]\nmatch = \"some-pkg\"\nchangelog = \"/tmp/should-not-be-written-here.md\"\n",
598 )
599 .expect("write callisto.toml");
600
601 let result = load(root);
602 assert!(
603 result.is_err(),
604 "expected load() to fail for absolute changelog path, got Ok \
605 (raw_pkg.changelog is currently read straight into a PathBuf with no validation \
606 — this is the bug under test)"
607 );
608 let err = result.unwrap_err();
609 assert!(
610 matches!(err, ConfigError::InvalidChangelogPath { .. }),
611 "expected InvalidChangelogPath error, got: {err:?}"
612 );
613 }
614
615 #[test]
616 fn test_config_resolve_accepts_normal_changesets_dir() {
617 let tmp = tempfile::tempdir().expect("tempdir");
618 let root = tmp.path();
619 fs::write(root.join("callisto.toml"), "[changesets]\ndir = \".changeset\"\n").expect("write callisto.toml");
620
621 let result = load(root);
622 assert!(
623 result.is_ok(),
624 "expected load() to succeed for normal changesets dir, got: {result:?}"
625 );
626 }
627
628 #[test]
629 fn test_package_set_is_parsed_into_resolved_config() {
630 let tmp = tempfile::tempdir().expect("tempdir");
631 let root = tmp.path();
632 fs::write(
633 root.join("callisto.toml"),
634 "[[package-set]]\nmatch = \"pkg-*\"\npublish-to = [\"none\"]\nrelease-trigger = \"auto\"\n",
635 )
636 .expect("write callisto.toml");
637
638 let config = load(root).expect("load should succeed");
639 assert_eq!(
640 config.package_sets.len(),
641 1,
642 "one [[package-set]] rule expected; got: {:?}",
643 config.package_sets
644 );
645 let (pattern, pkg_cfg) = &config.package_sets[0];
646 assert!(
647 pattern.matches(&callisto_model::PackageId::parse("pkg-a").unwrap()),
648 "pattern 'pkg-*' must match 'pkg-a'"
649 );
650 assert_eq!(
651 pkg_cfg.publish_to,
652 Some(vec![callisto_model::PublishTarget::None]),
653 "publish-to = [\"none\"] must be parsed"
654 );
655 assert_eq!(
656 pkg_cfg.release_trigger,
657 Some(callisto_model::ReleaseTrigger::Auto),
658 "release-trigger = \"auto\" must be parsed"
659 );
660 }
661
662 #[test]
663 fn test_typo_in_callisto_toml_is_rejected_not_silently_ignored() {
664 let tmp = tempfile::tempdir().expect("tempdir");
665 let root = tmp.path();
666 fs::write(root.join("callisto.toml"), "[cascade]\ncascade_mode = \"always\"\n").expect("write callisto.toml");
668
669 let result = load(root);
670 assert!(
671 result.is_err(),
672 "load() must reject unknown field 'cascade_mode'; \
673 silently ignoring it means the user's typo has no effect and they have no idea why"
674 );
675 }
676
677 #[test]
678 fn test_package_override_publish_to_is_parsed_not_discarded() {
679 let tmp = tempfile::tempdir().expect("tempdir");
680 let root = tmp.path();
681 fs::write(
682 root.join("callisto.toml"),
683 "[[package]]\nmatch = \"my-crate\"\npublish-to = [\"none\"]\n",
684 )
685 .expect("write callisto.toml");
686
687 let config = load(root).expect("load should succeed");
688 assert_eq!(config.packages.len(), 1, "one [[package]] rule expected");
689 let (_, pkg_cfg) = &config.packages[0];
690 assert!(
691 pkg_cfg.publish_to.is_some(),
692 "PackageConfig.publish_to must not be None when [[package]] publish-to is set;\
693 got: {:?}",
694 pkg_cfg.publish_to
695 );
696 let targets = pkg_cfg.publish_to.as_ref().unwrap();
697 assert_eq!(
698 targets,
699 &vec![PublishTarget::None],
700 "publish-to = [\"none\"] must produce [PublishTarget::None]; got: {targets:?}"
701 );
702 }
703
704 #[test]
705 fn test_registry_kind_pypi_resolves_to_pypi_ecosystem() {
706 let tmp = tempfile::tempdir().expect("tempdir");
707 let root = tmp.path();
708 fs::write(
709 root.join("callisto.toml"),
710 "[registries.my-pypi]\nkind = \"pypi\"\nurl = \"https://pypi.example.com/simple\"\n",
711 )
712 .expect("write callisto.toml");
713
714 let config = load(root).expect("load should succeed");
715 let reg = config
716 .registries
717 .get(&RegistryKey("my-pypi".to_string()))
718 .expect("my-pypi registry should be present");
719 assert_eq!(
720 reg.kind,
721 Ecosystem::Pypi,
722 "registry with kind = \"pypi\" must resolve to Ecosystem::Pypi, not {:?}",
723 reg.kind
724 );
725 }
726
727 #[test]
728 fn test_registry_kind_unknown_is_rejected() {
729 let tmp = tempfile::tempdir().expect("tempdir");
730 let root = tmp.path();
731 fs::write(
732 root.join("callisto.toml"),
733 "[registries.bad-registry]\nkind = \"maven\"\n",
734 )
735 .expect("write callisto.toml");
736
737 let result = load(root);
738 assert!(result.is_err(), "load() should fail for unknown registry kind, got Ok");
739 assert!(
740 matches!(result.unwrap_err(), ConfigError::UnknownKey { .. }),
741 "expected UnknownKey error for unknown registry kind"
742 );
743 }
744
745 #[test]
752 fn resolve_package_config_prefixed_beats_bare_when_bare_declared_first() {
753 let tmp = tempfile::tempdir().expect("tempdir");
754 let root = tmp.path();
755 fs::write(
756 root.join("callisto.toml"),
757 "[[package]]\nmatch = \"foo\"\nrelease-trigger = \"auto\"\n\n[[package]]\nmatch = \"npm/foo\"\nrelease-trigger = \"changeset\"\n",
758 )
759 .expect("write callisto.toml");
760 let cfg = load(root).expect("load should succeed");
761 let id = PackageId::parse("foo").unwrap();
762 let pcfg = resolve_package_config(&id, &cfg)
763 .unwrap()
764 .expect("resolve_package_config must return Some for npm/foo (Prefixed)");
765 assert_eq!(
766 pcfg.release_trigger,
767 Some(ReleaseTrigger::Changeset),
768 "Prefixed rule (npm/foo, changeset) must win over Bare rule (foo, auto) (AC-F1); \
769 Some(Auto) means the Bare rule incorrectly won. Got: {:?}",
770 pcfg.release_trigger
771 );
772 }
773
774 #[test]
778 fn resolve_package_config_first_matching_prefixed_wins_name_mismatch_skipped() {
779 let tmp = tempfile::tempdir().expect("tempdir");
780 let root = tmp.path();
781 fs::write(
785 root.join("callisto.toml"),
786 "[[package]]\nmatch = \"cargo/other\"\nchangelog = \"WRONG.md\"\n\n[[package]]\nmatch = \"npm/pkg\"\nchangelog = \"FIRST.md\"\n\n[[package]]\nmatch = \"pypi/pkg\"\nchangelog = \"SECOND.md\"\n",
787 )
788 .expect("write callisto.toml");
789 let cfg = load(root).expect("load should succeed");
790 let id = PackageId::parse("pkg").unwrap();
791 let pcfg = resolve_package_config(&id, &cfg)
792 .unwrap()
793 .expect("resolve_package_config must return Some for npm/pkg matching pkg");
794 assert!(
795 pcfg.changelog
796 .as_ref()
797 .map(|p| p.ends_with("FIRST.md"))
798 .unwrap_or(false),
799 "First MATCHING Prefixed entry (npm/pkg) must win (AC-F2): expected changelog ending \
800 in FIRST.md. cargo/other skipped (name mismatch); pypi/pkg never reached. \
801 Got changelog: {:?}",
802 pcfg.changelog
803 );
804 }
805
806 #[test]
809 fn resolve_package_config_bare_rule_wins_via_pass_2_when_no_prefixed_matches() {
810 let tmp = tempfile::tempdir().expect("tempdir");
811 let root = tmp.path();
812 fs::write(
815 root.join("callisto.toml"),
816 "[[package]]\nmatch = \"cargo/other\"\nchangelog = \"WRONG.md\"\n\n[[package]]\nmatch = \"pkg\"\nrelease-trigger = \"auto\"\n",
817 )
818 .expect("write callisto.toml");
819 let cfg = load(root).expect("load should succeed");
820 let id = PackageId::parse("pkg").unwrap();
821 let pcfg = resolve_package_config(&id, &cfg)
822 .unwrap()
823 .expect("resolve_package_config must return Some via pass 2 for Bare(\"pkg\")");
824 assert_eq!(
825 pcfg.release_trigger,
826 Some(ReleaseTrigger::Auto),
827 "Bare rule must win via pass 2 (AC-F3): expected release_trigger = Some(Auto). \
828 Got: {:?}",
829 pcfg.release_trigger
830 );
831 }
832
833 #[test]
836 fn resolve_package_config_returns_none_when_no_rule_matches() {
837 let tmp = tempfile::tempdir().expect("tempdir");
838 let root = tmp.path();
839 fs::write(
841 root.join("callisto.toml"),
842 "[[package]]\nmatch = \"cargo/other\"\nchangelog = \"WRONG.md\"\n",
843 )
844 .expect("write callisto.toml");
845 let cfg = load(root).expect("load should succeed");
846 let id = PackageId::parse("pkg").unwrap();
847 let result = resolve_package_config(&id, &cfg).unwrap();
848 assert!(
849 result.is_none(),
850 "resolve_package_config must return None when no rule matches (AC-F4); got Some(...)"
851 );
852 }
853
854 #[test]
857 fn resolve_package_config_returns_none_for_empty_packages() {
858 let tmp = tempfile::tempdir().expect("tempdir");
859 let root = tmp.path();
860 fs::write(root.join("callisto.toml"), "").expect("write callisto.toml");
862 let cfg = load(root).expect("load should succeed");
863 assert!(
864 cfg.packages.is_empty(),
865 "fixture must produce an empty packages Vec (AC-F4b); got: {:?}",
866 cfg.packages
867 );
868 for id_str in &["pkg", "npm/pkg", "cargo/pkg"] {
869 let id = PackageId::parse(id_str).unwrap();
870 let result = resolve_package_config(&id, &cfg).unwrap();
871 assert!(
872 result.is_none(),
873 "resolve_package_config must return None for empty packages, \
874 id={id_str} (AC-F4b)"
875 );
876 }
877 }
878
879 #[test]
887 fn resolve_package_config_vec_declaration_order_not_alphabetical_sort() {
888 let tmp = tempfile::tempdir().expect("tempdir");
889 let root = tmp.path();
890 fs::write(
895 root.join("callisto.toml"),
896 "[[package]]\nmatch = \"npm/foo\"\nrelease-trigger = \"auto\"\n\n[[package]]\nmatch = \"cargo/foo\"\nrelease-trigger = \"changeset\"\n",
897 )
898 .expect("write callisto.toml");
899 let cfg = load(root).expect("load should succeed");
900 let id = PackageId::parse("foo").unwrap();
901 let pcfg = resolve_package_config(&id, &cfg)
902 .unwrap()
903 .expect("resolve_package_config must return Some for Bare(\"foo\")");
904 assert_eq!(
905 pcfg.release_trigger,
906 Some(ReleaseTrigger::Auto),
907 "npm/foo (declared first) must win over cargo/foo (declared second) — Vec order, \
908 not alphabetical sort, governs first-match-wins (AC-F5). \
909 A sorted implementation would incorrectly return Some(Changeset). Got: {:?}",
910 pcfg.release_trigger
911 );
912 }
913
914 #[test]
915 fn resolve_package_config_returns_ambiguous_name_for_unprefixed_rule_matching_two_promoted_siblings() {
916 let tmp = tempfile::tempdir().expect("tempdir");
917 let root = tmp.path();
918 fs::write(
919 root.join("callisto.toml"),
920 "[[package]]\nmatch = \"native-core\"\nrelease-trigger = \"auto\"\n",
921 )
922 .expect("write callisto.toml");
923 let mut cfg = load(root).expect("load should succeed");
924
925 let cargo_id = PackageId::Prefixed {
926 ecosystem: Ecosystem::Cargo,
927 name: "native-core".to_string(),
928 };
929 let npm_id = PackageId::Prefixed {
930 ecosystem: Ecosystem::Npm,
931 name: "native-core".to_string(),
932 };
933 let mut cargo_set = BTreeSet::new();
934 cargo_set.insert(Ecosystem::Cargo);
935 let mut npm_set = BTreeSet::new();
936 npm_set.insert(Ecosystem::Npm);
937 cfg.promoted_siblings.insert(
938 "native-core".to_string(),
939 vec![(cargo_id.clone(), cargo_set), (npm_id, npm_set)],
940 );
941
942 let err = resolve_package_config(&cargo_id, &cfg).unwrap_err();
943 match err {
944 GraphError::AmbiguousName { name, candidates } => {
945 assert_eq!(name, "native-core");
946 assert_eq!(candidates.len(), 2);
947 }
948 other => panic!("expected AmbiguousName, got {other:?}"),
949 }
950 }
951
952 #[test]
955 fn parse_publish_target_parses_all_known_variants() {
956 assert_eq!(parse_publish_target("crates-io").unwrap(), PublishTarget::CratesIo);
957 assert_eq!(
958 parse_publish_target("npm").unwrap(),
959 PublishTarget::Npm {
960 registry: None,
961 access: None
962 }
963 );
964 assert_eq!(
965 parse_publish_target("pypi").unwrap(),
966 PublishTarget::Pypi { index: None }
967 );
968 assert_eq!(
969 parse_publish_target("nuget").unwrap(),
970 PublishTarget::NuGet { source: None }
971 );
972 assert_eq!(
973 parse_publish_target("github-release").unwrap(),
974 PublishTarget::GitHubRelease
975 );
976 assert_eq!(parse_publish_target("none").unwrap(), PublishTarget::None);
977 }
978
979 #[test]
980 fn parse_publish_target_rejects_unknown_string() {
981 let result = parse_publish_target("bogus-registry");
982 assert!(result.is_err(), "expected Err for unknown publish-to value, got Ok");
983 assert!(
984 matches!(result.unwrap_err(), ConfigError::UnknownKey { .. }),
985 "expected UnknownKey error variant"
986 );
987 }
988
989 #[test]
990 fn parse_release_trigger_parses_both_known_variants() {
991 assert_eq!(parse_release_trigger("changeset").unwrap(), ReleaseTrigger::Changeset);
992 assert_eq!(parse_release_trigger("auto").unwrap(), ReleaseTrigger::Auto);
993 }
994
995 #[test]
996 fn parse_release_trigger_rejects_unknown_string() {
997 let result = parse_release_trigger("sometimes");
998 assert!(
999 result.is_err(),
1000 "expected Err for unknown release-trigger value, got Ok"
1001 );
1002 assert!(
1003 matches!(result.unwrap_err(), ConfigError::UnknownKey { .. }),
1004 "expected UnknownKey error variant"
1005 );
1006 }
1007
1008 #[test]
1009 fn parse_pre_major_policy_parses_all_known_variants() {
1010 assert_eq!(parse_pre_major_policy("off").unwrap(), PreMajorInferencePolicy::Off);
1011 assert_eq!(parse_pre_major_policy("false").unwrap(), PreMajorInferencePolicy::Off);
1012 assert_eq!(
1013 parse_pre_major_policy("conservative").unwrap(),
1014 PreMajorInferencePolicy::Conservative
1015 );
1016 assert_eq!(
1017 parse_pre_major_policy("conservative-feat").unwrap(),
1018 PreMajorInferencePolicy::ConservativeFeat
1019 );
1020 }
1021
1022 #[test]
1023 fn parse_pre_major_policy_rejects_unknown_string() {
1024 let result = parse_pre_major_policy("aggressive");
1025 assert!(
1026 matches!(result, Err(ConfigError::InvalidPreMajorInference { .. })),
1027 "expected InvalidPreMajorInference, got: {result:?}"
1028 );
1029 }
1030
1031 #[test]
1036 fn package_block_parses_npm_pypi_nuget_github_release_publish_targets() {
1037 let tmp = tempfile::tempdir().expect("tempdir");
1038 let root = tmp.path();
1039 fs::write(
1040 root.join("callisto.toml"),
1041 "[[package]]\nmatch = \"npm/pkg-a\"\npublish-to = [\"npm\"]\n\n\
1042 [[package]]\nmatch = \"pypi/pkg-b\"\npublish-to = [\"pypi\"]\n\n\
1043 [[package]]\nmatch = \"cargo/pkg-c\"\npublish-to = [\"nuget\"]\n\n\
1044 [[package]]\nmatch = \"cargo/pkg-d\"\npublish-to = [\"github-release\"]\n",
1045 )
1046 .expect("write callisto.toml");
1047
1048 let cfg = load(root).expect("load should succeed");
1049 assert_eq!(cfg.packages.len(), 4);
1050 assert_eq!(
1051 cfg.packages[0].1.publish_to,
1052 Some(vec![PublishTarget::Npm {
1053 registry: None,
1054 access: None
1055 }]),
1056 "npm/pkg-a publish-to = [\"npm\"] must resolve to PublishTarget::Npm"
1057 );
1058 assert_eq!(
1059 cfg.packages[1].1.publish_to,
1060 Some(vec![PublishTarget::Pypi { index: None }]),
1061 "pypi/pkg-b publish-to = [\"pypi\"] must resolve to PublishTarget::Pypi"
1062 );
1063 assert_eq!(
1064 cfg.packages[2].1.publish_to,
1065 Some(vec![PublishTarget::NuGet { source: None }]),
1066 "cargo/pkg-c publish-to = [\"nuget\"] must resolve to PublishTarget::NuGet"
1067 );
1068 assert_eq!(
1069 cfg.packages[3].1.publish_to,
1070 Some(vec![PublishTarget::GitHubRelease]),
1071 "cargo/pkg-d publish-to = [\"github-release\"] must resolve to PublishTarget::GitHubRelease"
1072 );
1073 }
1074
1075 #[test]
1079 fn package_block_rejects_unknown_publish_to_value() {
1080 let tmp = tempfile::tempdir().expect("tempdir");
1081 let root = tmp.path();
1082 fs::write(
1083 root.join("callisto.toml"),
1084 "[[package]]\nmatch = \"my-crate\"\npublish-to = [\"bogus\"]\n",
1085 )
1086 .expect("write callisto.toml");
1087
1088 let result = load(root);
1089 assert!(
1090 result.is_err(),
1091 "expected load() to reject unknown publish-to value in [[package]] block, got Ok"
1092 );
1093 let err = result.unwrap_err();
1094 assert!(
1095 matches!(&err, ConfigError::UnknownKey { key, .. } if key.contains("[[package]] publish-to")),
1096 "expected UnknownKey error mentioning '[[package]] publish-to', got: {err:?}"
1097 );
1098 }
1099
1100 #[test]
1103 fn package_block_rejects_unknown_release_trigger_value() {
1104 let tmp = tempfile::tempdir().expect("tempdir");
1105 let root = tmp.path();
1106 fs::write(
1107 root.join("callisto.toml"),
1108 "[[package]]\nmatch = \"my-crate\"\nrelease-trigger = \"sometimes\"\n",
1109 )
1110 .expect("write callisto.toml");
1111
1112 let result = load(root);
1113 assert!(
1114 result.is_err(),
1115 "expected load() to reject unknown release-trigger value in [[package]] block, got Ok"
1116 );
1117 assert!(
1118 matches!(result.unwrap_err(), ConfigError::UnknownKey { .. }),
1119 "expected UnknownKey error variant"
1120 );
1121 }
1122}