Skip to main content

lib/ttycast.ex

defmodule TTYCast do
  @moduledoc """
  Seekable, compressed terminal recordings for BEAM applications.

  TTYCast records terminal output, input metadata, resize events, and custom
  semantic events into a self-contained `.ttycast` file. Recordings are split
  into independently compressed chunks with Ghostty terminal keyframes so readers
  can seek by timestamp without replaying the whole file.

  ## Writing

      TTYCast.write("demo.ttycast", [width: 120, height: 40], fn writer ->
        TTYCast.Writer.write(writer, "hello\\r\\n")
      end)

  ## Reading

      cast = TTYCast.open!("demo.ttycast")
      TTYCast.info(cast)
      TTYCast.snapshot!(cast, time_ms: 1_000)
      TTYCast.stream(cast) |> Enum.to_list()

  ## Recording commands

      TTYCast.record(["sh", "-lc", "echo hello"], path: "demo.ttycast")

  See `FORMAT.md` for the binary container layout.
  """

  alias TTYCast.Cast
  alias TTYCast.Writer

  @type event :: Writer.event()

  @doc "Starts a recording writer."
  @spec start_writer(keyword()) :: GenServer.on_start()
  defdelegate start_writer(opts), to: Writer, as: :start_link

  @doc "Returns a collectable sink that writes iodata into the recording."
  @spec into(pid()) :: TTYCast.Sink.t()
  def into(writer), do: %TTYCast.Sink{writer: writer}

  @doc "Opens a writer, calls `fun`, and closes the writer afterwards."
  @spec write(Path.t(), keyword(), (pid() -> term())) :: {:ok, term()} | {:error, term()}
  def write(path, opts \\ [], fun) when is_function(fun, 1) do
    with {:ok, writer} <- Writer.start_link(Keyword.put(opts, :path, path)) do
      try do
        result = fun.(writer)
        :ok = Writer.close(writer)
        {:ok, result}
      rescue
        error in [RuntimeError, ArgumentError, MatchError, FunctionClauseError, CaseClauseError] ->
          Writer.close(writer)
          reraise error, __STACKTRACE__
      end
    end
  end

  @doc "Records a command under a real pseudo-terminal."
  @spec record([String.t()] | {String.t(), [String.t()]}, keyword()) ::
          {:ok, TTYCast.Recorder.result()} | {:error, term()}
  def record({cmd, args}, opts) when is_binary(cmd) and is_list(args),
    do: record([cmd | args], opts)

  def record(command, opts), do: TTYCast.Recorder.record(command, opts)

  @spec record(String.t(), [String.t()], keyword()) ::
          {:ok, TTYCast.Recorder.result()} | {:error, term()}
  def record(cmd, args, opts) when is_binary(cmd) and is_list(args),
    do: record([cmd | args], opts)

  @doc "Records a command interactively, forwarding the current terminal to the child PTY."
  @spec record_interactive([String.t()], keyword()) :: {:ok, map()} | {:error, term()}
  defdelegate record_interactive(command, opts), to: TTYCast.Interactive, as: :record

  @doc "Opens a recording without decoding chunks eagerly."
  @spec open(Path.t() | Cast.t()) :: {:ok, Cast.t()} | {:error, term()}
  def open(%Cast{} = cast), do: {:ok, cast}

  def open(path) when is_binary(path), do: TTYCast.Reader.open(path)

  @doc "Opens a recording or raises."
  @spec open!(Path.t() | Cast.t()) :: Cast.t()
  def open!(%Cast{} = cast), do: cast

  def open!(path) do
    case open(path) do
      {:ok, cast} -> cast
      {:error, reason} -> raise "cannot open ttycast #{inspect(path)}: #{inspect(reason)}"
    end
  end

  @doc "Returns a compact summary map for a recording."
  @spec info(Path.t() | Cast.t()) :: map()
  def info(path_or_cast) do
    cast = open!(path_or_cast)
    chunks = Map.get(cast.index, :chunks, [])

    %{
      path: cast.path,
      version: cast.header.version,
      codec: cast.header.codec,
      width: cast.header.width,
      height: cast.header.height,
      cwd: cast.header.cwd,
      started_at_unix_ms: cast.header.started_at_unix_ms,
      duration_ms: div(Map.get(cast.index, :duration_us, 0), 1_000),
      chunks: length(chunks),
      events: Enum.reduce(chunks, 0, &(&1.event_count + &2)),
      input_policy: cast.header.input_policy,
      metadata: cast.header.metadata
    }
  end

  @doc "Returns a Ghostty-rendered terminal snapshot at a timestamp."
  @spec snapshot(Path.t() | Cast.t(), keyword()) :: {:ok, binary() | map()} | {:error, term()}
  def snapshot(path_or_cast, opts \\ []) do
    path_or_cast
    |> open!()
    |> TTYCast.Replay.snapshot(opts)
  end

  @doc "Returns a terminal snapshot or raises."
  @spec snapshot!(Path.t() | Cast.t(), keyword()) :: binary() | map()
  def snapshot!(path_or_cast, opts \\ []) do
    case snapshot(path_or_cast, opts) do
      {:ok, snapshot} -> snapshot
      {:error, reason} -> raise "cannot snapshot ttycast: #{inspect(reason)}"
    end
  end

  @doc "Streams decoded events lazily from chunks matching the optional time range."
  @spec stream(Path.t() | Cast.t(), keyword()) :: Enumerable.t()
  def stream(path_or_cast, opts \\ []) do
    path_or_cast
    |> open!()
    |> TTYCast.Events.stream(opts)
  end

  @doc "Returns decoded events as a list. Prefer `stream/2` for large recordings."
  @spec events(Path.t() | Cast.t(), keyword()) :: [event()]
  def events(path_or_cast, opts \\ []) do
    path_or_cast
    |> open!()
    |> TTYCast.Events.list(opts)
  end

  @doc "Finds times where plain terminal snapshots contain a string or regex match."
  @spec find(Path.t() | Cast.t(), binary() | Regex.t(), keyword()) :: [
          %{time_ms: non_neg_integer(), match: binary()}
        ]
  def find(path_or_cast, pattern, opts \\ []) do
    path_or_cast
    |> open!()
    |> TTYCast.Search.find(pattern, opts)
  end

  @doc "Rebuilds the trailer/footer index from intact chunks."
  @spec reindex(Path.t()) :: {:ok, map()} | {:error, term()}
  defdelegate reindex(path), to: TTYCast.Reindex, as: :run

  @doc "Exports a recording to a supported format."
  @spec export(Path.t() | Cast.t(), :asciinema, Path.t()) :: :ok | {:error, term()}
  def export(path_or_cast, :asciinema, output_path),
    do: export_asciinema(path_or_cast, output_path)

  @doc "Imports a recording from a supported format."
  @spec import(Path.t(), :asciinema, Path.t(), keyword()) :: {:ok, Cast.t()} | {:error, term()}
  def import(input_path, :asciinema, output_path, opts \\ []),
    do: import_asciinema(input_path, output_path, opts)

  @doc "Imports asciinema v2 JSON lines into a ttycast file."
  @spec import_asciinema(Path.t(), Path.t(), keyword()) :: {:ok, Cast.t()} | {:error, term()}
  def import_asciinema(input_path, output_path, opts \\ []),
    do: TTYCast.Asciinema.import(input_path, output_path, opts)

  @doc "Exports terminal input/output streams as asciinema v2 JSON lines."
  @spec export_asciinema(Path.t() | Cast.t(), Path.t()) :: :ok | {:error, term()}
  def export_asciinema(path_or_cast, output_path) do
    path_or_cast
    |> open!()
    |> TTYCast.Asciinema.export(output_path)
  end

  @doc "Reads and decodes one compressed chunk by index entry."
  @spec read_chunk(Path.t() | Cast.t(), map()) :: {:ok, map()} | {:error, term()}
  def read_chunk(path_or_cast, chunk_index) do
    path_or_cast
    |> open!()
    |> TTYCast.Reader.read_chunk(chunk_index)
  end

  @doc "Reads one compressed chunk or raises."
  @spec read_chunk!(Path.t() | Cast.t(), map()) :: map()
  def read_chunk!(path_or_cast, chunk_index) do
    case read_chunk(path_or_cast, chunk_index) do
      {:ok, chunk} ->
        chunk

      {:error, reason} ->
        raise "cannot read ttycast chunk #{inspect(chunk_index.seq)}: #{inspect(reason)}"
    end
  end
end