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}