Current section

Files

Jump to
ragex lib ragex editor backup.ex
Raw

lib/ragex/editor/backup.ex

defmodule Ragex.Editor.Backup do
@moduledoc """
Backup management for file editing operations.
Creates, lists, and restores backups of files before editing.
Backups are stored in a project-specific directory with timestamps.
"""
alias Ragex.Editor.Types
require Logger
@doc """
Creates a backup of a file.
## Parameters
- `path`: Path to the file to backup
- `opts`: Options
- `:backup_dir` - Custom backup directory (optional)
- `:compress` - Compress backup with gzip (default: false)
## Returns
- `{:ok, backup_info}` on success
- `{:error, reason}` on failure
## Examples
iex> Backup.create("/path/to/file.ex")
{:ok, %{id: "20240101_120000_abc123", ...}}
"""
@spec create(String.t(), keyword()) :: {:ok, Types.backup_info()} | {:error, term()}
def create(path, opts \\ []) do
with {:ok, abs_path} <- ensure_absolute_path(path),
{:ok, content} <- File.read(abs_path),
{:ok, stat} <- File.stat(abs_path),
backup_dir <- get_backup_dir(abs_path, opts),
:ok <- File.mkdir_p(backup_dir),
backup_id <- generate_backup_id(),
backup_path <- Path.join(backup_dir, backup_id),
:ok <- write_backup(backup_path, content, opts) do
info =
Types.backup_info(backup_id, abs_path, backup_path,
size: byte_size(content),
original_mtime: stat.mtime |> to_unix_time()
)
Logger.debug("Created backup: #{backup_id} for #{abs_path}")
{:ok, info}
else
{:error, reason} = error ->
Logger.error("Failed to create backup for #{path}: #{inspect(reason)}")
error
end
end
@doc """
Lists all backups for a file.
## Parameters
- `path`: Path to the file
- `opts`: Options
- `:backup_dir` - Custom backup directory (optional)
- `:limit` - Maximum number of backups to return (default: 10)
## Returns
List of backup info structs, sorted by creation time (newest first).
"""
@spec list(String.t(), keyword()) :: {:ok, [Types.backup_info()]} | {:error, term()}
def list(path, opts \\ []) do
with {:ok, abs_path} <- ensure_absolute_path(path),
backup_dir <- get_backup_dir(abs_path, opts),
{:ok, files} <- safe_list_dir(backup_dir) do
limit = Keyword.get(opts, :limit, 10)
backups =
files
|> Enum.filter(&backup_file?/1)
|> Enum.map(fn filename ->
backup_path = Path.join(backup_dir, filename)
case File.stat(backup_path) do
{:ok, stat} ->
Types.backup_info(filename, abs_path, backup_path,
size: stat.size,
created_at: stat.ctime |> to_datetime()
)
{:error, _} ->
nil
end
end)
|> Enum.reject(&is_nil/1)
|> Enum.sort_by(& &1.created_at, {:desc, DateTime})
|> Enum.take(limit)
{:ok, backups}
end
end
@doc """
Restores a file from a backup.
## Parameters
- `path`: Path to the file to restore
- `backup_id`: ID of the backup to restore (if nil, restores most recent)
- `opts`: Options
- `:backup_dir` - Custom backup directory (optional)
- `:delete_backup` - Delete backup after restore (default: false)
## Returns
- `{:ok, backup_info}` on success
- `{:error, reason}` on failure
"""
@spec restore(String.t(), String.t() | nil, keyword()) ::
{:ok, Types.backup_info()} | {:error, term()}
def restore(path, backup_id \\ nil, opts \\ []) do
with {:ok, abs_path} <- ensure_absolute_path(path),
{:ok, backups} <- list(abs_path, opts),
{:ok, backup} <- select_backup(backups, backup_id),
{:ok, content} <- read_backup(backup.backup_path, opts),
:ok <- File.write(abs_path, content) do
Logger.info("Restored #{abs_path} from backup #{backup.id}")
if Keyword.get(opts, :delete_backup, false) do
File.rm(backup.backup_path)
end
{:ok, backup}
else
{:error, :no_backups} ->
{:error, "No backups found for #{path}"}
{:error, :backup_not_found} ->
{:error, "Backup #{backup_id} not found"}
{:error, reason} = error ->
Logger.error("Failed to restore backup for #{path}: #{inspect(reason)}")
error
end
end
@doc """
Cleans up old backups for a file.
## Parameters
- `path`: Path to the file
- `opts`: Options
- `:backup_dir` - Custom backup directory (optional)
- `:keep` - Number of backups to keep (default: 10)
## Returns
Number of backups deleted.
"""
@spec cleanup(String.t(), keyword()) :: {:ok, non_neg_integer()} | {:error, term()}
def cleanup(path, opts \\ []) do
keep = Keyword.get(opts, :keep, 10)
with {:ok, backups} <- list(path, Keyword.put(opts, :limit, 1000)) do
to_delete = Enum.drop(backups, keep)
deleted =
Enum.reduce(to_delete, 0, fn backup, count ->
case File.rm(backup.backup_path) do
:ok ->
Logger.debug("Deleted old backup: #{backup.id}")
count + 1
{:error, reason} ->
Logger.warning("Failed to delete backup #{backup.id}: #{inspect(reason)}")
count
end
end)
{:ok, deleted}
end
end
@doc """
Gets the total size of all backups for a file.
"""
@spec total_size(String.t(), keyword()) :: {:ok, non_neg_integer()} | {:error, term()}
def total_size(path, opts \\ []) do
with {:ok, backups} <- list(path, Keyword.put(opts, :limit, 1000)) do
total = Enum.reduce(backups, 0, fn backup, acc -> acc + backup.size end)
{:ok, total}
end
end
# Private functions
defp ensure_absolute_path(path) do
{:ok, Path.expand(path)}
end
defp get_backup_dir(file_path, opts) do
case Keyword.get(opts, :backup_dir) do
nil ->
# Use project-specific backup directory
project_hash = compute_project_hash(file_path)
base_dir =
Application.get_env(:ragex, :editor, [])
|> Keyword.get(:backup_dir, Path.expand("~/.ragex/backups"))
Path.join([base_dir, project_hash, relative_path_hash(file_path)])
custom_dir ->
custom_dir
end
end
defp compute_project_hash(file_path) do
# Find the nearest git root or use the directory
case find_git_root(file_path) do
{:ok, git_root} ->
:crypto.hash(:sha256, git_root)
|> Base.encode16(case: :lower)
|> String.slice(0, 12)
:error ->
# Use parent directory as fallback
dir = Path.dirname(file_path)
:crypto.hash(:sha256, dir)
|> Base.encode16(case: :lower)
|> String.slice(0, 12)
end
end
defp find_git_root(path, depth \\ 0)
defp find_git_root(_path, depth) when depth > 10, do: :error
defp find_git_root(path, depth) do
dir = if File.dir?(path), do: path, else: Path.dirname(path)
git_dir = Path.join(dir, ".git")
if File.dir?(git_dir) do
{:ok, dir}
else
parent = Path.dirname(dir)
if parent == dir do
:error
else
find_git_root(parent, depth + 1)
end
end
end
defp relative_path_hash(file_path) do
# Create a hash of the relative file path for organizing backups
filename = Path.basename(file_path)
:crypto.hash(:sha256, file_path)
|> Base.encode16(case: :lower)
|> String.slice(0, 8)
|> then(&"#{filename}_#{&1}")
end
defp generate_backup_id do
timestamp = DateTime.utc_now() |> Calendar.strftime("%Y%m%d_%H%M%S")
random =
:crypto.strong_rand_bytes(4)
|> Base.encode16(case: :lower)
"#{timestamp}_#{random}"
end
defp write_backup(backup_path, content, opts) do
compress = Keyword.get(opts, :compress, false)
content_to_write =
if compress do
:zlib.gzip(content)
else
content
end
final_path =
if compress do
"#{backup_path}.gz"
else
backup_path
end
File.write(final_path, content_to_write)
end
defp read_backup(backup_path, _opts) do
compressed? = String.ends_with?(backup_path, ".gz")
with {:ok, content} <- File.read(backup_path) do
if compressed? do
{:ok, :zlib.gunzip(content)}
else
{:ok, content}
end
end
end
defp safe_list_dir(dir) do
case File.ls(dir) do
{:ok, files} -> {:ok, files}
{:error, :enoent} -> {:ok, []}
{:error, reason} -> {:error, reason}
end
end
defp backup_file?(filename) do
# Backup files match pattern: YYYYMMDD_HHMMSS_randomhex[.gz]
String.match?(filename, ~r/^\d{8}_\d{6}_[0-9a-f]+(.gz)?$/)
end
defp select_backup([], _backup_id), do: {:error, :no_backups}
defp select_backup(backups, nil) do
# Return most recent (first in list, already sorted)
{:ok, List.first(backups)}
end
defp select_backup(backups, backup_id) do
case Enum.find(backups, &(&1.id == backup_id)) do
nil -> {:error, :backup_not_found}
backup -> {:ok, backup}
end
end
defp to_unix_time({{year, month, day}, {hour, minute, second}}) do
{:ok, dt} = NaiveDateTime.new(year, month, day, hour, minute, second)
{:ok, datetime} = DateTime.from_naive(dt, "Etc/UTC")
DateTime.to_unix(datetime)
end
defp to_datetime({{year, month, day}, {hour, minute, second}}) do
{:ok, dt} = NaiveDateTime.new(year, month, day, hour, minute, second)
{:ok, datetime} = DateTime.from_naive(dt, "Etc/UTC")
datetime
end
end