lib/mix/tasks/archeometer.collect.apps.ex

defmodule Mix.Tasks.Arch.Collect.Apps do
  use Mix.Task
  alias Archeometer.Repo
  alias Archeometer.Collect.Project
  alias Archeometer.Util.DumpStats

  @moduledoc """
  Mix Task to collect application names, module application id,
  module implemented behaviours, module defined structs and store
  them into a Archeometer database.

  Usage:

      mix arch.collect.apps [options]

  The following options are accepted:

    * `--include-deps` -  Wildcard glob style to filter dependencies

  For more information see:
  [Basic usage guide](guides/introduction/basic_usage.md#include-dependencies-in-the-analysis)
  """

  @shortdoc "Dumps modules aplication id of current project into Archeometer DB"

  @impl Mix.Task
  def run(argv) do
    case get_args(argv) do
      [
        include_deps: deps_filter
      ] ->
        if Archeometer.Repo.db_ready?(:basic) do
          _run(deps_filter)
        else
          Mix.shell().error("Please run static analysis first")
          print_help()
          {:error, :no_static_analysis_found}
        end

      {:error, error} ->
        Mix.shell().error("Error: #{error}")
        print_help()
        {:error, error}
    end
  end

  defp missing_app_modules(),
    do: Repo.all("SELECT m.name, m.path FROM modules m WHERE m.app_id IS NULL;")

  defp guess_app_and_format(%Repo.Result{rows: rows}) do
    rows
    |> Enum.map(fn [name, path] ->
      case Project.guess_app_from_path(path) do
        {:ok, app} -> [{name, app}]
        {:error, _} -> []
      end
    end)
    |> Enum.concat()
    |> Enum.group_by(&elem(&1, 1), &elem(&1, 0))
  end

  defp get_args(argv) do
    {opts, _args, invalid} =
      OptionParser.parse(
        argv,
        strict: [
          include_deps: :keep
        ]
      )

    case invalid do
      [] ->
        deps_filter = Keyword.get_values(opts, :include_deps)

        validate_options(deps_filter)

      _ ->
        {:error, :wrong_arguments}
    end
  end

  defp filtered_deps(app, full_apps) do
    with {:ok, deps} <- Project.get_app_deps(app) do
      {:ok, Enum.filter(deps, &(&1 in full_apps))}
    end
  end

  defp _run(deps_filter) do
    Mix.shell().info("Starting application classification analysis... \n")
    Mix.Task.run("compile")

    extra_deps = append_deps(deps_filter)

    Mix.shell().info("Saving apps...")

    all_apps = Project.apps() ++ extra_deps

    all_apps
    |> Project.tag_external_apps()
    |> Archeometer.Util.DumpStats.save_apps()

    Mix.shell().info("Saving apps xrefs...")

    all_apps
    |> Enum.flat_map(fn app ->
      case filtered_deps(app, all_apps) do
        {:error, _} -> []
        {:ok, deps} -> Enum.map(deps, &{app, &1})
      end
    end)
    |> Archeometer.Util.DumpStats.save_apps_xrefs()

    Mix.shell().info("Saving apps modules...")

    Project.local_modules_by_app(extra_deps)
    |> Enum.map(fn {app, modules} -> %{app: app, modules: modules} end)
    |> DumpStats.add_modules_app_id()

    missing_app_modules()
    |> guess_app_and_format()
    |> Enum.map(fn {app, modules} -> %{app: app, modules: modules} end)
    |> DumpStats.add_modules_app_id()

    Mix.shell().info("Saving behaviours...")

    Project.local_modules(extra_deps)
    |> Project.list_behaviours()
    |> DumpStats.save_behaviours()

    Mix.shell().info("Saving structs...")

    Project.local_modules(extra_deps)
    |> Project.list_modules_with_struct()
    |> DumpStats.add_structs()

    Mix.shell().info("Done!\n")
  end

  defp validate_options(deps_filter) do
    case Project.filter_deps(deps_filter) do
      [] ->
        {:error, "Filter doesn't match any dependency"}

      _ ->
        [
          include_deps: deps_filter
        ]
    end
  end

  defp append_deps(deps_filter) do
    deps = Project.filter_deps(deps_filter)
    if deps, do: Keyword.keys(deps), else: []
  end

  defp print_help() do
    Mix.shell().info("""
    Usage: mix arch.collect.apps [opts]

    opts: --include-deps 'deps_filter'

    Where `deps_filter` is a glob Unix-like pattern, matches dependencies name
    --include-deps can be used more than once.

    - `*` matches none or many tokens
    - `?` matches exactly one token
    - `[abc]` matches a set of tokens
    - `[a-z]` matches a range of tokens
    - `[!...]` matches anything but a set of tokens
    """)
  end
end