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    pub packages: BTreeMap<PackageId, PackageConfig>,
22    pub groups: GroupTable,
23    provenance: BTreeMap<ConfigKey, ConfigProvenance>,
24}
25
26#[derive(Clone, Copy, Debug, PartialEq, Eq)]
27pub enum ConfigProvenance {
28    Default,
29    Explicit,
30}
31
32impl ResolvedConfig {
33    pub fn provenance(&self, key: &ConfigKey) -> ConfigProvenance {
34        self.provenance
35            .get(key)
36            .copied()
37            .unwrap_or(ConfigProvenance::Default)
38    }
39
40    pub fn rendered_value(&self, key: &ConfigKey) -> Option<String> {
41        if key == &ConfigKey::CASCADE_MODE {
42            Some(match self.cascade.mode {
43                CascadeMode::OutOfRange => "out-of-range".to_string(),
44                CascadeMode::Always => "always".to_string(),
45            })
46        } else if key == &ConfigKey::CASCADE_BUMP_SEVERITY {
47            Some(match self.cascade.bump_severity {
48                CascadeBumpSeverity::Patch => "patch".to_string(),
49                CascadeBumpSeverity::Minor => "minor".to_string(),
50            })
51        } else if key == &ConfigKey::CASCADE_PEER_ESCALATION {
52            Some(self.cascade.peer_escalation.to_string())
53        } else if key == &ConfigKey::CASCADE_PRESERVE_NPM_RANGES {
54            Some(self.cascade.preserve_npm_ranges.to_string())
55        } else if key == &ConfigKey::VALIDATION_ALLOW_EMPTY_CHANGESETS {
56            Some(self.validation.allow_empty_changesets.to_string())
57        } else {
58            None
59        }
60    }
61}
62
63#[derive(Clone, Copy, Debug, PartialEq, Eq)]
64pub struct CascadeConfig {
65    pub mode: CascadeMode,
66    pub bump_severity: CascadeBumpSeverity,
67    pub peer_escalation: bool,
68    pub preserve_npm_ranges: bool,
69}
70
71#[derive(Clone, Copy, Debug, PartialEq, Eq)]
72pub enum CascadeMode {
73    OutOfRange,
74    Always,
75}
76
77#[derive(Clone, Copy, Debug, PartialEq, Eq)]
78pub enum CascadeBumpSeverity {
79    Patch,
80    Minor,
81}
82
83impl CascadeBumpSeverity {
84    pub fn as_severity(self) -> Severity {
85        match self {
86            CascadeBumpSeverity::Patch => Severity::Patch,
87            CascadeBumpSeverity::Minor => Severity::Minor,
88        }
89    }
90}
91
92#[derive(Clone, Copy, Debug, PartialEq, Eq)]
93pub struct ValidationConfig {
94    pub allow_empty_changesets: bool,
95}
96
97#[derive(Clone, Debug)]
98pub struct RegistryConfig {
99    pub kind: Ecosystem,
100    pub url: Option<String>,
101}
102
103#[derive(Clone, Debug)]
104pub struct PackageConfig {
105    pub release_trigger: ReleaseTrigger,
106    pub publish_to: Vec<PublishTarget>,
107    pub tag_template: Option<TagTemplate>,
108    pub changelog: Option<PathBuf>,
109    pub pre_major_inference: PreMajorInferencePolicy,
110}
111
112#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
113pub struct PreMajorInferencePolicy {
114    pub breaking_to_minor: bool,
115    pub feat_to_patch: bool,
116}
117
118impl PreMajorInferencePolicy {
119    pub const OFF: Self = Self {
120        breaking_to_minor: false,
121        feat_to_patch: false,
122    };
123}
124
125pub fn parse_pre_major_policy(s: &str) -> Result<PreMajorInferencePolicy, ConfigError> {
126    match s {
127        "off" | "false" => Ok(PreMajorInferencePolicy::OFF),
128        "conservative" => Ok(PreMajorInferencePolicy {
129            breaking_to_minor: true,
130            feat_to_patch: false,
131        }),
132        "conservative-feat" => Ok(PreMajorInferencePolicy {
133            breaking_to_minor: true,
134            feat_to_patch: true,
135        }),
136        _ => Err(ConfigError::InvalidPreMajorInference {
137            found: s.to_string(),
138        }),
139    }
140}
141
142pub fn load(root: &Path) -> Result<ResolvedConfig, ConfigError> {
143    let callisto_toml = root.join("callisto.toml");
144    let raw = if callisto_toml.exists() {
145        let content = fs::read_to_string(&callisto_toml).map_err(|e| ConfigError::Read {
146            path: callisto_toml.clone(),
147            message: e.to_string(),
148        })?;
149        toml::from_str::<RawConfig>(&content).map_err(|e| ConfigError::ParseToml {
150            path: callisto_toml.clone(),
151            message: e.to_string(),
152        })?
153    } else {
154        RawConfig::default()
155    };
156
157    let mut provenance = BTreeMap::new();
158
159    let changesets_dir = PathBuf::from(
160        raw.changesets
161            .as_ref()
162            .and_then(|c| c.dir.as_deref())
163            .unwrap_or(".changeset"),
164    );
165
166    let cascade_raw = raw.cascade.unwrap_or_default();
167    let mode = match cascade_raw.mode.as_deref() {
168        Some("always") => {
169            provenance.insert(ConfigKey::CASCADE_MODE, ConfigProvenance::Explicit);
170            CascadeMode::Always
171        }
172        Some("out-of-range") | None => CascadeMode::OutOfRange,
173        Some(other) => {
174            return Err(ConfigError::UnknownKey {
175                path: callisto_toml,
176                key: format!("cascade.mode = {other}"),
177            })
178        }
179    };
180
181    let bump_severity = match cascade_raw.bump_severity.as_deref() {
182        Some("minor") => {
183            provenance.insert(ConfigKey::CASCADE_BUMP_SEVERITY, ConfigProvenance::Explicit);
184            CascadeBumpSeverity::Minor
185        }
186        Some("patch") | None => CascadeBumpSeverity::Patch,
187        Some(other) => {
188            return Err(ConfigError::InvalidBumpSeverity {
189                found: other.to_string(),
190            })
191        }
192    };
193
194    let peer_escalation = cascade_raw.peer_escalation.unwrap_or(true);
195    if cascade_raw.peer_escalation.is_some() {
196        provenance.insert(
197            ConfigKey::CASCADE_PEER_ESCALATION,
198            ConfigProvenance::Explicit,
199        );
200    }
201
202    let preserve_npm_ranges = cascade_raw.preserve_npm_ranges.unwrap_or(true);
203    if cascade_raw.preserve_npm_ranges.is_some() {
204        provenance.insert(
205            ConfigKey::CASCADE_PRESERVE_NPM_RANGES,
206            ConfigProvenance::Explicit,
207        );
208    }
209
210    let validation_raw = raw.validation.unwrap_or_default();
211    let allow_empty_changesets = validation_raw.allow_empty_changesets.unwrap_or(false);
212    if validation_raw.allow_empty_changesets.is_some() {
213        provenance.insert(
214            ConfigKey::VALIDATION_ALLOW_EMPTY_CHANGESETS,
215            ConfigProvenance::Explicit,
216        );
217    }
218
219    let mut registries = BTreeMap::new();
220    registries.insert(
221        RegistryKey(RegistryKey::CRATES_IO.to_string()),
222        RegistryConfig {
223            kind: Ecosystem::Cargo,
224            url: None,
225        },
226    );
227    registries.insert(
228        RegistryKey(RegistryKey::NPM.to_string()),
229        RegistryConfig {
230            kind: Ecosystem::Npm,
231            url: None,
232        },
233    );
234
235    if let Some(raw_regs) = raw.registries {
236        for (k_str, reg) in raw_regs {
237            let key = RegistryKey(k_str);
238            let kind = match reg.kind.as_deref() {
239                Some("cargo") => Ecosystem::Cargo,
240                Some("npm") => Ecosystem::Npm,
241                _ => Ecosystem::Npm,
242            };
243            registries.insert(key, RegistryConfig { kind, url: reg.url });
244        }
245    }
246
247    let raw_groups = RawGroupTable {
248        fixed: raw.fixed_group.unwrap_or_default(),
249        linked: raw.linked_group.unwrap_or_default(),
250    };
251    GroupTable::validate_syntactic(&raw_groups)?;
252
253    Ok(ResolvedConfig {
254        root: root.to_path_buf(),
255        changesets_dir,
256        cascade: CascadeConfig {
257            mode,
258            bump_severity,
259            peer_escalation,
260            preserve_npm_ranges,
261        },
262        validation: ValidationConfig {
263            allow_empty_changesets,
264        },
265        registries,
266        packages: BTreeMap::new(),
267        groups: GroupTable::default(),
268        provenance,
269    })
270}