1use std::collections::{BTreeSet, HashMap};
8use std::fmt::Write as _;
9use std::path::{Path, PathBuf};
10use std::process::Command;
11
12use sha2::{Digest, Sha256};
13
14use crate::error::MifRhError;
15
16#[must_use]
25pub fn goal_version_id(goal: &serde_json::Value) -> String {
26 let mut normalized = goal.clone();
27 if let Some(object) = normalized.as_object_mut() {
28 object.remove("version");
29 object.remove("supersedes");
30 object.remove("revision");
31 }
32 let compact = to_sorted_compact_json(&normalized);
33 let mut hasher = Sha256::new();
34 hasher.update(compact.as_bytes());
35 let digest = hasher
36 .finalize()
37 .iter()
38 .fold(String::new(), |mut hex, byte| {
39 let _ = write!(hex, "{byte:02x}");
40 hex
41 });
42 format!("gv-{}", &digest[..12])
43}
44
45fn to_sorted_compact_json(value: &serde_json::Value) -> String {
49 fn sort(value: &serde_json::Value) -> serde_json::Value {
50 match value {
51 serde_json::Value::Object(map) => {
52 let mut sorted: std::collections::BTreeMap<String, serde_json::Value> =
53 std::collections::BTreeMap::new();
54 for (key, entry) in map {
55 sorted.insert(key.clone(), sort(entry));
56 }
57 serde_json::Value::Object(sorted.into_iter().collect())
58 },
59 serde_json::Value::Array(items) => {
60 serde_json::Value::Array(items.iter().map(sort).collect())
61 },
62 other => other.clone(),
63 }
64 }
65 serde_json::to_string(&sort(value)).unwrap_or_default()
69}
70
71#[derive(Debug, Clone)]
73struct PackTarget {
74 name: String,
75 plugin_path: PathBuf,
76 skill_path: PathBuf,
77 doc_path: PathBuf,
78}
79
80pub struct BumpOptions<'a> {
82 pub root: &'a Path,
84 pub spec: &'a str,
86 pub packs: &'a [String],
89 pub date: Option<&'a str>,
92 pub check: bool,
94}
95
96#[derive(Debug, Clone)]
98pub struct BumpReport {
99 pub old_version: String,
101 pub new_version: String,
103 pub date: String,
105 pub packs: Vec<String>,
107 pub applied: bool,
109}
110
111const RELEASE_POINTER_FILE: &str = "harness.config.json";
112const MARKETPLACE_FILE: &str = ".claude-plugin/marketplace.json";
113const CHANGELOG_FILE: &str = "CHANGELOG.md";
114const PACK_DOC_DIR: &str = "docs/reference/packs";
115
116fn changelog_has_section(changelog: &str, version: &str) -> bool {
120 let prefix = format!("## [{version}]");
121 changelog.lines().any(|line| line.starts_with(&prefix))
122}
123
124fn is_semver(text: &str) -> bool {
125 let parts: Vec<&str> = text.split('.').collect();
126 parts.len() == 3
127 && parts
128 .iter()
129 .all(|part| !part.is_empty() && part.bytes().all(|b| b.is_ascii_digit()))
130}
131
132fn parse_semver(text: &str) -> Option<(u64, u64, u64)> {
133 let parts: Vec<&str> = text.split('.').collect();
134 if parts.len() != 3 {
135 return None;
136 }
137 Some((
138 parts[0].parse().ok()?,
139 parts[1].parse().ok()?,
140 parts[2].parse().ok()?,
141 ))
142}
143
144fn semver_gt(a: &str, b: &str) -> Result<bool, MifRhError> {
146 let (a, b) = (
147 parse_semver(a).ok_or_else(|| MifRhError::VersionNotSemver {
148 value: a.to_string(),
149 })?,
150 parse_semver(b).ok_or_else(|| MifRhError::VersionNotSemver {
151 value: b.to_string(),
152 })?,
153 );
154 Ok(a > b)
155}
156
157fn read_json(path: &Path) -> Result<serde_json::Value, MifRhError> {
158 let contents = std::fs::read_to_string(path).map_err(|source| MifRhError::Io {
159 path: path.display().to_string(),
160 source,
161 })?;
162 serde_json::from_str(&contents).map_err(|source| MifRhError::Json {
163 path: path.display().to_string(),
164 source,
165 })
166}
167
168fn write_json_pretty(path: &Path, value: &serde_json::Value) -> Result<(), MifRhError> {
169 let text = serde_json::to_string_pretty(value).map_err(|source| MifRhError::JsonSerialize {
170 path: path.display().to_string(),
171 source,
172 })?;
173 std::fs::write(path, format!("{text}\n")).map_err(|source| MifRhError::Io {
174 path: path.display().to_string(),
175 source,
176 })
177}
178
179fn read_text(path: &Path) -> Result<String, MifRhError> {
180 std::fs::read_to_string(path).map_err(|source| MifRhError::Io {
181 path: path.display().to_string(),
182 source,
183 })
184}
185
186fn write_text(path: &Path, contents: &str) -> Result<(), MifRhError> {
187 std::fs::write(path, contents).map_err(|source| MifRhError::Io {
188 path: path.display().to_string(),
189 source,
190 })
191}
192
193fn resolve_pack_target(root: &Path, name: &str) -> Result<PackTarget, MifRhError> {
196 let packs_dir = root.join("packs");
197 let mut hit: Option<(String, PathBuf)> = None;
198 let families = std::fs::read_dir(&packs_dir).map_err(|source| MifRhError::Io {
199 path: packs_dir.display().to_string(),
200 source,
201 })?;
202 for family_entry in families.flatten() {
203 let family_path = family_entry.path();
204 if !family_path.is_dir()
205 || family_path.file_name().and_then(|n| n.to_str()) == Some("ontologies")
206 {
207 continue;
208 }
209 let candidate = family_path.join(name);
210 if candidate.is_dir() {
211 let family = family_path
212 .file_name()
213 .and_then(|n| n.to_str())
214 .unwrap_or_default()
215 .to_string();
216 if hit.is_some() {
217 return Err(MifRhError::PackAmbiguous {
218 name: name.to_string(),
219 });
220 }
221 hit = Some((family, candidate));
222 }
223 }
224 let Some((family, dir)) = hit else {
225 return Err(MifRhError::PackNotFound {
226 name: name.to_string(),
227 });
228 };
229
230 let plugin_path = dir.join(".claude-plugin/plugin.json");
231 if !plugin_path.is_file() {
232 return Err(MifRhError::PackFileMissing {
233 name: name.to_string(),
234 path: plugin_path.display().to_string(),
235 });
236 }
237 let skill_path =
238 find_skill_md(&dir.join("skills")).ok_or_else(|| MifRhError::PackFileMissing {
239 name: name.to_string(),
240 path: dir.join("skills/*/*/SKILL.md").display().to_string(),
241 })?;
242 let doc_path = root.join(PACK_DOC_DIR).join(format!("{family}.md"));
243 if !doc_path.is_file() {
244 return Err(MifRhError::PackFileMissing {
245 name: name.to_string(),
246 path: doc_path.display().to_string(),
247 });
248 }
249 Ok(PackTarget {
250 name: name.to_string(),
251 plugin_path,
252 skill_path,
253 doc_path,
254 })
255}
256
257fn find_skill_md(skills_dir: &Path) -> Option<PathBuf> {
261 let mut skill_dirs: Vec<PathBuf> = std::fs::read_dir(skills_dir)
262 .ok()?
263 .flatten()
264 .map(|entry| entry.path())
265 .filter(|path| path.is_dir())
266 .collect();
267 skill_dirs.sort();
268 skill_dirs
269 .into_iter()
270 .map(|dir| dir.join("SKILL.md"))
271 .find(|path| path.is_file())
272}
273
274fn rewrite_skill_version(contents: &str, new_version: &str) -> Option<String> {
277 let mut done = false;
278 let mut out = String::with_capacity(contents.len());
279 for line in contents.lines() {
280 if !done && line.starts_with("version:") {
281 out.push_str("version: ");
282 out.push_str(new_version);
283 done = true;
284 } else {
285 out.push_str(line);
286 }
287 out.push('\n');
288 }
289 done.then_some(out)
290}
291
292fn skill_version(contents: &str) -> Option<String> {
294 contents.lines().find_map(|line| {
295 line.strip_prefix("version:")
296 .map(|rest| rest.trim().trim_matches(['"', '\'']).to_string())
297 })
298}
299
300fn rewrite_doc_version(contents: &str, component: &str, new_version: &str) -> Option<String> {
304 let heading2 = format!("## {component}");
305 let heading3 = format!("### {component}");
306 let mut in_section = false;
307 let mut done = false;
308 let mut out = String::with_capacity(contents.len());
309 for line in contents.lines() {
310 if line.starts_with('#') {
311 in_section = line == heading2 || line == heading3;
312 }
313 if in_section && !done && line.starts_with("**Version:**") {
314 let _ = write!(out, "**Version:** {new_version}");
315 done = true;
316 } else {
317 out.push_str(line);
318 }
319 out.push('\n');
320 }
321 done.then_some(out)
322}
323
324fn doc_version(contents: &str, component: &str) -> Option<String> {
326 let heading2 = format!("## {component}");
327 let heading3 = format!("### {component}");
328 let mut in_section = false;
329 for line in contents.lines() {
330 if line.starts_with('#') {
331 in_section = line == heading2 || line == heading3;
332 }
333 if in_section && let Some(rest) = line.strip_prefix("**Version:**") {
334 return Some(
335 rest.trim()
336 .split(|c: char| !(c.is_ascii_digit() || c == '.'))
337 .next()?
338 .to_string(),
339 );
340 }
341 }
342 None
343}
344
345fn doc_has_version_row(contents: &str, component: &str) -> bool {
348 doc_version(contents, component).is_some()
349}
350
351fn doc_has_section(contents: &str, component: &str) -> bool {
355 let heading2 = format!("## {component}");
356 let heading3 = format!("### {component}");
357 contents
358 .lines()
359 .any(|line| line == heading2 || line == heading3)
360}
361
362pub fn bump_version(opts: &BumpOptions<'_>) -> Result<BumpReport, MifRhError> {
386 let cfg_path = opts.root.join(RELEASE_POINTER_FILE);
387 let market_path = opts.root.join(MARKETPLACE_FILE);
388 let changelog_path = opts.root.join(CHANGELOG_FILE);
389
390 let cfg = read_json(&cfg_path)?;
391 let old_version = cfg
392 .get("version")
393 .and_then(serde_json::Value::as_str)
394 .map(str::to_string)
395 .ok_or_else(|| MifRhError::VersionMissing {
396 path: cfg_path.display().to_string(),
397 })?;
398 let new_version = resolve_new_version(opts.spec, &old_version)?;
399 let date = opts.date.map_or_else(
400 || chrono::Utc::now().format("%Y-%m-%d").to_string(),
401 str::to_string,
402 );
403 let targets: Vec<PackTarget> = opts
404 .packs
405 .iter()
406 .map(|name| resolve_pack_target(opts.root, name))
407 .collect::<Result<_, _>>()?;
408
409 let changelog = read_text(&changelog_path)?;
410 let has_new_section =
411 validate_bump_preconditions(&changelog, &changelog_path, &new_version, &targets)?;
412
413 if opts.check {
414 return Ok(BumpReport {
415 old_version,
416 new_version,
417 date,
418 packs: opts.packs.to_vec(),
419 applied: false,
420 });
421 }
422
423 apply_bump(&ApplyBumpInputs {
424 cfg,
425 cfg_path: &cfg_path,
426 market_path: &market_path,
427 changelog: &changelog,
428 changelog_path: &changelog_path,
429 new_version: &new_version,
430 date: &date,
431 has_new_section,
432 targets: &targets,
433 })?;
434 verify_bump(
435 &cfg_path,
436 &market_path,
437 &changelog_path,
438 &new_version,
439 &targets,
440 )?;
441
442 Ok(BumpReport {
443 old_version,
444 new_version,
445 date,
446 packs: opts.packs.to_vec(),
447 applied: true,
448 })
449}
450
451fn resolve_new_version(spec: &str, old_version: &str) -> Result<String, MifRhError> {
454 if !is_semver(old_version) {
455 return Err(MifRhError::VersionNotSemver {
456 value: old_version.to_string(),
457 });
458 }
459 let new_version = match spec {
460 "major" | "minor" | "patch" => {
461 let (major, minor, patch) =
462 parse_semver(old_version).ok_or_else(|| MifRhError::VersionNotSemver {
463 value: old_version.to_string(),
464 })?;
465 match spec {
466 "major" => format!("{}.0.0", major + 1),
467 "minor" => format!("{major}.{}.0", minor + 1),
468 _ => format!("{major}.{minor}.{}", patch + 1),
469 }
470 },
471 explicit => {
472 if !is_semver(explicit) {
473 return Err(MifRhError::VersionNotSemver {
474 value: explicit.to_string(),
475 });
476 }
477 explicit.to_string()
478 },
479 };
480 if new_version == old_version {
481 return Err(MifRhError::VersionUnchanged { value: new_version });
482 }
483 Ok(new_version)
484}
485
486fn validate_bump_preconditions(
491 changelog: &str,
492 changelog_path: &Path,
493 new_version: &str,
494 targets: &[PackTarget],
495) -> Result<bool, MifRhError> {
496 let has_new_section = changelog_has_section(changelog, new_version);
497 let has_anchor = changelog.lines().any(|l| l == "## [Unreleased]");
498 if !has_new_section && !has_anchor {
499 return Err(MifRhError::ChangelogAnchorMissing {
500 path: changelog_path.display().to_string(),
501 });
502 }
503 for target in targets {
504 let plugin = read_json(&target.plugin_path)?;
505 let pack_version = plugin
506 .get("version")
507 .and_then(serde_json::Value::as_str)
508 .unwrap_or_default();
509 if !is_semver(pack_version) {
510 return Err(MifRhError::PackVersionInvalid {
511 name: target.name.clone(),
512 path: target.plugin_path.display().to_string(),
513 value: pack_version.to_string(),
514 });
515 }
516 if semver_gt(pack_version, new_version)? {
517 return Err(MifRhError::PackAheadOfRelease {
518 name: target.name.clone(),
519 pack_version: pack_version.to_string(),
520 new_version: new_version.to_string(),
521 });
522 }
523 let skill_contents = read_text(&target.skill_path)?;
524 if skill_version(&skill_contents).is_none() {
525 return Err(MifRhError::PackFileMissing {
526 name: target.name.clone(),
527 path: format!("{} (no version: frontmatter)", target.skill_path.display()),
528 });
529 }
530 let doc_contents = read_text(&target.doc_path)?;
531 if !doc_has_section(&doc_contents, &target.name) {
532 return Err(MifRhError::PackFileMissing {
533 name: target.name.clone(),
534 path: format!(
535 "{} (no ## {} section)",
536 target.doc_path.display(),
537 target.name
538 ),
539 });
540 }
541 if !doc_has_version_row(&doc_contents, &target.name) {
542 return Err(MifRhError::PackFileMissing {
543 name: target.name.clone(),
544 path: format!(
545 "{} (## {} section has no **Version:** row)",
546 target.doc_path.display(),
547 target.name
548 ),
549 });
550 }
551 }
552 Ok(has_new_section)
553}
554
555struct ApplyBumpInputs<'a> {
558 cfg: serde_json::Value,
559 cfg_path: &'a Path,
560 market_path: &'a Path,
561 changelog: &'a str,
562 changelog_path: &'a Path,
563 new_version: &'a str,
564 date: &'a str,
565 has_new_section: bool,
566 targets: &'a [PackTarget],
567}
568
569fn apply_bump(inputs: &ApplyBumpInputs<'_>) -> Result<(), MifRhError> {
574 let mut cfg = inputs.cfg.clone();
575 cfg["version"] = serde_json::Value::String(inputs.new_version.to_string());
576 write_json_pretty(inputs.cfg_path, &cfg)?;
577
578 let mut market = read_json(inputs.market_path)?;
579 if let Some(metadata) = market.get_mut("metadata") {
580 metadata["version"] = serde_json::Value::String(inputs.new_version.to_string());
581 }
582 write_json_pretty(inputs.market_path, &market)?;
583
584 if !inputs.has_new_section {
585 let mut inserted = String::with_capacity(inputs.changelog.len() + 64);
586 let mut done = false;
587 for line in inputs.changelog.lines() {
588 inserted.push_str(line);
589 inserted.push('\n');
590 if !done && line == "## [Unreleased]" {
591 inserted.push('\n');
592 let _ = writeln!(inserted, "## [{}] - {}", inputs.new_version, inputs.date);
593 done = true;
594 }
595 }
596 write_text(inputs.changelog_path, &inserted)?;
597 }
598
599 for target in inputs.targets {
600 let mut plugin = read_json(&target.plugin_path)?;
601 plugin["version"] = serde_json::Value::String(inputs.new_version.to_string());
602 write_json_pretty(&target.plugin_path, &plugin)?;
603
604 let skill_contents = read_text(&target.skill_path)?;
605 if let Some(rewritten) = rewrite_skill_version(&skill_contents, inputs.new_version) {
606 write_text(&target.skill_path, &rewritten)?;
607 }
608
609 let doc_contents = read_text(&target.doc_path)?;
610 if let Some(rewritten) =
611 rewrite_doc_version(&doc_contents, &target.name, inputs.new_version)
612 {
613 write_text(&target.doc_path, &rewritten)?;
614 }
615 }
616 Ok(())
617}
618
619fn verify_bump(
621 cfg_path: &Path,
622 market_path: &Path,
623 changelog_path: &Path,
624 new_version: &str,
625 targets: &[PackTarget],
626) -> Result<(), MifRhError> {
627 let cfg_after = read_json(cfg_path)?;
628 if cfg_after.get("version").and_then(serde_json::Value::as_str) != Some(new_version) {
629 return Err(MifRhError::VerificationFailed {
630 path: cfg_path.display().to_string(),
631 });
632 }
633 let market_after = read_json(market_path)?;
634 if market_after
635 .get("metadata")
636 .and_then(|m| m.get("version"))
637 .and_then(serde_json::Value::as_str)
638 != Some(new_version)
639 {
640 return Err(MifRhError::VerificationFailed {
641 path: market_path.display().to_string(),
642 });
643 }
644 let changelog_after = read_text(changelog_path)?;
645 if !changelog_has_section(&changelog_after, new_version) {
646 return Err(MifRhError::VerificationFailed {
647 path: changelog_path.display().to_string(),
648 });
649 }
650 for target in targets {
651 let plugin_after = read_json(&target.plugin_path)?;
652 if plugin_after
653 .get("version")
654 .and_then(serde_json::Value::as_str)
655 != Some(new_version)
656 {
657 return Err(MifRhError::VerificationFailed {
658 path: target.plugin_path.display().to_string(),
659 });
660 }
661 let skill_after = read_text(&target.skill_path)?;
662 if skill_version(&skill_after).as_deref() != Some(new_version) {
663 return Err(MifRhError::VerificationFailed {
664 path: target.skill_path.display().to_string(),
665 });
666 }
667 let doc_after = read_text(&target.doc_path)?;
668 if doc_version(&doc_after, &target.name).as_deref() != Some(new_version) {
669 return Err(MifRhError::VerificationFailed {
670 path: target.doc_path.display().to_string(),
671 });
672 }
673 }
674 Ok(())
675}
676
677#[derive(Debug, Clone)]
679pub enum VersionGateFailure {
680 PackNotBumped {
682 pack: String,
684 version: String,
686 },
687 SkillNotBumped {
689 skill: String,
691 version: String,
693 },
694 PointerNotAhead {
696 current: String,
698 last_release: String,
700 },
701 PointerMissing,
703}
704
705#[derive(Debug, Clone, Default)]
707pub struct VersionGateReport {
708 pub failures: Vec<VersionGateFailure>,
710 pub pointer_at_head: Option<String>,
712}
713
714impl VersionGateReport {
715 #[must_use]
717 pub const fn ok(&self) -> bool {
718 self.failures.is_empty()
719 }
720}
721
722fn git_command(root: &Path) -> Command {
732 let mut command = Command::new("git");
733 command
734 .current_dir(root)
735 .env_remove("GIT_DIR")
736 .env_remove("GIT_WORK_TREE")
737 .env_remove("GIT_INDEX_FILE")
738 .env_remove("GIT_CEILING_DIRECTORIES");
739 command
740}
741
742fn run_git(root: &Path, args: &[&str]) -> Result<String, MifRhError> {
743 let output = git_command(root)
744 .args(args)
745 .output()
746 .map_err(|source| MifRhError::Io {
747 path: "git".to_string(),
748 source,
749 })?;
750 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
751}
752
753fn git_show_json_version(root: &Path, rev: &str, path: &str) -> Option<String> {
754 let output = git_command(root)
755 .args(["show", &format!("{rev}:{path}")])
756 .output()
757 .ok()?;
758 if !output.status.success() {
759 return None;
760 }
761 let text = String::from_utf8_lossy(&output.stdout);
762 let value: serde_json::Value = serde_json::from_str(&text).ok()?;
763 value.get("version")?.as_str().map(str::to_string)
764}
765
766fn git_show_frontmatter_version(root: &Path, rev: &str, path: &str) -> Option<String> {
767 let output = git_command(root)
768 .args(["show", &format!("{rev}:{path}")])
769 .output()
770 .ok()?;
771 if !output.status.success() {
772 return None;
773 }
774 let text = String::from_utf8_lossy(&output.stdout);
775 skill_version(&text)
776}
777
778pub fn check_version_bump(root: &Path, base: &str) -> Result<VersionGateReport, MifRhError> {
789 let merge_base = {
790 let candidate = run_git(root, &["merge-base", base, "HEAD"])?;
791 if candidate.is_empty() {
792 base.to_string()
793 } else {
794 candidate
795 }
796 };
797 let changed_output = run_git(
798 root,
799 &["diff", "--name-only", &format!("{merge_base}...HEAD")],
800 )?;
801 let changed: Vec<&str> = changed_output.lines().filter(|l| !l.is_empty()).collect();
802
803 let mut failures = Vec::new();
804 if !changed.is_empty() {
805 failures.extend(check_changed_packs(root, &merge_base, &changed));
806 failures.extend(check_changed_skills(root, &merge_base, &changed));
807 }
808 check_release_pointer(root, &mut failures);
809
810 let pointer_at_head = read_json(&root.join(RELEASE_POINTER_FILE))
811 .ok()
812 .and_then(|v| {
813 v.get("version")
814 .and_then(serde_json::Value::as_str)
815 .map(str::to_string)
816 });
817
818 Ok(VersionGateReport {
819 failures,
820 pointer_at_head,
821 })
822}
823
824fn check_changed_packs(root: &Path, merge_base: &str, changed: &[&str]) -> Vec<VersionGateFailure> {
827 let changed_packs: BTreeSet<String> = changed
828 .iter()
829 .filter(|path| path.starts_with("packs/") && !path.starts_with("packs/ontologies/"))
830 .filter_map(|path| {
831 let parts: Vec<&str> = path.split('/').collect();
832 (parts.len() > 3).then(|| format!("{}/{}/{}", parts[0], parts[1], parts[2]))
835 })
836 .collect();
837 let mut failures = Vec::new();
838 for pack in changed_packs {
839 let plugin_rel = format!("{pack}/.claude-plugin/plugin.json");
840 if !root.join(&plugin_rel).is_file() {
841 continue; }
843 let Some(base_version) = git_show_json_version(root, merge_base, &plugin_rel) else {
844 continue; };
846 let head_version = read_json(&root.join(&plugin_rel))
847 .ok()
848 .and_then(|v| {
849 v.get("version")
850 .and_then(serde_json::Value::as_str)
851 .map(str::to_string)
852 })
853 .unwrap_or_default();
854 if base_version == head_version {
855 failures.push(VersionGateFailure::PackNotBumped {
856 pack,
857 version: head_version,
858 });
859 }
860 }
861 failures
862}
863
864fn check_changed_skills(
867 root: &Path,
868 merge_base: &str,
869 changed: &[&str],
870) -> Vec<VersionGateFailure> {
871 let changed_skills: BTreeSet<String> = changed
872 .iter()
873 .filter(|path| path.starts_with(".claude/skills/"))
874 .filter_map(|path| {
875 let parts: Vec<&str> = path.split('/').collect();
876 (parts.len() > 3).then(|| parts[..3].join("/"))
877 })
878 .collect();
879 let mut failures = Vec::new();
880 for skill in changed_skills {
881 let skill_rel = format!("{skill}/SKILL.md");
882 if !root.join(&skill_rel).is_file() {
883 continue;
884 }
885 let Some(base_version) = git_show_frontmatter_version(root, merge_base, &skill_rel) else {
886 continue;
887 };
888 let head_version = read_text(&root.join(&skill_rel))
889 .ok()
890 .and_then(|c| skill_version(&c))
891 .unwrap_or_default();
892 if base_version == head_version {
893 failures.push(VersionGateFailure::SkillNotBumped {
894 skill,
895 version: head_version,
896 });
897 }
898 }
899 failures
900}
901
902fn check_release_pointer(root: &Path, failures: &mut Vec<VersionGateFailure>) {
905 let tags_output = run_git(root, &["tag", "--list", "v*"]);
906 let last_release = tags_output.ok().and_then(|tags| {
907 tags.lines()
908 .filter_map(|tag| tag.strip_prefix('v'))
909 .filter_map(parse_semver)
910 .max()
911 });
912 let Some((lm, ln, lp)) = last_release else {
913 return;
914 };
915 let head_version = read_json(&root.join(RELEASE_POINTER_FILE))
916 .ok()
917 .and_then(|v| {
918 v.get("version")
919 .and_then(serde_json::Value::as_str)
920 .map(str::to_string)
921 });
922 match head_version {
923 None => failures.push(VersionGateFailure::PointerMissing),
924 Some(head_version) => {
925 let head_parsed = parse_semver(&head_version).unwrap_or((0, 0, 0));
926 if head_parsed <= (lm, ln, lp) {
927 failures.push(VersionGateFailure::PointerNotAhead {
928 current: head_version,
929 last_release: format!("{lm}.{ln}.{lp}"),
930 });
931 }
932 },
933 }
934}
935
936#[derive(Debug, Clone, PartialEq, Eq)]
939pub enum ChangelogLinkChange {
940 LinkSet {
942 version: String,
944 },
945 Bracketed {
947 version: String,
949 },
950 Unbracketed {
954 version: String,
956 },
957 UnreleasedLinkSet,
959}
960
961#[derive(Debug, Clone, Default)]
963pub struct ChangelogLinkReport {
964 pub changes: Vec<ChangelogLinkChange>,
973}
974
975impl ChangelogLinkReport {
976 #[must_use]
979 pub const fn is_clean(&self) -> bool {
980 self.changes.is_empty()
981 }
982}
983
984struct Heading {
986 version: String,
987 bracketed: bool,
988 rest: String,
990}
991
992fn parse_heading(line: &str) -> Option<Heading> {
993 let rest = line.strip_prefix("## ")?;
994 if rest == "[Unreleased]" {
995 return None;
996 }
997 let (version, after) = rest
998 .strip_prefix('[')
999 .and_then(|inner| inner.split_once("] - "))
1000 .or_else(|| rest.split_once(" - "))?;
1001 is_semver(version).then(|| Heading {
1002 version: version.to_string(),
1003 bracketed: rest.starts_with('['),
1004 rest: after.to_string(),
1005 })
1006}
1007
1008fn render_heading(heading: &Heading, bracketed: bool) -> String {
1009 if bracketed {
1010 format!("## [{}] - {}", heading.version, heading.rest)
1011 } else {
1012 format!("## {} - {}", heading.version, heading.rest)
1013 }
1014}
1015
1016fn is_footer_link_line(line: &str) -> bool {
1018 line.starts_with('[') && line.contains("]: ")
1019}
1020
1021fn parse_footer_line(line: &str) -> Option<(&str, &str)> {
1023 let rest = line.strip_prefix('[')?;
1024 rest.split_once("]: ")
1025}
1026
1027fn last_footer_run(lines: &[&str]) -> (usize, usize) {
1037 let mut best = (lines.len(), lines.len());
1038 let mut i = 0;
1039 while i < lines.len() {
1040 if is_footer_link_line(lines[i]) {
1041 let start = i;
1042 while i < lines.len() && is_footer_link_line(lines[i]) {
1043 i += 1;
1044 }
1045 best = (start, i);
1046 } else {
1047 i += 1;
1048 }
1049 }
1050 best
1051}
1052
1053fn changelog_repo_url(changelog: &str) -> Option<String> {
1057 changelog.lines().find_map(|line| {
1058 let (label, url) = parse_footer_line(line)?;
1059 if label != "Unreleased" {
1060 return None;
1061 }
1062 url.split_once("/compare/")
1063 .map(|(base, _)| base.to_string())
1064 })
1065}
1066
1067fn run_git_checked(root: &Path, args: &[&str]) -> Result<String, MifRhError> {
1071 let output = git_command(root)
1072 .args(args)
1073 .output()
1074 .map_err(|source| MifRhError::Io {
1075 path: "git".to_string(),
1076 source,
1077 })?;
1078 if !output.status.success() {
1079 return Err(MifRhError::GitCommandFailed {
1080 command: args.join(" "),
1081 stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(),
1082 });
1083 }
1084 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
1085}
1086
1087fn read_real_tags(root: &Path) -> Result<Vec<(u64, u64, u64)>, MifRhError> {
1095 let out = run_git_checked(root, &["tag", "--list", "v*"])?;
1096 let mut tags: Vec<(u64, u64, u64)> = out
1097 .lines()
1098 .filter_map(|tag| tag.strip_prefix('v'))
1099 .filter_map(parse_semver)
1100 .collect();
1101 tags.sort_unstable();
1102 Ok(tags)
1103}
1104
1105fn rewrite_headings(
1109 lines: &[&str],
1110 tags: &[(u64, u64, u64)],
1111 max_tag: (u64, u64, u64),
1112 changes: &mut Vec<ChangelogLinkChange>,
1113) -> (String, Vec<(String, bool)>) {
1114 let mut body = String::new();
1115 let mut reconciled = Vec::new();
1116 for line in lines {
1117 let Some(heading) = parse_heading(line) else {
1118 body.push_str(line);
1119 body.push('\n');
1120 continue;
1121 };
1122 let version_tuple = parse_semver(&heading.version).unwrap_or((0, 0, 0));
1123 let has_tag = tags.contains(&version_tuple);
1124 let is_pending = !has_tag && version_tuple > max_tag;
1125 let should_bracket = has_tag || is_pending;
1126 if should_bracket != heading.bracketed {
1127 changes.push(if should_bracket {
1128 ChangelogLinkChange::Bracketed {
1129 version: heading.version.clone(),
1130 }
1131 } else {
1132 ChangelogLinkChange::Unbracketed {
1133 version: heading.version.clone(),
1134 }
1135 });
1136 }
1137 body.push_str(&render_heading(&heading, should_bracket));
1138 body.push('\n');
1139 reconciled.push((heading.version.clone(), has_tag));
1140 }
1141 (body, reconciled)
1142}
1143
1144fn build_footer(
1147 reconciled: &[(String, bool)],
1148 tags: &[(u64, u64, u64)],
1149 max_tag: (u64, u64, u64),
1150 repo_url: &str,
1151 old_footer: &HashMap<String, String>,
1152 changes: &mut Vec<ChangelogLinkChange>,
1153) -> String {
1154 let mut footer = String::new();
1155 let unreleased_link = format!(
1156 "{repo_url}/compare/v{}.{}.{}...HEAD",
1157 max_tag.0, max_tag.1, max_tag.2
1158 );
1159 if old_footer.get("Unreleased") != Some(&unreleased_link) {
1160 changes.push(ChangelogLinkChange::UnreleasedLinkSet);
1161 }
1162 let _ = writeln!(footer, "[Unreleased]: {unreleased_link}");
1163
1164 for (version, has_tag) in reconciled {
1165 if !has_tag {
1166 continue;
1167 }
1168 let v = parse_semver(version).unwrap_or((0, 0, 0));
1169 let prev = tags
1170 .binary_search(&v)
1171 .ok()
1172 .and_then(|idx| idx.checked_sub(1))
1173 .map(|idx| tags[idx]);
1174 let link = match prev {
1175 Some((pm, pn, pp)) => format!("{repo_url}/compare/v{pm}.{pn}.{pp}...v{version}"),
1176 None => format!("{repo_url}/releases/tag/v{version}"),
1177 };
1178 if old_footer.get(version) != Some(&link) {
1179 changes.push(ChangelogLinkChange::LinkSet {
1180 version: version.clone(),
1181 });
1182 }
1183 let _ = writeln!(footer, "[{version}]: {link}");
1184 }
1185 footer
1186}
1187
1188pub fn reconcile_changelog_links(
1207 root: &Path,
1208 check: bool,
1209) -> Result<ChangelogLinkReport, MifRhError> {
1210 let changelog_path = root.join(CHANGELOG_FILE);
1211 let changelog = read_text(&changelog_path)?;
1212
1213 let Some(repo_url) = changelog_repo_url(&changelog) else {
1214 return Ok(ChangelogLinkReport::default());
1215 };
1216 let tags = read_real_tags(root)?;
1217 let Some(&max_tag) = tags.last() else {
1218 return Ok(ChangelogLinkReport::default());
1219 };
1220
1221 let lines: Vec<&str> = changelog.lines().collect();
1222 let (footer_start, footer_end) = last_footer_run(&lines);
1223
1224 let mut old_footer = HashMap::new();
1225 for line in &lines[footer_start..footer_end] {
1226 if let Some((label, url)) = parse_footer_line(line) {
1227 old_footer.insert(label.to_string(), url.to_string());
1228 }
1229 }
1230
1231 let mut changes = Vec::new();
1232 let (body, reconciled) = rewrite_headings(&lines[..footer_start], &tags, max_tag, &mut changes);
1233 let footer = build_footer(
1234 &reconciled,
1235 &tags,
1236 max_tag,
1237 &repo_url,
1238 &old_footer,
1239 &mut changes,
1240 );
1241
1242 if changes.is_empty() {
1243 return Ok(ChangelogLinkReport::default());
1244 }
1245
1246 if !check {
1247 let mut out = body;
1248 out.push_str(&footer);
1249 for line in &lines[footer_end..] {
1250 out.push_str(line);
1251 out.push('\n');
1252 }
1253 write_text(&changelog_path, &out)?;
1254 }
1255
1256 Ok(ChangelogLinkReport { changes })
1257}
1258
1259#[cfg(test)]
1260mod tests {
1261 use super::goal_version_id;
1262
1263 #[test]
1264 fn goal_version_id_ignores_lineage_fields() {
1265 let a = serde_json::json!({"question": "why", "version": 1});
1266 let b = serde_json::json!({"question": "why", "version": 2, "supersedes": "gv-abc"});
1267 assert_eq!(goal_version_id(&a), goal_version_id(&b));
1268 }
1269
1270 #[test]
1271 fn goal_version_id_is_independent_of_key_order() {
1272 let a = serde_json::json!({"question": "why", "scope": "x"});
1273 let b = serde_json::json!({"scope": "x", "question": "why"});
1274 assert_eq!(goal_version_id(&a), goal_version_id(&b));
1275 }
1276
1277 #[test]
1278 fn goal_version_id_changes_with_content() {
1279 let a = serde_json::json!({"question": "why"});
1280 let b = serde_json::json!({"question": "why not"});
1281 assert_ne!(goal_version_id(&a), goal_version_id(&b));
1282 }
1283
1284 #[test]
1285 fn goal_version_id_has_the_expected_shape() {
1286 let id = goal_version_id(&serde_json::json!({"question": "why"}));
1287 assert!(id.starts_with("gv-"));
1288 assert_eq!(id.len(), "gv-".len() + 12);
1289 }
1290
1291 use super::{BumpOptions, PACK_DOC_DIR, bump_version};
1292 use std::fmt::Write as _;
1293 use std::fs;
1294
1295 fn write_base_fixture(root: &std::path::Path) {
1296 fs::write(
1297 root.join("harness.config.json"),
1298 r#"{"version": "0.4.0", "topics": []}"#,
1299 )
1300 .unwrap();
1301 fs::create_dir_all(root.join(".claude-plugin")).unwrap();
1302 fs::write(
1303 root.join(".claude-plugin/marketplace.json"),
1304 r#"{"name": "research-harness", "metadata": {"version": "0.4.0"}}"#,
1305 )
1306 .unwrap();
1307 fs::write(
1308 root.join("CHANGELOG.md"),
1309 "# Changelog\n\n## [Unreleased]\n\n## [0.4.0] - 2026-01-01\n\nInitial.\n",
1310 )
1311 .unwrap();
1312 }
1313
1314 fn write_pack_fixture(root: &std::path::Path, family: &str, name: &str, version: &str) {
1315 let dir = root.join("packs").join(family).join(name);
1316 fs::create_dir_all(dir.join(".claude-plugin")).unwrap();
1317 fs::write(
1318 dir.join(".claude-plugin/plugin.json"),
1319 format!(r#"{{"name": "{name}", "version": "{version}"}}"#),
1320 )
1321 .unwrap();
1322 fs::create_dir_all(dir.join("skills").join(format!("{name}-skill"))).unwrap();
1323 fs::write(
1324 dir.join("skills")
1325 .join(format!("{name}-skill"))
1326 .join("SKILL.md"),
1327 format!("---\nversion: {version}\n---\n\n# {name}\n"),
1328 )
1329 .unwrap();
1330 fs::create_dir_all(root.join(PACK_DOC_DIR)).unwrap();
1331 let doc_path = root.join(PACK_DOC_DIR).join(format!("{family}.md"));
1332 let mut existing = fs::read_to_string(&doc_path).unwrap_or_default();
1333 let _ = write!(existing, "\n## {name}\n\n**Version:** {version}\n");
1334 fs::write(&doc_path, existing).unwrap();
1335 }
1336
1337 #[test]
1338 fn bump_version_patch_moves_the_release_pointer_and_inserts_changelog() {
1339 let dir = tempfile::tempdir().unwrap();
1340 write_base_fixture(dir.path());
1341
1342 let report = bump_version(&BumpOptions {
1343 root: dir.path(),
1344 spec: "patch",
1345 packs: &[],
1346 date: Some("2026-02-01"),
1347 check: false,
1348 })
1349 .unwrap();
1350
1351 assert_eq!(report.old_version, "0.4.0");
1352 assert_eq!(report.new_version, "0.4.1");
1353 assert!(report.applied);
1354
1355 let cfg: serde_json::Value = serde_json::from_str(
1356 &fs::read_to_string(dir.path().join("harness.config.json")).unwrap(),
1357 )
1358 .unwrap();
1359 assert_eq!(cfg["version"], "0.4.1");
1360 let market: serde_json::Value = serde_json::from_str(
1361 &fs::read_to_string(dir.path().join(".claude-plugin/marketplace.json")).unwrap(),
1362 )
1363 .unwrap();
1364 assert_eq!(market["metadata"]["version"], "0.4.1");
1365 let changelog = fs::read_to_string(dir.path().join("CHANGELOG.md")).unwrap();
1366 assert!(changelog.contains("## [0.4.1] - 2026-02-01"));
1367 }
1368
1369 #[test]
1370 fn bump_version_check_mode_writes_nothing() {
1371 let dir = tempfile::tempdir().unwrap();
1372 write_base_fixture(dir.path());
1373
1374 let report = bump_version(&BumpOptions {
1375 root: dir.path(),
1376 spec: "minor",
1377 packs: &[],
1378 date: Some("2026-02-01"),
1379 check: true,
1380 })
1381 .unwrap();
1382
1383 assert!(!report.applied);
1384 assert_eq!(report.new_version, "0.5.0");
1385 let cfg: serde_json::Value = serde_json::from_str(
1386 &fs::read_to_string(dir.path().join("harness.config.json")).unwrap(),
1387 )
1388 .unwrap();
1389 assert_eq!(cfg["version"], "0.4.0", "check mode must not write");
1390 }
1391
1392 #[test]
1393 fn bump_version_rejects_an_unchanged_version() {
1394 let dir = tempfile::tempdir().unwrap();
1395 write_base_fixture(dir.path());
1396
1397 let error = bump_version(&BumpOptions {
1398 root: dir.path(),
1399 spec: "0.4.0",
1400 packs: &[],
1401 date: Some("2026-02-01"),
1402 check: false,
1403 })
1404 .unwrap_err();
1405 assert!(matches!(error, super::MifRhError::VersionUnchanged { .. }));
1406 }
1407
1408 #[test]
1409 fn bump_version_bumps_a_named_pack_across_all_three_files() {
1410 let dir = tempfile::tempdir().unwrap();
1411 write_base_fixture(dir.path());
1412 write_pack_fixture(dir.path(), "channels", "pdf", "0.4.0");
1413
1414 let report = bump_version(&BumpOptions {
1415 root: dir.path(),
1416 spec: "patch",
1417 packs: &["pdf".to_string()],
1418 date: Some("2026-02-01"),
1419 check: false,
1420 })
1421 .unwrap();
1422 assert_eq!(report.packs, ["pdf"]);
1423
1424 let plugin: serde_json::Value = serde_json::from_str(
1425 &fs::read_to_string(
1426 dir.path()
1427 .join("packs/channels/pdf/.claude-plugin/plugin.json"),
1428 )
1429 .unwrap(),
1430 )
1431 .unwrap();
1432 assert_eq!(plugin["version"], "0.4.1");
1433 let skill = fs::read_to_string(
1434 dir.path()
1435 .join("packs/channels/pdf/skills/pdf-skill/SKILL.md"),
1436 )
1437 .unwrap();
1438 assert!(skill.contains("version: 0.4.1"));
1439 let doc = fs::read_to_string(dir.path().join("docs/reference/packs/channels.md")).unwrap();
1440 assert!(doc.contains("**Version:** 0.4.1"));
1441 }
1442
1443 #[test]
1444 fn bump_version_rejects_a_pack_already_ahead_of_the_new_release() {
1445 let dir = tempfile::tempdir().unwrap();
1446 write_base_fixture(dir.path());
1447 write_pack_fixture(dir.path(), "channels", "pdf", "9.0.0");
1448
1449 let error = bump_version(&BumpOptions {
1450 root: dir.path(),
1451 spec: "patch",
1452 packs: &["pdf".to_string()],
1453 date: Some("2026-02-01"),
1454 check: false,
1455 })
1456 .unwrap_err();
1457 assert!(matches!(
1458 error,
1459 super::MifRhError::PackAheadOfRelease { .. }
1460 ));
1461 let cfg: serde_json::Value = serde_json::from_str(
1463 &fs::read_to_string(dir.path().join("harness.config.json")).unwrap(),
1464 )
1465 .unwrap();
1466 assert_eq!(cfg["version"], "0.4.0");
1467 }
1468
1469 #[test]
1470 fn bump_version_rejects_a_missing_changelog_anchor() {
1471 let dir = tempfile::tempdir().unwrap();
1472 write_base_fixture(dir.path());
1473 fs::write(
1474 dir.path().join("CHANGELOG.md"),
1475 "# Changelog\n\nNo anchor here.\n",
1476 )
1477 .unwrap();
1478
1479 let error = bump_version(&BumpOptions {
1480 root: dir.path(),
1481 spec: "patch",
1482 packs: &[],
1483 date: Some("2026-02-01"),
1484 check: false,
1485 })
1486 .unwrap_err();
1487 assert!(matches!(
1488 error,
1489 super::MifRhError::ChangelogAnchorMissing { .. }
1490 ));
1491 }
1492
1493 use super::{VersionGateFailure, check_version_bump};
1494
1495 fn git(root: &std::path::Path, args: &[&str]) {
1496 let status = super::git_command(root).args(args).status().unwrap();
1497 assert!(status.success(), "git {args:?} failed");
1498 }
1499
1500 fn init_repo(root: &std::path::Path) {
1501 git(root, &["init", "-q", "-b", "main"]);
1502 git(root, &["config", "user.email", "test@example.com"]);
1503 git(root, &["config", "user.name", "Test"]);
1504 }
1505
1506 #[test]
1507 fn check_version_bump_passes_when_a_changed_pack_moved_its_own_version() {
1508 let dir = tempfile::tempdir().unwrap();
1509 init_repo(dir.path());
1510 write_base_fixture(dir.path());
1511 write_pack_fixture(dir.path(), "channels", "pdf", "0.4.0");
1512 git(dir.path(), &["add", "-A"]);
1513 git(dir.path(), &["commit", "-q", "-m", "base"]);
1514 git(dir.path(), &["tag", "v0.4.0"]);
1515 git(dir.path(), &["checkout", "-q", "-b", "feature"]);
1516
1517 std::fs::write(
1519 dir.path()
1520 .join("packs/channels/pdf/.claude-plugin/plugin.json"),
1521 r#"{"name": "pdf", "version": "0.4.1"}"#,
1522 )
1523 .unwrap();
1524 std::fs::write(
1525 dir.path().join("harness.config.json"),
1526 r#"{"version": "0.4.1", "topics": []}"#,
1527 )
1528 .unwrap();
1529 git(dir.path(), &["add", "-A"]);
1530 git(dir.path(), &["commit", "-q", "-m", "bump pdf"]);
1531
1532 let report = check_version_bump(dir.path(), "main").unwrap();
1533 assert!(
1534 report.ok(),
1535 "expected no failures, got {:?}",
1536 report.failures
1537 );
1538 }
1539
1540 #[test]
1541 fn check_version_bump_fails_when_a_changed_pack_did_not_move_its_version() {
1542 let dir = tempfile::tempdir().unwrap();
1543 init_repo(dir.path());
1544 write_base_fixture(dir.path());
1545 write_pack_fixture(dir.path(), "channels", "pdf", "0.4.0");
1546 git(dir.path(), &["add", "-A"]);
1547 git(dir.path(), &["commit", "-q", "-m", "base"]);
1548 git(dir.path(), &["checkout", "-q", "-b", "feature"]);
1549
1550 std::fs::write(
1552 dir.path()
1553 .join("packs/channels/pdf/skills/pdf-skill/SKILL.md"),
1554 "---\nversion: 0.4.0\n---\n\n# pdf (edited)\n",
1555 )
1556 .unwrap();
1557 git(dir.path(), &["add", "-A"]);
1558 git(
1559 dir.path(),
1560 &["commit", "-q", "-m", "edit pdf, forgot to bump"],
1561 );
1562
1563 let report = check_version_bump(dir.path(), "main").unwrap();
1564 assert!(!report.ok());
1565 assert!(
1566 report
1567 .failures
1568 .iter()
1569 .any(|f| matches!(f, VersionGateFailure::PackNotBumped { pack, .. } if pack == "packs/channels/pdf"))
1570 );
1571 }
1572
1573 #[test]
1574 fn check_version_bump_fails_when_the_release_pointer_is_not_ahead_of_the_last_tag() {
1575 let dir = tempfile::tempdir().unwrap();
1576 init_repo(dir.path());
1577 write_base_fixture(dir.path());
1578 git(dir.path(), &["add", "-A"]);
1579 git(dir.path(), &["commit", "-q", "-m", "base"]);
1580 git(dir.path(), &["tag", "v0.4.0"]);
1581 git(dir.path(), &["checkout", "-q", "-b", "feature"]);
1582 std::fs::write(dir.path().join("README.md"), "unrelated change\n").unwrap();
1583 git(dir.path(), &["add", "-A"]);
1584 git(dir.path(), &["commit", "-q", "-m", "unrelated"]);
1585
1586 let report = check_version_bump(dir.path(), "main").unwrap();
1587 assert!(!report.ok());
1588 assert!(
1589 report
1590 .failures
1591 .iter()
1592 .any(|f| matches!(f, VersionGateFailure::PointerNotAhead { .. }))
1593 );
1594 }
1595
1596 use super::{ChangelogLinkChange, reconcile_changelog_links};
1597
1598 const REPO_URL: &str = "https://github.com/example/harness";
1599
1600 fn write_changelog(root: &std::path::Path, body: &str) {
1601 std::fs::write(root.join("CHANGELOG.md"), body).unwrap();
1602 }
1603
1604 #[test]
1605 fn reconcile_changelog_links_noop_when_pending_version_has_no_tag_yet() {
1606 let dir = tempfile::tempdir().unwrap();
1607 init_repo(dir.path());
1608 write_changelog(
1609 dir.path(),
1610 &format!(
1611 "# Changelog\n\n## [Unreleased]\n\n## [0.5.0] - 2026-02-01\n\nPending.\n\n\
1612 [Unreleased]: {REPO_URL}/compare/v0.4.0...HEAD\n\
1613 [0.4.0]: {REPO_URL}/releases/tag/v0.4.0\n"
1614 ),
1615 );
1616 git(dir.path(), &["add", "-A"]);
1617 git(dir.path(), &["commit", "-q", "-m", "base"]);
1618 git(dir.path(), &["tag", "v0.4.0"]);
1619
1620 let report = reconcile_changelog_links(dir.path(), false).unwrap();
1621 assert!(report.is_clean(), "expected no changes, got {report:?}");
1622 let after = std::fs::read_to_string(dir.path().join("CHANGELOG.md")).unwrap();
1623 assert!(after.contains("## [0.5.0] - 2026-02-01"));
1624 assert!(!after.contains("[0.5.0]:"));
1625 }
1626
1627 #[test]
1628 fn reconcile_changelog_links_adds_link_once_pending_version_is_tagged() {
1629 let dir = tempfile::tempdir().unwrap();
1630 init_repo(dir.path());
1631 write_changelog(
1632 dir.path(),
1633 &format!(
1634 "# Changelog\n\n## [Unreleased]\n\n## [0.5.0] - 2026-02-01\n\nShipped.\n\n\
1635 [Unreleased]: {REPO_URL}/compare/v0.4.0...HEAD\n\
1636 [0.4.0]: {REPO_URL}/releases/tag/v0.4.0\n"
1637 ),
1638 );
1639 git(dir.path(), &["add", "-A"]);
1640 git(dir.path(), &["commit", "-q", "-m", "base"]);
1641 git(dir.path(), &["tag", "v0.4.0"]);
1642 git(dir.path(), &["tag", "v0.5.0"]);
1643
1644 let report = reconcile_changelog_links(dir.path(), false).unwrap();
1645 assert!(report.changes.contains(&ChangelogLinkChange::LinkSet {
1646 version: "0.5.0".to_string()
1647 }));
1648 assert!(
1649 report
1650 .changes
1651 .contains(&ChangelogLinkChange::UnreleasedLinkSet)
1652 );
1653
1654 let after = std::fs::read_to_string(dir.path().join("CHANGELOG.md")).unwrap();
1655 assert!(after.contains(&format!("[0.5.0]: {REPO_URL}/compare/v0.4.0...v0.5.0")));
1656 assert!(after.contains(&format!("[Unreleased]: {REPO_URL}/compare/v0.5.0...HEAD")));
1657
1658 let second = reconcile_changelog_links(dir.path(), false).unwrap();
1660 assert!(second.is_clean(), "expected idempotence, got {second:?}");
1661 }
1662
1663 #[test]
1664 fn reconcile_changelog_links_check_mode_does_not_write() {
1665 let dir = tempfile::tempdir().unwrap();
1666 init_repo(dir.path());
1667 let original = format!(
1668 "# Changelog\n\n## [Unreleased]\n\n## [0.5.0] - 2026-02-01\n\nShipped.\n\n\
1669 [Unreleased]: {REPO_URL}/compare/v0.4.0...HEAD\n\
1670 [0.4.0]: {REPO_URL}/releases/tag/v0.4.0\n"
1671 );
1672 write_changelog(dir.path(), &original);
1673 git(dir.path(), &["add", "-A"]);
1674 git(dir.path(), &["commit", "-q", "-m", "base"]);
1675 git(dir.path(), &["tag", "v0.4.0"]);
1676 git(dir.path(), &["tag", "v0.5.0"]);
1677
1678 let report = reconcile_changelog_links(dir.path(), true).unwrap();
1679 assert!(!report.is_clean());
1680 let after = std::fs::read_to_string(dir.path().join("CHANGELOG.md")).unwrap();
1681 assert_eq!(after, original, "check mode must not write the file");
1682 }
1683
1684 #[test]
1685 fn reconcile_changelog_links_unbrackets_a_version_folded_into_a_later_release() {
1686 let dir = tempfile::tempdir().unwrap();
1687 init_repo(dir.path());
1688 write_changelog(
1689 dir.path(),
1690 &format!(
1691 "# Changelog\n\n## [Unreleased]\n\n\
1692 ## [0.6.0] - 2026-03-01\n\nReal release.\n\n\
1693 ## [0.5.1] - 2026-02-15\n\nBumped then folded into 0.6.0 without its own tag.\n\n\
1694 ## [0.5.0] - 2026-02-01\n\nReal release.\n\n\
1695 [Unreleased]: {REPO_URL}/compare/v0.6.0...HEAD\n\
1696 [0.6.0]: {REPO_URL}/compare/v0.5.0...v0.6.0\n\
1697 [0.5.1]: {REPO_URL}/compare/v0.5.0...v0.5.1\n\
1698 [0.5.0]: {REPO_URL}/releases/tag/v0.5.0\n"
1699 ),
1700 );
1701 git(dir.path(), &["add", "-A"]);
1702 git(dir.path(), &["commit", "-q", "-m", "base"]);
1703 git(dir.path(), &["tag", "v0.5.0"]);
1704 git(dir.path(), &["tag", "v0.6.0"]);
1705
1706 let report = reconcile_changelog_links(dir.path(), false).unwrap();
1707 assert!(report.changes.contains(&ChangelogLinkChange::Unbracketed {
1708 version: "0.5.1".to_string()
1709 }));
1710
1711 let after = std::fs::read_to_string(dir.path().join("CHANGELOG.md")).unwrap();
1712 assert!(after.contains("## 0.5.1 - 2026-02-15"));
1713 assert!(!after.contains("[0.5.1]:"));
1714 assert!(after.contains("## [0.6.0] - 2026-03-01"));
1715 assert!(after.contains(&format!("[0.6.0]: {REPO_URL}/compare/v0.5.0...v0.6.0")));
1716 }
1717
1718 #[test]
1719 fn reconcile_changelog_links_noop_without_any_real_tags() {
1720 let dir = tempfile::tempdir().unwrap();
1721 init_repo(dir.path());
1722 write_changelog(
1723 dir.path(),
1724 &format!(
1725 "# Changelog\n\n## [Unreleased]\n\n## [0.1.0] - 2026-01-01\n\nFirst.\n\n\
1726 [Unreleased]: {REPO_URL}/compare/v0.1.0...HEAD\n"
1727 ),
1728 );
1729 git(dir.path(), &["add", "-A"]);
1730 git(dir.path(), &["commit", "-q", "-m", "base"]);
1731
1732 let report = reconcile_changelog_links(dir.path(), false).unwrap();
1733 assert!(report.is_clean());
1734 }
1735
1736 #[test]
1737 fn reconcile_changelog_links_ignores_an_inline_reference_link_in_a_body_bullet() {
1738 let dir = tempfile::tempdir().unwrap();
1741 init_repo(dir.path());
1742 write_changelog(
1743 dir.path(),
1744 &format!(
1745 "# Changelog\n\n## [Unreleased]\n\n## [0.5.0] - 2026-02-01\n\n\
1746 - Fixes a bug. [#123]: not-a-real-footer-link\n\n\
1747 [Unreleased]: {REPO_URL}/compare/v0.4.0...HEAD\n\
1748 [0.4.0]: {REPO_URL}/releases/tag/v0.4.0\n"
1749 ),
1750 );
1751 git(dir.path(), &["add", "-A"]);
1752 git(dir.path(), &["commit", "-q", "-m", "base"]);
1753 git(dir.path(), &["tag", "v0.4.0"]);
1754 git(dir.path(), &["tag", "v0.5.0"]);
1755
1756 let report = reconcile_changelog_links(dir.path(), false).unwrap();
1757 assert!(report.changes.contains(&ChangelogLinkChange::LinkSet {
1758 version: "0.5.0".to_string()
1759 }));
1760 let after = std::fs::read_to_string(dir.path().join("CHANGELOG.md")).unwrap();
1761 assert!(after.contains("- Fixes a bug. [#123]: not-a-real-footer-link"));
1762 assert!(after.contains(&format!("[0.5.0]: {REPO_URL}/compare/v0.4.0...v0.5.0")));
1763 }
1764}