lib/atomic_writes.ex

# https://github.com/adobe/elixir-styler/issues/43#issuecomment-1555951813
opts_schema =
  NimbleOptions.new!(
    path: [
      type: :string,
      required: true,
      doc: "Path to the final file that is atomically written."
    ],
    overwrite?: [
      type: :boolean,
      default: true,
      doc: "Overwrite the target file if it already exists?"
    ],
    tmp_dir: [
      type: :string,
      default: ".",
      doc: "Directory in which the temporary files are written."
    ]
  )

defmodule AtomicWrites do
  @moduledoc """
  Performs atomic file writes.

  ## Options

  #{NimbleOptions.docs(opts_schema)}

  ## LWW Atomic Writes

  ```elixir
  AtomicWrites.write("Atomically written content.", path: "example.txt")
  ```

  ## FWW Atomic Writes

  ```elixir
  AtomicWrites.write("Atomically written content.", path: "example.txt", overwrite?: false)
  ```

  ## Serialized Atomic Writes

  ``` elixir
  alias AtomicWrites.AtomicFile

  {:ok, pid} = AtomicFile.start_link(path: "example.txt")
  AtomicFile.write(pid, "Serialized, atomically written content.")
  ```

  ## Installation

  The package can be installed by adding `atomic_writes` to your list of
  dependencies in `mix.exs`:

  ```elixir
  def deps do
  [
    {:atomic_writes, "~> 1.0"}
  ]
  end
  ```

  ## License

  AtomicWrites is released under the [`Apache License
  2.0`](https://github.com/elliotekj/atomic_writes/blob/main/LICENSE).

  ## About

  This package was written by [Elliot Jackson](https://elliotekj.com).

  - Blog: [https://elliotekj.com](https://elliotekj.com)
  - Email: elliot@elliotekj.com
  """

  @opts_schema opts_schema

  @doc """
  Atomically write to the path.
  """
  @spec write(iodata(), Keyword.t()) :: :ok | {:error, atom()}
  def write(content, opts) do
    with {:ok, opts} <- validate_opts(opts),
         opts <- expand_opts(opts),
         {:ok, tmp_file_path} <- preflight(opts),
         :ok <- File.write(tmp_file_path, content) do
      result = maybe_move_file(tmp_file_path, opts[:path], opts[:overwrite?])
      spawn(fn -> File.rm(tmp_file_path) end)
      result
    else
      e -> e
    end
  end

  @doc false
  def opts_schema, do: @opts_schema

  @doc false
  def validate_opts(opts) do
    case Keyword.get(opts, :valid?) == true do
      true -> {:ok, opts}
      false -> NimbleOptions.validate(opts, @opts_schema)
    end
  end

  @doc false
  def expand_opts(opts) do
    opts
    |> Keyword.put(:valid?, true)
    |> Keyword.put(:tmp_dir, Path.expand(opts[:tmp_dir]))
    |> Keyword.put(:path, Path.expand(opts[:path]))
  end

  defp uniq_filename, do: UUID.uuid4() <> ".atomicwrite"

  defp preflight(opts) do
    tmp_filename = uniq_filename()
    tmp_file_path = Path.join([opts[:tmp_dir], tmp_filename])
    dest_dir_path = Path.dirname(opts[:path])

    with :ok <- File.mkdir_p(opts[:tmp_dir]),
         :ok <- File.mkdir_p(dest_dir_path) do
      {:ok, tmp_file_path}
    else
      e -> e
    end
  end

  defp maybe_move_file(tmp_file_path, dest_file_path, overwrite?) do
    case File.exists?(dest_file_path) && overwrite? == false do
      true -> {:error, :eexist}
      false -> File.rename(tmp_file_path, dest_file_path)
    end
  end
end