Current section
Files
Jump to
Current section
Files
lib/condukt/workflows/lockfile.ex
defmodule Condukt.Workflows.Lockfile do
@moduledoc """
Workflow dependency lockfile loaded from `condukt.lock`.
"""
defstruct version: 1, packages: %{}
@doc false
def load(path) when is_binary(path) do
if File.exists?(path) do
case Toml.decode_file(path, keys: :strings) do
{:ok, document} -> parse(document)
{:error, reason} -> {:error, reason}
end
else
:missing
end
end
@doc false
def parse(%{"version" => version} = document) when is_integer(version) do
with {:ok, packages} <- parse_packages(Map.get(document, "packages", %{})) do
{:ok, %__MODULE__{version: version, packages: packages}}
end
end
def parse(_document), do: {:error, {:invalid_lockfile, "version is required"}}
@doc false
def write(%__MODULE__{} = lockfile, path) when is_binary(path) do
content = to_toml(lockfile)
with :ok <- File.mkdir_p(Path.dirname(path)) do
File.write(path, content)
end
end
@doc false
def satisfies?(%__MODULE__{packages: packages}, requirements) when is_list(requirements) do
Enum.all?(requirements, fn requirement ->
with %{version_spec: version_spec, url: url} <- requirement,
%{version: version} <- Map.get(packages, url) do
version_satisfies?(version, version_spec)
else
_ -> false
end
end)
end
defp parse_packages(packages) when is_map(packages) do
packages
|> Enum.reduce_while({:ok, %{}}, fn {url, package}, {:ok, acc} ->
case parse_package(url, package) do
{:ok, package} -> {:cont, {:ok, Map.put(acc, url, package)}}
{:error, reason} -> {:halt, {:error, reason}}
end
end)
end
defp parse_packages(_), do: {:error, {:invalid_lockfile, "packages must be a table"}}
defp parse_package(url, package) when is_map(package) do
with {:ok, version} <- required_string(package, "version", url),
{:ok, sha256} <- required_string(package, "sha256", url),
{:ok, integrity} <- required_string(package, "integrity", url),
{:ok, dependencies} <- dependencies(package, url) do
{:ok,
%{
version: version,
url: Map.get(package, "url", url),
sha256: sha256,
integrity: integrity,
dependencies: dependencies
}}
end
end
defp parse_package(url, _), do: {:error, {:invalid_lockfile, "package #{url} must be a table"}}
defp required_string(package, key, url) do
case Map.get(package, key) do
value when is_binary(value) -> {:ok, value}
_ -> {:error, {:invalid_lockfile, "package #{url} requires #{key}"}}
end
end
defp dependencies(package, url) do
dependencies = Map.get(package, "dependencies", [])
if is_list(dependencies) and Enum.all?(dependencies, &is_binary/1) do
{:ok, dependencies}
else
{:error, {:invalid_lockfile, "package #{url} dependencies must be strings"}}
end
end
defp to_toml(%__MODULE__{version: version, packages: packages}) do
package_sections =
packages
|> Enum.sort_by(fn {url, _package} -> url end)
|> Enum.map_join("\n", &package_to_toml/1)
"""
# Auto-generated by mix condukt.workflows.lock. Do not edit by hand.
version = #{version}
#{package_sections}
"""
end
defp package_to_toml({url, package}) do
dependencies =
package
|> Map.get(:dependencies, [])
|> Enum.sort()
|> Enum.map_join(", ", &toml_string/1)
"""
[packages.#{toml_string(url)}]
version = #{toml_string(Map.fetch!(package, :version))}
url = #{toml_string(Map.get(package, :url, url))}
sha256 = #{toml_string(Map.fetch!(package, :sha256))}
integrity = #{toml_string(Map.fetch!(package, :integrity))}
dependencies = [#{dependencies}]
"""
end
defp toml_string(value), do: inspect(to_string(value))
defp version_satisfies?(_version, "*"), do: true
defp version_satisfies?(version, "^" <> requirement) do
with {:ok, version} <- Version.parse(version),
{:ok, requirement} <- Version.parse(String.trim(requirement)) do
Version.compare(version, requirement) in [:eq, :gt] and Version.compare(version, caret_upper(requirement)) == :lt
else
_ -> false
end
end
defp version_satisfies?(version, "~" <> requirement) do
with {:ok, version} <- Version.parse(version),
{:ok, requirement} <- String.trim_leading(requirement, ">") |> String.trim() |> Version.parse() do
Version.compare(version, requirement) in [:eq, :gt] and Version.compare(version, tilde_upper(requirement)) == :lt
else
_ -> false
end
end
defp version_satisfies?(version, spec) do
case Regex.run(~r/^(>=|<=|>|<)\s*(.+)$/, spec) do
[_, operator, requirement] -> compare_version(version, operator, requirement)
_ -> exact_version?(version, spec)
end
end
defp compare_version(version, operator, requirement) do
with {:ok, version} <- Version.parse(version),
{:ok, requirement} <- Version.parse(String.trim(requirement)) do
comparison = Version.compare(version, requirement)
case operator do
">=" -> comparison in [:eq, :gt]
"<=" -> comparison in [:eq, :lt]
">" -> comparison == :gt
"<" -> comparison == :lt
end
else
_ -> false
end
end
defp exact_version?(version, spec) do
with {:ok, version} <- Version.parse(version),
{:ok, requirement} <- Version.parse(String.trim(spec)) do
Version.compare(version, requirement) == :eq
else
_ -> false
end
end
defp caret_upper(%Version{major: 0, minor: 0, patch: patch}), do: %Version{major: 0, minor: 0, patch: patch + 1}
defp caret_upper(%Version{major: 0, minor: minor}), do: %Version{major: 0, minor: minor + 1, patch: 0}
defp caret_upper(%Version{major: major}), do: %Version{major: major + 1, minor: 0, patch: 0}
defp tilde_upper(%Version{major: major, minor: minor}), do: %Version{major: major, minor: minor + 1, patch: 0}
end