Packages

fnord

0.8.39

AI code archaeology

Current section

Files

Jump to
fnord lib settings.ex
Raw

lib/settings.ex

defmodule Settings do
# ----------------------------------------------------------------------------
# Settings type
# ----------------------------------------------------------------------------
defstruct [:path, :data]
@type t :: %__MODULE__{
path: binary,
data: map
}
@spec new() :: t
def new() do
cleanup_default_project_dir()
path = settings_file()
maybe_migrate_settings(path)
%Settings{path: path}
|> slurp()
end
# ----------------------------------------------------------------------------
# Cleans up the "default" project directory within the store on startup.
#
# This function exists to remove any lingering "default" project data which
# might cause inconsistencies or conflicts. It is safe to call repeatedly
# and will not raise errors if the directory does not exist. Moreover, no
# code depends on this directory being present, so removing it has no adverse
# side effects.
# ----------------------------------------------------------------------------
defp cleanup_default_project_dir do
path = Path.join(home(), "default")
if File.exists?(path) do
File.rm_rf!(path)
end
end
defp get_running_version do
# Allow version override for testing
case Application.get_env(:fnord, :test_version_override) do
nil -> Application.spec(:fnord, :vsn) |> to_string()
version -> version
end
end
defp maybe_migrate_settings(path) do
ver = get_running_version()
if Version.compare(ver, "0.8.30") != :lt do
data = File.read!(path) |> Jason.decode!()
globals = ["approvals", "projects", "version"]
{projects, globals_map} = Enum.split_with(data, fn {k, _v} -> k not in globals end)
if Map.has_key?(data, "projects") do
:ok
else
new_data =
globals_map
|> Map.new()
|> Map.put("projects", Map.new(projects))
|> Map.put("version", "0.8.30")
|> Map.put_new("approvals", %{})
write_atomic!(path, Jason.encode!(new_data, pretty: true))
end
end
end
@doc """
Get the path to the store root directory.
"""
@spec home() :: binary
def home() do
path = "#{System.get_env("HOME")}/.fnord"
File.mkdir_p!(path)
path
end
@doc """
Get the path to the settings file. If the file does not exist, it will be
created.
"""
@spec settings_file() :: binary
def settings_file() do
path = "#{home()}/settings.json"
if !File.exists?(path) do
write_atomic!(path, "{}")
end
path
end
@doc """
Get a value from the settings store.
"""
@spec get(t, binary, any) :: any
def get(settings, key, default \\ nil) do
key = make_key(key)
Map.get(settings.data, key, default)
end
@doc """
Set a value in the settings store.
"""
@spec set(t, binary, any) :: t
def set(settings, key, value) do
key = make_key(key)
%Settings{settings | data: Map.put(settings.data, key, value)}
|> spew()
end
@doc """
Delete a value from the settings store.
"""
@spec delete(t, binary) :: t
def delete(settings, key) do
key = make_key(key)
%Settings{settings | data: Map.delete(settings.data, key)}
|> spew()
end
@doc """
Set the project name for the --project option.
"""
@spec set_project(atom | binary) :: :ok
def set_project(project_name) when is_atom(project_name) do
project_name |> Atom.to_string() |> set_project()
end
def set_project(project_name) do
Application.put_env(:fnord, :project, project_name)
UI.debug("Project selected", project_name)
Services.Notes.load_notes()
:ok
end
@doc """
Set the quiet mode for UI output.
"""
@spec set_quiet(boolean) :: :ok
def set_quiet(quiet) do
# Force quiet mode to be a boolean
Application.put_env(:fnord, :quiet, !!quiet)
:ok
end
@doc """
Set the number of workers for concurrent operations.
"""
@spec set_workers(pos_integer) :: :ok
def set_workers(workers) when is_integer(workers) and workers > 0 do
Application.put_env(:fnord, :workers, workers)
:ok
end
@doc """
Set edit mode for the application.
"""
@spec set_edit_mode(boolean) :: :ok
def set_edit_mode(edit_mode) do
Application.put_env(:fnord, :edit_mode, !!edit_mode)
:ok
end
@doc """
Get current edit mode setting.
"""
@spec get_edit_mode() :: boolean
def get_edit_mode() do
Application.get_env(:fnord, :edit_mode, false)
end
@doc """
Set auto-approve mode for the application. In this mode, file edits
are automatically approved without user confirmation.
"""
@spec set_auto_approve(boolean) :: :ok
def set_auto_approve(auto_approve) do
Application.put_env(:fnord, :auto_approve, !!auto_approve)
:ok
end
@doc """
Get current auto-approve setting.
"""
@spec get_auto_approve() :: boolean
def get_auto_approve() do
Application.get_env(:fnord, :auto_approve, false)
end
@doc """
Set auto-approval policy for the application. This setting controls how
unattended approvals are handled.
The `policy` is a tuple consisting of an action and a timeout (or `nil` to
disable):
- `:approve` to automatically approve changes after a timeout.
- `:deny` to automatically deny changes after a timeout.
- `nil` to disable auto-approval.
The `timeout` is specified in milliseconds and determines how long to wait
before applying the auto-approval policy.
When an approval is required, the system will first send a notification to
the user after 60 seconds. If the user does not respond within the timeout
specified by the auto-approval policy, the specified action will be taken
automatically.
"""
@spec set_auto_policy({:approve | :deny, non_neg_integer} | nil) :: :ok
def set_auto_policy(policy) do
case policy do
{policy, timeout} -> Application.put_env(:fnord, :auto_policy, {policy, timeout})
nil -> Application.delete_env(:fnord, :auto_policy)
end
end
@doc """
Get current auto-approval policy setting.
"""
@spec get_auto_policy ::
{:approve, non_neg_integer}
| {:deny, non_neg_integer}
| nil
def get_auto_policy() do
Application.get_env(:fnord, :auto_policy, nil)
end
@doc """
Check if the --project option is set.
"""
def project_is_set?() do
!is_nil(Application.get_env(:fnord, :project))
end
@doc """
Get the project specified with --project. If the project name is not set, an
error tuple is returned.
"""
@spec get_selected_project :: {:ok, binary} | {:error, :project_not_set}
def get_selected_project() do
Application.get_env(:fnord, :project)
|> case do
nil -> {:error, :project_not_set}
project -> {:ok, project}
end
end
@spec get_project(t) :: {:ok, map} | {:error, :project_not_found}
def get_project(settings) do
with {:ok, project} <- get_selected_project() do
case get_project_data(settings, project) do
nil -> {:error, :project_not_found}
data -> {:ok, data}
end
end
end
@doc """
Get project data from the settings, handling both old and new format.
"""
@spec get_project_data(t, binary) :: map | nil
def get_project_data(settings, project_name) do
projects_map = get(settings, "projects", %{})
case Map.get(projects_map, project_name) do
# Old style project data in the root of the settings object
nil -> get(settings, project_name, nil)
data -> data
end
|> case do
nil -> nil
data -> Map.put(data, "name", project_name)
end
end
@spec set_project(t, map) :: map
def set_project(settings, data) do
with {:ok, project} <- get_selected_project() do
# Validate that the project name is not a global config key
unless is_valid_project_name?(project) do
raise ArgumentError,
"Cannot use '#{project}' as project name - it conflicts with global configuration"
end
set_project_data(settings, project, data)
data
else
{:error, :project_not_set} -> raise "Project not set. Use --project to specify a project."
end
end
@doc """
Set project data in the settings using the new nested format.
"""
@spec set_project_data(t, binary, map) :: t
def set_project_data(settings, project_name, data) do
projects_map = get(settings, "projects", %{})
updated_projects_map = Map.put(projects_map, project_name, data)
set(settings, "projects", updated_projects_map)
end
@doc """
Delete project data from the settings, handling both old and new format.
"""
@spec delete_project_data(t, binary) :: t
def delete_project_data(settings, project_name) do
# Delete from new nested format
projects_map = get(settings, "projects", %{})
updated_projects_map = Map.delete(projects_map, project_name)
settings = set(settings, "projects", updated_projects_map)
# Also delete from old format for cleanup
delete(settings, project_name)
end
@spec list_projects(t) :: [binary]
def list_projects(settings) do
settings
|> get_projects()
|> Map.keys()
|> Enum.sort()
end
@spec get_root(t) :: {:ok, binary} | {:error, :not_found}
def get_root(settings) do
settings
|> get_project
|> case do
{:ok, %{"root" => root}} -> {:ok, Path.absname(root)}
{:error, _} -> {:error, :not_found}
end
end
defp slurp(settings) do
%Settings{
settings
| data:
settings.path
|> File.read!()
|> Jason.decode!()
}
end
defp spew(settings) do
settings = ensure_approvals_exist(settings)
json = Jason.encode!(settings.data, pretty: true)
write_atomic!(settings.path, json)
settings
end
defp write_atomic!(path, "") do
write_atomic!(path, "{}")
end
defp write_atomic!(path, content) do
dir = Path.dirname(path)
base = Path.basename(path)
# Create temp file in the same directory to avoid cross-device rename
# issues and preserve atomicity.
tmp = Path.join(dir, ".#{base}.#{System.unique_integer([:positive])}.tmp")
File.write!(tmp, content)
File.rename!(tmp, path)
:ok
end
@doc """
Get all project configurations.
Returns a map of project_name => project_config.
"""
@spec get_projects(t) :: %{binary => map}
def get_projects(settings) do
projects_map = get(settings, "projects", %{})
# Also check old format for backward compatibility
old_format_projects =
settings.data
|> Enum.filter(fn {key, value} ->
is_valid_project_name?(key) and is_map(value) and Map.has_key?(value, "root")
end)
|> Map.new()
Map.merge(old_format_projects, projects_map)
end
@doc """
Check if a given key represents a valid project name (not a global config key).
Returns false for global configuration keys like "approvals".
"""
@spec is_valid_project_name?(binary) :: boolean
def is_valid_project_name?(name) when is_binary(name) do
not Enum.member?(global_config_keys(), name)
end
def is_valid_project_name?(_), do: false
@doc """
Get the list of global configuration keys that should not be treated as project names.
"""
@spec global_config_keys() :: [binary]
def global_config_keys() do
["approvals", "projects", "version"]
end
defp make_key(key) when is_atom(key), do: Atom.to_string(key)
defp make_key(key), do: key
defp ensure_approvals_exist(settings) do
data = settings.data
data =
if Map.has_key?(data, "approvals") do
data
else
Map.put(data, "approvals", %{})
end
# Handle projects in new nested format
projects_map = Map.get(data, "projects", %{})
updated_projects_map =
Enum.reduce(projects_map, projects_map, fn {project_name, project_data}, acc ->
if is_map(project_data) and not Map.has_key?(project_data, "approvals") do
updated_project_data = Map.put(project_data, "approvals", %{})
Map.put(acc, project_name, updated_project_data)
else
acc
end
end)
data = Map.put(data, "projects", updated_projects_map)
# Handle old format projects at root level
data =
Enum.reduce(data, data, fn
{key, project_data}, acc
when is_map(project_data) and key not in ["approvals", "projects", "version"] ->
if Map.has_key?(project_data, "root") and
not Map.has_key?(project_data, "approvals") do
updated_project_data = Map.put(project_data, "approvals", %{})
Map.put(acc, key, updated_project_data)
else
acc
end
_, acc ->
acc
end)
%Settings{settings | data: data}
end
@doc """
Check if model performance debugging is enabled via environment variable.
"""
@spec debug_models?() :: boolean
def debug_models?() do
case System.get_env("FNORD_DEBUG_MODELS") do
nil -> false
"" -> false
_ -> true
end
end
end