# 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
See `Fortune` for an overview and `fortune_options/0` for modifying fortune
search paths.
"""
@spec random(fortune_options) :: {:ok, String.t()} | {:error, atom()}
def random(options \\ []) do
with {:ok, strfiles} <- open_all_strfiles(options) do
num_fortunes = count_fortunes(strfiles)
rand_fortune = :rand.uniform(num_fortunes - 1)
result = nth_fortune(strfiles, rand_fortune)
Enum.each(strfiles, &StrfileReader.close/1)
result
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_strfiles(options) do
merged_options = Keyword.merge(Application.get_all_env(:fortune), options)
strfiles = merged_options |> Finder.fortune_paths() |> open_all()
if strfiles != [] do
{:ok, strfiles}
else
{:error, :no_fortunes}
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 """
Return a random fortune or raise an exception
See `Fortune` for an overview and `fortune_options/0` for modifying fortune
search paths.
"""
@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
@doc """
Return statistics on fortunes
This can be useful for finding out what fortunes are available. The options are
the same as the ones for `random/1`.
NOTE: The returned map may change in the future which is why it is untyped.
"""
@spec info(fortune_options) :: {:ok, map()} | {:error, atom()}
def info(options \\ []) do
with {:ok, strfiles} <- open_all_strfiles(options) do
num_fortunes = count_fortunes(strfiles)
num_files = Enum.count(strfiles)
files =
Enum.reduce(strfiles, %{}, fn strfile, acc ->
Map.put(acc, strfile.path, %{
num_string: strfile.header.num_string,
size: file_size(strfile.path)
})
end)
Enum.each(strfiles, &StrfileReader.close/1)
{:ok, %{num_fortunes: num_fortunes, num_files: num_files, files: files}}
end
end
defp file_size(path) do
case File.stat(path) do
{:ok, stat} -> stat.size
_ -> -1
end
end
defp count_fortunes(strfiles) do
Enum.reduce(strfiles, 0, fn strfile, acc -> acc + strfile.header.num_string end)
end
end