use std::cell::RefCell;
use std::io;
use std::path::{Path, PathBuf};
use super::*;
use crate::ports::{Clock, CommandOutput, CommandRunner, RegistryQuery};
use crate::protocol::plan::{BumpLevel, BumpPlan, PinRewrite};
use crate::release::adapters::{EffectCtx, EMPTY_ARTIFACTS};
struct FakeRunner {
calls: RefCell<Vec<String>>,
bump_commit: String,
fail_substr: Option<String>,
hook_fail: bool,
hook_effect: Option<HookEffect>,
}
type HookEffect = Box<dyn Fn(&Path)>;
impl FakeRunner {
fn new(bump_commit: &str) -> Self {
Self {
calls: RefCell::new(Vec::new()),
bump_commit: bump_commit.to_string(),
fail_substr: None,
hook_fail: false,
hook_effect: None,
}
}
}
impl CommandRunner for FakeRunner {
fn run(&self, program: &str, args: &[&str], cwd: &Path) -> io::Result<CommandOutput> {
let line = format!("{program} {}", args.join(" "));
self.calls.borrow_mut().push(line.clone());
if let Some(sub) = &self.fail_substr {
if line.contains(sub.as_str()) {
return Ok(CommandOutput {
status: Some(1),
stdout: String::new(),
stderr: "boom".into(),
});
}
}
if program == "sh" {
if let Some(effect) = &self.hook_effect {
effect(cwd);
}
return Ok(CommandOutput {
status: Some(i32::from(self.hook_fail)),
stdout: String::new(),
stderr: if self.hook_fail {
"hook boom".into()
} else {
String::new()
},
});
}
if program == "git" && args == ["rev-parse", "HEAD"] {
return Ok(CommandOutput {
status: Some(0),
stdout: format!("{}\n", self.bump_commit),
stderr: String::new(),
});
}
Ok(CommandOutput {
status: Some(0),
stdout: String::new(),
stderr: String::new(),
})
}
}
struct FakeClock;
impl Clock for FakeClock {
fn now_unix(&self) -> u64 {
0
}
}
struct NoRegistry;
impl RegistryQuery for NoRegistry {
fn published_versions(&self, _e: &str, _p: &str) -> io::Result<Vec<String>> {
Ok(vec![])
}
}
fn temp_workspace() -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"ossctl-bump-test-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos())
));
std::fs::create_dir_all(dir.join("crates/core")).unwrap();
std::fs::create_dir_all(dir.join("crates/cli")).unwrap();
std::fs::write(
dir.join("Cargo.toml"),
"[workspace]\nresolver = \"2\"\nmembers = [\"crates/core\", \"crates/cli\"]\n\n[workspace.package]\nversion = \"0.4.0\"\nedition = \"2021\"\n",
)
.unwrap();
std::fs::write(
dir.join("crates/core/Cargo.toml"),
"[package]\nname = \"acme-core\"\nversion.workspace = true\n",
)
.unwrap();
std::fs::write(
dir.join("crates/cli/Cargo.toml"),
"[package]\nname = \"acme\"\nversion.workspace = true\n\n[dependencies]\nacme-core = { path = \"../core\", version = \"=0.4.0\" }\n",
)
.unwrap();
std::fs::write(
dir.join("CHANGELOG.md"),
"# Changelog\n\n## [Unreleased]\n### Added\n- a feature\n",
)
.unwrap();
std::fs::write(
dir.join("Cargo.lock"),
"# auto\n[[package]]\nname = \"acme-core\"\nversion = \"0.4.0\"\n",
)
.unwrap();
dir
}
fn bump_plan() -> BumpPlan {
BumpPlan {
level: BumpLevel::Minor,
from_version: "0.4.0".into(),
to_version: "0.5.0".into(),
pin_rewrites: vec![PinRewrite {
in_package: "acme".into(),
dependency: "acme-core".into(),
from: "=0.4.0".into(),
to: "=0.5.0".into(),
}],
changelog_finalize: true,
bump_hook: None,
}
}
fn ctx<'a>(
runner: &'a FakeRunner,
clock: &'a FakeClock,
reg: &'a NoRegistry,
root: &'a Path,
) -> EffectCtx<'a> {
EffectCtx {
runner,
clock,
registry: reg,
repo_root: root,
artifacts: &EMPTY_ARTIFACTS,
}
}
#[test]
fn applies_version_pin_changelog_and_commits() {
let dir = temp_workspace();
let runner = FakeRunner::new("abc123def456");
let (clock, reg) = (FakeClock, NoRegistry);
let ctx = ctx(&runner, &clock, ®, &dir);
let outcome = apply_bump(&ctx, &bump_plan(), "2026-08-13").unwrap();
assert_eq!(outcome.commit, "abc123def456");
assert_eq!(outcome.effective_date, "2026-08-13");
let root = std::fs::read_to_string(dir.join("Cargo.toml")).unwrap();
assert!(
root.contains("version = \"0.5.0\""),
"workspace version bumped: {root}"
);
let cli = std::fs::read_to_string(dir.join("crates/cli/Cargo.toml")).unwrap();
assert!(cli.contains("version = \"=0.5.0\""), "pin rewritten: {cli}");
let changelog = std::fs::read_to_string(dir.join("CHANGELOG.md")).unwrap();
assert!(
changelog.contains("## [Unreleased]\n\n## [0.5.0] - 2026-08-13"),
"changelog finalized: {changelog}"
);
let calls = runner.calls.borrow();
assert!(calls.iter().any(|c| c == "cargo update --workspace"));
assert!(calls
.iter()
.any(|c| c.starts_with("git commit -m release: v0.5.0")));
assert!(
!calls.iter().any(|c| c.starts_with("git push")),
"the executor must not push the branch: {calls:?}"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn skips_the_lockfile_refresh_when_no_lockfile_is_tracked() {
let dir = temp_workspace();
std::fs::remove_file(dir.join("Cargo.lock")).unwrap();
let runner = FakeRunner::new("abc");
let (clock, reg) = (FakeClock, NoRegistry);
let ctx = ctx(&runner, &clock, ®, &dir);
apply_bump(&ctx, &bump_plan(), "2026-08-13").unwrap();
assert!(
!runner
.calls
.borrow()
.iter()
.any(|c| c == "cargo update --workspace"),
"no lockfile ⇒ no refresh: {:?}",
runner.calls.borrow()
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn fails_closed_on_a_missing_pin() {
let dir = temp_workspace();
std::fs::write(
dir.join("crates/cli/Cargo.toml"),
"[package]\nname = \"acme\"\n\n[dependencies]\nacme-core = { path = \"../core\", version = \"^0.4\" }\n",
)
.unwrap();
let runner = FakeRunner::new("abc");
let (clock, reg) = (FakeClock, NoRegistry);
let ctx = ctx(&runner, &clock, ®, &dir);
let err = apply_bump(&ctx, &bump_plan(), "2026-08-13").unwrap_err();
assert!(matches!(
err,
BumpExecError::Edit(BumpEditError::PinNotFound { .. })
));
assert!(!runner
.calls
.borrow()
.iter()
.any(|c| c.starts_with("git commit")));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn fails_closed_when_the_hook_fails() {
let dir = temp_workspace();
let mut runner = FakeRunner::new("abc");
runner.hook_fail = true;
let (clock, reg) = (FakeClock, NoRegistry);
let ctx = ctx(&runner, &clock, ®, &dir);
let mut plan = bump_plan();
plan.bump_hook = Some("cargo insta test --accept".into());
let err = apply_bump(&ctx, &plan, "2026-08-13").unwrap_err();
assert!(matches!(err, BumpExecError::Hook { .. }));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn fails_closed_when_the_hook_reverts_the_version() {
let dir = temp_workspace();
let mut runner = FakeRunner::new("abc");
runner.hook_effect = Some(Box::new(|cwd: &Path| {
std::fs::write(
cwd.join("Cargo.toml"),
"[workspace]\nmembers = [\"crates/core\", \"crates/cli\"]\n\n[workspace.package]\nversion = \"9.9.9\"\n",
)
.unwrap();
}));
let (clock, reg) = (FakeClock, NoRegistry);
let ctx = ctx(&runner, &clock, ®, &dir);
let mut plan = bump_plan();
plan.pin_rewrites.clear();
plan.bump_hook = Some("evil".into());
let err = apply_bump(&ctx, &plan, "2026-08-13").unwrap_err();
assert!(
matches!(err, BumpExecError::HookViolatedVersion { .. }),
"expected a hook-version violation, got {err}"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn civil_date_converts_known_timestamps() {
assert_eq!(civil_date(1_786_579_200), "2026-08-13");
assert_eq!(civil_date(0), "1970-01-01");
assert_eq!(civil_date(1_709_208_000), "2024-02-29");
}
#[test]
fn fails_closed_when_lockfile_refresh_fails() {
let dir = temp_workspace();
let mut runner = FakeRunner::new("abc");
runner.fail_substr = Some("cargo update".into());
let (clock, reg) = (FakeClock, NoRegistry);
let ctx = ctx(&runner, &clock, ®, &dir);
let err = apply_bump(&ctx, &bump_plan(), "2026-08-13").unwrap_err();
assert!(matches!(err, BumpExecError::LockRefresh(_)));
let _ = std::fs::remove_dir_all(&dir);
}