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: Vec<(PackageId, PackageConfig)>,
24 pub groups: GroupTable,
25 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#[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 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 {
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 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}