Skip to content

Commit 7800991

Browse files
Add mix desktop.check_toolchain and .tool-versions parsing
Introduce Desktop.ToolVersions and Desktop.Toolchain to compare the running OTP major and Elixir semver against project .tool-versions without shelling out to mise/asdf. Mix task fails fast with actionable messages when mismatched. Adds ExUnit coverage for parsing edge cases and verification behavior. Refs #67 Co-authored-by: Dominic Letz <dominicletz@users.noreply.github.com>
1 parent 694cd78 commit 7800991

5 files changed

Lines changed: 336 additions & 0 deletions

File tree

lib/desktop/tool_versions.ex

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
defmodule Desktop.ToolVersions do
2+
@moduledoc false
3+
4+
@doc """
5+
Parses a `.tool-versions` file body (asdf/mise format).
6+
7+
Returns a map with optional string values for `:erlang` and `:elixir` keys
8+
(raw version fields from the file, e.g. `"26.2.5.5 system"`, `"1.19.1-otp-26"`).
9+
"""
10+
@spec parse(String.t()) :: %{optional(:erlang) => String.t(), optional(:elixir) => String.t()}
11+
def parse(content) when is_binary(content) do
12+
content
13+
|> String.split("\n")
14+
|> Enum.reduce(%{}, &parse_line/2)
15+
end
16+
17+
defp parse_line(line, acc) do
18+
line = String.trim(line)
19+
20+
cond do
21+
line == "" ->
22+
acc
23+
24+
String.starts_with?(line, "#") ->
25+
acc
26+
27+
true ->
28+
case Regex.run(~r/^(elixir|erlang)\s+(.+)$/, line) do
29+
[_, "elixir", rest] ->
30+
Map.put(acc, :elixir, String.trim(rest))
31+
32+
[_, "erlang", rest] ->
33+
Map.put(acc, :erlang, String.trim(rest))
34+
35+
_ ->
36+
acc
37+
end
38+
end
39+
end
40+
end

lib/desktop/toolchain.ex

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
defmodule Desktop.Toolchain do
2+
@moduledoc false
3+
4+
alias Desktop.ToolVersions
5+
6+
@doc """
7+
Verifies that running OTP major and Elixir version match entries parsed from `.tool-versions`.
8+
9+
`requirements` is the map returned by `Desktop.ToolVersions.parse/1`.
10+
11+
Optional `otp_release` and `elixir_version` override `System` values (for testing).
12+
13+
Returns `:ok` or `{:error, message}` where `message` is human-readable.
14+
"""
15+
@spec verify(map(), keyword()) :: :ok | {:error, String.t()}
16+
def verify(requirements, opts \\ []) when is_map(requirements) do
17+
otp_actual = Keyword.get_lazy(opts, :otp_release, fn -> System.otp_release() end)
18+
elixir_actual = Keyword.get_lazy(opts, :elixir_version, fn -> System.version() end)
19+
20+
errors =
21+
[]
22+
|> maybe_check_erlang(Map.get(requirements, :erlang), otp_actual)
23+
|> maybe_check_elixir(Map.get(requirements, :elixir), elixir_actual)
24+
25+
case errors do
26+
[] -> :ok
27+
msgs -> {:error, Enum.join(msgs, "\n")}
28+
end
29+
end
30+
31+
defp maybe_check_erlang(acc, nil, _otp_actual), do: acc
32+
33+
defp maybe_check_erlang(acc, raw, otp_actual) when is_binary(raw) do
34+
expected_major = erlang_major(raw)
35+
36+
case Integer.parse(to_string(otp_actual)) do
37+
{actual_major, _} when actual_major == expected_major ->
38+
acc
39+
40+
{actual_major, _} ->
41+
[
42+
"Erlang/OTP major mismatch: running OTP #{actual_major}, `.tool-versions` expects OTP #{expected_major} (from erlang #{inspect(raw)})."
43+
| acc
44+
]
45+
46+
:error ->
47+
["Could not parse running OTP release #{inspect(otp_actual)}." | acc]
48+
end
49+
end
50+
51+
defp maybe_check_elixir(acc, nil, _elixir_actual), do: acc
52+
53+
defp maybe_check_elixir(acc, raw, elixir_actual) when is_binary(raw) do
54+
expected_base = elixir_base_version(raw)
55+
56+
case Version.parse(expected_base) do
57+
{:ok, expected_ver} ->
58+
case Version.parse(elixir_actual) do
59+
{:ok, actual_ver} ->
60+
if Version.compare(actual_ver, expected_ver) == :eq do
61+
acc
62+
else
63+
[
64+
"Elixir version mismatch: running #{elixir_actual}, `.tool-versions` expects #{expected_base} (from elixir #{inspect(raw)})."
65+
| acc
66+
]
67+
end
68+
69+
:error ->
70+
["Could not parse running Elixir version #{inspect(elixir_actual)}." | acc]
71+
end
72+
73+
:error ->
74+
["Could not parse Elixir version in `.tool-versions`: #{inspect(raw)}." | acc]
75+
end
76+
end
77+
78+
@doc false
79+
def erlang_major(raw) when is_binary(raw) do
80+
raw
81+
|> String.split()
82+
|> hd()
83+
|> String.split(".")
84+
|> hd()
85+
|> String.to_integer()
86+
end
87+
88+
@doc false
89+
def elixir_base_version(raw) when is_binary(raw) do
90+
case Regex.run(~r/^(\d+\.\d+\.\d+)/, raw) do
91+
[_, base] ->
92+
base
93+
94+
nil ->
95+
raw
96+
|> String.split("-")
97+
|> hd()
98+
end
99+
end
100+
101+
@doc """
102+
Loads `.tool-versions` from `path`, parses it, and verifies the toolchain.
103+
"""
104+
@spec verify_file(String.t(), keyword()) :: :ok | {:error, String.t()}
105+
def verify_file(path, opts \\ []) do
106+
case File.read(path) do
107+
{:ok, content} ->
108+
content |> ToolVersions.parse() |> verify(opts)
109+
110+
{:error, reason} ->
111+
{:error, "Could not read #{inspect(path)}: #{inspect(reason)}"}
112+
end
113+
end
114+
end
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
defmodule Mix.Tasks.Desktop.CheckToolchain do
2+
@shortdoc "Checks running Erlang/OTP and Elixir against `.tool-versions`"
3+
4+
@moduledoc """
5+
#{@shortdoc}
6+
7+
Compares the **currently running** BEAM (`System.otp_release/0`, `System.version/0`)
8+
to `erlang` and `elixir` lines in `.tool-versions`. It does not invoke mise, asdf, or
9+
other installers—activate your toolchain however you prefer, then run this task to fail fast.
10+
11+
## Examples
12+
13+
mix desktop.check_toolchain
14+
15+
"""
16+
17+
use Mix.Task
18+
19+
@impl Mix.Task
20+
def run(_argv) do
21+
root = Mix.Project.config()[:root] || File.cwd!()
22+
path = Path.join(root, ".tool-versions")
23+
24+
unless File.exists?(path) do
25+
Mix.shell().error("No `.tool-versions` found at #{path}.")
26+
exit({:shutdown, 1})
27+
end
28+
29+
case Desktop.Toolchain.verify_file(path) do
30+
:ok ->
31+
Mix.shell().info("Toolchain matches `.tool-versions`.")
32+
33+
{:error, msg} ->
34+
Mix.shell().error(msg)
35+
exit({:shutdown, 1})
36+
end
37+
end
38+
end
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
defmodule Desktop.ToolVersionsTest do
2+
use ExUnit.Case, async: true
3+
4+
alias Desktop.ToolVersions
5+
6+
test "parse ignores comments and blank lines" do
7+
content = """
8+
# comment
9+
erlang 26.2.5.5 system
10+
11+
elixir 1.19.1-otp-26
12+
"""
13+
14+
assert ToolVersions.parse(content) == %{
15+
erlang: "26.2.5.5 system",
16+
elixir: "1.19.1-otp-26"
17+
}
18+
end
19+
20+
test "parse handles extra whitespace on values" do
21+
content = "elixir 1.12.3 \n"
22+
23+
assert ToolVersions.parse(content) == %{elixir: "1.12.3"}
24+
end
25+
26+
test "parse empty file" do
27+
assert ToolVersions.parse("") == %{}
28+
end
29+
end

test/desktop/toolchain_test.exs

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
defmodule Desktop.ToolchainTest do
2+
use ExUnit.Case, async: true
3+
4+
alias Desktop.Toolchain
5+
6+
describe "verify/2" do
7+
test "ok when erlang major matches and elixir semver matches" do
8+
req = %{erlang: "26.2.5.5 system", elixir: "1.19.1-otp-26"}
9+
10+
assert Toolchain.verify(req,
11+
otp_release: 26,
12+
elixir_version: "1.19.1"
13+
) == :ok
14+
end
15+
16+
test "error when OTP major mismatches" do
17+
req = %{erlang: "26.2.5.5 system", elixir: "1.19.1-otp-26"}
18+
19+
assert {:error, msg} =
20+
Toolchain.verify(req,
21+
otp_release: 25,
22+
elixir_version: "1.19.1"
23+
)
24+
25+
assert msg =~ "OTP"
26+
assert msg =~ "25"
27+
assert msg =~ "26"
28+
end
29+
30+
test "error when Elixir semver mismatches" do
31+
req = %{erlang: "26.2.5.5 system", elixir: "1.19.1-otp-26"}
32+
33+
assert {:error, msg} =
34+
Toolchain.verify(req,
35+
otp_release: 26,
36+
elixir_version: "1.18.0"
37+
)
38+
39+
assert msg =~ "Elixir"
40+
assert msg =~ "1.18.0"
41+
assert msg =~ "1.19.1"
42+
end
43+
44+
test "ok when only erlang line present" do
45+
req = %{erlang: "24.0.1"}
46+
47+
assert Toolchain.verify(req, otp_release: 24, elixir_version: "9.9.9") == :ok
48+
end
49+
50+
test "ok when only elixir line present" do
51+
req = %{elixir: "1.12.0"}
52+
53+
assert Toolchain.verify(req, otp_release: 99, elixir_version: "1.12.0") == :ok
54+
end
55+
56+
test "empty requirements always ok" do
57+
assert Toolchain.verify(%{}, otp_release: 1, elixir_version: "0.1.0") == :ok
58+
end
59+
end
60+
61+
describe "verify_file/2" do
62+
test "reads and verifies temp file" do
63+
path =
64+
Path.join(
65+
System.tmp_dir!(),
66+
"desktop-tool-versions-test-#{:erlang.unique_integer([:positive])}"
67+
)
68+
69+
content = """
70+
erlang 26.0.1
71+
elixir 1.14.0
72+
"""
73+
74+
:ok = File.write(path, content)
75+
76+
try do
77+
assert Toolchain.verify_file(path,
78+
otp_release: 26,
79+
elixir_version: "1.14.0"
80+
) == :ok
81+
82+
assert {:error, _} =
83+
Toolchain.verify_file(path,
84+
otp_release: 25,
85+
elixir_version: "1.14.0"
86+
)
87+
after
88+
File.rm(path)
89+
end
90+
end
91+
92+
test "missing file returns error" do
93+
path =
94+
Path.join(
95+
System.tmp_dir!(),
96+
"nonexistent-tool-versions-#{:erlang.unique_integer([:positive])}"
97+
)
98+
99+
assert {:error, msg} = Toolchain.verify_file(path)
100+
assert msg =~ "Could not read"
101+
end
102+
end
103+
104+
describe "helpers" do
105+
test "erlang_major/1" do
106+
assert Toolchain.erlang_major("26.2.5.5 system") == 26
107+
assert Toolchain.erlang_major("24.0.1") == 24
108+
end
109+
110+
test "elixir_base_version/1" do
111+
assert Toolchain.elixir_base_version("1.19.1-otp-26") == "1.19.1"
112+
assert Toolchain.elixir_base_version("1.12.0") == "1.12.0"
113+
end
114+
end
115+
end

0 commit comments

Comments
 (0)