lib/mix/tasks/licenses.ex

# SPDX-FileCopyrightText: 2021 Rosa Richter
#
# SPDX-License-Identifier: MIT

defmodule Mix.Tasks.Licenses do
  @moduledoc """
  Lists all dependencies along with a summary of their licenses.

  This task checks each entry in dependency package's `:licenses` list against the SPDX License List.

  To see details about licenses that are not found in the SPDX list, use `mix licenses.explain`.

  ## Command line options

    * `--osi` - additionally check if all licenses are approved by the [Open Source Initiative](https://opensource.org/licenses)
    * `--update` - pull down a fresh copy of the SPDX license list instead of using the version checked in with this tool.

  """
  @shortdoc "Lists all dependencies along with a summary of their licenses."

  use Mix.Task

  @impl Mix.Task
  def run(args) do
    check_osi_approved = "--osi" in args

    license_list =
      if "--update" in args do
        HexLicenses.SPDX.fetch_licenses()
        |> HexLicenses.SPDX.parse_licenses()
      else
        HexLicenses.SPDX.licenses()
      end

    check =
      HexLicenses.license_check(license_list)
      |> Map.new(fn {dep, licenses} ->
        {dep, summary(licenses, check_osi_approved)}
      end)

    first_column_width =
      Map.keys(check)
      |> Enum.map(&to_string/1)
      |> Enum.map(&String.length/1)
      |> Enum.max(fn -> 0 end)
      |> max(String.length("Dependency"))
      |> Kernel.+(2)

    rows =
      Enum.sort_by(check, fn {dep, _summary} -> to_string(dep) end)
      |> Enum.map(fn {dep, summary} ->
        dep = String.pad_trailing(to_string(dep), first_column_width)

        IO.ANSI.format([dep, summary])
      end)

    header =
      IO.ANSI.format([:faint, String.pad_trailing("Dependency", first_column_width), "Status"])

    shell = Mix.shell()

    shell.info(header)
    Enum.each(rows, &shell.info/1)
  end

  defp summary(:not_in_hex, _check_osi_approved) do
    IO.ANSI.format([:red, "not in Hex"])
  end

  defp summary(licenses, check_osi_approved) when is_map(licenses) do
    values = Map.values(licenses)

    all_approved = Enum.all?(values, &(&1 == :osi_approved))
    count_not_approved = Enum.count(values, &(&1 == :not_approved))
    count_not_recognized = Enum.count(values, &(&1 == :not_recognized))

    check_passed? =
      (check_osi_approved && all_approved) or
        count_not_recognized == 0

    if check_passed? do
      pass_message(check_osi_approved)
    else
      fail_message(count_not_approved, count_not_recognized, check_osi_approved)
    end
  end

  defp pass_message(true), do: IO.ANSI.format([:green, "all OSI approved"])
  defp pass_message(false), do: IO.ANSI.format([:green, "all valid"])

  defp fail_message(count_not_approved, count_not_recognized, check_osi_approved) do
    not_approved_message = "#{count_not_approved} not OSI approved"
    not_recognized_message = "#{count_not_recognized} not recognized"

    message =
      cond do
        check_osi_approved && count_not_approved > 0 && count_not_recognized > 0 ->
          not_approved_message <> ", " <> not_recognized_message

        check_osi_approved && count_not_approved > 0 ->
          not_approved_message

        count_not_recognized > 0 ->
          not_recognized_message
      end

    IO.ANSI.format([:red, message])
  end
end