use std::fs;
use std::path::{Path, PathBuf};
use callisto_manifests::{open, OpenContext, WorkspaceCargoResolver};
use callisto_model::{
ApplyPermit, CommandError, CommandRunner, LockfileRefreshResult, ManifestRole,
};
use crate::cascade::DepWriteTarget;
use crate::error::GraphError;
use crate::plan::{VersionPlan, VersionWriteTarget};
#[derive(Clone, Debug, Default)]
pub struct ApplyOptions {
pub refresh_lockfiles: bool,
}
#[derive(Clone, Debug, Default)]
pub struct ApplyOutcome {
pub lockfile_refresh_results: Option<Vec<LockfileRefreshResult>>,
pub staged: Vec<PathBuf>,
}
pub fn apply_version_plan<R: CommandRunner>(
root: &Path,
plan: &VersionPlan,
runner: &R,
_opts: &ApplyOptions,
permit: &ApplyPermit,
) -> Result<ApplyOutcome, GraphError> {
let mut outcome = ApplyOutcome::default();
let mut modified_paths = Vec::new();
let cargo_workspace = if root.join("Cargo.toml").exists() {
if let Ok(resolver) = WorkspaceCargoResolver::load(&root.join("Cargo.toml")) {
resolver.inheritance().ok().map(std::sync::Arc::new)
} else {
None
}
} else {
None
};
let npm_workspace_kind = callisto_manifests::detect_npm_workspace_kind(root)
.ok()
.flatten();
let ctx = OpenContext {
workspace_root: root,
cargo_workspace,
npm_workspace_kind,
};
for bump in &plan.bumps {
for write in &bump.writes {
match write {
VersionWriteTarget::Manifest(p) => {
let fmt = callisto_model::ManifestFormat::from_path(p)?;
let decl =
callisto_model::ManifestDecl::new(p.clone(), ManifestRole::Canonical, fmt)?;
let mut handle = open(&decl, &ctx)?;
handle.write_version(&bump.to, permit)?;
modified_paths.push(p.clone());
}
VersionWriteTarget::CargoWorkspacePackage { root_manifest } => {
let mut ws_res = WorkspaceCargoResolver::load(&root.join(root_manifest))?;
ws_res.write_version(&bump.to, permit)?;
modified_paths.push(root_manifest.clone());
}
}
}
}
for rewrite in &plan.rewrites {
match &rewrite.key.target {
DepWriteTarget::Manifest(p) => {
let fmt = callisto_model::ManifestFormat::from_path(p)?;
let decl =
callisto_model::ManifestDecl::new(p.clone(), ManifestRole::Canonical, fmt)?;
let mut handle = open(&decl, &ctx)?;
handle.update_dependency_spec(
&rewrite.key.name,
rewrite.key.kind.unwrap_or(callisto_model::DepKind::Runtime),
rewrite.to.clone(),
permit,
)?;
modified_paths.push(p.clone());
}
DepWriteTarget::CargoWorkspaceDependency { root_manifest } => {
let mut ws_res = WorkspaceCargoResolver::load(&root.join(root_manifest))?;
ws_res.write_dependency(&rewrite.key.name, rewrite.to.clone(), permit)?;
modified_paths.push(root_manifest.clone());
}
}
}
for cl in &plan.changelog_writes {
let rendered = callisto_changelog::render_section(&cl.input)?;
callisto_changelog::prepend(
root,
&cl.changelog_path,
&cl.input.package.display_name(),
&rendered,
permit,
)?;
modified_paths.push(cl.changelog_path.clone());
}
for cs_path in &plan.consumed_changesets {
let full = root.join(cs_path);
if full.exists() {
fs::remove_file(&full).map_err(|e| {
GraphError::Command(CommandError::Io {
program: "fs".to_string(),
message: e.to_string(),
})
})?;
modified_paths.push(cs_path.clone());
}
}
if let Some(ref pre_state) = plan.pre_state_update {
let default_dir = PathBuf::from(".changeset");
let pre_dir = plan
.consumed_changesets
.first()
.and_then(|p| p.parent())
.unwrap_or(&default_dir);
let rel_pre_path = pre_dir.join("pre.json");
let pre_path = root.join(&rel_pre_path);
let text = callisto_format::write_pre_json(pre_state);
callisto_manifests::atomic::atomic_write(&pre_path, &text, permit).map_err(|e| {
GraphError::Command(CommandError::Io {
program: "fs".to_string(),
message: e.to_string(),
})
})?;
modified_paths.push(rel_pre_path);
} else if let Some(rel_pre_path) = &plan.delete_pre_json {
let pre_path = root.join(rel_pre_path);
if pre_path.exists() {
fs::remove_file(&pre_path).map_err(|e| {
GraphError::Command(CommandError::Io {
program: "fs".to_string(),
message: e.to_string(),
})
})?;
modified_paths.push(rel_pre_path.clone());
}
}
use callisto_model::Ecosystem;
let active_ecosystems: std::collections::HashSet<Ecosystem> = plan
.bumps
.iter()
.filter_map(|b| {
if let Some(eco) = b.package.ecosystem() {
return Some(eco);
}
for write in &b.writes {
let eco = match write {
VersionWriteTarget::CargoWorkspacePackage { .. } => Ecosystem::Cargo,
VersionWriteTarget::Manifest(p) => {
match callisto_model::ManifestFormat::from_path(p) {
Ok(fmt) => fmt.ecosystem(),
Err(_) => continue,
}
}
};
return Some(eco);
}
None
})
.collect();
let lockfile_ecosystems: &[(&str, Ecosystem)] = &[
("Cargo.lock", Ecosystem::Cargo),
("package-lock.json", Ecosystem::Npm),
("pnpm-lock.yaml", Ecosystem::Npm),
("yarn.lock", Ecosystem::Npm),
("bun.lockb", Ecosystem::Npm),
("uv.lock", Ecosystem::Pypi),
("poetry.lock", Ecosystem::Pypi),
("pdm.lock", Ecosystem::Pypi),
("Pipfile.lock", Ecosystem::Pypi),
];
for (lockfile, ecosystem) in lockfile_ecosystems {
if !active_ecosystems.contains(ecosystem) {
continue;
}
let p = PathBuf::from(lockfile);
if root.join(&p).exists() && !modified_paths.contains(&p) {
modified_paths.push(p);
}
}
if !modified_paths.is_empty() {
let (existing, deleted): (Vec<_>, Vec<_>) =
modified_paths.iter().partition(|p| root.join(p).exists());
if !existing.is_empty() {
let mut args = vec!["add", "--"];
let strs: Vec<String> = existing.iter().map(|p| p.display().to_string()).collect();
for s in &strs {
args.push(s);
}
let output = runner.run("git", &args, root)?;
if !output.success() {
return Err(GraphError::Command(CommandError::Failed {
program: "git".to_string(),
exit_code: output.exit_code,
stderr: output.stderr,
}));
}
}
if !deleted.is_empty() {
let mut args = vec!["rm", "--cached", "--ignore-unmatch", "--"];
let strs: Vec<String> = deleted.iter().map(|p| p.display().to_string()).collect();
for s in &strs {
args.push(s);
}
let output = runner.run("git", &args, root)?;
if !output.success() {
return Err(GraphError::Command(CommandError::Failed {
program: "git".to_string(),
exit_code: output.exit_code,
stderr: output.stderr,
}));
}
}
outcome.staged = modified_paths;
}
Ok(outcome)
}
#[cfg(test)]
mod tests {
use std::path::Path;
use callisto_model::{
ApplyPermit, CommandError, CommandOutput, CommandRunner, PackageId, Severity, Version,
VersionGrammar,
};
use super::*;
use crate::plan::{PlannedBump, VersionPlan};
struct NoopRunner;
impl CommandRunner for NoopRunner {
fn run(
&self,
_program: &str,
_args: &[&str],
_cwd: &Path,
) -> Result<CommandOutput, CommandError> {
Ok(CommandOutput {
exit_code: Some(0),
stdout: String::new(),
stderr: String::new(),
})
}
}
fn cargo_version(v: &str) -> Version {
Version::parse(v, VersionGrammar::SemVer).expect("valid semver")
}
#[test]
fn cargo_only_bump_does_not_stage_python_lockfile() {
let dir = tempfile::tempdir().expect("create tempdir");
let root = dir.path();
std::fs::write(root.join("Cargo.lock"), "# fake Cargo.lock").unwrap();
std::fs::write(root.join("uv.lock"), "# fake uv.lock").unwrap();
let plan = VersionPlan {
bumps: vec![PlannedBump {
package: PackageId::parse("cargo:my-crate").expect("valid package id"),
from: cargo_version("1.0.0"),
to: cargo_version("1.1.0"),
severity: Severity::Minor,
governed_by: None,
reason: None,
writes: vec![], }],
..Default::default()
};
let permit = ApplyPermit::force_for_tests();
let opts = ApplyOptions::default();
let outcome = apply_version_plan(root, &plan, &NoopRunner, &opts, &permit)
.expect("apply_version_plan should succeed");
let staged_names: Vec<&str> = outcome.staged.iter().filter_map(|p| p.to_str()).collect();
assert!(
staged_names.contains(&"Cargo.lock"),
"Cargo.lock should be staged when a Cargo package is bumped, got: {staged_names:?}"
);
assert!(
!staged_names.contains(&"uv.lock"),
"uv.lock must NOT be staged when no Python package is bumped, got: {staged_names:?}"
);
}
}