lib/graft/list.ex

defmodule Graft.List do
  @moduledoc """
  Render the workspace manifest as a concise, read-only listing.

  Unlike `Graft.Status`, this does **not** snapshot the workspace, parse
  `mix.exs`, call `git`, or check Hex. It only reads `graft.exs` and
  checks whether each declared sibling path is present, missing, or
  obviously invalid on disk.

  ## Output formats

    * `:text` — human-readable table
    * `:json` — structured data for piping

  """

  alias Graft.{Error, Manifest}
  alias Graft.Manifest.Sibling

  @type format :: :text | :json

  @doc """
  Load the manifest from `dir` and render it.

  Returns `{:ok, String.t()}` on success or `{:error, Error.t()}` if the
  manifest cannot be read or validated.
  """
  @spec render(Path.t(), format()) :: {:ok, String.t()} | {:error, Error.t()}
  def render(dir \\ File.cwd!(), format \\ :text) when format in [:text, :json] do
    with {:ok, manifest} <- Manifest.load(dir) do
      siblings = annotate_siblings(manifest.siblings)

      output =
        case format do
          :text -> render_text(manifest.root, siblings)
          :json -> render_json(manifest.root, siblings)
        end

      {:ok, output}
    end
  end

  ## ─── Data gathering ─────────────────────────────────────────────────

  defp annotate_siblings(siblings) do
    Enum.map(siblings, fn %Sibling{name: name, path: path, absolute_path: abs, origin: origin} ->
      status = sibling_status(abs)

      %{
        name: name,
        path: path,
        absolute_path: abs,
        origin: origin,
        exists: File.exists?(abs),
        status: status.status,
        detail: status.detail
      }
    end)
  end

  defp sibling_status(abs) do
    cond do
      not File.exists?(abs) ->
        %{status: :missing, detail: "path does not exist"}

      not File.dir?(abs) ->
        %{status: :invalid, detail: "path is not a directory"}

      not File.regular?(Path.join(abs, "mix.exs")) ->
        %{status: :invalid, detail: "missing mix.exs"}

      true ->
        %{status: :present, detail: "ready"}
    end
  end

  ## ─── Text rendering ─────────────────────────────────────────────────

  defp render_text(root, siblings) do
    count = length(siblings)

    header = [
      "Graft workspace: #{root}",
      "Siblings (#{count}):",
      ""
    ]

    lines =
      Enum.map(siblings, fn s ->
        status = "[#{s.status}]"
        detail = if s.status == :present, do: "", else: " #{s.detail}"
        "  #{pad_name(s.name)} #{pad_path(s.path)} #{status}#{detail}"
      end)

    (header ++ lines)
    |> Enum.intersperse("\n")
    |> IO.iodata_to_binary()
  end

  defp pad_name(name) do
    name
    |> to_string()
    |> String.pad_trailing(12)
  end

  defp pad_path(path) do
    path
    |> to_string()
    |> String.pad_trailing(16)
  end

  ## ─── JSON rendering ─────────────────────────────────────────────────

  defp render_json(root, siblings) do
    %{
      root: root,
      sibling_count: length(siblings),
      siblings:
        Enum.map(siblings, fn s ->
          %{
            name: to_string(s.name),
            path: s.path,
            absolute_path: s.absolute_path,
            origin: s.origin,
            exists: s.exists,
            status: Atom.to_string(s.status),
            detail: s.detail
          }
        end)
    }
    |> Jason.encode!()
  end
end