Skip to main content

lib/ex_storage_service_cli/config.ex

defmodule ExStorageServiceCli.Config do
  @moduledoc """
  Manages CLI configuration profiles stored in `~/.config/ess/config.toml`.

  Supports multiple named profiles with credentials and endpoint settings.

  ## Config File Format

      [default]
      endpoint = "http://localhost:9000"
      access_key_id = "AKIA..."
      secret_access_key = "..."
      region = "us-east-1"

      [profiles.production]
      endpoint = "https://s3.example.com"
      access_key_id = "AKIA..."
      secret_access_key = "..."
      region = "us-east-1"
  """

  @config_dir "~/.config/ess"
  @config_file "config.toml"

  @doc """
  Returns the path to the config directory.
  """
  def config_dir do
    Path.expand(@config_dir)
  end

  @doc """
  Returns the path to the config file.
  """
  def config_path do
    Path.join(config_dir(), @config_file)
  end

  @doc """
  Loads a named profile from the config file.

  Returns a map with `:endpoint`, `:access_key_id`, `:secret_access_key`, `:region`.
  Returns an empty map if the profile or config file doesn't exist.
  """
  @spec load_profile(String.t()) :: map()
  def load_profile(profile_name) do
    path = config_path()

    if File.exists?(path) do
      case File.read(path) do
        {:ok, content} ->
          case Toml.decode(content) do
            {:ok, config} ->
              extract_profile(config, profile_name)

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

        {:error, _} ->
          %{}
      end
    else
      %{}
    end
  end

  @doc """
  Saves a profile to the config file.

  Creates the config directory and file if they don't exist.
  Merges with existing config if the file already exists.
  """
  @spec save_profile(String.t(), map()) :: :ok | {:error, term()}
  def save_profile(profile_name, profile_data) do
    dir = config_dir()
    path = config_path()

    File.mkdir_p!(dir)

    existing =
      if File.exists?(path) do
        case File.read(path) do
          {:ok, content} ->
            case Toml.decode(content) do
              {:ok, config} -> config
              _ -> %{}
            end

          _ ->
            %{}
        end
      else
        %{}
      end

    updated = put_profile(existing, profile_name, profile_data)
    content = encode_toml(updated)

    File.write(path, content)
  end

  @doc """
  Lists all available profile names.
  """
  @spec list_profiles() :: [String.t()]
  def list_profiles do
    path = config_path()

    if File.exists?(path) do
      case File.read(path) do
        {:ok, content} ->
          case Toml.decode(content) do
            {:ok, config} ->
              profiles =
                case Map.get(config, "profiles") do
                  nil -> []
                  profiles_map -> Map.keys(profiles_map)
                end

              if Map.has_key?(config, "default"), do: ["default" | profiles], else: profiles

            _ ->
              []
          end

        _ ->
          []
      end
    else
      []
    end
  end

  # Private helpers

  defp extract_profile(config, "default") do
    case Map.get(config, "default") do
      nil -> %{}
      profile -> normalize_profile(profile)
    end
  end

  defp extract_profile(config, profile_name) do
    case get_in(config, ["profiles", profile_name]) do
      nil -> %{}
      profile -> normalize_profile(profile)
    end
  end

  defp normalize_profile(profile) when is_map(profile) do
    %{
      endpoint: Map.get(profile, "endpoint"),
      access_key_id: Map.get(profile, "access_key_id"),
      secret_access_key: Map.get(profile, "secret_access_key"),
      region: Map.get(profile, "region")
    }
    |> Enum.reject(fn {_k, v} -> is_nil(v) end)
    |> Map.new()
  end

  defp put_profile(config, "default", data) do
    Map.put(config, "default", serialize_profile(data))
  end

  defp put_profile(config, profile_name, data) do
    profiles = Map.get(config, "profiles", %{})
    updated_profiles = Map.put(profiles, profile_name, serialize_profile(data))
    Map.put(config, "profiles", updated_profiles)
  end

  defp serialize_profile(data) do
    data
    |> Enum.map(fn {k, v} -> {to_string(k), v} end)
    |> Map.new()
  end

  defp encode_toml(config) do
    parts = []

    # Encode [default] section
    parts =
      case Map.get(config, "default") do
        nil ->
          parts

        default ->
          section = encode_toml_section("default", default)
          parts ++ [section]
      end

    # Encode [profiles.*] sections
    parts =
      case Map.get(config, "profiles") do
        nil ->
          parts

        profiles ->
          profile_sections =
            profiles
            |> Enum.sort_by(fn {name, _} -> name end)
            |> Enum.map(fn {name, data} ->
              encode_toml_section("profiles.#{name}", data)
            end)

          parts ++ profile_sections
      end

    Enum.join(parts, "\n")
  end

  defp encode_toml_section(header, data) when is_map(data) do
    lines =
      data
      |> Enum.sort_by(fn {k, _} -> k end)
      |> Enum.map(fn {key, value} ->
        "#{key} = #{encode_toml_value(value)}"
      end)

    "[#{header}]\n#{Enum.join(lines, "\n")}\n"
  end

  defp encode_toml_value(value) when is_binary(value), do: ~s("#{value}")
  defp encode_toml_value(value) when is_integer(value), do: to_string(value)
  defp encode_toml_value(value) when is_float(value), do: to_string(value)
  defp encode_toml_value(true), do: "true"
  defp encode_toml_value(false), do: "false"
  defp encode_toml_value(value), do: ~s("#{inspect(value)}")
end