Skip to main content

lib/mix/tasks/scoria.install.ex

defmodule Mix.Tasks.Scoria.Install do
  @moduledoc """
  Installs Scoria through planner-led safe apply with three verification modes.

  ## Modes

  * **Default apply** — `mix scoria.install` runs the planner and applies only
    planner-classified create/update operations. `manual_review` entries never
    receive silent overwrites.
  * **`--dry-run`** — builds and prints the install plan without writing host files.
  * **`--check`** — builds the install plan, prints it, and exits with a tri-state
    result. `--check` never writes host files.

  ## Exit codes and automation trailer

  | Result | Exit code | Trailer status |
  |--------|-----------|----------------|
  | Compliant (all surfaces converged) | 0 | `compliant` |
  | Drift or manual review required | 1 | `drift` or `manual_review` |
  | Planner/check failure | 2 | `error` |

  Check mode prints a machine-parseable trailer as the final line:

      SCORIA_CHECK_RESULT status=<status> exit_code=<code>

  Post-upgrade verification:

      #{Scoria.Install.Contract.default_verify_command()}

  See `docs/operator_verification.md` for the upgrade-safe dry-run → check →
  remediate → apply workflow.

  ## Apply freshness

  Apply compares each plan entry fingerprint captured at plan build time to the
  live disk fingerprint before writes (`Scoria.Install.Manifest.validate_freshness/2`).
  The stored `.scoria/install/manifest.json` file is not the apply gate input. If
  apply blocks for stale fingerprints, re-run `--dry-run` and `--check` without
  editing managed files between check and apply.

  See `docs/operator_verification.md` for the **Check vs apply drift detection**
  subsection.
  """

  use Mix.Task
  alias Scoria.Install.ApplyExecutor
  alias Scoria.Install.Planner
  alias Scoria.Install.Report

  @shortdoc "Installs Scoria through planner-led safe apply"
  @switches [dry_run: :boolean, check: :boolean, format: :string]

  def run(args) do
    {opts, argv, invalid} = OptionParser.parse(args, strict: @switches)
    ensure_valid_args!(argv, invalid)
    ensure_valid_mode_flags!(opts)
    format = parse_format!(opts)

    router_paths = Path.wildcard("lib/*_web/router.ex")
    config_paths = ["config/runtime.exs", "config/config.exs"]

    router_path = List.first(router_paths)
    config_path = Enum.find(config_paths, &File.exists?/1)

    cond do
      opts[:dry_run] ->
        plan =
          Planner.build(router_path, config_path,
            mode: :dry_run
          )

        print_report(plan, format, :dry_run)

      opts[:check] ->
        run_check_mode(router_path, config_path, format)

      true ->
        run_apply_mode(router_path, config_path, format)
    end
  end

  def do_run(router_path, config_path \\ nil) do
    project_root = project_root(router_path, config_path)
    plan = Planner.build(router_path, config_path, mode: :apply)
    ApplyExecutor.run(plan, project_root: project_root)
  end

  defp project_root(router_path, config_path) do
    [
      config_root(config_path),
      router_root(router_path)
    ]
    |> Enum.find(& &1)
  end

  defp config_root(nil), do: nil

  defp config_root(path) do
    path
    |> Path.expand()
    |> Path.dirname()
    |> Path.dirname()
  end

  defp router_root(nil), do: nil

  defp router_root(path) do
    expanded = Path.expand(path)

    case String.split(expanded, "/lib/", parts: 2) do
      [root, _rest] when root != "" -> root
      _ -> Path.dirname(expanded)
    end
  end

  defp run_check_mode(router_path, config_path, format) do
    {plan, result} =
      try do
        plan =
          Planner.build(router_path, config_path,
            mode: :check
          )

        {plan, Report.check_result(plan)}
      rescue
        error ->
          {check_error_plan(error), Report.check_result(error)}
      end

    print_report(plan, format, :check)

    exit_code = result.exit_code
    Mix.shell().info(Report.trailer_line(result))
    System.halt(exit_code)
  end

  defp run_apply_mode(router_path, config_path, format) do
    project_root = project_root(router_path, config_path)

    {plan, result} =
      try do
        plan =
          Planner.build(router_path, config_path,
            mode: :apply
          )

        {plan, ApplyExecutor.run(plan, project_root: project_root)}
      rescue
        error ->
          {apply_error_plan(error), %{status: :error, exit_code: 2, reason: Exception.message(error)}}
      end

    print_report(plan, format, :apply)

    exit_code = result.exit_code
    Mix.shell().info(Report.trailer_line(result))
    System.halt(exit_code)
  end

  defp print_report(plan, "human", mode) do
    Mix.shell().info(Report.render_human(plan, mode))
  end

  defp print_report(plan, "json", mode) do
    Mix.shell().info(Report.render_json(plan, mode))
  end

  defp check_error_plan(error) do
    %{
      schema_version: 1,
      mode: :check,
      entries: [
        %{
          id: "check:error",
          surface: :check,
          target_path: "n/a",
          classification: :manual_review,
          rationale: "Check mode failed before planner output was available.",
          evidence: %{error: Exception.message(error)},
          order: 1
        }
      ],
      summary: %{create: 0, update: 0, no_op: 0, manual_review: 1}
    }
  end

  defp apply_error_plan(error) do
    %{
      schema_version: 1,
      mode: :apply,
      entries: [
        %{
          id: "apply:error",
          surface: :apply,
          target_path: "n/a",
          classification: :manual_review,
          operation: :manual_review,
          ownership_mode: :marker_region,
          manifest_key: "apply:error",
          fingerprint: "error",
          drift: %{reason_code: "apply_execution_error", details: Exception.message(error)},
          remediation: %{
            reason_code: "apply_execution_error",
            summary: "Apply mode failed before writes completed.",
            steps: [
              "Inspect the error details in this report output.",
              "Resolve the issue and rerun `mix scoria.install --check` before applying again."
            ],
            verify_command: "mix scoria.install --check"
          },
          rationale: "Apply mode failed before planner execution could complete.",
          evidence: %{error: Exception.message(error)},
          order: 1
        }
      ],
      summary: %{create: 0, update: 0, no_op: 0, manual_review: 1}
    }
  end

  defp ensure_valid_args!([], []), do: :ok

  defp ensure_valid_args!(argv, invalid) do
    invalid_switches =
      invalid
      |> Enum.map(fn {switch, value} -> "--#{switch}=#{value}" end)

    unsupported_args = invalid_switches ++ argv

    raise Mix.Error,
          "Unsupported arguments: #{Enum.join(unsupported_args, ", ")}. " <>
            "Supported options: --dry-run, --check, --format (human|json)."
  end

  defp ensure_valid_mode_flags!(opts) do
    if opts[:dry_run] && opts[:check] do
      raise Mix.Error, "Choose either --dry-run or --check, not both."
    end
  end

  defp parse_format!(opts) do
    format =
      opts
      |> Keyword.get(:format, "human")
      |> to_string()
      |> String.downcase()

    if format in ["human", "json"] do
      format
    else
      raise Mix.Error, "Unsupported --format #{inspect(format)}. Supported values: human, json."
    end
  end
end