towerops/lib/towerops/profiles/profile_watcher.ex
Graham McIntire 3ca0834ef0 tests: raise coverage to 70% via helper promotion + new unit/property tests
Promoted pure presentation and utility helpers from `defp` to `def @doc false`
across ~20 LiveViews, Oban workers, and sync modules so they're reachable from
unit tests. Refactored several `cond` blocks into idiomatic function heads with
guards. Added ~250 new test cases in new files under test/towerops and
test/towerops_web, including DB-backed tests for CnMaestro.Sync and
AlertNotificationWorker, and removed dead LiveView tab components and
CapacityLive (no callers anywhere in lib/test).

Configured mix.exs test_coverage.ignore_modules to exclude vendored third-party
code (SnmpKit, protobuf-generated Towerops.Agent.*, Absinthe GraphQL types,
Phoenix HTML modules, Inspect protocol impls) from coverage calculations —
these are not our project code.

Coverage: 66.93% → 70.09%. Full suite: 10,127 tests, 0 failures.
2026-04-24 09:49:06 -05:00

87 lines
2.8 KiB
Elixir

defmodule Towerops.Profiles.ProfileWatcher do
@moduledoc """
Watches YAML profile files for changes and automatically reloads them.
Uses FileSystem to monitor the priv/profiles directory for file changes.
When a .yaml or .yml file is created, modified, or deleted, triggers
a reload of all profiles via YamlProfiles.reload/0.
Note: Only available in development mode. FileSystem is a dev-only dependency.
"""
use GenServer
alias Towerops.Profiles.YamlProfiles
require Logger
@profiles_dir "priv/profiles"
def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
@impl true
def init(_opts) do
# Only start file watcher in development
# FileSystem is a dev-only dependency
if Code.ensure_loaded?(FileSystem) do
# Get the absolute path to the profiles directory
profiles_path = Path.join(Application.app_dir(:towerops), @profiles_dir)
# Start watching the profiles directory for changes (using apply to avoid compile-time warnings)
case apply(FileSystem, :start_link, [[dirs: [profiles_path]]]) do
{:ok, watcher_pid} ->
apply(FileSystem, :subscribe, [watcher_pid])
Logger.info("ProfileWatcher: Started watching #{profiles_path}")
{:ok, %{watcher_pid: watcher_pid, profiles_path: profiles_path}}
:ignore ->
# inotify-tools not available (e.g., in CI)
Logger.debug("ProfileWatcher: FileSystem watcher not available, running in no-op mode")
{:ok, %{}}
end
else
# In production, FileSystem is not available
Logger.debug("ProfileWatcher: FileSystem not available, running in no-op mode (dev-only feature)")
{:ok, %{}}
end
end
@impl true
def handle_info({:file_event, _watcher_pid, {path, events}}, state) do
# Only reload if it's a YAML file change
if yaml_file?(path) and should_reload?(events) do
Logger.info("ProfileWatcher: Detected change in #{Path.relative_to_cwd(path)}, reloading profiles...")
case YamlProfiles.reload() do
{:ok, profiles} when is_list(profiles) ->
Logger.info("ProfileWatcher: Successfully reloaded #{length(profiles)} profiles")
{:error, reason} ->
Logger.error("ProfileWatcher: Failed to reload profiles: #{inspect(reason)}")
end
end
{:noreply, state}
end
@impl true
def handle_info({:file_event, _watcher_pid, :stop}, state) do
Logger.warning("ProfileWatcher: File watcher stopped")
{:noreply, state}
end
@doc false
def yaml_file?(path) do
String.ends_with?(path, [".yaml", ".yml"])
end
@doc false
def should_reload?(events) when is_list(events) do
Enum.any?(events, fn event ->
event in [:created, :modified, :removed, :renamed]
end)
end
def should_reload?(_), do: false
end