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