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