Skip to main content

lib/hexorcist_update/mix_edit.ex

defmodule HexorcistUpdate.MixEdit do
  @moduledoc """
  Minimal, safe `mix.exs` constraint editing — the bit `mix deps.update` can't do.

  Like Renovate's `mix` manager, it only touches a **literal** `{:dep, "~> x.y"}`
  tuple, found by a targeted regex; it never evaluates `mix.exs`. Anything it
  can't confidently rewrite (non-`~>` constraints, dynamic deps, multiple
  matches) is reported as a suggestion instead of edited.
  """

  @doc ~S"""
  A new `~>` constraint that admits `version`, e.g. `new_constraint("2.3.1")`
  → `{:ok, "~> 2.3"}`. Returns `:error` if the version can't be parsed.
  """
  @spec new_constraint(String.t()) :: {:ok, String.t()} | :error
  def new_constraint(version) do
    case Version.parse(pad(version)) do
      {:ok, %Version{major: major, minor: minor}} -> {:ok, "~> #{major}.#{minor}"}
      :error -> :error
    end
  end

  @doc ~S"""
  The umbrella `apps_path` declared in a root `mix.exs` (e.g. `{:ok, "apps"}`),
  or `:none` for a plain (non-umbrella) project. Used to discover the child
  `mix.exs` files that may declare the dep whose constraint we want to raise.
  """
  @spec umbrella_apps_path(String.t()) :: {:ok, String.t()} | :none
  def umbrella_apps_path(root_mixexs) do
    case Regex.run(~r/apps_path:\s*"([^"]+)"/, root_mixexs) do
      [_, path] -> {:ok, path}
      _ -> :none
    end
  end

  @doc ~S"""
  True if `dep` is declared with `override: true` in `contents`.

  An `override: true` pin is deliberate — usually forcing a specific version for
  cross-app compatibility (acutely so in umbrellas, where one app pins a dep the
  others must accept). Raising such a constraint is exactly what breaks the
  dependency graph, so `--edit-mix` leaves overridden deps for manual review
  rather than bumping them.
  """
  @spec overridden?(String.t(), String.t()) :: boolean()
  def overridden?(contents, dep) do
    Regex.match?(~r/\{\s*:#{Regex.escape(dep)}\s*,[^}]*override:\s*true/, contents)
  end

  @doc """
  Rewrites `dep`'s constraint in `contents` to `new_constraint`. Returns:

    * `{:ok, new_contents, old_constraint}` — rewrote a single literal `~>` tuple
    * `{:skip, old_constraint}` — found it, but it isn't a `~>` requirement
    * `:not_found` — no literal `{:dep, "…"}` tuple
    * `:ambiguous` — more than one match (won't guess)
  """
  @spec bump(String.t(), String.t(), String.t()) ::
          {:ok, String.t(), String.t()} | {:skip, String.t()} | :not_found | :ambiguous
  def bump(contents, dep, new_constraint) do
    regex = ~r/(\{\s*:#{Regex.escape(dep)}\s*,\s*")([^"]*)(")/

    case Regex.scan(regex, contents) do
      [[_full, _pre, old, _post]] ->
        if requirement?(old) do
          new =
            Regex.replace(regex, contents, fn _m, pre, _old, post ->
              pre <> new_constraint <> post
            end)

          {:ok, new, old}
        else
          {:skip, old}
        end

      [] ->
        :not_found

      _many ->
        :ambiguous
    end
  end

  defp requirement?(constraint), do: String.starts_with?(String.trim(constraint), "~>")

  # Version.parse needs three segments; pad "2.0" → "2.0.0".
  defp pad(version) do
    case String.split(version, ".") do
      [maj] -> "#{maj}.0.0"
      [maj, min] -> "#{maj}.#{min}.0"
      _ -> version
    end
  end
end