lib/fortune.ex

# SPDX-FileCopyrightText: 2023 Frank Hunleth, Masatoshi Nishiguchi
#
# SPDX-License-Identifier: Apache-2.0
defmodule Fortune do
  @moduledoc """
  Get a fortune!

  Fortune reads a string, usually a random one, from one or more fortune files.
  Fortune files contain a list of strings and an associated index for for quick
  retrieval of a randomly chosen string. This implementation provides an Elixir
  take on the ubiquitous Unix fortune implementation. It is compatible with
  Unix fortune and can read most Unix fortune files.

  ```elixir
  iex> Fortune.random()
  {:ok, "Harness the power of the BEAM, one Elixir potion at a time."}
  ```

  No fortunes are provided, though. You'll need to add your own, add Elixir
  libraries to your mix dependencies that have fortunes, or use your system's fortunes.

  Fortunes provided by Elixir libraries are stored in that library's
  `priv/fortune` directory. See the `README.md` for using the `:fortune_compiler`
  for adding your own.

  If no Elixir-provided fortunes are found, `fortune` checks the system's
  `/usr/share/games/fortune` directory (and various similar ones)  for fortunes
  to find something.

  See `fortune_options/0` for modifying fortune search paths.
  """

  alias Fortune.Finder
  alias Fortune.StrfileReader

  @typedoc """
  Fortune options

  Pass these to `Fortune.random/1` and similar functions:

  * `:paths` - a list of absolute paths to `fortune` directories or files. Overrides other options
  * `:included_applications` - specifically include these applications that contain fortunes
  * `:excluded_applications` - exclude these applications from being scanned for fortunes
  * `:include_system_fortunes?` - set to `true` to include system fortunes. Defaults to `true` if no Elixir projects supply fortunes.

  To set defaults for your project, add Fortune options to your application's `config.exs`:

  ```elixir
  config :fortune, include_system_fortunes?: true
  ```
  """
  @type fortune_options() ::
          [
            paths: String.t() | [String.t()] | nil,
            included_applications: atom | [atom] | nil,
            excluded_applications: atom | [atom] | nil
          ]

  @doc """
  Return a random fortune
  """
  @spec random(fortune_options) :: {:ok, String.t()} | {:error, atom()}
  def random(options \\ []) do
    merged_options = Keyword.merge(Application.get_all_env(:fortune), options)
    strfiles = merged_options |> Finder.fortune_paths() |> open_all()

    if strfiles != [] do
      num_fortunes =
        Enum.reduce(strfiles, 0, fn strfile, acc -> acc + strfile.header.num_string end)

      rand_fortune = :rand.uniform(num_fortunes - 1)
      result = nth_fortune(strfiles, rand_fortune)

      Enum.each(strfiles, &StrfileReader.close/1)
      result
    else
      {:error, :no_fortunes}
    end
  end

  defp nth_fortune([strfile | rest], n) do
    if n >= strfile.header.num_string do
      nth_fortune(rest, n - strfile.header.num_string)
    else
      StrfileReader.read_string(strfile, n)
    end
  end

  defp open_all(paths, acc \\ [])
  defp open_all([], acc), do: acc

  defp open_all([path | rest], acc) do
    case StrfileReader.open(path) do
      {:ok, info} -> open_all(rest, [info | acc])
      {:error, _} -> open_all(rest, acc)
    end
  end

  @doc """
  Raising version of random/1
  """
  @spec random!(fortune_options()) :: String.t()
  def random!(options \\ []) do
    case random(options) do
      {:ok, string} -> string
      {:error, reason} -> raise RuntimeError, "Fortune.random failed with #{reason}"
    end
  end
end