lib/say.ex

defmodule Say do
  @moduledoc """
  Lets the System say a given text via text-to-speech.

  ## Usage

      Import Say

      def foo() do
        if func() do
          say("success")
        else
          say("error in foo")
        end
      end

  ## Configuration (How to say something)

  You can configure the way how to say something...

  via an elixir function:

      config :say,
         func: &IO.inspect/1

  via shell command:

      config :say,
         exec: "say"

      config :say,
         exec: "say"
         exec_args: ~w(-v somevoice)

  via shell command over an SSH tunnel:

      config :say,
         exec: "say",
         ssh_args: ~w(-p 2222 localhost)

      config :say,
         exec: "say",
         exec_args: ~w(-v somevoice)
         ssh_args: ~w(-p 2222 localhost)

  ## OS specialities

    * On the Mac the executable `say` can be used directly.

    * On Linux I haven't tried, but these should work [https://linuxhint.com/command-line-text-speech-apps-linux/](https://linuxhint.com/command-line-text-speech-apps-linux/)

  """

  @doc """
  Lets the system say the given `text`.

  `text` is expected to be a binary.

  Returns a tuple containing :ok or :error, the collected result
  and the command exit status.

  ## Examples

      iex> Say.say("hello")

  """
  @spec say(binary) :: {:ok | :error, Collectable.t(), exit_status :: non_neg_integer}
  def say(text) when is_binary(text) do
    func = Application.get_env(:say, :func)
    exec = Application.get_env(:say, :exec)
    exec_args = Application.get_env(:say, :exec_args)
    ssh_args = Application.get_env(:say, :ssh_args)

    if exec && !is_binary(exec), do: raise ArgumentError, "exec must be a binary"
    if exec_args do
      if !is_list(exec_args), do: raise ArgumentError, "exec_args must be a list of binaries"
      unless Enum.all?(exec_args, &is_binary/1), do: raise ArgumentError, "all exec_args must be binaries"
    end
    if ssh_args do
      if !is_list(ssh_args), do: raise ArgumentError, "ssh_args must be a list of binaries"
      unless Enum.all?(ssh_args, &is_binary/1), do: raise ArgumentError, "all ssh_args must be binaries"
    end

    do_say(text, func, exec, exec_args, ssh_args)
  end

  defp do_say(text, func, exec, _exec_args, ssh_args) when func != nil and exec == nil and ssh_args == nil do
    result = func.(text)
    {:ok, result, 0}
  end

  defp do_say(text, func, exec, exec_args, ssh_args) when func == nil and exec != nil and exec_args != nil and ssh_args == nil do
    cmd = exec
    args = exec_args ++ [text] |> Enum.reject(&is_nil/1)
    do_system_cmd(cmd, args)
  end

  defp do_say(text, func, exec, exec_args, ssh_args) when func == nil and exec != nil and exec_args == nil and ssh_args == nil do
    cmd = exec
    args = [text]
    do_system_cmd(cmd, args)
  end

  defp do_say(text, func, exec, exec_args, ssh_args) when func == nil and exec != nil and ssh_args != nil do
    cmd = "ssh"
    exec_args = if exec_args != nil, do: exec_args, else: []
    args = ssh_args ++ [exec] ++ exec_args ++ [text] |> Enum.reject(&is_nil/1)
    do_system_cmd(cmd, args)
  end

  defp do_say(_text, _func, _exec, _exec_args, _ssh_args) do
    raise ArgumentError, "see config samples"
  end

  defp do_system_cmd(cmd, args) do
    if Application.get_env(:say, :test) do
      {cmd, args}
    else
      {result, exit_code} = System.cmd(cmd, args)
      case exit_code do
        0 -> {:ok, result, 0}
        e -> {:error, result, e}
      end
    end
  end

end