Skip to main content

lib/systemd/unit_file.ex

defmodule Systemd.UnitFile do
  @moduledoc """
  Loss-aware representation of a systemd unit file.

  Unit files preserve ordering and duplicate directives. They are intentionally
  not represented as maps because repeated directives and reset directives such
  as `ExecStart=` are meaningful in systemd syntax.
  """

  alias Systemd.UnitFile.{Blank, Builder, Comment, Directive, Parser, Raw, Section, Validator}

  @type entry :: Blank.t() | Comment.t() | Directive.t() | Raw.t() | Section.t()
  @type t :: %__MODULE__{entries: [entry()]}

  defstruct entries: []

  @doc """
  Builds a service unit file from common `Unit`, `Service`, and `Install` sections.
  """
  @spec service(keyword()) :: t()
  defdelegate service(opts), to: Builder

  @doc """
  Builds a socket unit file from common `Unit`, `Socket`, and `Install` sections.
  """
  @spec socket(keyword()) :: t()
  defdelegate socket(opts), to: Builder

  @doc """
  Builds a timer unit file from common `Unit`, `Timer`, and `Install` sections.
  """
  @spec timer(keyword()) :: t()
  defdelegate timer(opts), to: Builder

  @doc """
  Builds a mount unit file from common `Unit`, `Mount`, and `Install` sections.
  """
  @spec mount(keyword()) :: t()
  defdelegate mount(opts), to: Builder

  @doc """
  Builds a path unit file from common `Unit`, `Path`, and `Install` sections.
  """
  @spec path(keyword()) :: t()
  defdelegate path(opts), to: Builder

  @doc """
  Builds a target unit file from common `Unit`, `Target`, and `Install` sections.
  """
  @spec target(keyword()) :: t()
  defdelegate target(opts), to: Builder

  @doc """
  Parses unit file text.
  """
  @spec parse(String.t()) :: {:ok, t()} | {:error, term()}
  defdelegate parse(text), to: Parser

  @doc """
  Parses unit file text, raising on failure.
  """
  @spec parse!(String.t()) :: t()
  def parse!(text) do
    case parse(text) do
      {:ok, unit_file} -> unit_file
      {:error, reason} -> raise ArgumentError, "invalid unit file: #{inspect(reason)}"
    end
  end

  @doc """
  Validates a unit file.
  """
  @spec validate(t(), String.t() | atom() | nil) ::
          :ok | {:error, [Systemd.UnitFile.ValidationError.t()]}
  defdelegate validate(unit_file, type \\ nil), to: Validator

  @doc """
  Returns a normalized representation suitable for semantic-ish comparison.

  This intentionally ignores trivia, directive ordering, and equivalent list
  spellings for directives such as `Wants=` and `ReadWritePaths=`.
  """
  @spec normalize(t() | String.t()) :: map()
  def normalize(text) when is_binary(text), do: text |> parse!() |> normalize()

  def normalize(%__MODULE__{entries: entries}) do
    entries
    |> entries_with_sections()
    |> Enum.reduce(%{}, &collect_normalized_entry/2)
    |> Map.new(fn {section, directives} -> {section, drop_defaults(directives)} end)
  end

  @doc """
  Compares two unit files after normalization.
  """
  @spec equivalent?(t() | String.t(), t() | String.t()) :: boolean()
  def equivalent?(left, right), do: normalize(left) == normalize(right)

  @doc """
  Renders a unit file.
  """
  @spec to_string(t()) :: String.t()
  def to_string(%__MODULE__{entries: entries}) do
    entries
    |> Enum.map(&entry_to_iodata/1)
    |> IO.iodata_to_binary()
  end

  @doc """
  Returns all directive values matching a section and directive name.
  """
  @spec get_all(t(), String.t(), String.t()) :: [String.t()]
  def get_all(%__MODULE__{} = unit_file, section, name) do
    unit_file.entries
    |> entries_with_sections()
    |> Enum.flat_map(fn
      {^section, %Directive{name: ^name, value: value}} -> [value]
      _entry -> []
    end)
  end

  @doc """
  Appends a directive to the last matching section, creating the section if needed.
  """
  @spec append(t(), String.t(), String.t(), String.t()) :: t()
  def append(%__MODULE__{entries: entries} = unit_file, section, name, value) do
    directive = %Directive{name: name, value: value}

    entries =
      if section?(entries, section) do
        append_to_last_section(entries, section, directive)
      else
        entries ++ [%Section{name: section}, directive]
      end

    %{unit_file | entries: entries}
  end

  @doc """
  Replaces all matching directives in a section with a single directive.
  """
  @spec put(t(), String.t(), String.t(), String.t()) :: t()
  def put(%__MODULE__{} = unit_file, section, name, value) do
    unit_file
    |> delete(section, name)
    |> append(section, name, value)
  end

  @doc """
  Deletes matching directives from a section.
  """
  @spec delete(t(), String.t(), String.t()) :: t()
  def delete(%__MODULE__{entries: entries} = unit_file, section, name) do
    {entries, _current_section} =
      Enum.reduce(entries, {[], nil}, fn
        %Section{name: section_name} = entry, {acc, _current_section} ->
          {[entry | acc], section_name}

        %Directive{name: ^name}, {acc, ^section} ->
          {acc, section}

        entry, {acc, current_section} ->
          {[entry | acc], current_section}
      end)

    %{unit_file | entries: Enum.reverse(entries)}
  end

  defp entry_to_iodata(%Blank{}), do: "\n"
  defp entry_to_iodata(%Comment{marker: marker, text: text}), do: [marker, text, "\n"]
  defp entry_to_iodata(%Section{name: name}), do: ["[", name, "]\n"]
  defp entry_to_iodata(%Directive{name: name, value: value}), do: [name, "=", value, "\n"]
  defp entry_to_iodata(%Raw{content: content}), do: [content, "\n"]

  @list_directives MapSet.new([
                     "after",
                     "before",
                     "bindsto",
                     "conflicts",
                     "documentation",
                     "environmentfile",
                     "readwritepaths",
                     "requires",
                     "requiredby",
                     "wants",
                     "wantedby"
                   ])

  defp collect_normalized_entry({section, %Directive{}}, acc) when is_nil(section), do: acc

  defp collect_normalized_entry({section, %Directive{} = directive}, acc) do
    section = normalize_name(section)
    key = normalize_name(directive.name)
    values = normalize_directive_values(key, directive.value)

    update_in(acc, [Access.key(section, %{}), Access.key(key, [])], &(values ++ &1))
  end

  defp collect_normalized_entry(_entry, acc), do: acc

  defp normalize_directive_values(key, value) do
    value = normalize_value(value)

    values =
      if MapSet.member?(@list_directives, key) do
        String.split(value, ~r/\s+/, trim: true)
      else
        [value]
      end

    Enum.sort(values)
  end

  defp drop_defaults(%{"type" => ["simple"]} = directives),
    do: directives |> Map.delete("type") |> sort_values()

  defp drop_defaults(directives), do: sort_values(directives)

  defp sort_values(directives),
    do: Map.new(directives, fn {key, values} -> {key, Enum.sort(values)} end)

  defp normalize_name(name) do
    name
    |> String.trim()
    |> String.replace("_", "")
    |> String.downcase()
  end

  defp normalize_value(value) do
    value
    |> String.trim()
    |> String.replace(~r/\s+/, " ")
  end

  defp entries_with_sections(entries) do
    {_section, entries} =
      Enum.reduce(entries, {nil, []}, fn
        %Section{name: section}, {_current_section, acc} ->
          {section, acc}

        entry, {section, acc} ->
          {section, [{section, entry} | acc]}
      end)

    Enum.reverse(entries)
  end

  defp section?(entries, section) do
    Enum.any?(entries, &match?(%Section{name: ^section}, &1))
  end

  defp append_to_last_section(entries, section, directive) do
    {before_section, section_entries, after_section} = split_last_section(entries, section)
    before_section ++ append_before_trailing_trivia(section_entries, directive) ++ after_section
  end

  defp split_last_section(entries, section) do
    index =
      entries
      |> Enum.with_index()
      |> Enum.filter(fn
        {%Section{name: ^section}, _index} -> true
        _entry -> false
      end)
      |> List.last()
      |> elem(1)

    {before_section, [%Section{} = section_entry | rest]} = Enum.split(entries, index)
    {body, after_section} = Enum.split_while(rest, &(not match?(%Section{}, &1)))
    {before_section, [section_entry | body], after_section}
  end

  defp append_before_trailing_trivia([%Section{} = section | entries], directive) do
    {trailing, body} = Enum.split_while(Enum.reverse(entries), &trivia?/1)
    [section | Enum.reverse(body, [directive | Enum.reverse(trailing)])]
  end

  defp trivia?(%Blank{}), do: true
  defp trivia?(%Comment{}), do: true
  defp trivia?(_entry), do: false
end