Skip to main content

mif_rh/
harness_release.rs

1//! Harness-native release/versioning tooling (rht Category B, Story #298).
2//!
3//! Ports rht's `scripts/goal-version.sh`, `scripts/bump-version.sh`, and
4//! `scripts/check-version-bump.sh` (ADR-0010's change-driven versioning) to
5//! the compiled engine.
6
7use 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/// Computes a goal's content-hash identity: `gv-<sha256(normalized
17/// goal)[:12]>`.
18///
19/// "Normalized" is the goal JSON with the lineage fields (`version`,
20/// `supersedes`, `revision`) removed and all keys sorted, compact. Removing
21/// the lineage fields makes the hash a stable function of the goal's
22/// *content* — minting a new version never perturbs the hash of the
23/// content it describes.
24#[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
45/// Serializes `value` to compact JSON with object keys sorted, matching
46/// `jq -cS`'s canonical form (needed for [`goal_version_id`]'s hash to be
47/// independent of source key order).
48fn 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    // Sorted, then serialized without pretty-printing; serde_json's default
66    // object serialization preserves insertion order, and `sort` above
67    // already inserted in sorted key order.
68    serde_json::to_string(&sort(value)).unwrap_or_default()
69}
70
71/// One `--pack` target resolved to its concrete files.
72#[derive(Debug, Clone)]
73struct PackTarget {
74    name: String,
75    plugin_path: PathBuf,
76    skill_path: PathBuf,
77    doc_path: PathBuf,
78}
79
80/// Options for [`bump_version`].
81pub struct BumpOptions<'a> {
82    /// Repo root (`harness.config.json` and friends resolve against this).
83    pub root: &'a Path,
84    /// `"patch"`, `"minor"`, `"major"`, or an explicit `X.Y.Z`.
85    pub spec: &'a str,
86    /// Component names to also bump (each under `packs/<family>/<name>/`,
87    /// excluding `packs/ontologies/*`, which versions independently).
88    pub packs: &'a [String],
89    /// CHANGELOG date for the new section. Defaults to today (UTC) if
90    /// omitted.
91    pub date: Option<&'a str>,
92    /// Dry run: validate and report, write nothing.
93    pub check: bool,
94}
95
96/// The outcome of a [`bump_version`] call.
97#[derive(Debug, Clone)]
98pub struct BumpReport {
99    /// The version before the bump.
100    pub old_version: String,
101    /// The version after the bump.
102    pub new_version: String,
103    /// The CHANGELOG date used.
104    pub date: String,
105    /// Component names also bumped.
106    pub packs: Vec<String>,
107    /// Whether files were actually written (`false` for `--check`).
108    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
116/// Whether `changelog` already has a `## [<version>]` section header —
117/// matching `grep -q "^## \[$NEW\]"`'s prefix semantics, since the real
118/// header line also carries a trailing `- YYYY-MM-DD` date.
119fn 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
144/// Whether `a` is a semver release strictly ahead of `b`.
145fn 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
193/// Resolves one `--pack` component name to its plugin/skill/doc files under
194/// `root`, excluding `packs/ontologies/*` (those version independently).
195fn 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
257/// Finds the first `<skills_dir>/*/SKILL.md` (one level of skill-name
258/// nesting under `skills/`), matching `find "$dir/skills" -mindepth 2
259/// -maxdepth 2 -name SKILL.md | head -1`.
260fn 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
274/// Rewrites the first `version: X` line in SKILL.md's YAML frontmatter to
275/// `version: <new>`.
276fn 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
292/// The first `version:` line's value in SKILL.md's frontmatter, if any.
293fn 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
300/// Rewrites the first `**Version:** X` row inside `## <comp>`/`### <comp>`'s
301/// section (bounded by the next heading of any level) to `**Version:**
302/// <new>`.
303fn 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
324/// The `**Version:**` row's value inside `component`'s section, if any.
325fn 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
345/// Whether `component`'s section (bounded by the next heading) exists and
346/// has a `**Version:**` row.
347fn doc_has_version_row(contents: &str, component: &str) -> bool {
348    doc_version(contents, component).is_some()
349}
350
351/// Whether `component`'s section heading (`## <component>` or `###
352/// <component>`) exists at all, independent of whether it carries a
353/// `**Version:**` row.
354fn 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
362/// Change-driven version bump (ADR-0010).
363///
364/// Moves the release pointer (`harness.config.json` `.version`), the
365/// marketplace catalog (`.claude-plugin/marketplace.json`
366/// `.metadata.version`), inserts a dated CHANGELOG section, and — for each
367/// named `--pack` — moves that component's own `plugin.json` version,
368/// `SKILL.md` frontmatter version, and family-doc `**Version:**` row.
369/// Validates every mutation before writing any of them (transactional: a
370/// malformed input fails with the tree untouched), and self-verifies every
371/// write afterward.
372///
373/// # Errors
374///
375/// Returns [`MifRhError::VersionNotSemver`] if the current version, a
376/// pack's version, or the requested spec is not well-formed semver,
377/// [`MifRhError::VersionUnchanged`] if the new version equals the current
378/// one, [`MifRhError::PackNotFound`]/[`MifRhError::PackAmbiguous`]/
379/// [`MifRhError::PackFileMissing`] for an unresolvable `--pack` target,
380/// [`MifRhError::ChangelogAnchorMissing`] if the CHANGELOG has neither an
381/// `[Unreleased]` anchor nor an existing section for the new version,
382/// [`MifRhError::PackAheadOfRelease`] if a pack's current version is
383/// already ahead of the new release, and [`MifRhError::VerificationFailed`]
384/// if a post-write self-check finds a file that did not update.
385pub 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
451/// Resolves `spec` (`"major"`/`"minor"`/`"patch"` or an explicit `X.Y.Z`)
452/// against `old_version` into the concrete new version.
453fn 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
486/// Validates every mutation `apply_bump` would make, before any of them are
487/// written (transactional: a malformed input fails with the tree
488/// untouched). Returns whether the CHANGELOG already has a section for
489/// `new_version`.
490fn 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
555/// Inputs for [`apply_bump`], bundled to stay under this workspace's
556/// too-many-arguments threshold.
557struct 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
569/// Writes every mutation `bump_version` makes: the release pointer, the
570/// marketplace catalog, the CHANGELOG insertion, and each pack's three
571/// stamps. Called only after [`validate_bump_preconditions`] has already
572/// confirmed every one of these writes is well-formed.
573fn 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
619/// Confirms every file `apply_bump` touched now reads `new_version`.
620fn 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/// One version-bump-gate failure (ADR-0010's two independent rules).
678#[derive(Debug, Clone)]
679pub enum VersionGateFailure {
680    /// Rule A.1: a changed pack did not move its `plugin.json` version.
681    PackNotBumped {
682        /// The pack directory.
683        pack: String,
684        /// Its unchanged version.
685        version: String,
686    },
687    /// Rule A.2: a changed core skill did not move its `SKILL.md` version.
688    SkillNotBumped {
689        /// The skill directory.
690        skill: String,
691        /// Its unchanged version.
692        version: String,
693    },
694    /// Rule B: the release pointer is not ahead of the last release tag.
695    PointerNotAhead {
696        /// The current release pointer.
697        current: String,
698        /// The last released tag (without the leading `v`).
699        last_release: String,
700    },
701    /// Rule B: `harness.config.json` has no `.version` at HEAD.
702    PointerMissing,
703}
704
705/// The outcome of a [`check_version_bump`] call.
706#[derive(Debug, Clone, Default)]
707pub struct VersionGateReport {
708    /// Every rule violation found, empty if the gate passes.
709    pub failures: Vec<VersionGateFailure>,
710    /// The release pointer's value at HEAD, if readable.
711    pub pointer_at_head: Option<String>,
712}
713
714impl VersionGateReport {
715    /// Whether every rule held.
716    #[must_use]
717    pub const fn ok(&self) -> bool {
718        self.failures.is_empty()
719    }
720}
721
722/// Builds a `git` [`Command`] rooted at `root`, with `GIT_DIR`/
723/// `GIT_WORK_TREE`/`GIT_INDEX_FILE`/`GIT_CEILING_DIRECTORIES` explicitly
724/// cleared.
725///
726/// Without this, a caller invoked from inside another git hook (e.g. a
727/// pre-push hook wrapping this very test suite) can have those variables
728/// set in its environment; git honors an inherited `GIT_DIR` over
729/// `current_dir`, silently redirecting every git call here to the
730/// *caller's* repository instead of `root`.
731fn 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
778/// Enforces ADR-0010's change-driven versioning invariants against `base`.
779///
780/// Every changed pack/core-skill under `packs/` (excluding
781/// `packs/ontologies/*`) or `.claude/skills/` must move its own version,
782/// and `harness.config.json`'s release pointer must stay strictly ahead of
783/// the last `v*` release tag.
784///
785/// # Errors
786///
787/// Returns [`MifRhError::Io`] if `git` cannot be invoked.
788pub 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
824/// Rule A.1: every changed pack (excluding `packs/ontologies/*`) must move
825/// its own `plugin.json` `.version`.
826fn 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            // packs/<family>/<component>/... — need family AND component,
833            // not just family.
834            (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; // pack removed at HEAD — no requirement
842        }
843        let Some(base_version) = git_show_json_version(root, merge_base, &plugin_rel) else {
844            continue; // new pack, absent at base
845        };
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
864/// Rule A.2: every changed core skill must move its own `SKILL.md`
865/// frontmatter `version:`.
866fn 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
902/// Rule B: the release pointer must stay strictly ahead of the last `v*`
903/// release tag.
904fn 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/// A heading/footer-link edit [`reconcile_changelog_links`] made — or, in
937/// check mode, would make.
938#[derive(Debug, Clone, PartialEq, Eq)]
939pub enum ChangelogLinkChange {
940    /// Added or corrected the footer compare-link for a tagged version.
941    LinkSet {
942        /// The tagged version the link is for.
943        version: String,
944    },
945    /// Re-bracketed a heading now that a real tag exists for it.
946    Bracketed {
947        /// The now-tagged version.
948        version: String,
949    },
950    /// Stripped brackets (and any footer link) from a version that was
951    /// bumped but folded into a later tagged release without ever getting
952    /// its own tag.
953    Unbracketed {
954        /// The orphaned version.
955        version: String,
956    },
957    /// Updated `[Unreleased]`'s compare-from tag.
958    UnreleasedLinkSet,
959}
960
961/// The outcome of a [`reconcile_changelog_links`] call.
962#[derive(Debug, Clone, Default)]
963pub struct ChangelogLinkReport {
964    /// Every change made (or, in check mode, that would be made). Heading
965    /// bracket changes ([`ChangelogLinkChange::Bracketed`]/
966    /// [`ChangelogLinkChange::Unbracketed`]) are collected first, in file
967    /// order, followed by footer-link changes
968    /// ([`ChangelogLinkChange::LinkSet`]/
969    /// [`ChangelogLinkChange::UnreleasedLinkSet`]) — the two are found in
970    /// separate passes, so overall ordering is two-phase, not strictly by
971    /// file position.
972    pub changes: Vec<ChangelogLinkChange>,
973}
974
975impl ChangelogLinkReport {
976    /// Whether the CHANGELOG's headings/footer already matched real tag
977    /// state, i.e. there was nothing to reconcile.
978    #[must_use]
979    pub const fn is_clean(&self) -> bool {
980        self.changes.is_empty()
981    }
982}
983
984/// A parsed `## [X.Y.Z] - DATE` or `## X.Y.Z - DATE` heading line.
985struct Heading {
986    version: String,
987    bracketed: bool,
988    /// Everything after `- ` (normally just the date), kept verbatim.
989    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
1016/// Whether `line` is a reference-style footer link (`[label]: url`).
1017fn is_footer_link_line(line: &str) -> bool {
1018    line.starts_with('[') && line.contains("]: ")
1019}
1020
1021/// Parses a footer link line into its `(label, url)` pair.
1022fn parse_footer_line(line: &str) -> Option<(&str, &str)> {
1023    let rest = line.strip_prefix('[')?;
1024    rest.split_once("]: ")
1025}
1026
1027/// Finds the real reference-link footer: the *last* maximal contiguous run
1028/// of [`is_footer_link_line`] lines in the file.
1029///
1030/// Scanning for the first such line instead (as a naive top-down search
1031/// would) misfires if a body bullet happens to contain an inline
1032/// `[label]: url`-shaped reference before the real footer. Keep a
1033/// Changelog's actual footer is always the final such block in the file, so
1034/// anchoring on the last run is what makes this robust against that false
1035/// positive.
1036fn 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
1053/// Extracts the `https://github.com/<owner>/<repo>` prefix from the
1054/// `[Unreleased]` footer link, so generated links target this harness
1055/// instance's actual remote rather than a hardcoded org/repo.
1056fn 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
1067/// Like [`run_git`], but treats a non-zero exit status as a real failure
1068/// (not a repo, corrupted refs, etc.) instead of silently returning
1069/// whatever partial stdout was produced.
1070fn 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
1087/// Real (parsed, ascending-sorted) `v*` tags read from `git tag --list`.
1088///
1089/// # Errors
1090///
1091/// Returns [`MifRhError::GitCommandFailed`] if `git` itself fails (not a
1092/// repository, corrupted refs) — distinct from a valid repo that simply has
1093/// no tags yet.
1094fn 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
1105/// Rewrites every dated heading's brackets to match real tag state, and
1106/// returns the rewritten body plus `(version, has_tag)` for each section in
1107/// file order (feeds [`build_footer`]).
1108fn 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
1144/// Regenerates the `[Unreleased]`/`[X.Y.Z]` footer link block from scratch,
1145/// comparing each entry against `old_footer` to record what changed.
1146fn 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
1188/// Reconciles `CHANGELOG.md`'s heading brackets and footer compare-links
1189/// against real git tags.
1190///
1191/// The convention established by #392/#393: a dated section is bracketed
1192/// with a footer compare-link once a real tag exists for it, or while it is
1193/// still awaiting its first release (nothing newer has been tagged yet); a
1194/// version that was bumped but superseded by a later tagged release without
1195/// ever getting its own tag loses both, since there is nothing real left to
1196/// compare it against. In `check` mode, computes and returns the changes
1197/// without writing the file — used to detect drift without mutating
1198/// anything.
1199///
1200/// # Errors
1201///
1202/// Returns [`MifRhError::Io`] if the file can't be read (or, when `check`
1203/// is `false`, written), or [`MifRhError::GitCommandFailed`] if `git`
1204/// itself fails (not a repository, corrupted refs) — distinct from a valid
1205/// repo that simply has no tags yet.
1206pub 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        // Nothing should have been written: pre-flight runs before any apply.
1462        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        // Change the pack's content and bump its own version.
1518        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        // Touch the pack's content WITHOUT bumping its version.
1551        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        // Idempotent: running again finds nothing left to reconcile.
1659        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        // A body bullet shaped like a footer link (e.g. an issue reference)
1739        // must not be mistaken for the real footer, which always sits last.
1740        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}