use std::collections::BTreeSet;
use std::fmt::Write as _;
use std::path::{Path, PathBuf};
use std::process::Command;
use sha2::{Digest, Sha256};
use crate::error::MifRhError;
#[must_use]
pub fn goal_version_id(goal: &serde_json::Value) -> String {
let mut normalized = goal.clone();
if let Some(object) = normalized.as_object_mut() {
object.remove("version");
object.remove("supersedes");
object.remove("revision");
}
let compact = to_sorted_compact_json(&normalized);
let mut hasher = Sha256::new();
hasher.update(compact.as_bytes());
let digest = hasher
.finalize()
.iter()
.fold(String::new(), |mut hex, byte| {
let _ = write!(hex, "{byte:02x}");
hex
});
format!("gv-{}", &digest[..12])
}
fn to_sorted_compact_json(value: &serde_json::Value) -> String {
fn sort(value: &serde_json::Value) -> serde_json::Value {
match value {
serde_json::Value::Object(map) => {
let mut sorted: std::collections::BTreeMap<String, serde_json::Value> =
std::collections::BTreeMap::new();
for (key, entry) in map {
sorted.insert(key.clone(), sort(entry));
}
serde_json::Value::Object(sorted.into_iter().collect())
},
serde_json::Value::Array(items) => {
serde_json::Value::Array(items.iter().map(sort).collect())
},
other => other.clone(),
}
}
serde_json::to_string(&sort(value)).unwrap_or_default()
}
#[derive(Debug, Clone)]
struct PackTarget {
name: String,
plugin_path: PathBuf,
skill_path: PathBuf,
doc_path: PathBuf,
}
pub struct BumpOptions<'a> {
pub root: &'a Path,
pub spec: &'a str,
pub packs: &'a [String],
pub date: Option<&'a str>,
pub check: bool,
}
#[derive(Debug, Clone)]
pub struct BumpReport {
pub old_version: String,
pub new_version: String,
pub date: String,
pub packs: Vec<String>,
pub applied: bool,
}
const RELEASE_POINTER_FILE: &str = "harness.config.json";
const MARKETPLACE_FILE: &str = ".claude-plugin/marketplace.json";
const CHANGELOG_FILE: &str = "CHANGELOG.md";
const PACK_DOC_DIR: &str = "docs/reference/packs";
fn changelog_has_section(changelog: &str, version: &str) -> bool {
let prefix = format!("## [{version}]");
changelog.lines().any(|line| line.starts_with(&prefix))
}
fn is_semver(text: &str) -> bool {
let parts: Vec<&str> = text.split('.').collect();
parts.len() == 3
&& parts
.iter()
.all(|part| !part.is_empty() && part.bytes().all(|b| b.is_ascii_digit()))
}
fn parse_semver(text: &str) -> Option<(u64, u64, u64)> {
let parts: Vec<&str> = text.split('.').collect();
if parts.len() != 3 {
return None;
}
Some((
parts[0].parse().ok()?,
parts[1].parse().ok()?,
parts[2].parse().ok()?,
))
}
fn semver_gt(a: &str, b: &str) -> Result<bool, MifRhError> {
let (a, b) = (
parse_semver(a).ok_or_else(|| MifRhError::VersionNotSemver {
value: a.to_string(),
})?,
parse_semver(b).ok_or_else(|| MifRhError::VersionNotSemver {
value: b.to_string(),
})?,
);
Ok(a > b)
}
fn read_json(path: &Path) -> Result<serde_json::Value, MifRhError> {
let contents = std::fs::read_to_string(path).map_err(|source| MifRhError::Io {
path: path.display().to_string(),
source,
})?;
serde_json::from_str(&contents).map_err(|source| MifRhError::Json {
path: path.display().to_string(),
source,
})
}
fn write_json_pretty(path: &Path, value: &serde_json::Value) -> Result<(), MifRhError> {
let text = serde_json::to_string_pretty(value).map_err(|source| MifRhError::JsonSerialize {
path: path.display().to_string(),
source,
})?;
std::fs::write(path, format!("{text}\n")).map_err(|source| MifRhError::Io {
path: path.display().to_string(),
source,
})
}
fn read_text(path: &Path) -> Result<String, MifRhError> {
std::fs::read_to_string(path).map_err(|source| MifRhError::Io {
path: path.display().to_string(),
source,
})
}
fn write_text(path: &Path, contents: &str) -> Result<(), MifRhError> {
std::fs::write(path, contents).map_err(|source| MifRhError::Io {
path: path.display().to_string(),
source,
})
}
fn resolve_pack_target(root: &Path, name: &str) -> Result<PackTarget, MifRhError> {
let packs_dir = root.join("packs");
let mut hit: Option<(String, PathBuf)> = None;
let families = std::fs::read_dir(&packs_dir).map_err(|source| MifRhError::Io {
path: packs_dir.display().to_string(),
source,
})?;
for family_entry in families.flatten() {
let family_path = family_entry.path();
if !family_path.is_dir()
|| family_path.file_name().and_then(|n| n.to_str()) == Some("ontologies")
{
continue;
}
let candidate = family_path.join(name);
if candidate.is_dir() {
let family = family_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or_default()
.to_string();
if hit.is_some() {
return Err(MifRhError::PackAmbiguous {
name: name.to_string(),
});
}
hit = Some((family, candidate));
}
}
let Some((family, dir)) = hit else {
return Err(MifRhError::PackNotFound {
name: name.to_string(),
});
};
let plugin_path = dir.join(".claude-plugin/plugin.json");
if !plugin_path.is_file() {
return Err(MifRhError::PackFileMissing {
name: name.to_string(),
path: plugin_path.display().to_string(),
});
}
let skill_path =
find_skill_md(&dir.join("skills")).ok_or_else(|| MifRhError::PackFileMissing {
name: name.to_string(),
path: dir.join("skills/*/*/SKILL.md").display().to_string(),
})?;
let doc_path = root.join(PACK_DOC_DIR).join(format!("{family}.md"));
if !doc_path.is_file() {
return Err(MifRhError::PackFileMissing {
name: name.to_string(),
path: doc_path.display().to_string(),
});
}
Ok(PackTarget {
name: name.to_string(),
plugin_path,
skill_path,
doc_path,
})
}
fn find_skill_md(skills_dir: &Path) -> Option<PathBuf> {
let mut skill_dirs: Vec<PathBuf> = std::fs::read_dir(skills_dir)
.ok()?
.flatten()
.map(|entry| entry.path())
.filter(|path| path.is_dir())
.collect();
skill_dirs.sort();
skill_dirs
.into_iter()
.map(|dir| dir.join("SKILL.md"))
.find(|path| path.is_file())
}
fn rewrite_skill_version(contents: &str, new_version: &str) -> Option<String> {
let mut done = false;
let mut out = String::with_capacity(contents.len());
for line in contents.lines() {
if !done && line.starts_with("version:") {
out.push_str("version: ");
out.push_str(new_version);
done = true;
} else {
out.push_str(line);
}
out.push('\n');
}
done.then_some(out)
}
fn skill_version(contents: &str) -> Option<String> {
contents.lines().find_map(|line| {
line.strip_prefix("version:")
.map(|rest| rest.trim().trim_matches(['"', '\'']).to_string())
})
}
fn rewrite_doc_version(contents: &str, component: &str, new_version: &str) -> Option<String> {
let heading2 = format!("## {component}");
let heading3 = format!("### {component}");
let mut in_section = false;
let mut done = false;
let mut out = String::with_capacity(contents.len());
for line in contents.lines() {
if line.starts_with('#') {
in_section = line == heading2 || line == heading3;
}
if in_section && !done && line.starts_with("**Version:**") {
let _ = write!(out, "**Version:** {new_version}");
done = true;
} else {
out.push_str(line);
}
out.push('\n');
}
done.then_some(out)
}
fn doc_version(contents: &str, component: &str) -> Option<String> {
let heading2 = format!("## {component}");
let heading3 = format!("### {component}");
let mut in_section = false;
for line in contents.lines() {
if line.starts_with('#') {
in_section = line == heading2 || line == heading3;
}
if in_section && let Some(rest) = line.strip_prefix("**Version:**") {
return Some(
rest.trim()
.split(|c: char| !(c.is_ascii_digit() || c == '.'))
.next()?
.to_string(),
);
}
}
None
}
fn doc_has_version_row(contents: &str, component: &str) -> bool {
doc_version(contents, component).is_some()
}
fn doc_has_section(contents: &str, component: &str) -> bool {
let heading2 = format!("## {component}");
let heading3 = format!("### {component}");
contents
.lines()
.any(|line| line == heading2 || line == heading3)
}
pub fn bump_version(opts: &BumpOptions<'_>) -> Result<BumpReport, MifRhError> {
let cfg_path = opts.root.join(RELEASE_POINTER_FILE);
let market_path = opts.root.join(MARKETPLACE_FILE);
let changelog_path = opts.root.join(CHANGELOG_FILE);
let cfg = read_json(&cfg_path)?;
let old_version = cfg
.get("version")
.and_then(serde_json::Value::as_str)
.map(str::to_string)
.ok_or_else(|| MifRhError::VersionMissing {
path: cfg_path.display().to_string(),
})?;
let new_version = resolve_new_version(opts.spec, &old_version)?;
let date = opts.date.map_or_else(
|| chrono::Utc::now().format("%Y-%m-%d").to_string(),
str::to_string,
);
let targets: Vec<PackTarget> = opts
.packs
.iter()
.map(|name| resolve_pack_target(opts.root, name))
.collect::<Result<_, _>>()?;
let changelog = read_text(&changelog_path)?;
let has_new_section =
validate_bump_preconditions(&changelog, &changelog_path, &new_version, &targets)?;
if opts.check {
return Ok(BumpReport {
old_version,
new_version,
date,
packs: opts.packs.to_vec(),
applied: false,
});
}
apply_bump(&ApplyBumpInputs {
cfg,
cfg_path: &cfg_path,
market_path: &market_path,
changelog: &changelog,
changelog_path: &changelog_path,
new_version: &new_version,
date: &date,
has_new_section,
targets: &targets,
})?;
verify_bump(
&cfg_path,
&market_path,
&changelog_path,
&new_version,
&targets,
)?;
Ok(BumpReport {
old_version,
new_version,
date,
packs: opts.packs.to_vec(),
applied: true,
})
}
fn resolve_new_version(spec: &str, old_version: &str) -> Result<String, MifRhError> {
if !is_semver(old_version) {
return Err(MifRhError::VersionNotSemver {
value: old_version.to_string(),
});
}
let new_version = match spec {
"major" | "minor" | "patch" => {
let (major, minor, patch) =
parse_semver(old_version).ok_or_else(|| MifRhError::VersionNotSemver {
value: old_version.to_string(),
})?;
match spec {
"major" => format!("{}.0.0", major + 1),
"minor" => format!("{major}.{}.0", minor + 1),
_ => format!("{major}.{minor}.{}", patch + 1),
}
},
explicit => {
if !is_semver(explicit) {
return Err(MifRhError::VersionNotSemver {
value: explicit.to_string(),
});
}
explicit.to_string()
},
};
if new_version == old_version {
return Err(MifRhError::VersionUnchanged { value: new_version });
}
Ok(new_version)
}
fn validate_bump_preconditions(
changelog: &str,
changelog_path: &Path,
new_version: &str,
targets: &[PackTarget],
) -> Result<bool, MifRhError> {
let has_new_section = changelog_has_section(changelog, new_version);
let has_anchor = changelog.lines().any(|l| l == "## [Unreleased]");
if !has_new_section && !has_anchor {
return Err(MifRhError::ChangelogAnchorMissing {
path: changelog_path.display().to_string(),
});
}
for target in targets {
let plugin = read_json(&target.plugin_path)?;
let pack_version = plugin
.get("version")
.and_then(serde_json::Value::as_str)
.unwrap_or_default();
if !is_semver(pack_version) {
return Err(MifRhError::PackVersionInvalid {
name: target.name.clone(),
path: target.plugin_path.display().to_string(),
value: pack_version.to_string(),
});
}
if semver_gt(pack_version, new_version)? {
return Err(MifRhError::PackAheadOfRelease {
name: target.name.clone(),
pack_version: pack_version.to_string(),
new_version: new_version.to_string(),
});
}
let skill_contents = read_text(&target.skill_path)?;
if skill_version(&skill_contents).is_none() {
return Err(MifRhError::PackFileMissing {
name: target.name.clone(),
path: format!("{} (no version: frontmatter)", target.skill_path.display()),
});
}
let doc_contents = read_text(&target.doc_path)?;
if !doc_has_section(&doc_contents, &target.name) {
return Err(MifRhError::PackFileMissing {
name: target.name.clone(),
path: format!(
"{} (no ## {} section)",
target.doc_path.display(),
target.name
),
});
}
if !doc_has_version_row(&doc_contents, &target.name) {
return Err(MifRhError::PackFileMissing {
name: target.name.clone(),
path: format!(
"{} (## {} section has no **Version:** row)",
target.doc_path.display(),
target.name
),
});
}
}
Ok(has_new_section)
}
struct ApplyBumpInputs<'a> {
cfg: serde_json::Value,
cfg_path: &'a Path,
market_path: &'a Path,
changelog: &'a str,
changelog_path: &'a Path,
new_version: &'a str,
date: &'a str,
has_new_section: bool,
targets: &'a [PackTarget],
}
fn apply_bump(inputs: &ApplyBumpInputs<'_>) -> Result<(), MifRhError> {
let mut cfg = inputs.cfg.clone();
cfg["version"] = serde_json::Value::String(inputs.new_version.to_string());
write_json_pretty(inputs.cfg_path, &cfg)?;
let mut market = read_json(inputs.market_path)?;
if let Some(metadata) = market.get_mut("metadata") {
metadata["version"] = serde_json::Value::String(inputs.new_version.to_string());
}
write_json_pretty(inputs.market_path, &market)?;
if !inputs.has_new_section {
let mut inserted = String::with_capacity(inputs.changelog.len() + 64);
let mut done = false;
for line in inputs.changelog.lines() {
inserted.push_str(line);
inserted.push('\n');
if !done && line == "## [Unreleased]" {
inserted.push('\n');
let _ = writeln!(inserted, "## [{}] - {}", inputs.new_version, inputs.date);
done = true;
}
}
write_text(inputs.changelog_path, &inserted)?;
}
for target in inputs.targets {
let mut plugin = read_json(&target.plugin_path)?;
plugin["version"] = serde_json::Value::String(inputs.new_version.to_string());
write_json_pretty(&target.plugin_path, &plugin)?;
let skill_contents = read_text(&target.skill_path)?;
if let Some(rewritten) = rewrite_skill_version(&skill_contents, inputs.new_version) {
write_text(&target.skill_path, &rewritten)?;
}
let doc_contents = read_text(&target.doc_path)?;
if let Some(rewritten) =
rewrite_doc_version(&doc_contents, &target.name, inputs.new_version)
{
write_text(&target.doc_path, &rewritten)?;
}
}
Ok(())
}
fn verify_bump(
cfg_path: &Path,
market_path: &Path,
changelog_path: &Path,
new_version: &str,
targets: &[PackTarget],
) -> Result<(), MifRhError> {
let cfg_after = read_json(cfg_path)?;
if cfg_after.get("version").and_then(serde_json::Value::as_str) != Some(new_version) {
return Err(MifRhError::VerificationFailed {
path: cfg_path.display().to_string(),
});
}
let market_after = read_json(market_path)?;
if market_after
.get("metadata")
.and_then(|m| m.get("version"))
.and_then(serde_json::Value::as_str)
!= Some(new_version)
{
return Err(MifRhError::VerificationFailed {
path: market_path.display().to_string(),
});
}
let changelog_after = read_text(changelog_path)?;
if !changelog_has_section(&changelog_after, new_version) {
return Err(MifRhError::VerificationFailed {
path: changelog_path.display().to_string(),
});
}
for target in targets {
let plugin_after = read_json(&target.plugin_path)?;
if plugin_after
.get("version")
.and_then(serde_json::Value::as_str)
!= Some(new_version)
{
return Err(MifRhError::VerificationFailed {
path: target.plugin_path.display().to_string(),
});
}
let skill_after = read_text(&target.skill_path)?;
if skill_version(&skill_after).as_deref() != Some(new_version) {
return Err(MifRhError::VerificationFailed {
path: target.skill_path.display().to_string(),
});
}
let doc_after = read_text(&target.doc_path)?;
if doc_version(&doc_after, &target.name).as_deref() != Some(new_version) {
return Err(MifRhError::VerificationFailed {
path: target.doc_path.display().to_string(),
});
}
}
Ok(())
}
#[derive(Debug, Clone)]
pub enum VersionGateFailure {
PackNotBumped {
pack: String,
version: String,
},
SkillNotBumped {
skill: String,
version: String,
},
PointerNotAhead {
current: String,
last_release: String,
},
PointerMissing,
}
#[derive(Debug, Clone, Default)]
pub struct VersionGateReport {
pub failures: Vec<VersionGateFailure>,
pub pointer_at_head: Option<String>,
}
impl VersionGateReport {
#[must_use]
pub const fn ok(&self) -> bool {
self.failures.is_empty()
}
}
fn git_command(root: &Path) -> Command {
let mut command = Command::new("git");
command
.current_dir(root)
.env_remove("GIT_DIR")
.env_remove("GIT_WORK_TREE")
.env_remove("GIT_INDEX_FILE")
.env_remove("GIT_CEILING_DIRECTORIES");
command
}
fn run_git(root: &Path, args: &[&str]) -> Result<String, MifRhError> {
let output = git_command(root)
.args(args)
.output()
.map_err(|source| MifRhError::Io {
path: "git".to_string(),
source,
})?;
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
fn git_show_json_version(root: &Path, rev: &str, path: &str) -> Option<String> {
let output = git_command(root)
.args(["show", &format!("{rev}:{path}")])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let text = String::from_utf8_lossy(&output.stdout);
let value: serde_json::Value = serde_json::from_str(&text).ok()?;
value.get("version")?.as_str().map(str::to_string)
}
fn git_show_frontmatter_version(root: &Path, rev: &str, path: &str) -> Option<String> {
let output = git_command(root)
.args(["show", &format!("{rev}:{path}")])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let text = String::from_utf8_lossy(&output.stdout);
skill_version(&text)
}
pub fn check_version_bump(root: &Path, base: &str) -> Result<VersionGateReport, MifRhError> {
let merge_base = {
let candidate = run_git(root, &["merge-base", base, "HEAD"])?;
if candidate.is_empty() {
base.to_string()
} else {
candidate
}
};
let changed_output = run_git(
root,
&["diff", "--name-only", &format!("{merge_base}...HEAD")],
)?;
let changed: Vec<&str> = changed_output.lines().filter(|l| !l.is_empty()).collect();
let mut failures = Vec::new();
if !changed.is_empty() {
failures.extend(check_changed_packs(root, &merge_base, &changed));
failures.extend(check_changed_skills(root, &merge_base, &changed));
}
check_release_pointer(root, &mut failures);
let pointer_at_head = read_json(&root.join(RELEASE_POINTER_FILE))
.ok()
.and_then(|v| {
v.get("version")
.and_then(serde_json::Value::as_str)
.map(str::to_string)
});
Ok(VersionGateReport {
failures,
pointer_at_head,
})
}
fn check_changed_packs(root: &Path, merge_base: &str, changed: &[&str]) -> Vec<VersionGateFailure> {
let changed_packs: BTreeSet<String> = changed
.iter()
.filter(|path| path.starts_with("packs/") && !path.starts_with("packs/ontologies/"))
.filter_map(|path| {
let parts: Vec<&str> = path.split('/').collect();
(parts.len() > 3).then(|| format!("{}/{}/{}", parts[0], parts[1], parts[2]))
})
.collect();
let mut failures = Vec::new();
for pack in changed_packs {
let plugin_rel = format!("{pack}/.claude-plugin/plugin.json");
if !root.join(&plugin_rel).is_file() {
continue; }
let Some(base_version) = git_show_json_version(root, merge_base, &plugin_rel) else {
continue; };
let head_version = read_json(&root.join(&plugin_rel))
.ok()
.and_then(|v| {
v.get("version")
.and_then(serde_json::Value::as_str)
.map(str::to_string)
})
.unwrap_or_default();
if base_version == head_version {
failures.push(VersionGateFailure::PackNotBumped {
pack,
version: head_version,
});
}
}
failures
}
fn check_changed_skills(
root: &Path,
merge_base: &str,
changed: &[&str],
) -> Vec<VersionGateFailure> {
let changed_skills: BTreeSet<String> = changed
.iter()
.filter(|path| path.starts_with(".claude/skills/"))
.filter_map(|path| {
let parts: Vec<&str> = path.split('/').collect();
(parts.len() > 3).then(|| parts[..3].join("/"))
})
.collect();
let mut failures = Vec::new();
for skill in changed_skills {
let skill_rel = format!("{skill}/SKILL.md");
if !root.join(&skill_rel).is_file() {
continue;
}
let Some(base_version) = git_show_frontmatter_version(root, merge_base, &skill_rel) else {
continue;
};
let head_version = read_text(&root.join(&skill_rel))
.ok()
.and_then(|c| skill_version(&c))
.unwrap_or_default();
if base_version == head_version {
failures.push(VersionGateFailure::SkillNotBumped {
skill,
version: head_version,
});
}
}
failures
}
fn check_release_pointer(root: &Path, failures: &mut Vec<VersionGateFailure>) {
let tags_output = run_git(root, &["tag", "--list", "v*"]);
let last_release = tags_output.ok().and_then(|tags| {
tags.lines()
.filter_map(|tag| tag.strip_prefix('v'))
.filter_map(parse_semver)
.max()
});
let Some((lm, ln, lp)) = last_release else {
return;
};
let head_version = read_json(&root.join(RELEASE_POINTER_FILE))
.ok()
.and_then(|v| {
v.get("version")
.and_then(serde_json::Value::as_str)
.map(str::to_string)
});
match head_version {
None => failures.push(VersionGateFailure::PointerMissing),
Some(head_version) => {
let head_parsed = parse_semver(&head_version).unwrap_or((0, 0, 0));
if head_parsed <= (lm, ln, lp) {
failures.push(VersionGateFailure::PointerNotAhead {
current: head_version,
last_release: format!("{lm}.{ln}.{lp}"),
});
}
},
}
}
#[cfg(test)]
mod tests {
use super::goal_version_id;
#[test]
fn goal_version_id_ignores_lineage_fields() {
let a = serde_json::json!({"question": "why", "version": 1});
let b = serde_json::json!({"question": "why", "version": 2, "supersedes": "gv-abc"});
assert_eq!(goal_version_id(&a), goal_version_id(&b));
}
#[test]
fn goal_version_id_is_independent_of_key_order() {
let a = serde_json::json!({"question": "why", "scope": "x"});
let b = serde_json::json!({"scope": "x", "question": "why"});
assert_eq!(goal_version_id(&a), goal_version_id(&b));
}
#[test]
fn goal_version_id_changes_with_content() {
let a = serde_json::json!({"question": "why"});
let b = serde_json::json!({"question": "why not"});
assert_ne!(goal_version_id(&a), goal_version_id(&b));
}
#[test]
fn goal_version_id_has_the_expected_shape() {
let id = goal_version_id(&serde_json::json!({"question": "why"}));
assert!(id.starts_with("gv-"));
assert_eq!(id.len(), "gv-".len() + 12);
}
use super::{BumpOptions, PACK_DOC_DIR, bump_version};
use std::fmt::Write as _;
use std::fs;
fn write_base_fixture(root: &std::path::Path) {
fs::write(
root.join("harness.config.json"),
r#"{"version": "0.4.0", "topics": []}"#,
)
.unwrap();
fs::create_dir_all(root.join(".claude-plugin")).unwrap();
fs::write(
root.join(".claude-plugin/marketplace.json"),
r#"{"name": "research-harness", "metadata": {"version": "0.4.0"}}"#,
)
.unwrap();
fs::write(
root.join("CHANGELOG.md"),
"# Changelog\n\n## [Unreleased]\n\n## [0.4.0] - 2026-01-01\n\nInitial.\n",
)
.unwrap();
}
fn write_pack_fixture(root: &std::path::Path, family: &str, name: &str, version: &str) {
let dir = root.join("packs").join(family).join(name);
fs::create_dir_all(dir.join(".claude-plugin")).unwrap();
fs::write(
dir.join(".claude-plugin/plugin.json"),
format!(r#"{{"name": "{name}", "version": "{version}"}}"#),
)
.unwrap();
fs::create_dir_all(dir.join("skills").join(format!("{name}-skill"))).unwrap();
fs::write(
dir.join("skills")
.join(format!("{name}-skill"))
.join("SKILL.md"),
format!("---\nversion: {version}\n---\n\n# {name}\n"),
)
.unwrap();
fs::create_dir_all(root.join(PACK_DOC_DIR)).unwrap();
let doc_path = root.join(PACK_DOC_DIR).join(format!("{family}.md"));
let mut existing = fs::read_to_string(&doc_path).unwrap_or_default();
let _ = write!(existing, "\n## {name}\n\n**Version:** {version}\n");
fs::write(&doc_path, existing).unwrap();
}
#[test]
fn bump_version_patch_moves_the_release_pointer_and_inserts_changelog() {
let dir = tempfile::tempdir().unwrap();
write_base_fixture(dir.path());
let report = bump_version(&BumpOptions {
root: dir.path(),
spec: "patch",
packs: &[],
date: Some("2026-02-01"),
check: false,
})
.unwrap();
assert_eq!(report.old_version, "0.4.0");
assert_eq!(report.new_version, "0.4.1");
assert!(report.applied);
let cfg: serde_json::Value = serde_json::from_str(
&fs::read_to_string(dir.path().join("harness.config.json")).unwrap(),
)
.unwrap();
assert_eq!(cfg["version"], "0.4.1");
let market: serde_json::Value = serde_json::from_str(
&fs::read_to_string(dir.path().join(".claude-plugin/marketplace.json")).unwrap(),
)
.unwrap();
assert_eq!(market["metadata"]["version"], "0.4.1");
let changelog = fs::read_to_string(dir.path().join("CHANGELOG.md")).unwrap();
assert!(changelog.contains("## [0.4.1] - 2026-02-01"));
}
#[test]
fn bump_version_check_mode_writes_nothing() {
let dir = tempfile::tempdir().unwrap();
write_base_fixture(dir.path());
let report = bump_version(&BumpOptions {
root: dir.path(),
spec: "minor",
packs: &[],
date: Some("2026-02-01"),
check: true,
})
.unwrap();
assert!(!report.applied);
assert_eq!(report.new_version, "0.5.0");
let cfg: serde_json::Value = serde_json::from_str(
&fs::read_to_string(dir.path().join("harness.config.json")).unwrap(),
)
.unwrap();
assert_eq!(cfg["version"], "0.4.0", "check mode must not write");
}
#[test]
fn bump_version_rejects_an_unchanged_version() {
let dir = tempfile::tempdir().unwrap();
write_base_fixture(dir.path());
let error = bump_version(&BumpOptions {
root: dir.path(),
spec: "0.4.0",
packs: &[],
date: Some("2026-02-01"),
check: false,
})
.unwrap_err();
assert!(matches!(error, super::MifRhError::VersionUnchanged { .. }));
}
#[test]
fn bump_version_bumps_a_named_pack_across_all_three_files() {
let dir = tempfile::tempdir().unwrap();
write_base_fixture(dir.path());
write_pack_fixture(dir.path(), "channels", "pdf", "0.4.0");
let report = bump_version(&BumpOptions {
root: dir.path(),
spec: "patch",
packs: &["pdf".to_string()],
date: Some("2026-02-01"),
check: false,
})
.unwrap();
assert_eq!(report.packs, ["pdf"]);
let plugin: serde_json::Value = serde_json::from_str(
&fs::read_to_string(
dir.path()
.join("packs/channels/pdf/.claude-plugin/plugin.json"),
)
.unwrap(),
)
.unwrap();
assert_eq!(plugin["version"], "0.4.1");
let skill = fs::read_to_string(
dir.path()
.join("packs/channels/pdf/skills/pdf-skill/SKILL.md"),
)
.unwrap();
assert!(skill.contains("version: 0.4.1"));
let doc = fs::read_to_string(dir.path().join("docs/reference/packs/channels.md")).unwrap();
assert!(doc.contains("**Version:** 0.4.1"));
}
#[test]
fn bump_version_rejects_a_pack_already_ahead_of_the_new_release() {
let dir = tempfile::tempdir().unwrap();
write_base_fixture(dir.path());
write_pack_fixture(dir.path(), "channels", "pdf", "9.0.0");
let error = bump_version(&BumpOptions {
root: dir.path(),
spec: "patch",
packs: &["pdf".to_string()],
date: Some("2026-02-01"),
check: false,
})
.unwrap_err();
assert!(matches!(
error,
super::MifRhError::PackAheadOfRelease { .. }
));
let cfg: serde_json::Value = serde_json::from_str(
&fs::read_to_string(dir.path().join("harness.config.json")).unwrap(),
)
.unwrap();
assert_eq!(cfg["version"], "0.4.0");
}
#[test]
fn bump_version_rejects_a_missing_changelog_anchor() {
let dir = tempfile::tempdir().unwrap();
write_base_fixture(dir.path());
fs::write(
dir.path().join("CHANGELOG.md"),
"# Changelog\n\nNo anchor here.\n",
)
.unwrap();
let error = bump_version(&BumpOptions {
root: dir.path(),
spec: "patch",
packs: &[],
date: Some("2026-02-01"),
check: false,
})
.unwrap_err();
assert!(matches!(
error,
super::MifRhError::ChangelogAnchorMissing { .. }
));
}
use super::{VersionGateFailure, check_version_bump};
fn git(root: &std::path::Path, args: &[&str]) {
let status = super::git_command(root).args(args).status().unwrap();
assert!(status.success(), "git {args:?} failed");
}
fn init_repo(root: &std::path::Path) {
git(root, &["init", "-q", "-b", "main"]);
git(root, &["config", "user.email", "test@example.com"]);
git(root, &["config", "user.name", "Test"]);
}
#[test]
fn check_version_bump_passes_when_a_changed_pack_moved_its_own_version() {
let dir = tempfile::tempdir().unwrap();
init_repo(dir.path());
write_base_fixture(dir.path());
write_pack_fixture(dir.path(), "channels", "pdf", "0.4.0");
git(dir.path(), &["add", "-A"]);
git(dir.path(), &["commit", "-q", "-m", "base"]);
git(dir.path(), &["tag", "v0.4.0"]);
git(dir.path(), &["checkout", "-q", "-b", "feature"]);
std::fs::write(
dir.path()
.join("packs/channels/pdf/.claude-plugin/plugin.json"),
r#"{"name": "pdf", "version": "0.4.1"}"#,
)
.unwrap();
std::fs::write(
dir.path().join("harness.config.json"),
r#"{"version": "0.4.1", "topics": []}"#,
)
.unwrap();
git(dir.path(), &["add", "-A"]);
git(dir.path(), &["commit", "-q", "-m", "bump pdf"]);
let report = check_version_bump(dir.path(), "main").unwrap();
assert!(
report.ok(),
"expected no failures, got {:?}",
report.failures
);
}
#[test]
fn check_version_bump_fails_when_a_changed_pack_did_not_move_its_version() {
let dir = tempfile::tempdir().unwrap();
init_repo(dir.path());
write_base_fixture(dir.path());
write_pack_fixture(dir.path(), "channels", "pdf", "0.4.0");
git(dir.path(), &["add", "-A"]);
git(dir.path(), &["commit", "-q", "-m", "base"]);
git(dir.path(), &["checkout", "-q", "-b", "feature"]);
std::fs::write(
dir.path()
.join("packs/channels/pdf/skills/pdf-skill/SKILL.md"),
"---\nversion: 0.4.0\n---\n\n# pdf (edited)\n",
)
.unwrap();
git(dir.path(), &["add", "-A"]);
git(
dir.path(),
&["commit", "-q", "-m", "edit pdf, forgot to bump"],
);
let report = check_version_bump(dir.path(), "main").unwrap();
assert!(!report.ok());
assert!(
report
.failures
.iter()
.any(|f| matches!(f, VersionGateFailure::PackNotBumped { pack, .. } if pack == "packs/channels/pdf"))
);
}
#[test]
fn check_version_bump_fails_when_the_release_pointer_is_not_ahead_of_the_last_tag() {
let dir = tempfile::tempdir().unwrap();
init_repo(dir.path());
write_base_fixture(dir.path());
git(dir.path(), &["add", "-A"]);
git(dir.path(), &["commit", "-q", "-m", "base"]);
git(dir.path(), &["tag", "v0.4.0"]);
git(dir.path(), &["checkout", "-q", "-b", "feature"]);
std::fs::write(dir.path().join("README.md"), "unrelated change\n").unwrap();
git(dir.path(), &["add", "-A"]);
git(dir.path(), &["commit", "-q", "-m", "unrelated"]);
let report = check_version_bump(dir.path(), "main").unwrap();
assert!(!report.ok());
assert!(
report
.failures
.iter()
.any(|f| matches!(f, VersionGateFailure::PointerNotAhead { .. }))
);
}
}