Packages

A simple snapshot testing library inspired by Birdie (Gleam) and Insta (Rust).

Current section

Files

Jump to
calque lib calque.ex
Raw

lib/calque.ex

defmodule Calque do
@moduledoc """
Calque — a lightweight snapshot testing and review tool for Elixir projects.
Calque allows you to:
- **Record** test outputs as snapshot files (`*.snap`)
- **Compare** new outputs against previously accepted snapshots
- **Review and accept/reject** snapshots interactively from the CLI
## Typical usage
Calque.check(result, "my test title")
If no accepted snapshot exists, Calque will create one and fail the test,
prompting you to review it with:
mix calque review
Calque stores all snapshots under `calque_snapshots/` in your project root.
## CLI commands
- `mix calque review` — review new or changed snapshots interactively
- `mix calque accept-all` — accept all pending snapshots
- `mix calque reject-all` — reject all pending snapshots
- `mix calque clean` — delete pending, rejected, and unreferenced snapshots
- `mix calque help` — show CLI usage information
---
"""
alias Calque.{Diff, Error, Levenshtein, Snapshot}
@type snapshot :: Snapshot.t()
@version Mix.Project.config()[:version]
@snapshot_folder "calque_snapshots"
@snapshot_test_failed_message "Calque snapshot test failed"
@hint_review_message "Please review this snapshot using #{IO.ANSI.bright()}`mix calque review`#{IO.ANSI.reset()}"
@doc """
Performs a snapshot test with an explicit snapshot title.
This function writes the given content to a snapshot file (if none exists)
and compares it against the previously accepted snapshot.
In practice, you will usually prefer `check/1`, which automatically derives
the snapshot title from the calling test. Use `check/2` when you need full
control over the snapshot name.
## Examples
Using an explicit snapshot title:
test "renders order confirmation" do
html = render_confirmation(order)
Calque.check(html, "order_confirmation")
end
If the snapshot matches the accepted version, the test passes silently:
:ok
If no snapshot exists yet, or if the content differs, the test fails and
prints a helpful diff.
"""
@spec check(String.t(), String.t()) :: :ok | no_return()
defmacro check(content, title) do
defining_mod = __MODULE__
file = __CALLER__.file
line = __CALLER__.line
relative_path = Path.relative_to_cwd(file) |> to_string()
source = "#{relative_path}:#{line}"
quote do
unquote(defining_mod).check(unquote(content), unquote(title), unquote(source))
end
end
@doc """
Performs a snapshot test using the **calling function name** as the snapshot title.
This macro is a convenience wrapper around `check/2`. The snapshot title is
automatically inferred from the surrounding function or test name, making it
the preferred API in most cases.
It behaves exactly like `check/2`: the content is compared against the accepted
snapshot, and the test fails if a new snapshot is created or a difference is
detected.
## Examples
Inside an ExUnit test:
test "renders empty state" do
html = render_empty_state()
Calque.check(html)
end
The snapshot title will automatically be set to:
"renders empty state"
This keeps snapshot names stable and closely aligned with test intent, without
requiring any manual naming.
"""
@spec check(String.t()) :: :ok | no_return()
defmacro check(content) do
defining_mod = __MODULE__
{fun_name, _arity} = __CALLER__.function || {:no_function, 0}
file = __CALLER__.file
line = __CALLER__.line
title = strip_test_prefix(to_string(fun_name))
relative_path = Path.relative_to_cwd(file) |> to_string()
source = "#{relative_path}:#{line}"
quote do
unquote(defining_mod).check(unquote(content), unquote(title), unquote(source))
end
end
# -------------------------
# INTERNAL LOGIC
# -------------------------
@doc false
@spec check(String.t(), String.t(), String.t()) :: :ok | no_return()
def check(content, title, source) do
case do_check(content, title, source) do
{:ok, :same} ->
:ok
{:ok, {:new_snapshot_created, %{snapshot: snapshot}}} ->
hint_message = hint_text(@hint_review_message)
hint = %{
type: :info_line_with_title,
content: hint_message,
split: :no_split,
title: "hint"
}
box = new_snapshot_box(snapshot, [hint])
IO.puts(:stderr, "\n\n#{box}\n")
raise(@snapshot_test_failed_message)
{:ok, {:different, %{accepted: accepted, new: new}}} ->
hint_message = hint_text(@hint_review_message)
hint = %{
type: :info_line_with_title,
content: hint_message,
split: :no_split,
title: "hint"
}
box = diff_snapshot_box(accepted, new, [hint])
IO.puts(:stderr, "\n\n#{box}\n")
raise(@snapshot_test_failed_message)
{:error, error} ->
IO.puts(:stderr, "\n" <> Error.explain(error) <> "\n")
IO.puts(@snapshot_test_failed_message)
fail!("snapshot #{inspect(title)} failed: #{Error.format_error(error)}")
end
end
@doc false
@spec do_check(String.t(), String.t(), String.t()) ::
{:ok, :same | {:new_snapshot_created, map()} | {:different, map()}}
| {:error, Error.t()}
defp do_check(content, title, source) do
with(
{:ok, title} <- verify_title(title),
{:ok, folder} <- find_snapshots_folder(),
snapshot <- Snapshot.new(title, content, source),
new_snapshot_path <- new_destination(snapshot, folder),
accepted_snapshot_path <- to_accepted_path(new_snapshot_path),
{:ok, accepted} <- read_accepted(accepted_snapshot_path)
) do
compare_snapshots(accepted, snapshot, content, new_snapshot_path)
end
end
@doc false
@spec compare_snapshots(snapshot | nil, snapshot, String.t(), Path.t()) ::
{:ok, :same | {:new_snapshot_created, map()} | {:different, map()}}
| {:error, Error.t()}
defp compare_snapshots(nil, snapshot, _content, new_snapshot_path) do
with :ok <- save(snapshot, new_snapshot_path) do
{:ok, {:new_snapshot_created, %{snapshot: snapshot, path: new_snapshot_path}}}
end
end
defp compare_snapshots(%Snapshot{} = accepted, snapshot, content, new_snapshot_path) do
if normalize_body(accepted.content) == normalize_body(content) do
cleanup_if_present(new_snapshot_path)
{:ok, :same}
else
save_different_snapshot(accepted, snapshot, new_snapshot_path)
end
end
@doc false
@spec save_different_snapshot(snapshot, snapshot, Path.t()) ::
{:ok, {:different, map()}} | {:error, Error.t()}
defp save_different_snapshot(accepted, snapshot, new_snapshot_path) do
with :ok <- save(snapshot, new_snapshot_path) do
{:ok, {:different, %{accepted: accepted, new: snapshot, path: new_snapshot_path}}}
end
end
@doc false
@spec verify_title(binary() | nil) :: {:ok, binary()} | {:error, Error.t()}
defp verify_title(nil), do: {:error, :snapshot_with_empty_title}
defp verify_title(""), do: {:error, :snapshot_with_empty_title}
defp verify_title(title), do: {:ok, title}
@doc false
@spec normalize_body(binary()) :: binary()
defp normalize_body(str) when is_binary(str) do
String.replace(str, "\r\n", "\n")
end
@doc false
@spec fail!(binary()) :: no_return()
defp fail!(message) do
if Code.ensure_loaded?(ExUnit.AssertionError) do
raise ExUnit.AssertionError, message: message
else
raise message: message
end
end
# -------------------------
# FILE OPERATIONS
# -------------------------
@doc false
@spec read_accepted(Path.t()) :: {:ok, snapshot | nil} | {:error, Error.t()}
defp read_accepted(path) do
case File.read(path) do
{:ok, content} ->
case deserialise(content, :accepted) do
{:ok, snapshot} -> {:ok, snapshot}
{:error, :invalid_snapshot} -> {:error, {:corrupted_snapshot, path}}
end
{:error, :enoent} ->
{:ok, nil}
{:error, reason} ->
{:error, {:cannot_read_accepted_snapshot, reason, path}}
end
end
@doc false
@spec cleanup_if_present(Path.t()) :: :ok
defp cleanup_if_present(path) do
case File.rm(path) do
:ok -> :ok
{:error, :enoent} -> :ok
{:error, _} -> :ok
end
end
@doc false
@spec save(snapshot, Path.t()) :: :ok | {:error, Error.t()}
defp save(%Snapshot{status: :new} = snapshot, destination) do
# We store new snapshots as *.snap. Accepted snapshots are *.accepted.snap.
cond do
String.ends_with?(destination, ".accepted.snap") ->
{:error, {:cannot_save_new_snapshot, :invalid_destination, snapshot.title, destination}}
String.ends_with?(destination, ".snap") ->
with(
:ok <- ensure_parent_directory(destination),
:ok <- File.write(destination, serialise(snapshot))
) do
:ok
else
{:error, reason} ->
{:error, {:cannot_save_new_snapshot, reason, snapshot.title, destination}}
end
true ->
{:error, {:cannot_save_new_snapshot, :invalid_destination, snapshot.title, destination}}
end
end
@doc false
@spec ensure_parent_directory(Path.t()) :: :ok | {:error, term()}
defp ensure_parent_directory(path) do
path
|> Path.dirname()
|> File.mkdir_p()
end
@doc false
@spec find_snapshots_folder() :: {:ok, Path.t()} | {:error, Error.t()}
defp find_snapshots_folder do
with(
:ok <- File.mkdir_p(@snapshot_folder),
{:ok, %File.Stat{type: :directory}} <- File.stat(@snapshot_folder)
) do
{:ok, @snapshot_folder}
else
{:error, reason} ->
{:error, {:cannot_create_snapshots_folder, reason}}
{:ok, %File.Stat{}} ->
{:error, {:cannot_create_snapshots_folder, :not_a_directory}}
end
end
@doc false
@spec new_destination(snapshot, Path.t()) :: Path.t()
defp new_destination(%Snapshot{} = snapshot, folder),
do: Path.join(folder, safe_basename(snapshot.title) <> ".snap")
@doc false
@spec strip_test_prefix(binary()) :: binary()
defp strip_test_prefix("test " <> rest), do: rest
defp strip_test_prefix(title), do: title
@doc false
@spec safe_basename(binary()) :: binary()
defp safe_basename(title) do
title
|> String.replace(~r/[\/\\]/u, "⧸")
|> String.replace(~r/[^[:alnum:]\- _.!+:]/u, "_")
|> String.trim()
|> String.replace(~r/\s+/u, " ")
end
@doc false
@spec to_accepted_path(Path.t()) :: Path.t()
defp to_accepted_path(path), do: String.replace_suffix(path, ".snap", ".accepted.snap")
# -------------------------
# SNAPSHOT DE/SERIALIZATION
# -------------------------
@doc false
@spec serialise(snapshot) :: binary()
defp serialise(%Snapshot{title: title, content: content, source: source}) do
escaped_title = String.replace(title, "\n", "\\n")
[
"---",
"version: #{@version}",
"title: #{escaped_title}",
"source: #{source}",
"---",
content
]
|> Enum.join("\n")
end
@doc false
@spec deserialise(binary(), :new | :accepted) ::
{:ok, snapshot} | {:error, :invalid_snapshot}
defp deserialise(raw, status) when status in [:new, :accepted] do
raw = String.replace(raw, "\r\n", "\n")
with(
{:ok, {open_line, rest}} <- split_once(raw),
true <- open_line == "---",
{:ok, {_version_line, rest}} <- split_once(rest),
{:ok, {title_line, rest}} <- split_once(rest),
"title: " <> escaped_title <- title_line,
{:ok, {source, content}} <- parse_source_and_content(rest)
) do
title = String.replace(escaped_title, "\\n", "\n")
{:ok, build_snapshot(title, content, status, source)}
else
_ -> {:error, :invalid_snapshot}
end
end
@doc false
@spec parse_source_and_content(binary()) :: {:ok, {binary() | nil, binary()}} | {:error, nil}
defp parse_source_and_content(rest) do
with {:ok, {next_line, remaining}} <- split_once(rest) do
parse_next_line(next_line, remaining)
end
end
@doc false
@spec parse_next_line(binary(), binary()) :: {:ok, {binary() | nil, binary()}} | {:error, nil}
defp parse_next_line("source: " <> escaped_source, remaining) do
with {:ok, {"---", content}} <- split_once(remaining) do
source = String.replace(escaped_source, "\\n", "\n")
{:ok, {source, content}}
end
end
defp parse_next_line("---", remaining), do: {:ok, {nil, remaining}}
defp parse_next_line(_, _), do: {:error, nil}
@doc false
@spec build_snapshot(binary(), binary(), :new | :accepted, binary() | nil) :: snapshot
defp build_snapshot(title, content, :new, source), do: Snapshot.new(title, content, source)
defp build_snapshot(title, content, :accepted, source),
do: Snapshot.accepted(title, content, source)
@doc false
@spec split_once(binary()) :: {:ok, {binary(), binary()}} | {:error, nil}
defp split_once(str) do
case String.split(str, "\n", parts: 2) do
[first, rest] -> {:ok, {first, rest}}
[_] -> {:error, nil}
[] -> {:error, nil}
end
end
# -------------------------
# SNAPSHOT DIFFING
# -------------------------
@doc false
@spec snapshot_body(snapshot) :: binary()
defp snapshot_body(%Snapshot{content: content}), do: snapshot_body(content)
@doc false
@spec snapshot_body(binary()) :: binary()
defp snapshot_body(content) when is_binary(content) do
String.replace(content, "\r\n", "\n")
end
@doc false
@spec to_diff_lines(snapshot, snapshot) ::
[%{number: non_neg_integer(), line: binary(), kind: :shared | :new | :old}]
defp to_diff_lines(%Snapshot{} = accepted, %Snapshot{} = new) do
accepted_body = snapshot_body(accepted)
new_body = snapshot_body(new)
Diff.line_by_line(accepted_body, new_body)
end
# -------------------------
# PRETTY PRINTING
# -------------------------
@doc false
@spec hint_text(binary()) :: binary()
defp hint_text(message), do: IO.ANSI.yellow() <> message <> IO.ANSI.reset()
@doc false
@spec new_snapshot_box(snapshot, list(map())) :: binary()
defp new_snapshot_box(%Snapshot{} = snapshot, additional_info_lines) do
body = snapshot_body(snapshot)
content_lines =
body
|> String.split("\n", trim: false)
|> Enum.with_index()
|> Enum.map(fn {line, i} -> %{number: i + 1, line: line, kind: :new} end)
info_lines =
[
%{
type: :info_line_with_title,
content: snapshot.title,
split: :split_words,
title: "title"
},
%{
type: :info_line_with_title,
content: snapshot.source,
split: :no_split,
title: "source"
}
] ++ List.wrap(additional_info_lines)
pretty_box("new snapshot", content_lines, info_lines)
end
@doc false
@spec diff_snapshot_box(snapshot, snapshot, list(map())) :: binary()
defp diff_snapshot_box(%Snapshot{} = accepted, %Snapshot{} = new, additional_info_lines) do
info_lines =
[
%{
type: :info_line_with_title,
content: new.title,
split: :split_words,
title: "title"
},
%{
type: :info_line_with_title,
content: new.source,
split: :no_split,
title: "source"
}
] ++
List.wrap(additional_info_lines) ++
[
%{type: :info_line_without_title, content: "", split: :no_split},
%{
type: :info_line_without_title,
content: IO.ANSI.red() <> "- old snapshot" <> IO.ANSI.reset(),
split: :no_split
},
%{
type: :info_line_without_title,
content: IO.ANSI.green() <> "+ new snapshot" <> IO.ANSI.reset(),
split: :no_split
}
]
pretty_box("mismatched snapshots", to_diff_lines(accepted, new), info_lines)
end
@doc false
@spec pretty_box(
binary(),
[%{number: non_neg_integer(), line: binary(), kind: :shared | :new | :old}],
list(map())
) :: binary()
defp pretty_box(title, content_lines, info_lines) do
terminal_width =
case :io.columns() do
{:ok, w} when is_integer(w) and w > 20 -> w
_ -> 80
end
lines_count = length(content_lines) + 1
digits_count = Integer.digits(lines_count) |> length()
padding = digits_count * 2 + 5
title_length = String.length(title)
title_line_right = String.duplicate("─", max(terminal_width - 5 - title_length, 0))
title_line = "── " <> title <> " ─" <> title_line_right
info_lines = Enum.map(info_lines, &pretty_info_line(&1, terminal_width))
content = Enum.map_join(content_lines, "\n", &pretty_diff_line(&1, padding))
left_padding_line = String.duplicate("─", padding)
right_padding_line = String.duplicate("─", terminal_width - padding - 1)
open_line = left_padding_line <> "┬" <> right_padding_line
closed_line = left_padding_line <> "â”´" <> right_padding_line
Enum.join(
[
title_line,
"",
Enum.join(info_lines, "\n"),
"",
open_line,
content,
closed_line
],
"\n"
)
end
@doc false
@spec pretty_info_line(
%{
type: :info_line_with_title,
title: binary(),
content: binary(),
split: :no_split | :split_words | :truncate
},
pos_integer()
) :: binary()
defp pretty_info_line(
%{type: :info_line_with_title, title: title, content: content, split: split},
width
) do
title_length = String.length(title)
line_doc =
case split do
:no_split ->
content
:split_words ->
content |> String.split("\n") |> Enum.join(" ")
:truncate ->
max_len = width - title_length - 6
if String.length(content) <= max_len,
do: content,
else: String.slice(content, 0, max_len - 3) <> "..."
end
IO.ANSI.blue() <> " " <> title <> ": " <> IO.ANSI.reset() <> " " <> line_doc
end
@doc false
@spec pretty_info_line(
%{
type: :info_line_without_title,
content: binary(),
split: :no_split | :split_words | :truncate
},
pos_integer()
) :: binary()
defp pretty_info_line(%{type: :info_line_without_title, content: content, split: split}, width) do
line_doc =
case split do
:no_split ->
content
:split_words ->
content |> String.split("\n") |> Enum.join(" ")
:truncate ->
max_len = width - 6
if String.length(content) <= max_len,
do: content,
else: String.slice(content, 0, max_len - 3) <> "..."
end
" " <> line_doc
end
@doc false
@spec pretty_diff_line(
%{number: non_neg_integer(), line: binary(), kind: :shared | :new | :old},
pos_integer()
) :: binary()
defp pretty_diff_line(%{number: number, line: line, kind: kind}, padding) do
{pretty_number, pretty_line, separator} =
case kind do
:shared ->
number_str = pad_left(Integer.to_string(number), padding - 1)
{
IO.ANSI.faint() <> number_str <> IO.ANSI.reset(),
IO.ANSI.faint() <> line <> IO.ANSI.reset(),
" │ "
}
:new ->
number_str = pad_left(Integer.to_string(number), padding - 1)
{
IO.ANSI.green() <> IO.ANSI.bright() <> number_str <> IO.ANSI.reset(),
IO.ANSI.green() <> line <> IO.ANSI.reset(),
IO.ANSI.green() <> " + " <> IO.ANSI.reset()
}
:old ->
number_str = pad_right(" " <> Integer.to_string(number), padding - 1)
{
IO.ANSI.red() <> number_str <> IO.ANSI.reset(),
IO.ANSI.red() <> line <> IO.ANSI.reset(),
IO.ANSI.red() <> " - " <> IO.ANSI.reset()
}
end
pretty_number <> separator <> pretty_line
end
@doc false
@spec pad_left(binary(), non_neg_integer()) :: binary()
defp pad_left(str, width), do: String.pad_leading(str, width, " ")
@doc false
@spec pad_right(binary(), non_neg_integer()) :: binary()
defp pad_right(str, width), do: String.pad_trailing(str, width, " ")
# -------------------------
# CLI Entry
# -------------------------
@doc false
@spec main([String.t()]) :: :ok
def main(args \\ []) do
args
|> normalize_command()
|> execute_command()
end
@type cli_command :: :review | :accept_all | :reject_all | :clean | :help
@command_aliases %{
"review" => :review,
"r" => :review,
"accept-all" => :accept_all,
"aa" => :accept_all,
"reject-all" => :reject_all,
"ra" => :reject_all,
"clean" => :clean,
"c" => :clean,
"help" => :help,
"h" => :help
}
@doc false
@spec normalize_command([String.t()]) ::
{:ok, cli_command()}
| {:error, {:unknown_command, String.t()} | {:too_many_commands, [String.t()]}}
defp normalize_command([]), do: {:ok, :review}
defp normalize_command([command]) do
normalized = Map.get(@command_aliases, String.downcase(command))
case normalized do
nil -> {:error, {:unknown_command, command}}
_ -> {:ok, normalized}
end
end
defp normalize_command(commands), do: {:error, {:too_many_commands, commands}}
@doc false
@spec execute_command({:ok, cli_command()} | {:error, term()}) :: :ok
defp execute_command({:ok, :review}), do: review_snapshots()
defp execute_command({:ok, :accept_all}), do: accept_all_snapshots()
defp execute_command({:ok, :reject_all}), do: reject_all_snapshots()
defp execute_command({:ok, :clean}), do: clean_snapshots()
defp execute_command({:ok, :help}), do: show_help()
defp execute_command({:error, {:unknown_command, command}}),
do: unexpected_subcommand(command)
defp execute_command({:error, {:too_many_commands, commands}}),
do: more_than_one_command(commands)
# -------------------------
# CLI Internal logic
# -------------------------
@doc false
@spec review_snapshots() :: :ok
defp review_snapshots do
case find_snapshots_folder() do
{:ok, folder} ->
case list_new_snapshots(folder) do
{:ok, []} ->
IO.puts(IO.ANSI.green() <> "No new snapshots to review." <> IO.ANSI.reset())
{:ok, paths} ->
do_review(paths, 1, length(paths))
{:error, tagged} ->
IO.puts(Error.explain(tagged))
end
{:error, reason} ->
IO.puts(Error.explain(reason))
end
end
@doc false
@spec do_review([], non_neg_integer(), non_neg_integer()) :: :ok
defp do_review([], _current, _total), do: :ok
@doc false
@spec do_review([Path.t()], pos_integer(), pos_integer()) :: :ok
defp do_review([snapshot_path | rest], current, total) do
IO.write("\e[H\e[2J")
snapshot_path
|> read_snapshot()
|> handle_review(snapshot_path, rest, current, total)
end
@doc false
@spec handle_review(
{:ok, snapshot} | {:error, Error.t()},
Path.t(),
[Path.t()],
pos_integer(),
pos_integer()
) :: :ok
defp handle_review({:ok, new_snapshot}, snapshot_path, rest, current, total) do
snapshot_path
|> accepted_path_for()
|> review_box(new_snapshot)
|> display_review(current, total)
case resolve_choice(snapshot_path) do
:aborted -> :ok
:continue -> do_review(rest, current + 1, total)
end
end
defp handle_review({:error, tagged_reason}, _snapshot_path, rest, current, total) do
IO.puts(Error.explain(tagged_reason))
do_review(rest, current + 1, total)
end
@doc false
@spec accepted_path_for(Path.t()) :: Path.t()
defp accepted_path_for(path), do: String.replace_suffix(path, ".snap", ".accepted.snap")
@doc false
@spec review_box(Path.t(), snapshot) :: binary()
defp review_box(accepted_path, new_snapshot) do
case read_accepted_for(new_snapshot, accepted_path) do
{:ok, %Snapshot{} = accepted} ->
diff_snapshot_box(accepted, new_snapshot, [])
{:ok, nil} ->
new_snapshot_box(new_snapshot, [])
{:error, tagged_reason} ->
IO.puts(Error.explain(tagged_reason))
new_snapshot_box(new_snapshot, [])
end
end
@doc false
@spec display_review(binary(), pos_integer(), pos_integer()) :: :ok
defp display_review(box, current, total) do
IO.puts(IO.ANSI.cyan() <> "Reviewing snapshot #{current} of #{total}" <> IO.ANSI.reset())
IO.puts("\n#{box}\n")
:ok
end
@doc false
@spec resolve_choice(Path.t()) :: :continue | :aborted
defp resolve_choice(snapshot_path) do
case ask_choice() do
:accept ->
_ = accept_snapshot(snapshot_path)
:continue
:reject ->
_ = reject_snapshot(snapshot_path)
:continue
:skip ->
:continue
:aborted ->
IO.puts(IO.ANSI.cyan() <> "Review aborted by the user." <> IO.ANSI.reset())
:aborted
{:error, {:cannot_read_user_input, reason}} ->
IO.puts(Error.format_error({:cannot_read_user_input, reason}))
:continue
end
end
# -------------------------
# Batch commands
# -------------------------
@doc false
@spec accept_all_snapshots() :: :ok
defp accept_all_snapshots do
IO.puts("Looking for new snapshots...")
with(
{:ok, folder} <- find_snapshots_folder(),
{:ok, paths} <- list_new_snapshots(folder)
) do
paths |> Enum.each(&accept_snapshot/1)
IO.puts(IO.ANSI.green() <> "All new snapshots accepted!" <> IO.ANSI.reset())
else
{:error, tagged} ->
IO.puts(Error.explain(tagged))
end
end
@doc false
@spec reject_all_snapshots() :: :ok
defp reject_all_snapshots do
IO.puts("Looking for new snapshots...")
with(
{:ok, folder} <- find_snapshots_folder(),
{:ok, paths} <- list_new_snapshots(folder)
) do
paths |> Enum.each(&reject_snapshot/1)
IO.puts(IO.ANSI.red() <> "All new snapshots rejected!" <> IO.ANSI.reset())
else
{:error, tagged} ->
IO.puts(Error.explain(tagged))
end
end
@doc false
@spec clean_snapshots() :: :ok
defp clean_snapshots do
IO.puts("Looking for snapshots to clean...")
with(
{:ok, folder} <- find_snapshots_folder(),
{:ok, pending} <- list_new_snapshots(folder),
{:ok, rejected} <- list_rejected_snapshots(folder),
{:ok, accepted} <- list_accepted_snapshots(folder)
) do
unreferenced = Enum.filter(accepted, &unreferenced?/1)
total = length(pending) + length(rejected) + length(unreferenced)
if total == 0 do
IO.puts(IO.ANSI.green() <> "Nothing to clean." <> IO.ANSI.reset())
else
Enum.each(pending, &delete_snapshot(&1, "pending"))
Enum.each(rejected, &delete_snapshot(&1, "rejected"))
Enum.each(unreferenced, &delete_snapshot(&1, "unreferenced"))
IO.puts(IO.ANSI.green() <> "\nDone! Cleaned #{total} snapshot(s)." <> IO.ANSI.reset())
end
else
{:error, tagged} ->
IO.puts(Error.explain(tagged))
end
end
# -------------------------
# Helper functions
# -------------------------
@doc false
@spec list_new_snapshots(Path.t()) :: {:ok, [Path.t()]} | {:error, Error.t()}
defp list_new_snapshots(folder) do
case File.ls(folder) do
{:ok, entries} ->
snapshots =
entries
|> Enum.filter(fn name ->
String.ends_with?(name, ".snap") and
not String.ends_with?(name, ".accepted.snap") and
not String.ends_with?(name, ".rejected.snap")
end)
|> Enum.sort()
|> Enum.map(&Path.join(folder, &1))
{:ok, snapshots}
{:error, reason} ->
{:error, {:cannot_read_snapshots, reason, folder}}
end
end
@doc false
@spec list_rejected_snapshots(Path.t()) :: {:ok, [Path.t()]} | {:error, Error.t()}
defp list_rejected_snapshots(folder) do
case File.ls(folder) do
{:ok, entries} ->
snapshots =
entries
|> Enum.filter(&String.ends_with?(&1, ".rejected.snap"))
|> Enum.sort()
|> Enum.map(&Path.join(folder, &1))
{:ok, snapshots}
{:error, reason} ->
{:error, {:cannot_read_snapshots, reason, folder}}
end
end
@doc false
@spec list_accepted_snapshots(Path.t()) :: {:ok, [Path.t()]} | {:error, Error.t()}
defp list_accepted_snapshots(folder) do
case File.ls(folder) do
{:ok, entries} ->
snapshots =
entries
|> Enum.filter(&String.ends_with?(&1, ".accepted.snap"))
|> Enum.sort()
|> Enum.map(&Path.join(folder, &1))
{:ok, snapshots}
{:error, reason} ->
{:error, {:cannot_read_snapshots, reason, folder}}
end
end
@doc false
@spec unreferenced?(Path.t()) :: boolean()
defp unreferenced?(path) do
case File.read(path) do
{:ok, raw} ->
case deserialise(raw, :accepted) do
{:ok, %Snapshot{title: title}} ->
not title_referenced_anywhere?(title)
_ ->
false
end
_ ->
false
end
end
@doc false
@spec title_referenced_anywhere?(String.t()) :: boolean()
defp title_referenced_anywhere?(title) do
files = Path.wildcard("test/**/*.{exs,ex}")
Enum.any?(files, &title_in_file?(&1, title))
end
@doc false
@spec title_in_file?(Path.t(), String.t()) :: boolean()
defp title_in_file?(file_path, title) do
case File.read(file_path) do
{:ok, content} -> String.contains?(content, "\"#{title}\"")
_ -> false
end
end
@doc false
@spec delete_snapshot(Path.t(), String.t()) :: :ok | Error.t()
defp delete_snapshot(path, label) do
case File.rm(path) do
:ok ->
IO.puts(
IO.ANSI.red() <>
"Removed " <>
Path.basename(path) <>
IO.ANSI.reset() <>
IO.ANSI.faint() <>
" (#{label})" <>
IO.ANSI.reset()
)
:ok
{:error, reason} ->
tagged = {:cannot_delete_snapshot, reason, path}
IO.puts(Error.explain(tagged))
tagged
end
end
@doc false
@spec read_snapshot(Path.t()) :: {:ok, snapshot} | {:error, Error.t()}
defp read_snapshot(path) do
case File.read(path) do
{:ok, raw} ->
case deserialise(raw, :new) do
{:ok, snapshot} ->
{:ok, snapshot}
{:error, reason} ->
{:error, {:cannot_read_new_snapshot, reason, path}}
end
{:error, reason} ->
{:error, {:cannot_read_new_snapshot, reason, path}}
end
end
@doc false
@spec read_accepted_for(snapshot, Path.t()) ::
{:ok, snapshot | nil} | {:error, Error.t()}
defp read_accepted_for(_snapshot, path) do
case File.read(path) do
{:ok, raw} ->
case deserialise(raw, :accepted) do
{:ok, snap} -> {:ok, snap}
{:error, err} -> {:error, {:cannot_read_accepted_snapshot, err, path}}
end
# ➜ This is the normal case on first review for a title: no accepted snapshot yet
{:error, :enoent} ->
{:ok, nil}
{:error, reason} ->
{:error, {:cannot_read_accepted_snapshot, reason, path}}
end
end
@doc false
@spec ask_choice() ::
:accept
| :reject
| :skip
| :aborted
| {:error, Error.t()}
defp ask_choice do
print_choice_menu()
prompt_choice()
end
@doc false
@spec print_choice_menu() :: :ok
defp print_choice_menu do
IO.puts(
IO.ANSI.green() <>
" a" <>
IO.ANSI.reset() <>
" accept " <>
IO.ANSI.red() <>
" r" <>
IO.ANSI.reset() <>
" reject " <>
IO.ANSI.yellow() <>
" s" <>
IO.ANSI.reset() <>
" skip " <>
IO.ANSI.cyan() <> " q" <> IO.ANSI.reset() <> " quit"
)
:ok
end
@doc false
@spec prompt_choice() :: :accept | :reject | :skip | :aborted | {:error, Error.t()}
defp prompt_choice do
case IO.gets("> ") do
:eof ->
{:error, {:cannot_read_user_input, :eof}}
data when is_binary(data) ->
data
|> String.trim()
|> parse_choice()
other ->
{:error, {:cannot_read_user_input, other}}
end
end
@doc false
@spec parse_choice(String.t()) :: :accept | :reject | :skip | :aborted | {:error, Error.t()}
defp parse_choice(choice) do
case choice do
"a" -> :accept
"r" -> :reject
"s" -> :skip
"q" -> :aborted
"" -> retry_choice()
_ -> retry_choice()
end
end
@doc false
@spec retry_choice() :: :accept | :reject | :skip | :aborted | {:error, Error.t()}
defp retry_choice do
print_choice_menu()
prompt_choice()
end
@doc false
@spec accept_snapshot(Path.t()) :: :ok | Error.t()
defp accept_snapshot(path) do
accepted_path = String.replace_suffix(path, ".snap", ".accepted.snap")
case File.rename(path, accepted_path) do
:ok ->
IO.puts(IO.ANSI.green() <> "Accepted #{Path.basename(path)}" <> IO.ANSI.reset())
:ok
{:error, reason} ->
tagged = {:cannot_accept_snapshot, reason, path}
IO.puts(Error.explain(tagged))
tagged
end
end
@doc false
@spec reject_snapshot(Path.t()) :: :ok | Error.t()
defp reject_snapshot(path) do
rejected_path = String.replace_suffix(path, ".snap", ".rejected.snap")
case File.rename(path, rejected_path) do
:ok ->
IO.puts(IO.ANSI.red() <> "Rejected #{Path.basename(path)}" <> IO.ANSI.reset())
:ok
{:error, reason} ->
tagged = {:cannot_reject_snapshot, reason, path}
IO.puts(Error.explain(tagged))
tagged
end
end
# -------------------------
# Error handling / help
# -------------------------
@doc false
@spec show_help() :: :ok
defp show_help do
IO.puts("""
#{IO.ANSI.yellow()}USAGE:#{IO.ANSI.reset()}
calque [ <SUBCOMMAND> ]
#{IO.ANSI.yellow()}SUBCOMMANDS:#{IO.ANSI.reset()}
#{IO.ANSI.green()}review#{IO.ANSI.reset()} Review all new snapshots one by one
#{IO.ANSI.green()}accept-all#{IO.ANSI.reset()} Accept all new snapshots
#{IO.ANSI.green()}reject-all#{IO.ANSI.reset()} Reject all new snapshots
#{IO.ANSI.green()}clean#{IO.ANSI.reset()} Delete pending, rejected, and unreferenced snapshots
#{IO.ANSI.green()}help#{IO.ANSI.reset()} Show this help text
""")
end
@doc false
@spec unexpected_subcommand(String.t()) :: :ok
defp unexpected_subcommand(cmd) do
IO.puts(IO.ANSI.red() <> "Error: #{cmd} isn't a valid subcommand." <> IO.ANSI.reset())
case closest_command(cmd) do
nil ->
show_help()
command ->
suggest_command(command)
end
end
@doc false
@spec suggest_command(String.t()) :: :ok
defp suggest_command(suggestion) do
msg =
IO.ANSI.yellow() <>
"I think you misspelled `#{suggestion}` would you like to run it instead ? [Y/n]\n" <>
IO.ANSI.reset() <> "\n> "
case IO.gets(msg) do
data when is_binary(data) ->
case data |> String.trim() |> String.downcase() do
value when value in ["yes", "y"] ->
command = Map.get(@command_aliases, suggestion)
execute_command({:ok, command})
_ ->
show_help()
end
_ ->
show_help()
end
end
@doc false
@spec more_than_one_command([String.t()]) :: :ok
defp more_than_one_command(subs) do
IO.puts(
IO.ANSI.red() <>
"Error: Only one subcommand is allowed: #{Enum.join(subs, ", ")}" <> IO.ANSI.reset()
)
show_help()
end
@commands [
"review",
"accept-all",
"reject-all",
"clean",
"help"
]
@doc false
@spec closest_command(String.t()) :: String.t() | nil
defp closest_command(s) do
commands =
@commands
|> Enum.map(fn c -> {c, Levenshtein.distance(s, c)} end)
|> Enum.filter(fn {_, d} -> d <= 3 end)
|> Enum.sort(fn x, y -> x <= y end)
case commands do
[] ->
nil
[head | _tail] ->
{command, _} = head
command
end
end
end