Skip to main content

callisto_graph/config/
resolve.rs

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    /// Per-package override rules from `[[package]]` blocks, in TOML declaration order.
22    ///
23    /// Lookup uses two-pass specificity (see `resolve_package_config`):
24    /// - Pass 1 (Prefixed tier): the first entry whose `rule_id.ecosystem().is_some()` AND
25    ///   `rule_id.matches(pkg_id)` is true wins, regardless of its position relative to Bare rules.
26    /// - Pass 2 (Bare tier): only if Pass 1 finds nothing, the first entry where
27    ///   `rule_id.matches(pkg_id)` is true wins.
28    ///   Within each tier, first-match-wins in TOML declaration order.
29    pub packages: Vec<(PackageId, PackageConfig)>,
30    /// Bulk config-override rules from `[[package-set]]` blocks, in TOML declaration order.
31    /// Applied as a fallback when no `[[package]]` rule matches a package.
32    /// Unlike `[[package]]` (exact `PackageId` match, first-wins), each
33    /// `[[package-set]]` rule uses a glob pattern and can match many packages simultaneously.
34    pub package_sets: Vec<(PackagePattern, PackageConfig)>,
35    pub groups: GroupTable,
36    /// Raw group declarations from `callisto.toml`, kept so that
37    /// `Workspace::load` can call `GroupTable::resolve` once the
38    /// `IdentityIndex` is available after `ManifestWalkResolver::build`.
39    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/// Per-package overrides from a `[[package]]` block in `callisto.toml`.
128///
129/// Every field is `Option<T>` — `None` means "not specified; use the package's default."
130/// Only fields that the user explicitly set in the `[[package]]` block are `Some`.
131#[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    /// Changelog path relative to the package's own root directory.
137    pub changelog: Option<PathBuf>,
138    pub pre_major_inference: Option<PreMajorInferencePolicy>,
139}
140
141/// Pre-1.0 (`0.y.z`) severity-downgrade policy. A closed 3-state choice --
142/// modeled as an enum rather than two independent bools so the type system
143/// rules out the unreachable-via-parser 4th combination a `{breaking_to_minor:
144/// false, feat_to_patch: true}`-shaped struct literal could otherwise
145/// construct.
146#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
147pub enum PreMajorInferencePolicy {
148    #[default]
149    Off,
150    /// Downgrades an inferred Major bump to Minor.
151    Conservative,
152    /// Downgrades an inferred Major bump to Minor, and Minor to Patch.
153    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
185/// Two-pass `[[package]]` rule lookup with Prefixed-over-Bare specificity.
186///
187/// Pass 1: iterate `cfg.packages` in TOML-declaration order, return the
188/// first entry where `rule_id.ecosystem().is_some()` AND
189/// `rule_id.matches(id)` -- prefixed rules (`cargo/`, `npm/`, `pypi/`)
190/// always beat bare ones regardless of declaration order.
191///
192/// Pass 2 (only if Pass 1 found nothing): same order, first entry where
193/// `rule_id.matches(id)` with no ecosystem restriction.
194///
195/// `None` if neither pass matches.
196///
197/// MUST NOT sort or partition `cfg.packages` -- specificity comes solely
198/// from two separate linear scans over the unmodified slice.
199pub(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    // Reject any changesets.dir value that is absolute or contains '..'
255    // components — either would allow load_changesets / atomic_write to
256    // escape the workspace root. We check components() rather than
257    // canonicalizing because the directory may not exist yet (e.g. a fresh
258    // workspace).
259    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    // Resolve [[package]] blocks into per-package override rules.
349    // Order is preserved: first matching rule wins during package construction.
350    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    // Resolve [[package-set]] blocks into bulk config-override rules.
418    // Semantics: each rule matches ALL packages whose PackageId matches the pattern.
419    // A [[package]] rule takes priority; [[package-set]] is the fallback (see walk.rs).
420    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        // "cascade_mode" is a common typo for [cascade] mode = "always"
667        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    /// AC-F1: Bare rule declared first, Prefixed rule declared second.
746    /// Both rules set release_trigger with different values so the winner is directly observable.
747    /// Bare foo: release-trigger = "auto" -> release_trigger = Some(Auto).
748    /// Prefixed npm/foo: release-trigger = "changeset" -> release_trigger = Some(Changeset).
749    /// Pass 1 finds npm/foo first (ecosystem is Some, name matches) -> result must be Some(Changeset).
750    /// If the Bare rule incorrectly won: release_trigger == Some(Auto).
751    #[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    /// AC-F2: Three-rule scenario. First Prefixed rule matches a different name.
775    /// Pass 1 skips cargo/other (name mismatch), finds npm/pkg as the first Prefixed entry
776    /// whose name matches, and returns it. The third entry (pypi/pkg) is never reached.
777    #[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        // cargo/other: Prefixed but name "other" != "pkg" — skipped in pass 1
782        // npm/pkg:     Prefixed, name matches — wins: changelog = "FIRST.md"
783        // pypi/pkg:    Prefixed, name matches — never reached
784        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    /// AC-F3: No Prefixed rule matches "pkg". Pass 1 finds nothing.
807    /// Pass 2 finds the Bare "pkg" entry and returns it.
808    #[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        // cargo/other: Prefixed but name "other" != "pkg" — pass 1 skips, pass 2 skips
813        // pkg (Bare):  pass 1 skips (ecosystem() is None), pass 2 finds it — wins
814        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    /// AC-F4: No rule matches "pkg" at all.
834    /// Pass 1 and pass 2 both iterate zero matching entries; result is None.
835    #[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        // Only a Prefixed rule for "other" — neither pass finds a match for "pkg".
840        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    /// AC-F4b: cfg.packages is empty. Both passes iterate zero entries.
855    /// Every query returns None regardless of the id argument.
856    #[test]
857    fn resolve_package_config_returns_none_for_empty_packages() {
858        let tmp = tempfile::tempdir().expect("tempdir");
859        let root = tmp.path();
860        // No [[package]] sections at all — packages Vec is empty.
861        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    /// AC-F5: Two Prefixed rules — npm/foo declared first, cargo/foo declared second in Vec order.
880    /// Both rules set release_trigger with different values so the winner is directly observable.
881    /// npm/foo (first): release-trigger = "auto" -> release_trigger = Some(Auto).
882    /// cargo/foo (second): release-trigger = "changeset" -> release_trigger = Some(Changeset).
883    /// Pass 1 returns the first matching Prefixed entry in Vec (TOML declaration) order.
884    /// Vec order must govern: npm/foo is first -> release_trigger must be Some(Auto).
885    /// A sorted implementation (cargo < npm alphabetically) would return Some(Changeset).
886    #[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        // npm/foo FIRST in Vec: release-trigger = "auto"
891        // cargo/foo SECOND in Vec: release-trigger = "changeset"
892        // Querying Bare("foo"): pass 1 returns npm/foo (first Prefixed match) -> Some(Auto).
893        // A sorted implementation would return cargo/foo (cargo < npm) -> Some(Changeset).
894        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    // --- parse_publish_target / parse_release_trigger direct coverage ------
953
954    #[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    /// End-to-end: `[[package]] publish-to = ["npm"|"pypi"|"nuget"|"github-release"]`
1032    /// must actually reach `PackageConfig.publish_to` through the full TOML load path,
1033    /// not just through calling `parse_publish_target` directly. Previously only
1034    /// "none"/"crates-io" were ever exercised through `load()`.
1035    #[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    /// An unknown `publish-to` value inside a `[[package]]` block must fail
1076    /// `load()` with a `[[package]] publish-to: ...`-prefixed `UnknownKey`
1077    /// error, not be silently dropped or panic.
1078    #[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    /// An unknown `release-trigger` value inside a `[[package]]` block must
1101    /// fail `load()` rather than be silently dropped.
1102    #[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}