lib/foundry/context/lock_file.ex

defmodule Foundry.Context.LockFile do
  @moduledoc """
  Manages the context lock file (.foundry/context.lock) which tracks whether
  the cached project context is fresh.

  The lock file stores a SHA256 hash of all lib/**/*.ex and test/**/*.ex files.
  If any of these files change, the hash becomes stale.
  """

  @lock_path ".foundry/context.lock"

  @spec compute_hash(String.t()) :: String.t()
  def compute_hash(project_root) do
    # Deterministic: sort files, hash each file's content individually,
    # then hash the concatenated per-file hashes. Sorting is mandatory —
    # glob order is filesystem-dependent and non-deterministic across platforms.
    files =
      [Path.join(project_root, "lib/**/*.ex"),
       Path.join(project_root, "test/**/*.ex")]
      |> Enum.flat_map(&Path.wildcard/1)
      |> Enum.sort()

    per_file_hashes =
      Enum.map_join(files, "", fn path ->
        :crypto.hash(:sha256, File.read!(path)) |> Base.encode16(case: :lower)
      end)

    :crypto.hash(:sha256, per_file_hashes) |> Base.encode16(case: :lower)
  end

  @spec write(String.t()) :: :ok
  def write(project_root) do
    hash = compute_hash(project_root)
    lock_path = Path.join(project_root, @lock_path)
    File.mkdir_p!(Path.dirname(lock_path))
    File.write!(lock_path, hash <> "\n")
  end

  @spec check(String.t()) :: :ok | {:error, :stale | :missing}
  def check(project_root) do
    case File.read(Path.join(project_root, @lock_path)) do
      {:error, :enoent} -> {:error, :missing}
      {:ok, stored} ->
        current = compute_hash(project_root)
        if String.trim(stored) == current, do: :ok, else: {:error, :stale}
    end
  end
end