Skip to main content

lib/mix/tasks/bloccs.coverage.ex

defmodule Mix.Tasks.Bloccs.Coverage do
  @shortdoc "Report structural coverage for a network"

  @moduledoc """
      mix bloccs.coverage <network.bloccs> [--message <json>] [--trace <file>] [--port <name>] [--wait <secs>]

  Reports structural coverage — every in-port, out-port, and edge — against a
  *reached* set:

    * `--message <json>` runs the network live, feeds one message into the
      exposed intake port, records a trace, and reports what was reached.
    * `--trace <file>` loads a previously recorded `.bloccs-trace` and reports
      against it.
    * with neither, reports the pure structural enumeration (0 reached).
  """

  use Mix.Task

  alias Bloccs.{Compiler, Coverage, Parser, Producer, Router, Trace, Validator}

  @impl Mix.Task
  def run([]), do: Mix.raise("usage: mix bloccs.coverage <network.bloccs> [--message <json>]")

  def run([path | _] = args) do
    {opts, _, _} =
      OptionParser.parse(args,
        strict: [message: :string, trace: :string, port: :string, wait: :integer]
      )

    {:ok, _} = Application.ensure_all_started(:bloccs)

    with {:ok, network} <- Parser.parse_network(path),
         :ok <- Validator.validate_network(network) do
      reached = reached_for(network, opts)
      report = Coverage.report(network, reached)
      Mix.shell().info(Coverage.render(network, report))
    else
      {:error, errs} ->
        Enum.each(errs, &Mix.shell().error("  - #{inspect(&1)}"))
        exit({:shutdown, 1})
    end
  end

  defp reached_for(network, opts) do
    cond do
      Keyword.has_key?(opts, :trace) ->
        file = opts[:trace]

        case Trace.load(file) do
          {:ok, events} -> Trace.reached(events)
          {:error, reason} -> Mix.raise("could not load trace #{file}: #{inspect(reason)}")
        end

      Keyword.has_key?(opts, :message) ->
        reached_from_live_run(network, opts[:message], opts)

      true ->
        []
    end
  end

  defp reached_from_live_run(network, json, opts) do
    payload =
      case Jason.decode(json) do
        {:ok, p} -> p
        {:error, _} -> Mix.raise("--message must be valid JSON")
      end

    Mix.Task.run("app.start")
    {:ok, sup} = Compiler.compile_and_load(network)
    {:ok, _} = sup.start_link([])

    network_id = String.to_atom(network.id)
    rec = Trace.record(network_id)

    {node, port} = resolve_intake(network, Keyword.get(opts, :port))
    _ = Producer.push(Router.producer_name(network_id, node, port), payload)

    Process.sleep(Keyword.get(opts, :wait, 2) * 1000)
    Trace.reached(Trace.stop(rec))
  end

  defp resolve_intake(network, nil) do
    case network.expose.in do
      empty when map_size(empty) == 0 ->
        node = Enum.at(network.nodes, 0) |> elem(1)
        {node.local_id, node.manifest.ports_in |> Map.keys() |> List.first()}

      ins ->
        {_name, endpoint} = Enum.at(ins, 0)
        endpoint
    end
  end

  defp resolve_intake(network, name) do
    case Map.fetch(network.expose.in, String.to_atom(name)) do
      {:ok, endpoint} -> endpoint
      :error -> Mix.raise("no exposed input port `#{name}` in network #{network.id}")
    end
  end
end