callisto-graph 0.3.3

Callisto Release Engine — Dependency DAG solver and topological cascade release planner.
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>,
    /// Per-package override rules in TOML declaration order.
    /// The first rule whose `PackageId` matches a discovered package wins.
    pub packages: Vec<(PackageId, PackageConfig)>,
    pub groups: GroupTable,
    /// Raw group declarations from `callisto.toml`, kept so that
    /// `Workspace::load` can call `GroupTable::resolve` once the
    /// `IdentityIndex` is available after `ManifestWalkResolver::build`.
    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>,
}

/// Per-package overrides from a `[[package]]` block in `callisto.toml`.
///
/// Every field is `Option<T>` — `None` means "not specified; use the package's default."
/// Only fields that the user explicitly set in the `[[package]]` block are `Some`.
#[derive(Clone, Debug)]
pub struct PackageConfig {
    pub release_trigger: Option<ReleaseTrigger>,
    pub publish_to: Option<Vec<PublishTarget>>,
    pub tag_template: Option<TagTemplate>,
    /// Changelog path relative to the package's own root directory.
    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");

    // Reject any changesets.dir value that contains '..' components — they
    // would allow load_changesets / atomic_write to escape the workspace root.
    // We check Path::components() rather than canonicalizing because the
    // directory may not exist yet (e.g. a fresh workspace).
    {
        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)?;

    // Resolve [[package]] blocks into per-package override rules.
    // Order is preserved: first matching rule wins during package construction.
    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:?}"
        );
    }
}