callisto-graph 0.3.3

Callisto Release Engine — Dependency DAG solver and topological cascade release planner.
Documentation
use std::collections::{HashMap, HashSet};

use callisto_changelog::{ChangeSource, ChangelogEntry, ChangelogInput};
use callisto_model::{BumpReason, CommandRunner, GroupKind, Severity};

use crate::aggregate::aggregate;
use crate::cascade::{run_cascade, CascadeInput};
use crate::commands::escalate;
use crate::error::GraphError;
use crate::infer::SeverityInference;
use crate::plan::{PlannedBump, VersionPlan, VersionWriteTarget};
use crate::resolver::DependencyResolver;
use crate::Workspace;

#[derive(Clone, Debug, Default)]
pub struct VersionOptions {
    pub strict: bool,
    pub strict_graph: bool,
    pub allow_empty_changesets: bool,
}

pub fn plan_version<R: CommandRunner, D: DependencyResolver, I: SeverityInference>(
    ws: &Workspace<'_, R, D>,
    inference: &I,
    opts: &VersionOptions,
) -> Result<VersionPlan, GraphError> {
    let base_versions = ws.base_versions()?;

    let pre_path = ws.root.join(".changeset/pre.json");
    let pre_state = if pre_path.exists() {
        let text = std::fs::read_to_string(&pre_path).map_err(|e| GraphError::PreJsonRead {
            message: e.to_string(),
        })?;
        Some(callisto_format::parse_pre_json(&text).map_err(GraphError::PreJson)?)
    } else {
        None
    };

    let mut agg = aggregate(
        &ws.graph,
        &ws.config,
        ws.runner,
        ws.tags()?,
        &base_versions,
        pre_state.as_ref(),
        inference,
    )?;

    // For PreMode::Exit, inject a synthetic Patch severity for every package
    // that is currently at a pre-release version. This triggers bump_target
    // (which in Exit mode calls versioning.bump on the pre-release, stripping
    // the tag and finalizing to stable) even when no pending changeset files
    // exist on disk — they were consumed and deleted during the pre-release phase
    // and their stems recorded in pre.json.
    if let Some(ref pre) = pre_state {
        if pre.mode == callisto_format::PreMode::Exit {
            for (id, ver) in &base_versions {
                if ver.is_prerelease() {
                    agg.severities.entry(id.clone()).or_insert(Severity::Patch);
                    agg.reasons
                        .entry(id.clone())
                        .or_insert(BumpReason::PreRelease {
                            tag: pre.tag.clone(),
                        });
                }
            }
        }
    }

    let input = CascadeInput {
        graph: &ws.graph,
        groups: &ws.config.groups,
        cfg: &ws.config.cascade,
        seed: &agg.severities,
        reasons: &agg.reasons,
        named_by: &agg.named_by,
        base: &base_versions,
        pre: pre_state.as_ref(),
    };

    let outcome = run_cascade(input)?;

    let mut bumps = Vec::new();
    let mut changelog_writes = Vec::new();

    // PERF-006: build a map once so each lookup inside the loop is O(1)
    // instead of O(N) (the previous packages().find() call).
    let pkg_map: HashMap<_, _> = ws.graph.packages().map(|p| (&p.id, p)).collect();

    for (id, &sev) in &outcome.severities {
        if sev == Severity::None {
            continue;
        }
        let pkg = pkg_map.get(id).copied().unwrap();
        let from = base_versions.get(id).cloned().ok_or_else(|| {
            GraphError::Manifest(callisto_model::ManifestError::MissingField {
                path: pkg
                    .manifests
                    .first()
                    .map(|m| m.path.clone())
                    .unwrap_or_default(),
                field: "version",
            })
        })?;
        let to = outcome.targets.get(id).cloned().ok_or_else(|| {
            GraphError::Manifest(callisto_model::ManifestError::MissingField {
                path: pkg
                    .manifests
                    .first()
                    .map(|m| m.path.clone())
                    .unwrap_or_default(),
                field: "version",
            })
        })?;

        let mut writes = Vec::new();
        for decl in &pkg.manifests {
            if decl.role == callisto_model::ManifestRole::Canonical {
                writes.push(VersionWriteTarget::Manifest(decl.path.clone()));
            }
        }

        bumps.push(PlannedBump {
            package: id.clone(),
            from: from.clone(),
            to: to.clone(),
            severity: sev,
            governed_by: outcome.governed_by.get(id).cloned(),
            reason: outcome.reasons.get(id).cloned(),
            writes,
        });

        if let Some(ch_path) = &pkg.changelog {
            let input = if let Some(mut agg_input) = agg.changelog_inputs.get(id).cloned() {
                // Real changeset data: use it, but set the resolved target version.
                agg_input.from = from.clone();
                agg_input.to = Some(to.clone());
                agg_input
            } else {
                // No changeset drove this bump (cascade, group, inference, pre-release).
                // Synthesize a single entry describing the reason so that render_section()
                // never receives an empty entries list (which returns EmptyInput).
                let source = match outcome.reasons.get(id) {
                    Some(BumpReason::FixedGroupUnion { group }) => ChangeSource::GroupUnion {
                        group: group.clone(),
                        kind: GroupKind::Fixed,
                    },
                    Some(BumpReason::LinkedGroupUnion { group }) => ChangeSource::GroupUnion {
                        group: group.clone(),
                        kind: GroupKind::Linked,
                    },
                    Some(BumpReason::Cascade {
                        via,
                        dep_kind,
                        dependency_to,
                        ..
                    }) => ChangeSource::DependencyUpdate {
                        dependency: via.clone(),
                        dep_kind: *dep_kind,
                        to: dependency_to.clone(),
                    },
                    Some(BumpReason::PeerEscalation { via, .. }) => {
                        let dep_to = outcome
                            .targets
                            .get(via)
                            .cloned()
                            .unwrap_or_else(|| to.clone());
                        ChangeSource::PeerEscalation {
                            dependency: via.clone(),
                            to: dep_to,
                        }
                    }
                    _ => ChangeSource::Changeset {
                        filename: String::new(),
                        summary: format!(
                            "Version bump ({})",
                            match sev {
                                Severity::Major => "major",
                                Severity::Minor => "minor",
                                Severity::Patch => "patch",
                                Severity::None => "none",
                            }
                        ),
                    },
                };
                ChangelogInput {
                    package: id.clone(),
                    from: from.clone(),
                    to: Some(to.clone()),
                    entries: vec![ChangelogEntry {
                        severity: sev,
                        source,
                    }],
                }
            };
            changelog_writes.push(crate::plan::ChangelogWrite {
                changelog_path: ch_path.clone(),
                input,
            });
        }
    }

    let mut diagnostics = outcome.diagnostics;
    if agg.consumed.is_empty()
        && !opts.allow_empty_changesets
        && !ws.config.validation.allow_empty_changesets
    {
        diagnostics.push(callisto_model::Diagnostic {
            code: callisto_model::DiagnosticCode::EmptyChangeset,
            severity: callisto_model::DiagnosticSeverity::Warning,
            message: "No pending changesets found in workspace".to_string(),
            package: None,
            path: None,
            escalated_by: Some(callisto_model::StrictFlag::Strict),
            governed_by: Some(callisto_model::ConfigKey::VALIDATION_ALLOW_EMPTY_CHANGESETS),
        });
    }

    escalate(&mut diagnostics, opts.strict, opts.strict_graph);

    let (pre_state_update, delete_pre_json) = if let Some(mut state) = pre_state {
        if state.mode == callisto_format::PreMode::Exit {
            let rel_pre_path = ws.config.changesets_dir.join("pre.json");
            (None, Some(rel_pre_path))
        } else {
            // Update pre-release state changesets.
            // PERF-008: build an owned HashSet so each membership check is O(1)
            // instead of O(|state.changesets|); owned strings avoid the borrow
            // conflict that would arise from holding &str refs into the Vec
            // while also pushing to it.
            let seen: HashSet<String> = state.changesets.iter().cloned().collect();
            for cs in &agg.consumed {
                if let Some(name) = cs.file_name().and_then(|n| n.to_str()) {
                    let stem = name.strip_suffix(".md").unwrap_or(name).to_string();
                    if !seen.contains(&stem) {
                        state.changesets.push(stem);
                    }
                }
            }
            (Some(state), None)
        }
    } else {
        (None, None)
    };

    Ok(VersionPlan {
        bumps,
        rewrites: outcome.rewrites.into_values().collect(),
        platform_writes: Vec::new(),
        optional_dep_updates: Vec::new(),
        changelog_writes,
        consumed_changesets: agg.consumed,
        pre_state_update,
        delete_pre_json,
        pre_cursor_updates: Vec::new(),
        observed_versions: base_versions,
        diagnostics,
    })
}