johnhamelink
3/2/2016 - 1:13 AM

utils.ex

defmodule ReleaseManager.Utils do
  @moduledoc """
  This module provides helper functions for the `mix release` and
  `mix release.clean` tasks.
  """
  import Mix.Shell,    only: [cmd: 2]
  alias ReleaseManager.Utils.Logger

  # Relx constants
  @relx_output_path      "rel"

  @doc """
  Perform some actions within the context of a specific mix environment
  """
  def with_env(env, fun) do
    old_env = Mix.env
    try do
      # Change env
      Mix.env(env)
      fun.()
    after
      # Change back
      Mix.env(old_env)
    end
  end

  @doc """
  Load the current project's configuration
  """
  def load_config(env, project_config \\ Mix.Project.config) do
    config_path = Keyword.get(project_config, :config_path, "config/config.exs")
    with_env env, fn ->
      if File.regular?(config_path) do
        Mix.Config.read! config_path
      else
        []
      end
    end
  end

  @doc """
  Call the _elixir mix binary with the given arguments
  """
  def mix(command, :quiet),        do: mix(command, :dev, :quiet)
  def mix(command, :verbose),      do: mix(command, :dev, :verbose)
  def mix(command, env),           do: mix(command, env, :quiet)
  def mix(command, env, :quiet) do
    case :os.type() do
      {:nt}            -> do_cmd("(set MIX_ENV=#{env}) & (mix #{command})", &ignore/1)
      {:win32, :nt}    -> do_cmd("(set MIX_ENV=#{env}) & (mix #{command})", &ignore/1)
      _                -> do_cmd("MIX_ENV=#{env} mix #{command}", &ignore/1)
    end
  end
  def mix(command, env, :verbose) do
    case :os.type() do
      {:nt}            -> do_cmd("(set MIX_ENV=#{env}) & (mix #{command})", &IO.write/1)
      {:win32, :nt}    -> do_cmd("(set MIX_ENV=#{env}) & (mix #{command})", &IO.write/1)
      _                -> do_cmd("MIX_ENV=#{env} mix #{command}", &IO.write/1)
    end
  end
  @doc """
  Change user permissions for a target file or directory
  """
  def chmod(target, mode) do
    case File.chmod(target, mode) do
      :ok         -> :ok
      {:error, _} -> :ok
    end
  end
  @doc """
  Execute `relx`
  """
  def relx(name, version, verbosity, upgrade?, dev_mode?) do
    # Setup paths
    config     = rel_file_dest_path "relx.config"
    output_dir = @relx_output_path |> Path.expand
    # Convert friendly verbosity names to relx values
    v = case verbosity do
      :silent  -> 0
      :quiet   -> 1
      :normal  -> 2
      :verbose -> 3
      _        -> 2 # Normal if we get an odd value
    end
    # Let relx do the heavy lifting
    relx_args = [
        log_level: v,
        root_dir: '#{File.cwd!}',
        config: '#{config}',
        relname: '#{name}',
        relvsn: '#{version}',
        output_dir: '#{output_dir}',
        dev_mode: dev_mode?
      ]
    result = cond do
      upgrade? && dev_mode? ->
        last_release = get_last_release(name)
        :relx.do [{:upfrom, '#{last_release}'} | relx_args], ['release', 'relup']
      upgrade? ->
        last_release = get_last_release(name)
        :relx.do [{:upfrom, '#{last_release}'} | relx_args], ['release', 'relup', 'tar']
      dev_mode? ->
        :relx.do relx_args, ['release']
      true ->
        :relx.do relx_args, ['release', 'tar']
    end
    case result do
      {:ok, _state} -> :ok
      {:error, _e}  -> {:error, "Failed to build release. Please fix any errors and try again."}
    end
  end

  @doc "Exits with exit status 1"
  def abort!, do: exit({:shutdown, 1})

  @doc """
  Get a list of tuples representing the previous releases:

  ## Examples

      get_releases #=> [{"test", "0.0.1"}, {"test", "0.0.2"}]

  """
  def get_releases(project) do
    release_path = Path.join([File.cwd!, "rel", project, "releases"])
    case release_path |> File.exists? do
      false -> []
      true  ->
        release_path
        |> File.ls!
        |> Enum.reject(fn entry -> entry in ["RELEASES", "start_erl.data"] end)
        |> Enum.map(fn version -> {project, version} end)
    end
  end

  @doc """
  Get the most recent release prior to the current one
  """
  def get_last_release(project) do
    hd(project |> get_releases |> Enum.map(fn {_, v} -> v end) |> sort_versions)
  end

  @doc """
  Sort a list of versions, latest one first. Tries to use semver version
  compare, but can fall back to regular string compare.
  """
  def sort_versions(versions) do
    versions
    |> Enum.map(fn ver ->
        # Special handling for git-describe versions
        compared = case Regex.named_captures(~r/(?<ver>\d+\.\d+\.\d+)-(?<commits>\d+)-(?<sha>[A-Ga-g0-9]+)/, ver) do
          nil ->
            {:standard, ver, nil}
          %{"ver" => version, "commits" => n, "sha" => sha} ->
            {:describe, <<version::binary, ?+, n::binary, ?-, sha::binary>>, String.to_integer(n)}
        end
        {ver, compared}
      end)
    |> Enum.sort(
      fn {_, {v1type, v1str, v1_commits_since}}, {_, {v2type, v2str, v2_commits_since}} ->
        case { parse_version(v1str), parse_version(v2str) } do
          {{:semantic, v1}, {:semantic, v2}} ->
            case Version.compare(v1, v2) do
              :gt -> true
              :eq ->
                case {v1type, v2type} do
                  {:standard, :standard} -> v1 > v2 # probably always false
                  {:standard, :describe} -> false   # v2 is an incremental version over v1
                  {:describe, :standard} -> true    # v1 is an incremental version over v2
                  {:describe, :describe} ->         # need to parse out the bits
                    v1_commits_since > v2_commits_since
                end
              :lt -> false
            end;
          {{_, v1}, {_, v2}} ->
            v1 >  v2
        end
      end)
    |> Enum.map(fn {v, _} -> v end)
  end

  defp parse_version(ver) do
    case Version.parse(ver) do
      {:ok, semver} -> {:semantic, semver}
      :error        -> {:nonsemantic, ver}
    end
  end

  @doc """
  Get the local paths of the current Elixir libraries
  """
  def get_elixir_lib_paths() do
    [elixir_lib_path, _] = String.split("#{:code.which(:elixir)}", "/elixir/ebin/elixir.beam")
    elixir_lib_path
    |> Path.expand
    |> File.ls!
    |> Enum.map(&(Path.join(elixir_lib_path, &1 <> "/ebin")))
  end

  @doc """
  Writes an Elixir/Erlang term to the provided path
  """
  def write_term(path, term) do
    :file.write_file('#{path}', :io_lib.fwrite('~p.\n', [term]))
  end

  @doc """
  Writes a collection of Elixir/Erlang terms to the provided path
  """
  def write_terms(path, terms) when is_list(terms) do
    format_str = String.duplicate("~p.\n\n", Enum.count(terms)) |> String.to_char_list
    :file.write_file('#{path}', :io_lib.fwrite(format_str, terms |> Enum.reverse), [encoding: :utf8])
  end

  @doc """
  Reads a file as Erlang terms
  """
  def read_terms(path) do
    result = case '#{path}' |> :file.consult do
      {:ok, terms} ->
        terms
      {:error, {line, type, msg}} ->
        Logger.error "Unable to parse #{path}: Line #{line}, #{type}, - #{msg}"
        abort!
      {:error, reason} ->
        Logger.error "Unable to access #{path}: #{reason}"
        abort!
    end
    result
  end

  @doc """
  Convert a string to Erlang terms
  """
  def string_to_terms(str) do
    str
    |> String.split("}.")
    |> Stream.map(&(String.strip(&1, ?\n)))
    |> Stream.map(&String.strip/1)
    |> Stream.map(&('#{&1}}.'))
    |> Stream.map(&(:erl_scan.string(&1)))
    |> Stream.map(fn {:ok, tokens, _} -> :erl_parse.parse_term(tokens) end)
    |> Stream.filter(fn {:ok, _} -> true; {:error, _} -> false end)
    |> Enum.reduce([], fn {:ok, term}, acc -> [term|acc] end)
    |> Enum.reverse
  end

  @doc """
  Merges two sets of Elixir/Erlang terms, where the terms come
  in the form of lists of tuples. For example, such as is found
  in the relx.config file
  """
  def merge(old, new) when is_list(old) and is_list(new) do
    merge(old, new, [])
  end

  defp merge([h|t], new, acc) when is_tuple(h) do
    case :lists.keytake(elem(h, 0), 1, new) do
      {:value, new_value, rest} ->
        # Value is present in new, so merge the value
        merged = merge_term(h, new_value)
        merge(t, rest, [merged|acc])
      false ->
        # Value doesn't exist in new, so add it
        merge(t, new, [h|acc])
    end
  end
  defp merge([], new, acc) do
    Enum.reverse(acc, new)
  end

  defp merge_term(old, new) when is_tuple(old) and is_tuple(new) do
    old
    |> Tuple.to_list
    |> Enum.with_index
    |> Enum.reduce([], fn
        {[], idx}, acc ->
          [elem(new, idx)|acc]
        {val, idx}, acc when is_list(val) ->
          case :io_lib.char_list(val) do
            true ->
              [elem(new, idx)|acc]
            false ->
              merged = val |> Enum.concat(elem(new, idx)) |> Enum.uniq
              [merged|acc]
          end
        {val, idx}, acc when is_tuple(val) ->
          [merge_term(val, elem(new, idx))|acc]
        {_val, idx}, acc ->
          [elem(new, idx)|acc]
       end)
    |> Enum.reverse
    |> List.to_tuple
  end

  @doc "Get the priv path of the exrm dependency"
  def priv_path, do: "#{:code.priv_dir('exrm')}"
  @doc "Get the priv/rel path of the exrm dependency"
  def rel_source_path,       do: Path.join(priv_path, "rel")
  @doc "Get the path to a file located in priv/rel of the exrm dependency"
  def rel_source_path(file), do: Path.join(rel_source_path, file)
  @doc "Get the priv/rel/files path of the exrm dependency"
  def rel_file_source_path,       do: Path.join([priv_path, "rel", "files"])
  @doc "Get the path to a file located in priv/rel/files of the exrm dependency"
  def rel_file_source_path(file), do: Path.join(rel_file_source_path, file)
  @doc """
  Get the path to a file located in the rel directory of the current project.
  You can pass either a file name, or a list of directories to a file, like:

      iex> ReleaseManager.Utils.rel_dest_path "relx.config"
      "path/to/project/rel/relx.config"

      iex> ReleaseManager.Utils.rel_dest_path ["<project>", "lib", "<project>.appup"]
      "path/to/project/rel/<project>/lib/<project>.appup"

  """
  def rel_dest_path(files) when is_list(files), do: Path.join([rel_dest_path] ++ files)
  def rel_dest_path(file),                      do: Path.join(rel_dest_path, file)
  @doc "Get the rel path of the current project."
  def rel_dest_path,                            do: Path.join(File.cwd!, "rel")
  @doc """
  Get the path to a file located in the rel/files directory of the current project.
  You can pass either a file name, or a list of directories to a file, like:

      iex> ReleaseManager.Utils.rel_file_dest_path "sys.config"
      "path/to/project/rel/files/sys.config"

      iex> ReleaseManager.Utils.rel_dest_path ["some", "path", "file.txt"]
      "path/to/project/rel/files/some/path/file.txt"

  """
  def rel_file_dest_path(files) when is_list(files), do: Path.join([rel_file_dest_path] ++ files)
  def rel_file_dest_path(file),                      do: Path.join(rel_file_dest_path, file)
  @doc "Get the rel/files path of the current project."
  def rel_file_dest_path,                            do: Path.join([File.cwd!, "rel", "files"])

  defdelegate debug(message),  to: ReleaseManager.Utils.Logger
  defdelegate info(message),   to: ReleaseManager.Utils.Logger
  defdelegate warn(message),   to: ReleaseManager.Utils.Logger
  defdelegate notice(message), to: ReleaseManager.Utils.Logger
  defdelegate error(message),  to: ReleaseManager.Utils.Logger

  # Ignore a message when used as the callback for Mix.Shell.cmd
  defp ignore(_), do: nil

  defp do_cmd(command, callback) do
    case cmd(command, callback) do
      0 -> :ok
      _ -> {:error, "Release step failed. Please fix any errors and try again."}
    end
  end

end