Current section
Files
Jump to
Current section
Files
lib/mix/tasks/reqord.edit.ex
defmodule Mix.Tasks.Reqord.Edit do
@moduledoc """
Edit cassette entries in your editor with automatic JSON encoding/decoding.
This task opens cassette entries in your configured editor (via $EDITOR or $VISUAL),
handles JSONL parsing and formatting, and validates changes before saving.
Useful for manually redacting sensitive data like emails, account IDs, or
other PII from response bodies.
## Usage
# Edit all entries in a cassette (relative to cassette dir)
mix reqord.edit my_test.jsonl
# Edit with full/relative path
mix reqord.edit test/support/cassettes/auth_param_test.jsonl
# Edit a specific entry by index (0-based)
mix reqord.edit my_test.jsonl --entry 0
# Edit entries matching a URL pattern
mix reqord.edit my_test.jsonl --grep "/users"
# Use a specific cassette directory (for short names only)
mix reqord.edit my_test.jsonl --dir test/fixtures
## Options
* `--entry INDEX` - Edit only the entry at INDEX (0-based)
* `--grep PATTERN` - Edit only entries matching URL pattern
* `--dir PATH` - Cassette directory (default: test/support/cassettes)
## Workflow
1. Task loads cassette entries from JSONL file
2. Filters entries based on --entry or --grep if specified
3. Formats entries as pretty-printed JSON
4. Opens your editor with the JSON content
5. Validates JSON after you save and close the editor
6. Writes valid entries back to the cassette file
## Editor Configuration
The task uses your configured editor in this order:
1. $VISUAL environment variable
2. $EDITOR environment variable
3. Falls back to 'vim' if neither is set
## Example: Redacting Emails
# Edit the cassette
mix reqord.edit AccountTest/fetches_user.jsonl
# In your editor, find the response body and change:
# "email": "user@example.com"
# to:
# "email": "[REDACTED]"
# Save and close - the task validates and writes back to JSONL
## Error Handling
- If JSON is invalid after editing, the task shows errors and exits without saving
- Original cassette is preserved if validation fails
- Use `mix reqord.show` to verify changes after editing
"""
use Mix.Task
alias Reqord.Tasks.Helpers
@shortdoc "Edit cassette entries in your editor"
@impl Mix.Task
def run(args) do
{opts, positional} =
OptionParser.parse!(args,
strict: [
entry: :integer,
grep: :string,
dir: :string
]
)
case positional do
[cassette_name] ->
edit_cassette(cassette_name, opts)
[] ->
Mix.Shell.IO.error("Usage: mix reqord.edit <cassette>")
exit({:shutdown, 1})
_ ->
Mix.Shell.IO.error("Too many arguments. Usage: mix reqord.edit <cassette>")
exit({:shutdown, 1})
end
end
defp edit_cassette(name, opts) do
path = Helpers.resolve_cassette_path(name, opts)
Helpers.ensure_cassette_exists!(path)
# Load and parse entries
entries = Helpers.load_entries(path)
if entries == [] do
Mix.Shell.IO.info("Cassette is empty: #{path}")
exit({:shutdown, 0})
end
# Filter entries if needed
filtered_entries = filter_entries(entries, opts)
if filtered_entries == [] do
Mix.Shell.IO.info("No entries match the filter criteria")
exit({:shutdown, 0})
end
# Show what we're editing
count = length(filtered_entries)
total = length(entries)
if count == total do
Mix.Shell.IO.info("Editing all #{count} entries from #{path}")
else
Mix.Shell.IO.info("Editing #{count} of #{total} entries from #{path}")
end
# Format as pretty JSON for editing
json_content = format_entries_for_editing(filtered_entries, opts)
# Open editor
edited_content = open_in_editor(json_content)
# Parse edited content
case parse_edited_content(edited_content) do
{:ok, edited_entries} ->
# Validate entry count
if length(edited_entries) != length(filtered_entries) do
Mix.Shell.IO.error(
"Entry count mismatch: started with #{length(filtered_entries)}, got #{length(edited_entries)}"
)
exit({:shutdown, 1})
end
# Re-encode bodies back to base64
edited_entries = Enum.map(edited_entries, &encode_body_after_edit/1)
# Replace edited entries in original list
updated_entries = replace_entries(entries, filtered_entries, edited_entries, opts)
# Write back to file
Helpers.write_entries(path, updated_entries)
Mix.Shell.IO.info("✓ Successfully updated #{count} entries in #{path}")
{:error, reason} ->
Mix.Shell.IO.error("Failed to parse edited content: #{reason}")
Mix.Shell.IO.error("Cassette was not modified")
exit({:shutdown, 1})
end
end
defp filter_entries(entries, opts) do
entries
|> filter_by_index(opts[:entry])
|> filter_by_grep(opts[:grep])
end
defp filter_by_index(entries, nil), do: entries
defp filter_by_index(entries, index) do
case Enum.at(entries, index) do
nil -> []
entry -> [entry]
end
end
defp filter_by_grep(entries, nil), do: entries
defp filter_by_grep(entries, pattern) do
Enum.filter(entries, fn entry ->
url = get_in(entry, ["req", "url"]) || ""
String.contains?(url, pattern)
end)
end
defp format_entries_for_editing(entries, _opts) do
entries_for_edit = Enum.map(entries, &decode_body_for_edit/1)
Enum.map_join(entries_for_edit, "\n---\n", &Jason.encode!(&1, pretty: true))
|> Kernel.<>("\n")
end
defp decode_body_for_edit(entry) do
if body_b64 = get_in(entry, ["resp", "body_b64"]) do
try do
body = Base.decode64!(body_b64)
headers = get_in(entry, ["resp", "headers"]) || %{}
body = Helpers.decompress_body(body, headers)
# Try to parse as JSON for better editing
decoded_body =
try do
Jason.decode!(body)
rescue
_ -> body
end
put_in(entry, ["resp", "body_decoded"], decoded_body)
|> Map.update!("resp", &Map.delete(&1, "body_b64"))
rescue
_ -> entry
end
else
entry
end
end
defp encode_body_after_edit(entry) do
if body_decoded = get_in(entry, ["resp", "body_decoded"]) do
# Convert body back to base64
body = if is_binary(body_decoded), do: body_decoded, else: Jason.encode!(body_decoded)
body_b64 = Base.encode64(body)
put_in(entry, ["resp", "body_b64"], body_b64)
|> Map.update!("resp", &Map.delete(&1, "body_decoded"))
else
entry
end
end
defp open_in_editor(content) do
editor = System.get_env("VISUAL") || System.get_env("EDITOR") || "vim"
# Create temp file
temp_file = Path.join(System.tmp_dir!(), "reqord_edit_#{:rand.uniform(999_999)}.json")
try do
File.write!(temp_file, content)
# Open editor
case System.cmd(editor, [temp_file], into: IO.stream(:stdio, :line)) do
{_, 0} ->
File.read!(temp_file)
{_, exit_code} ->
Mix.Shell.IO.error("Editor exited with code #{exit_code}")
exit({:shutdown, 1})
end
after
File.rm(temp_file)
end
end
defp parse_edited_content(content) do
# Split by separator and parse each entry
entries =
content
|> String.split("---")
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
|> Enum.map(fn entry_json ->
case Jason.decode(entry_json) do
{:ok, entry} -> validate_entry_structure(entry)
{:error, error} -> {:error, "Invalid JSON: #{inspect(error)}"}
end
end)
# Check for any errors
case Enum.find(entries, &match?({:error, _}, &1)) do
nil -> {:ok, Enum.map(entries, fn {:ok, entry} -> entry end)}
{:error, reason} -> {:error, reason}
end
end
defp validate_entry_structure(entry) do
cond do
not is_map(entry) ->
{:error, "Entry must be a JSON object"}
not Map.has_key?(entry, "req") ->
{:error, "Entry missing 'req' field"}
not Map.has_key?(entry, "resp") ->
{:error, "Entry missing 'resp' field"}
not is_map(entry["req"]) ->
{:error, "'req' must be an object"}
not is_map(entry["resp"]) ->
{:error, "'resp' must be an object"}
not Map.has_key?(entry["req"], "url") ->
{:error, "'req' missing 'url' field"}
not Map.has_key?(entry["req"], "method") ->
{:error, "'req' missing 'method' field"}
not Map.has_key?(entry["resp"], "status") ->
{:error, "'resp' missing 'status' field"}
true ->
{:ok, entry}
end
end
defp replace_entries(all_entries, filtered_entries, edited_entries, _opts) do
# Build a map of filtered entries for quick lookup
filtered_set =
filtered_entries
|> Enum.with_index()
|> Map.new(fn {entry, idx} -> {entry_key(entry), idx} end)
# Replace entries in original list
all_entries
|> Enum.with_index()
|> Enum.map(fn {entry, _original_idx} ->
key = entry_key(entry)
case Map.get(filtered_set, key) do
nil ->
# Not filtered, keep original
entry
filtered_idx ->
# Was filtered, use edited version
Enum.at(edited_entries, filtered_idx)
end
end)
end
# Create a unique key for an entry to match it after editing
defp entry_key(entry) do
# Use URL + method + recorded_at for unique identification
url = get_in(entry, ["req", "url"]) || ""
method = get_in(entry, ["req", "method"]) || ""
recorded_at = entry["recorded_at"] || ""
"#{method}:#{url}:#{recorded_at}"
end
end