defmodule Settings do require Logger # ---------------------------------------------------------------------------- # Settings type # ---------------------------------------------------------------------------- defstruct [:path, :data] @type t :: %__MODULE__{ path: binary, data: map } @spec new() :: t def new() do Settings.Migrate.cleanup_default_project_dir() path = settings_file() Settings.Migrate.maybe_migrate_settings(path) settings = %Settings{path: path} |> slurp() settings end def get_running_version do # Allow version override for testing case Services.Globals.get_env(:fnord, :test_version_override) do nil -> Application.spec(:fnord, :vsn) |> to_string() version -> version end end @doc """ Get the user's home directory, allowing override for testing. """ @spec get_user_home() :: binary | nil | no_return def get_user_home do Services.Globals.get_env(:fnord, :test_home_override, System.get_env("HOME")) |> case do nil -> raise "Could not determine user home directory. Is $HOME set?" home -> home end end @doc """ Get the path to the store root directory. """ @spec fnord_home() :: binary def fnord_home() do path = "#{get_user_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 = "#{fnord_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 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 previous = Services.Globals.get_env(:fnord, :project) Services.Globals.put_env(:fnord, :project, project_name) if previous != project_name do UI.debug("Project selected", project_name) end 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 Services.Globals.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 Services.Globals.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 Services.Globals.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 Services.Globals.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 Services.Globals.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 Services.Globals.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} -> Services.Globals.put_env(:fnord, :auto_policy, {policy, timeout}) nil -> Services.Globals.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 Services.Globals.get_env(:fnord, :auto_policy, nil) end @doc """ Set or clear a temporary project root override path. Pass a directory path to override or nil to clear the override. """ @spec set_project_root_override(binary | nil) :: :ok def set_project_root_override(nil) do Services.Globals.delete_env(:fnord, :project_root_override) :ok end def set_project_root_override(path) when is_binary(path) do Services.Globals.put_env(:fnord, :project_root_override, path) :ok end @doc """ Gets the project root override path, or nil if not set. """ @spec get_project_root_override() :: binary | nil def get_project_root_override() do Services.Globals.get_env(:fnord, :project_root_override, nil) end @doc """ Check if hint docs feature is enabled. Defaults to `true` if unset. """ @spec get_hint_docs_enabled?() :: boolean def get_hint_docs_enabled?() do Services.Globals.get_env(:fnord, :hint_docs_enabled, true) end @doc """ Check if automatic injection of hint docs is enabled. Defaults to `true` if unset. """ @spec get_hint_docs_auto_inject?() :: boolean def get_hint_docs_auto_inject?() do Services.Globals.get_env(:fnord, :hint_docs_auto_inject, true) end @doc """ Check if the --project option is set. """ def project_is_set?() do !is_nil(Services.Globals.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 Services.Globals.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", %{}) # Merge new data into any existing project entry existing = Map.get(projects_map, project_name, %{}) merged = Map.merge(existing, data) updated_projects_map = Map.put(projects_map, project_name, merged) update(settings, "projects", fn _ -> updated_projects_map end) 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 = update(settings, "projects", fn _ -> updated_projects_map end) # Also delete from old format for cleanup update(settings, project_name, fn _ -> :delete end) 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 @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() # Merge old format projects into the new format old_format_projects |> Map.merge(projects_map) |> fix_current_project_bug() end # There was a bug at one point where an empty project named "current" was # created without a "root" defined. If that's found, delete it from settings, # then return the projects map with it removed. This is a one-time fix. # # If it was not found or it had a legitimate "root" defined, return the # projects map unchanged. defp fix_current_project_bug(projects) do with {:ok, current} <- Map.fetch(projects, "current"), false <- Map.has_key?(current, "root"), {:ok, project} <- Store.get_project("current") do # Delete the project from the store Store.Project.torch(project) # Remove from projects map Map.delete(projects, "current") else # Not found or "current" has a "root" -> looks like a real project _ -> projects end 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 @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 @doc """ Atomically update a top-level key in the settings file using a cross-process lock and a read-merge-write cycle. The updater receives the current value for `key` (or `default` when missing) and must return the new value. If the updater returns `:delete`, the key will be removed from the settings. """ @spec update(t, binary, (any -> any | :delete), any) :: t | no_return def update(%Settings{path: path}, key, updater, default \\ %{}) do key = make_key(key) FileLock.with_lock(path, fn -> data = fresh_read(path) value = Map.get(data, key, default) data = value |> updater.() |> case do :delete -> Map.delete(data, key) value -> Map.put(data, key, value) end data |> Jason.encode!(pretty: true) |> then(&write_atomic!(path, &1)) %Settings{path: path, data: data} end) |> case do {:ok, settings} -> settings {:error, reason} -> raise "Failed to update settings: #{inspect(reason)}" end end defp slurp(%Settings{} = settings) do data = with {:ok, content} <- File.read(settings.path), {:ok, parsed} <- Jason.decode(content) do parsed else _ -> raise """ Corrupted settings file: #{settings.path} You may need to reset your fnord settings. Consider backing up the file and running 'fnord init' to recreate it. """ end %Settings{settings | data: data} end def write_atomic!(path, "") do write_atomic!(path, "{}") end def 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 # Read the latest JSON from disk ignoring the cached struct data. defp fresh_read(path) do case File.read(path) do {:ok, content} -> case Jason.decode(content) do {:ok, parsed} -> parsed _ -> %{} end _ -> %{} end end defp make_key(key) when is_atom(key), do: Atom.to_string(key) defp make_key(key), do: key @doc """ Set yes count in settings. """ @spec set_yes_count(non_neg_integer) :: :ok def set_yes_count(count) when is_integer(count) and count >= 0 do Services.Globals.put_env(:fnord, :yes_count, count) :ok end @doc """ Get yes count from settings. """ @spec get_yes_count() :: non_neg_integer def get_yes_count() do Services.Globals.get_env(:fnord, :yes_count, 0) end @doc """ Set fonz mode for application. """ @spec set_fonz_mode(boolean) :: :ok def set_fonz_mode(fonz_mode) do Services.Globals.put_env(:fnord, :fonz_mode, !!fonz_mode) :ok end @doc """ Get fonz mode setting. """ @spec get_fonz_mode() :: boolean def get_fonz_mode() do Services.Globals.get_env(:fnord, :fonz_mode, false) end end