harn-stdlib 0.8.49

Embedded Harn standard library source catalog
Documentation
// std/semver — Semantic-version helpers used by release tooling, bump
// orchestrators, and any harness that needs to parse, compare, or emit
// SemVer (https://semver.org/) version strings.
//
// Scope: parse "MAJOR.MINOR.PATCH" triples (no prerelease/build metadata),
// strip/add the canonical leading `v`, compute single-step bumps, classify
// which bump separates two versions, and unpack release branches / tags.
// These helpers used to live in each release-tooling repo (harn-bump-fleet's
// `lib/semver.harn`, harn's own `scripts/detect_bump_type.harn`); promoting
// them to the stdlib removes the per-repo drift and the inconsistent error
// messages.
//
// Import with:
//   import {
//     strip_v, add_v, is_v_semver, parse, next, bump_type,
//     version_from_release_branch, version_from_tag,
//   } from "std/semver"
/** Parsed "MAJOR.MINOR.PATCH" triple. All three fields are non-negative ints. */
type Version = {major: int, minor: int, patch: int}

/**
 * Strip an optional leading `v` from a version string. Returns the empty
 * string for `nil` input so callers can `strip_v(maybe_tag)` without a
 * separate nil guard.
 *
 * @effects: []
 * @allocation: heap
 * @errors: []
 * @api_stability: stable
 * @example: strip_v("v1.2.3")
 */
pub fn strip_v(version) -> string {
  let text = to_string(version ?? "")
  if starts_with(text, "v") {
    return substring(text, 1, len(text))
  }
  return text
}

/**
 * Add a leading `v` to a bare semver string. No-op if already prefixed.
 * Returns the empty string for `nil` input.
 *
 * @effects: []
 * @allocation: heap
 * @errors: []
 * @api_stability: stable
 * @example: add_v("1.2.3")
 */
pub fn add_v(version) -> string {
  let text = to_string(version ?? "")
  if text == "" || starts_with(text, "v") {
    return text
  }
  return "v" + text
}

/**
 * Return true for canonical release tags like `v1.2.3`. Rejects prerelease
 * tails (`v1.2.3-rc.1`) and build metadata (`v1.2.3+ci.42`) — release
 * tooling that cares about those forms should match its own regex.
 *
 * @effects: []
 * @allocation: heap
 * @errors: []
 * @api_stability: stable
 * @example: is_v_semver("v1.2.3")
 */
pub fn is_v_semver(value) -> bool {
  return regex_match("^v[0-9]+\\.[0-9]+\\.[0-9]+$", to_string(value ?? "")) != nil
}

/**
 * Parse a "MAJOR.MINOR.PATCH" string into a `Version` dict. Accepts an
 * optional leading `v`. Returns `nil` (not an exception) when the input
 * is not exactly three non-negative integers separated by dots, so
 * callers can match-on-nil for soft validation. Use `next` / `bump_type`
 * when a throwing variant is more ergonomic.
 *
 * @effects: []
 * @allocation: heap
 * @errors: []
 * @api_stability: stable
 * @example: parse("v1.2.3")
 */
pub fn parse(version) -> Version? {
  let text = strip_v(version)
  let parts = split(text, ".")
  if len(parts) != 3 {
    return nil
  }
  let major = to_int(parts[0])
  let minor = to_int(parts[1])
  let patch = to_int(parts[2])
  if major == nil || minor == nil || patch == nil {
    return nil
  }
  if major < 0 || minor < 0 || patch < 0 {
    return nil
  }
  return {major: major, minor: minor, patch: patch}
}

/**
 * Return the next "MAJOR.MINOR.PATCH" version for a `"major"`, `"minor"`,
 * or `"patch"` bump. Throws on non-semver `current` or unknown `bump`.
 * The returned string never carries a leading `v` — wrap with `add_v` if
 * the caller wants the tag-shaped form.
 *
 * @effects: []
 * @allocation: heap
 * @errors: ["std/semver: current version is not semver", "std/semver: bump must be major, minor, or patch"]
 * @api_stability: stable
 * @example: next("1.2.3", "minor")
 */
pub fn next(current, bump) -> string {
  let parsed = parse(current)
  if parsed == nil {
    throw "std/semver: current version is not semver: " + to_string(current ?? "")
  }
  if bump == "major" {
    return to_string(parsed.major + 1) + ".0.0"
  }
  if bump == "minor" {
    return to_string(parsed.major) + "." + to_string(parsed.minor + 1) + ".0"
  }
  if bump == "patch" {
    return to_string(parsed.major) + "." + to_string(parsed.minor) + "."
      + to_string(parsed.patch + 1)
  }
  throw "std/semver: bump must be major, minor, or patch, got: " + to_string(bump ?? "")
}

/**
 * Classify the single step that takes `current` to `target`. Returns
 * `"major"`, `"minor"`, `"patch"`, or `nil` when the gap is not exactly
 * one bump (downgrades, two-step jumps, or equal versions all yield
 * `nil`). Throws when either side is not parseable as semver — that's a
 * programmer-error condition, not user input drift.
 *
 * @effects: []
 * @allocation: heap
 * @errors: ["std/semver: current/target version is not semver"]
 * @api_stability: stable
 * @example: bump_type("1.2.3", "1.2.4")
 */
pub fn bump_type(current, target) {
  let cur = parse(current)
  let tgt = parse(target)
  if cur == nil {
    throw "std/semver: current version is not semver: " + to_string(current ?? "")
  }
  if tgt == nil {
    throw "std/semver: target version is not semver: " + to_string(target ?? "")
  }
  if tgt.major == cur.major + 1 && tgt.minor == 0 && tgt.patch == 0 {
    return "major"
  }
  if tgt.major == cur.major && tgt.minor == cur.minor + 1 && tgt.patch == 0 {
    return "minor"
  }
  if tgt.major == cur.major && tgt.minor == cur.minor && tgt.patch == cur.patch + 1 {
    return "patch"
  }
  return nil
}

/**
 * Extract `X.Y.Z` from a release branch named `release/vX.Y.Z`. Returns
 * the empty string for branches that don't match the canonical shape —
 * including `release/X.Y.Z` without the `v` and `release/v1.2` with a
 * truncated triple. Use the empty-string sentinel to gate
 * release-only code paths.
 *
 * @effects: []
 * @allocation: heap
 * @errors: []
 * @api_stability: stable
 * @example: version_from_release_branch("release/v1.2.3")
 */
pub fn version_from_release_branch(branch) -> string {
  let text = to_string(branch ?? "")
  if !starts_with(text, "release/v") {
    return ""
  }
  let candidate = substring(text, 9, len(text))
  if parse(candidate) == nil {
    return ""
  }
  return candidate
}

/**
 * Extract `X.Y.Z` from a `vX.Y.Z` tag. Non-`v` tags pass through unchanged
 * (this is just `strip_v` named in a domain-specific way so callers reading
 * release pipelines can grep for it).
 *
 * @effects: []
 * @allocation: heap
 * @errors: []
 * @api_stability: stable
 * @example: version_from_tag("v1.2.3")
 */
pub fn version_from_tag(tag) -> string {
  return strip_v(tag)
}