Skip to main content

src/version_bump.gleam

//// CLI entrypoint for version_bump, a native Gleam port of semantic-release.
////
//// The default invocation runs the full release pipeline against the working
//// directory (`--cwd <path>`, default `.`):
////
////   1. read every environment variable into a `Dict(String, String)`
////   2. load configuration from the project (`config.load`)
////   3. apply CLI overrides (e.g. `--dry-run`)
////   4. run `engine.run` and report the resulting `Summary`
////
//// On error the formatted message is printed and the process exits non-zero.
//// `--version`/`version` prints the tool version; `--help`/`-h` prints usage.

import argv
import envoy
import gleam/io
import gleam/list
import gleam/option.{None, Some}
import gleam/string
import version_bump/config.{type Config, Config}
import version_bump/engine.{type Summary}
import version_bump/error.{type ReleaseError}
import version_bump/logging
import version_bump/task

/// The tool version, kept in sync with `gleam.toml`. Bump this alongside a
/// release: the pipeline updates `gleam.toml` but not this compiled-in constant,
/// so it must be set to the version the release will publish.
const version = "0.1.2"

/// The parsed CLI invocation. The default run is `Release(cwd, dry_run)`; the
/// other variants short-circuit before touching the pipeline. Public so the pure
/// argument parser can be unit-tested.
pub type Command {
  Release(cwd: String, dry_run: Bool)
  ShowVersion
  ShowHelp
}

pub fn main() -> Nil {
  let args = argv.load().arguments
  case parse_args(args) {
    Ok(ShowVersion) -> io.println(version)
    Ok(ShowHelp) -> io.println(usage())
    Ok(Release(cwd, dry_run)) -> run_release(cwd, dry_run)
    Error(message) -> {
      logging.error(message)
      io.println(usage())
      halt(2)
    }
  }
}

// --- Argument parsing -------------------------------------------------------

/// Classify the raw arguments into a `Command`. Unknown flags are rejected so
/// the user gets clear feedback rather than a silent no-op. Pure, so it is
/// unit-tested directly.
pub fn parse_args(args: List(String)) -> Result(Command, String) {
  case list.contains(args, "--version") || list.contains(args, "version") {
    True -> Ok(ShowVersion)
    False ->
      case list.contains(args, "--help") || list.contains(args, "-h") {
        True -> Ok(ShowHelp)
        False -> parse_release_args(args)
      }
  }
}

/// Parse the flags accepted by the default (release) command: `--dry-run` and
/// `--cwd <path>` (the `--cwd=<path>` form is also accepted). Unknown flags are
/// rejected so the user gets clear feedback rather than a silent no-op.
fn parse_release_args(args: List(String)) -> Result(Command, String) {
  accumulate_release(args, ".", False)
}

/// Walk the release flags, threading the working directory and dry-run state.
fn accumulate_release(
  args: List(String),
  cwd: String,
  dry_run: Bool,
) -> Result(Command, String) {
  case args {
    [] -> Ok(Release(cwd: cwd, dry_run: dry_run))
    ["--dry-run", ..rest] -> accumulate_release(rest, cwd, True)
    ["--cwd"] -> Error("--cwd requires a path argument")
    ["--cwd", value, ..rest] ->
      case string.starts_with(value, "-") {
        True -> Error("--cwd requires a path argument")
        False -> accumulate_release(rest, value, dry_run)
      }
    [arg, ..rest] ->
      case string.split_once(arg, "=") {
        Ok(#("--cwd", "")) -> Error("--cwd requires a path argument")
        Ok(#("--cwd", value)) -> accumulate_release(rest, value, dry_run)
        _ -> Error("Unknown flag: " <> arg)
      }
  }
}

// --- Release run ------------------------------------------------------------

/// Load config, apply the `--dry-run` override, run the pipeline, and report.
fn run_release(cwd: String, dry_run: Bool) -> Nil {
  let env = envoy.all()
  case config.load(cwd) {
    Error(err) -> fail(err)
    Ok(loaded) -> {
      let config = apply_dry_run(loaded, dry_run)
      // `engine.run` is asynchronous (a `Task`); run it and report when it
      // settles. On Erlang this is immediate; on JavaScript it awaits the
      // underlying promise.
      use result <- task.run(engine.run(config, cwd, env))
      case result {
        Ok(summary) -> print_summary(summary)
        Error(err) -> fail(err)
      }
    }
  }
}

/// Apply the `--dry-run` flag, only ever turning dry-run on (the flag is an
/// override, never a way to force a real release when config disables it). Pure,
/// so it is unit-tested directly.
pub fn apply_dry_run(config: Config, dry_run: Bool) -> Config {
  case dry_run {
    True -> Config(..config, dry_run: True)
    False -> config
  }
}

/// Print a human-readable summary of a successful (or no-op) run.
fn print_summary(summary: Summary) -> Nil {
  case summary.released, summary.version {
    True, Some(v) -> logging.success("Published release " <> v)
    False, Some(v) -> logging.info("Dry-run: next release would be " <> v)
    _, None -> logging.info("No release published")
  }
}

/// Report an error to the log and exit non-zero.
fn fail(err: ReleaseError) -> Nil {
  logging.error(error.to_string(err))
  halt(1)
}

// --- Help text --------------------------------------------------------------

fn usage() -> String {
  string.join(
    [
      "version_bump " <> version,
      "",
      "Usage:",
      "  version_bump [--cwd <path>] [--dry-run]   Run the release pipeline",
      "  version_bump --version                    Print the version and exit",
      "  version_bump --help                       Print this help and exit",
      "",
      "Flags:",
      "  --cwd <path>   Run against the project at <path> (default: .)",
      "  --dry-run      Compute the next release without tagging or publishing",
    ],
    "\n",
  )
}

// --- Side effects -----------------------------------------------------------

/// Halt the BEAM with the given exit code.
@external(erlang, "version_bump_ffi", "halt")
@external(javascript, "./version_bump_ffi.mjs", "halt")
fn halt(code: Int) -> Nil