harn-stdlib 0.10.27

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 canonical "MAJOR.MINOR.PATCH" triples (no leading zeroes or
// prerelease/build metadata), strip/add the canonical leading `v`, compare
// release tags, compute single-step bumps, classify which bump separates two
// versions, and unpack release branches / tags.
// Keep release-tooling repos on this stdlib implementation so SemVer parsing,
// bump detection, and error messages do not drift between repos.
//
// Import with:
//   import {
//     Version, BumpKind, strip_v, add_v, is_v_semver, parse,
//     max_canonical_tag, 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. */
pub type Version = {major: int, minor: int, patch: int}

/** One canonical semantic-version increment. */
pub type BumpKind = "major" | "minor" | "patch"

/**
 * 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: []
 * @errors: []
 * @example: strip_v("v1.2.3")
 */
pub fn strip_v(version: string?) -> string {
  const text = 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: []
 * @errors: []
 * @example: add_v("1.2.3")
 */
pub fn add_v(version: string?) -> string {
  const text = 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: []
 * @errors: []
 * @example: is_v_semver("v1.2.3")
 */
pub fn is_v_semver(value: string?) -> bool {
  const text = value ?? ""
  return starts_with(text, "v") && parse(text) != nil
}

/**
 * Parse a canonical "MAJOR.MINOR.PATCH" string into a `Version` dict. Accepts
 * an optional leading `v`. Each component is either `0` or begins with a
 * non-zero digit. Returns `nil` (not an exception) for non-canonical input or
 * components outside the integer range, so callers can match-on-nil for soft
 * validation. Use `next` / `bump_type` when a throwing variant is more
 * ergonomic.
 *
 * @effects: []
 * @errors: []
 * @example: parse("v1.2.3")
 */
pub fn parse(version: string?) -> Version? {
  const text = strip_v(version)
  if regex_match("^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)$", text) == nil {
    return nil
  }
  const parts = split(text, ".")
  const major = to_int(parts[0])
  const minor = to_int(parts[1])
  const patch = to_int(parts[2])
  if major == nil || minor == nil || patch == nil {
    return nil
  }
  return {major: major, minor: minor, patch: patch}
}

/**
 * Return the numerically greatest canonical `vX.Y.Z` release tag. Input order
 * does not affect the result. Bare versions, malformed tags, leading-zero
 * components, prereleases, build metadata, and integer overflow are ignored.
 * Returns `nil` when no canonical tag remains.
 *
 * @effects: []
 * @errors: []
 * @example: max_canonical_tag(["v2.9.0", "v2.10.0"])
 */
pub fn max_canonical_tag(tags: list<string>) -> string? {
  let best_tag: string? = nil
  let best_major = -1
  let best_minor = -1
  let best_patch = -1
  for tag in tags {
    const parsed = parse(tag)
    if parsed == nil || !starts_with(tag, "v") {
      continue
    }
    if parsed.major > best_major
      || (parsed.major == best_major && parsed.minor > best_minor)
      || (parsed.major == best_major
      && parsed
      .minor
      == best_minor
      && parsed.patch > best_patch) {
      best_tag = tag
      best_major = parsed.major
      best_minor = parsed.minor
      best_patch = parsed.patch
    }
  }
  return best_tag
}

/**
 * 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: []
 * @errors: ["std/semver: current version is not semver", "std/semver: bump must be major, minor, or patch"]
 * @example: next("1.2.3", "minor")
 */
pub fn next(current: string, bump: BumpKind) -> string {
  const parsed = parse(current)
  if parsed == nil {
    throw "std/semver: current version is not semver: " + 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: ${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: []
 * @errors: ["std/semver: current/target version is not semver"]
 * @example: bump_type("1.2.3", "1.2.4")
 */
pub fn bump_type(current: string, target: string) -> BumpKind? {
  const cur = parse(current)
  const tgt = parse(target)
  if cur == nil {
    throw "std/semver: current version is not semver: " + current
  }
  if tgt == nil {
    throw "std/semver: target version is not semver: " + 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: []
 * @errors: []
 * @example: version_from_release_branch("release/v1.2.3")
 */
pub fn version_from_release_branch(branch: string?) -> string {
  const text = branch ?? ""
  if !starts_with(text, "release/v") {
    return ""
  }
  const candidate = substring(text, 9, len(text))
  if starts_with(candidate, "v") || 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: []
 * @errors: []
 * @example: version_from_tag("v1.2.3")
 */
pub fn version_from_tag(tag: string?) -> string {
  return strip_v(tag)
}