Skip to main content

callisto_graph/config/
resolve.rs

1use std::collections::BTreeMap;
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use callisto_model::{
6    ConfigKey, Ecosystem, PackageId, PublishTarget, RegistryKey, ReleaseTrigger, Severity,
7    TagTemplate,
8};
9
10use crate::config::groups::{GroupTable, RawGroupTable};
11use crate::config::raw::RawConfig;
12use crate::error::ConfigError;
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 in TOML declaration order.
22    /// The first rule whose `PackageId` matches a discovered package wins.
23    pub packages: Vec<(PackageId, PackageConfig)>,
24    pub groups: GroupTable,
25    /// Raw group declarations from `callisto.toml`, kept so that
26    /// `Workspace::load` can call `GroupTable::resolve` once the
27    /// `IdentityIndex` is available after `ManifestWalkResolver::build`.
28    pub(crate) raw_groups: RawGroupTable,
29    provenance: BTreeMap<ConfigKey, ConfigProvenance>,
30}
31
32#[derive(Clone, Copy, Debug, PartialEq, Eq)]
33pub enum ConfigProvenance {
34    Default,
35    Explicit,
36}
37
38impl ResolvedConfig {
39    pub fn provenance(&self, key: &ConfigKey) -> ConfigProvenance {
40        self.provenance
41            .get(key)
42            .copied()
43            .unwrap_or(ConfigProvenance::Default)
44    }
45
46    pub fn rendered_value(&self, key: &ConfigKey) -> Option<String> {
47        if key == &ConfigKey::CASCADE_MODE {
48            Some(match self.cascade.mode {
49                CascadeMode::OutOfRange => "out-of-range".to_string(),
50                CascadeMode::Always => "always".to_string(),
51            })
52        } else if key == &ConfigKey::CASCADE_BUMP_SEVERITY {
53            Some(match self.cascade.bump_severity {
54                CascadeBumpSeverity::Patch => "patch".to_string(),
55                CascadeBumpSeverity::Minor => "minor".to_string(),
56            })
57        } else if key == &ConfigKey::CASCADE_PEER_ESCALATION {
58            Some(self.cascade.peer_escalation.to_string())
59        } else if key == &ConfigKey::CASCADE_PRESERVE_NPM_RANGES {
60            Some(self.cascade.preserve_npm_ranges.to_string())
61        } else if key == &ConfigKey::VALIDATION_ALLOW_EMPTY_CHANGESETS {
62            Some(self.validation.allow_empty_changesets.to_string())
63        } else {
64            None
65        }
66    }
67}
68
69#[derive(Clone, Copy, Debug, PartialEq, Eq)]
70pub struct CascadeConfig {
71    pub mode: CascadeMode,
72    pub bump_severity: CascadeBumpSeverity,
73    pub peer_escalation: bool,
74    pub preserve_npm_ranges: bool,
75}
76
77#[derive(Clone, Copy, Debug, PartialEq, Eq)]
78pub enum CascadeMode {
79    OutOfRange,
80    Always,
81}
82
83#[derive(Clone, Copy, Debug, PartialEq, Eq)]
84pub enum CascadeBumpSeverity {
85    Patch,
86    Minor,
87}
88
89impl CascadeBumpSeverity {
90    pub fn as_severity(self) -> Severity {
91        match self {
92            CascadeBumpSeverity::Patch => Severity::Patch,
93            CascadeBumpSeverity::Minor => Severity::Minor,
94        }
95    }
96}
97
98#[derive(Clone, Copy, Debug, PartialEq, Eq)]
99pub struct ValidationConfig {
100    pub allow_empty_changesets: bool,
101}
102
103#[derive(Clone, Debug)]
104pub struct RegistryConfig {
105    pub kind: Ecosystem,
106    pub url: Option<String>,
107}
108
109/// Per-package overrides from a `[[package]]` block in `callisto.toml`.
110///
111/// Every field is `Option<T>` — `None` means "not specified; use the package's default."
112/// Only fields that the user explicitly set in the `[[package]]` block are `Some`.
113#[derive(Clone, Debug)]
114pub struct PackageConfig {
115    pub release_trigger: Option<ReleaseTrigger>,
116    pub publish_to: Option<Vec<PublishTarget>>,
117    pub tag_template: Option<TagTemplate>,
118    /// Changelog path relative to the package's own root directory.
119    pub changelog: Option<PathBuf>,
120    pub pre_major_inference: Option<PreMajorInferencePolicy>,
121}
122
123#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
124pub struct PreMajorInferencePolicy {
125    pub breaking_to_minor: bool,
126    pub feat_to_patch: bool,
127}
128
129impl PreMajorInferencePolicy {
130    pub const OFF: Self = Self {
131        breaking_to_minor: false,
132        feat_to_patch: false,
133    };
134}
135
136pub fn parse_release_trigger(s: &str) -> Result<ReleaseTrigger, ConfigError> {
137    match s {
138        "changeset" => Ok(ReleaseTrigger::Changeset),
139        "auto" => Ok(ReleaseTrigger::Auto),
140        other => Err(ConfigError::UnknownKey {
141            path: PathBuf::new(),
142            key: format!("release-trigger = {other}"),
143        }),
144    }
145}
146
147pub fn parse_pre_major_policy(s: &str) -> Result<PreMajorInferencePolicy, ConfigError> {
148    match s {
149        "off" | "false" => Ok(PreMajorInferencePolicy::OFF),
150        "conservative" => Ok(PreMajorInferencePolicy {
151            breaking_to_minor: true,
152            feat_to_patch: false,
153        }),
154        "conservative-feat" => Ok(PreMajorInferencePolicy {
155            breaking_to_minor: true,
156            feat_to_patch: true,
157        }),
158        _ => Err(ConfigError::InvalidPreMajorInference {
159            found: s.to_string(),
160        }),
161    }
162}
163
164pub fn load(root: &Path) -> Result<ResolvedConfig, ConfigError> {
165    let callisto_toml = root.join("callisto.toml");
166    let raw = if callisto_toml.exists() {
167        let content = fs::read_to_string(&callisto_toml).map_err(|e| ConfigError::Read {
168            path: callisto_toml.clone(),
169            message: e.to_string(),
170        })?;
171        toml::from_str::<RawConfig>(&content).map_err(|e| ConfigError::ParseToml {
172            path: callisto_toml.clone(),
173            message: e.to_string(),
174        })?
175    } else {
176        RawConfig::default()
177    };
178
179    let mut provenance = BTreeMap::new();
180
181    let changesets_dir_str = raw
182        .changesets
183        .as_ref()
184        .and_then(|c| c.dir.as_deref())
185        .unwrap_or(".changeset");
186
187    // Reject any changesets.dir value that contains '..' components — they
188    // would allow load_changesets / atomic_write to escape the workspace root.
189    // We check Path::components() rather than canonicalizing because the
190    // directory may not exist yet (e.g. a fresh workspace).
191    {
192        use std::path::Component;
193        if PathBuf::from(changesets_dir_str)
194            .components()
195            .any(|c| c == Component::ParentDir)
196        {
197            return Err(ConfigError::InvalidChangesetsDir {
198                dir: changesets_dir_str.to_string(),
199            });
200        }
201    }
202
203    let changesets_dir = PathBuf::from(changesets_dir_str);
204
205    let cascade_raw = raw.cascade.unwrap_or_default();
206    let mode = match cascade_raw.mode.as_deref() {
207        Some("always") => {
208            provenance.insert(ConfigKey::CASCADE_MODE, ConfigProvenance::Explicit);
209            CascadeMode::Always
210        }
211        Some("out-of-range") | None => CascadeMode::OutOfRange,
212        Some(other) => {
213            return Err(ConfigError::UnknownKey {
214                path: callisto_toml,
215                key: format!("cascade.mode = {other}"),
216            })
217        }
218    };
219
220    let bump_severity = match cascade_raw.bump_severity.as_deref() {
221        Some("minor") => {
222            provenance.insert(ConfigKey::CASCADE_BUMP_SEVERITY, ConfigProvenance::Explicit);
223            CascadeBumpSeverity::Minor
224        }
225        Some("patch") | None => CascadeBumpSeverity::Patch,
226        Some(other) => {
227            return Err(ConfigError::InvalidBumpSeverity {
228                found: other.to_string(),
229            })
230        }
231    };
232
233    let peer_escalation = cascade_raw.peer_escalation.unwrap_or(true);
234    if cascade_raw.peer_escalation.is_some() {
235        provenance.insert(
236            ConfigKey::CASCADE_PEER_ESCALATION,
237            ConfigProvenance::Explicit,
238        );
239    }
240
241    let preserve_npm_ranges = cascade_raw.preserve_npm_ranges.unwrap_or(true);
242    if cascade_raw.preserve_npm_ranges.is_some() {
243        provenance.insert(
244            ConfigKey::CASCADE_PRESERVE_NPM_RANGES,
245            ConfigProvenance::Explicit,
246        );
247    }
248
249    let validation_raw = raw.validation.unwrap_or_default();
250    let allow_empty_changesets = validation_raw.allow_empty_changesets.unwrap_or(false);
251    if validation_raw.allow_empty_changesets.is_some() {
252        provenance.insert(
253            ConfigKey::VALIDATION_ALLOW_EMPTY_CHANGESETS,
254            ConfigProvenance::Explicit,
255        );
256    }
257
258    let mut registries = BTreeMap::new();
259    registries.insert(
260        RegistryKey(RegistryKey::CRATES_IO.to_string()),
261        RegistryConfig {
262            kind: Ecosystem::Cargo,
263            url: None,
264        },
265    );
266    registries.insert(
267        RegistryKey(RegistryKey::NPM.to_string()),
268        RegistryConfig {
269            kind: Ecosystem::Npm,
270            url: None,
271        },
272    );
273
274    if let Some(raw_regs) = raw.registries {
275        for (k_str, reg) in raw_regs {
276            let key = RegistryKey(k_str);
277            let kind = match reg.kind.as_deref() {
278                Some("cargo") => Ecosystem::Cargo,
279                Some("npm") => Ecosystem::Npm,
280                _ => Ecosystem::Npm,
281            };
282            registries.insert(key, RegistryConfig { kind, url: reg.url });
283        }
284    }
285
286    let raw_groups = RawGroupTable {
287        fixed: raw.fixed_group.unwrap_or_default(),
288        linked: raw.linked_group.unwrap_or_default(),
289    };
290    GroupTable::validate_syntactic(&raw_groups)?;
291
292    // Resolve [[package]] blocks into per-package override rules.
293    // Order is preserved: first matching rule wins during package construction.
294    let mut packages: Vec<(PackageId, PackageConfig)> = Vec::new();
295    for raw_pkg in raw.package.unwrap_or_default() {
296        let pattern = PackageId::parse(&raw_pkg.pattern).map_err(|e| ConfigError::UnknownKey {
297            path: callisto_toml.clone(),
298            key: format!("[[package]] match = {:?}: {e}", raw_pkg.pattern),
299        })?;
300
301        let release_trigger = raw_pkg
302            .release_trigger
303            .as_deref()
304            .map(parse_release_trigger)
305            .transpose()?;
306
307        let tag_template = raw_pkg
308            .tag_template
309            .as_deref()
310            .map(TagTemplate::parse)
311            .transpose()
312            .map_err(ConfigError::Tag)?;
313
314        let changelog = raw_pkg.changelog.as_deref().map(PathBuf::from);
315
316        let pre_major_inference = raw_pkg
317            .pre_major_inference
318            .as_deref()
319            .map(parse_pre_major_policy)
320            .transpose()?;
321
322        packages.push((
323            pattern,
324            PackageConfig {
325                release_trigger,
326                publish_to: None,
327                tag_template,
328                changelog,
329                pre_major_inference,
330            },
331        ));
332    }
333
334    Ok(ResolvedConfig {
335        root: root.to_path_buf(),
336        changesets_dir,
337        cascade: CascadeConfig {
338            mode,
339            bump_severity,
340            peer_escalation,
341            preserve_npm_ranges,
342        },
343        validation: ValidationConfig {
344            allow_empty_changesets,
345        },
346        registries,
347        packages,
348        groups: GroupTable::default(),
349        raw_groups,
350        provenance,
351    })
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357    use std::fs;
358
359    #[test]
360    fn test_config_resolve_rejects_traversal_in_changesets_dir() {
361        let tmp = tempfile::tempdir().expect("tempdir");
362        let root = tmp.path();
363        fs::write(
364            root.join("callisto.toml"),
365            "[changesets]\ndir = \"../../tmp\"\n",
366        )
367        .expect("write callisto.toml");
368
369        let result = load(root);
370        assert!(
371            result.is_err(),
372            "expected load() to fail for traversal changesets dir, got Ok"
373        );
374        let err = result.unwrap_err();
375        assert!(
376            matches!(err, ConfigError::InvalidChangesetsDir { .. }),
377            "expected InvalidChangesetsDir error, got: {err:?}"
378        );
379    }
380
381    #[test]
382    fn test_config_resolve_accepts_normal_changesets_dir() {
383        let tmp = tempfile::tempdir().expect("tempdir");
384        let root = tmp.path();
385        fs::write(
386            root.join("callisto.toml"),
387            "[changesets]\ndir = \".changeset\"\n",
388        )
389        .expect("write callisto.toml");
390
391        let result = load(root);
392        assert!(
393            result.is_ok(),
394            "expected load() to succeed for normal changesets dir, got: {result:?}"
395        );
396    }
397}