Skip to main content

lib/mix/tasks/safe/config.ex

defmodule Safe.Config do
  @moduledoc """
  Generates the SAFE JSON configuration from the Mix project structure.

  Generates `.safe/config.json` for the SAFE scanner.
  """

  require Logger

  @config_version "1.1"
  @config_dir ".safe"
  @config_file "config.json"

  # ---------------------------------------------------------------------------
  # Public API
  # ---------------------------------------------------------------------------

  @doc "Returns the path to the SAFE config file for the given project."
  def config_path(project_dir) do
    Path.join([project_dir, @config_dir, @config_file])
  end

  @doc """
  Generates the SAFE JSON config string from the live Mix project.

  Returns `{:ok, json_string}`. Does not write to disk.
  """
  def make_config(project_dir) do
    build_config(project_dir, primary_app_name(), get_apps(project_dir))
  end

  @doc false
  def build_config(project_dir, app_name, apps) do
    ebin_paths = Enum.map(apps, &ebin_path(project_dir, &1.name))
    common_path = longest_common_prefix(ebin_paths)

    config = %{
      "output" => ["stdio", "file"],
      "version" => @config_version,
      "project" => %{
        "name" => to_string(app_name),
        "type" => "beam",
        "apps" => Enum.map(apps, &app_entry(project_dir, &1)),
        "paths" => [common_path]
      }
    }

    {:ok, Jason.encode!(config, pretty: true)}
  end

  @doc """
  Reads `.safe/config.json` from the project directory.

  Returns `{:ok, json_string}` or `{:error, {:config_read_error, reason}}`.
  """
  def read_config(project_dir) do
    case File.read(config_path(project_dir)) do
      {:ok, contents} -> {:ok, contents}
      {:error, reason} -> {:error, {:config_read_error, reason}}
    end
  end

  @doc "Writes `json` to `.safe/config.json` in the project directory."
  def write_config(project_dir, json) do
    path = config_path(project_dir)

    with :ok <- File.mkdir_p(Path.dirname(path)) do
      File.write(path, json)
    end
  end

  @doc """
  Finds the longest common directory prefix shared by all `paths`.

  Each path is split into segments; shared leading segments are rejoined.
  Returns an empty string when `paths` is empty or no segments are shared.
  """
  def longest_common_prefix([]), do: ""

  def longest_common_prefix([single]), do: single

  def longest_common_prefix(paths) do
    split = Enum.map(paths, &Path.split/1)

    common =
      split
      |> Enum.zip_with(& &1)
      |> Enum.take_while(fn segments -> length(Enum.uniq(segments)) == 1 end)
      |> Enum.map(&hd/1)

    case common do
      [] -> ""
      segments -> Path.join(segments)
    end
  end

  # ---------------------------------------------------------------------------
  # Private helpers
  # ---------------------------------------------------------------------------

  defp get_apps(_project_dir) do
    if Mix.Project.umbrella?() do
      Mix.Project.apps_paths()
      |> Enum.map(fn {app_name, _path} ->
        %{name: app_name}
      end)
    else
      app_name = Mix.Project.config()[:app]
      [%{name: app_name}]
    end
  end

  defp primary_app_name do
    Mix.Project.config()[:app]
  end

  defp app_entry(_project_dir, %{name: name}) do
    env = to_string(Mix.env())
    app_name = to_string(name)
    app_file = Path.join(["_build", env, "lib", app_name, "ebin", "#{app_name}.app"])

    %{
      "name" => app_name,
      "app_file" => app_file,
      "additional_includes" => []
    }
  end

  defp ebin_path(project_dir, app_name) do
    env = to_string(Mix.env())
    Path.join([project_dir, "_build", env, "lib", to_string(app_name), "ebin"])
  end
end