use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use callisto_model::{
ConfigKey, Ecosystem, PackageId, PublishTarget, RegistryKey, ReleaseTrigger, Severity,
TagTemplate,
};
use crate::config::groups::{GroupTable, RawGroupTable};
use crate::config::raw::RawConfig;
use crate::error::ConfigError;
#[derive(Clone, Debug)]
pub struct ResolvedConfig {
pub root: PathBuf,
pub changesets_dir: PathBuf,
pub cascade: CascadeConfig,
pub validation: ValidationConfig,
pub registries: BTreeMap<RegistryKey, RegistryConfig>,
pub packages: Vec<(PackageId, PackageConfig)>,
pub groups: GroupTable,
pub(crate) raw_groups: RawGroupTable,
provenance: BTreeMap<ConfigKey, ConfigProvenance>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ConfigProvenance {
Default,
Explicit,
}
impl ResolvedConfig {
pub fn provenance(&self, key: &ConfigKey) -> ConfigProvenance {
self.provenance
.get(key)
.copied()
.unwrap_or(ConfigProvenance::Default)
}
pub fn rendered_value(&self, key: &ConfigKey) -> Option<String> {
if key == &ConfigKey::CASCADE_MODE {
Some(match self.cascade.mode {
CascadeMode::OutOfRange => "out-of-range".to_string(),
CascadeMode::Always => "always".to_string(),
})
} else if key == &ConfigKey::CASCADE_BUMP_SEVERITY {
Some(match self.cascade.bump_severity {
CascadeBumpSeverity::Patch => "patch".to_string(),
CascadeBumpSeverity::Minor => "minor".to_string(),
})
} else if key == &ConfigKey::CASCADE_PEER_ESCALATION {
Some(self.cascade.peer_escalation.to_string())
} else if key == &ConfigKey::CASCADE_PRESERVE_NPM_RANGES {
Some(self.cascade.preserve_npm_ranges.to_string())
} else if key == &ConfigKey::VALIDATION_ALLOW_EMPTY_CHANGESETS {
Some(self.validation.allow_empty_changesets.to_string())
} else {
None
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct CascadeConfig {
pub mode: CascadeMode,
pub bump_severity: CascadeBumpSeverity,
pub peer_escalation: bool,
pub preserve_npm_ranges: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CascadeMode {
OutOfRange,
Always,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CascadeBumpSeverity {
Patch,
Minor,
}
impl CascadeBumpSeverity {
pub fn as_severity(self) -> Severity {
match self {
CascadeBumpSeverity::Patch => Severity::Patch,
CascadeBumpSeverity::Minor => Severity::Minor,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ValidationConfig {
pub allow_empty_changesets: bool,
}
#[derive(Clone, Debug)]
pub struct RegistryConfig {
pub kind: Ecosystem,
pub url: Option<String>,
}
#[derive(Clone, Debug)]
pub struct PackageConfig {
pub release_trigger: Option<ReleaseTrigger>,
pub publish_to: Option<Vec<PublishTarget>>,
pub tag_template: Option<TagTemplate>,
pub changelog: Option<PathBuf>,
pub pre_major_inference: Option<PreMajorInferencePolicy>,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct PreMajorInferencePolicy {
pub breaking_to_minor: bool,
pub feat_to_patch: bool,
}
impl PreMajorInferencePolicy {
pub const OFF: Self = Self {
breaking_to_minor: false,
feat_to_patch: false,
};
}
pub fn parse_release_trigger(s: &str) -> Result<ReleaseTrigger, ConfigError> {
match s {
"changeset" => Ok(ReleaseTrigger::Changeset),
"auto" => Ok(ReleaseTrigger::Auto),
other => Err(ConfigError::UnknownKey {
path: PathBuf::new(),
key: format!("release-trigger = {other}"),
}),
}
}
pub fn parse_pre_major_policy(s: &str) -> Result<PreMajorInferencePolicy, ConfigError> {
match s {
"off" | "false" => Ok(PreMajorInferencePolicy::OFF),
"conservative" => Ok(PreMajorInferencePolicy {
breaking_to_minor: true,
feat_to_patch: false,
}),
"conservative-feat" => Ok(PreMajorInferencePolicy {
breaking_to_minor: true,
feat_to_patch: true,
}),
_ => Err(ConfigError::InvalidPreMajorInference {
found: s.to_string(),
}),
}
}
pub fn load(root: &Path) -> Result<ResolvedConfig, ConfigError> {
let callisto_toml = root.join("callisto.toml");
let raw = if callisto_toml.exists() {
let content = fs::read_to_string(&callisto_toml).map_err(|e| ConfigError::Read {
path: callisto_toml.clone(),
message: e.to_string(),
})?;
toml::from_str::<RawConfig>(&content).map_err(|e| ConfigError::ParseToml {
path: callisto_toml.clone(),
message: e.to_string(),
})?
} else {
RawConfig::default()
};
let mut provenance = BTreeMap::new();
let changesets_dir_str = raw
.changesets
.as_ref()
.and_then(|c| c.dir.as_deref())
.unwrap_or(".changeset");
{
use std::path::Component;
if PathBuf::from(changesets_dir_str)
.components()
.any(|c| c == Component::ParentDir)
{
return Err(ConfigError::InvalidChangesetsDir {
dir: changesets_dir_str.to_string(),
});
}
}
let changesets_dir = PathBuf::from(changesets_dir_str);
let cascade_raw = raw.cascade.unwrap_or_default();
let mode = match cascade_raw.mode.as_deref() {
Some("always") => {
provenance.insert(ConfigKey::CASCADE_MODE, ConfigProvenance::Explicit);
CascadeMode::Always
}
Some("out-of-range") | None => CascadeMode::OutOfRange,
Some(other) => {
return Err(ConfigError::UnknownKey {
path: callisto_toml,
key: format!("cascade.mode = {other}"),
})
}
};
let bump_severity = match cascade_raw.bump_severity.as_deref() {
Some("minor") => {
provenance.insert(ConfigKey::CASCADE_BUMP_SEVERITY, ConfigProvenance::Explicit);
CascadeBumpSeverity::Minor
}
Some("patch") | None => CascadeBumpSeverity::Patch,
Some(other) => {
return Err(ConfigError::InvalidBumpSeverity {
found: other.to_string(),
})
}
};
let peer_escalation = cascade_raw.peer_escalation.unwrap_or(true);
if cascade_raw.peer_escalation.is_some() {
provenance.insert(
ConfigKey::CASCADE_PEER_ESCALATION,
ConfigProvenance::Explicit,
);
}
let preserve_npm_ranges = cascade_raw.preserve_npm_ranges.unwrap_or(true);
if cascade_raw.preserve_npm_ranges.is_some() {
provenance.insert(
ConfigKey::CASCADE_PRESERVE_NPM_RANGES,
ConfigProvenance::Explicit,
);
}
let validation_raw = raw.validation.unwrap_or_default();
let allow_empty_changesets = validation_raw.allow_empty_changesets.unwrap_or(false);
if validation_raw.allow_empty_changesets.is_some() {
provenance.insert(
ConfigKey::VALIDATION_ALLOW_EMPTY_CHANGESETS,
ConfigProvenance::Explicit,
);
}
let mut registries = BTreeMap::new();
registries.insert(
RegistryKey(RegistryKey::CRATES_IO.to_string()),
RegistryConfig {
kind: Ecosystem::Cargo,
url: None,
},
);
registries.insert(
RegistryKey(RegistryKey::NPM.to_string()),
RegistryConfig {
kind: Ecosystem::Npm,
url: None,
},
);
if let Some(raw_regs) = raw.registries {
for (k_str, reg) in raw_regs {
let key = RegistryKey(k_str);
let kind = match reg.kind.as_deref() {
Some("cargo") => Ecosystem::Cargo,
Some("npm") => Ecosystem::Npm,
_ => Ecosystem::Npm,
};
registries.insert(key, RegistryConfig { kind, url: reg.url });
}
}
let raw_groups = RawGroupTable {
fixed: raw.fixed_group.unwrap_or_default(),
linked: raw.linked_group.unwrap_or_default(),
};
GroupTable::validate_syntactic(&raw_groups)?;
let mut packages: Vec<(PackageId, PackageConfig)> = Vec::new();
for raw_pkg in raw.package.unwrap_or_default() {
let pattern = PackageId::parse(&raw_pkg.pattern).map_err(|e| ConfigError::UnknownKey {
path: callisto_toml.clone(),
key: format!("[[package]] match = {:?}: {e}", raw_pkg.pattern),
})?;
let release_trigger = raw_pkg
.release_trigger
.as_deref()
.map(parse_release_trigger)
.transpose()?;
let tag_template = raw_pkg
.tag_template
.as_deref()
.map(TagTemplate::parse)
.transpose()
.map_err(ConfigError::Tag)?;
let changelog = raw_pkg.changelog.as_deref().map(PathBuf::from);
let pre_major_inference = raw_pkg
.pre_major_inference
.as_deref()
.map(parse_pre_major_policy)
.transpose()?;
packages.push((
pattern,
PackageConfig {
release_trigger,
publish_to: None,
tag_template,
changelog,
pre_major_inference,
},
));
}
Ok(ResolvedConfig {
root: root.to_path_buf(),
changesets_dir,
cascade: CascadeConfig {
mode,
bump_severity,
peer_escalation,
preserve_npm_ranges,
},
validation: ValidationConfig {
allow_empty_changesets,
},
registries,
packages,
groups: GroupTable::default(),
raw_groups,
provenance,
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn test_config_resolve_rejects_traversal_in_changesets_dir() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path();
fs::write(
root.join("callisto.toml"),
"[changesets]\ndir = \"../../tmp\"\n",
)
.expect("write callisto.toml");
let result = load(root);
assert!(
result.is_err(),
"expected load() to fail for traversal changesets dir, got Ok"
);
let err = result.unwrap_err();
assert!(
matches!(err, ConfigError::InvalidChangesetsDir { .. }),
"expected InvalidChangesetsDir error, got: {err:?}"
);
}
#[test]
fn test_config_resolve_accepts_normal_changesets_dir() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path();
fs::write(
root.join("callisto.toml"),
"[changesets]\ndir = \".changeset\"\n",
)
.expect("write callisto.toml");
let result = load(root);
assert!(
result.is_ok(),
"expected load() to succeed for normal changesets dir, got: {result:?}"
);
}
}