lib/mix/tasks/archeometer.dsm.ex

defmodule Mix.Tasks.Arch.Dsm do
  @moduledoc """
  Mix Task to perform a Design Structure Matrix (DSM) analysis.

  Usage:

      mix arch.dsm [options]

  The following options are accepted:

    * `--app` - Application name
    * `--ns` - Namespace of the modules considered
    * `--db` - Database filename
    * `--format` - Can be one of `txt` (default) or `svg`
    * `--out` - Output filename. Defaults to console
    * `--skip-tests` - Skips test related modules (default)
    * `--no-skip-tests` - Avoids skipping test related modules

  Both options `--app` or `--ns` can be used at the same time in order to have
  fine grained control of the set of modules considered in the analysis.

  If none of `--app` or `--ns` options are given,
  all modules in the project are considered.

  Namespace is interpreted very broadly since Elixir doesn't have that concept.
  Namespace can be interpreted as a string matching the beginning of the module names to
  be considered, such as "Foo" or "Foo.Bar", after which a last part of the name must
  be given to create a full module name.

  For example the "namespace" `Foo` will include in the analysis modules such as
  `Foo.Bar`, `Foo.Baz` and `Foo.Bar.Buzz`, but not `FooBar` or `Food`.
  """
  @shortdoc "Performs a Design Structure Matrix analysis"

  require Logger
  use Mix.Task
  use Archeometer.Repo

  alias Archeometer.Analysis.DSM

  @supported_formats ["svg", "txt"]

  @impl Mix.Task
  def run(argv) do
    case get_args(argv) do
      [
        app: app,
        namespace: namespace,
        db_name: dbname,
        format: format,
        out: out_fname,
        skip_tests: skip_tests
      ] ->
        case dsm_analysis(app, namespace, dbname, skip_tests) do
          {:ok, dsm, module_names} ->
            dsm
            |> render(module_names, format)
            |> write_report(out_fname)

          {:error, e} = error ->
            Mix.shell().error("Error: #{e}")
            Mix.shell().error("No DSM is generated.")
            error
        end

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

  defp get_args(argv) do
    {opts, _args, invalid} =
      OptionParser.parse(
        argv,
        strict: [
          app: :string,
          ns: :string,
          db: :string,
          format: :string,
          out: :string,
          skip_tests: :boolean
        ]
      )

    case invalid do
      [] ->
        app = Keyword.get(opts, :app, :none)
        namespace = Keyword.get(opts, :ns, "*")
        db_name = Keyword.get(opts, :db, default_db_name())
        format = Keyword.get(opts, :format, "txt")
        out = Keyword.get(opts, :out, "console")
        skip_tests = Keyword.get(opts, :skip_tests, true)

        case validate_options(%{format: format, db: db_name, out: out}) do
          :ok ->
            [
              app: app,
              namespace: namespace,
              db_name: db_name,
              format: format,
              out: out,
              skip_tests: skip_tests
            ]

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

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

  defp validate_options(%{format: format, db: db_name}) do
    cond do
      format not in @supported_formats ->
        {:error, :unsupported_output_format}

      not Archeometer.Repo.db_ready?(:full, db_name) ->
        {:error, "Database is not existent or doesn't have the required tables."}

      true ->
        :ok
    end
  end

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

    opts: --app app --ns namespace ---db db_file_name --format (svg, txt) --out fname --skip-tests (default)
    """)
  end

  defp dsm_analysis(app, namespace, db_name, skip_tests) do
    case DSM.gen_dsm(app, namespace, db_name, skip_tests) do
      {:ok, dsm, module_names} ->
        {:ok, DSM.triangularize(dsm) |> DSM.cyclic_deps_groups(), module_names}

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

  defp render(dsm, module_names, "txt") do
    DSM.ConsoleRender.render(dsm, module_names)
  end

  defp render(dsm, module_names, "svg") do
    DSM.SVGRender.render(dsm, module_names)
  end

  defp write_report(str, "console") do
    IO.puts(str)
  end

  defp write_report(str, file_name) do
    file_name |> Path.dirname() |> File.mkdir_p()
    File.write!(file_name, str)
    Mix.shell().info("Report ready in '#{file_name}'!")
  end
end