callisto-graph 0.3.1

Dependency graph, cascade, aggregation, and config resolution for callisto.
Documentation
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: BTreeMap<PackageId, PackageConfig>,
    pub groups: GroupTable,
    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: ReleaseTrigger,
    pub publish_to: Vec<PublishTarget>,
    pub tag_template: Option<TagTemplate>,
    pub changelog: Option<PathBuf>,
    pub pre_major_inference: 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_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 = PathBuf::from(
        raw.changesets
            .as_ref()
            .and_then(|c| c.dir.as_deref())
            .unwrap_or(".changeset"),
    );

    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)?;

    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: BTreeMap::new(),
        groups: GroupTable::default(),
        provenance,
    })
}