lib/foundry/chat/file_session_store.ex

defmodule Foundry.Chat.FileSessionStore do
  @moduledoc """
  File-backed session store for Studio copilot sessions.

  Sessions are stored as JSON files in `.foundry/local/chat_sessions/` relative to the project root.
  Each file is named `<session_id>.json` and contains the full session state.

  Atomic writes are ensured via temp-file + rename pattern.
  """

  @behaviour Foundry.Chat.SessionStore

  @impl true
  def list(workspace_id, project_fingerprint) do
    store_root = store_root()

    case File.mkdir_p(store_root) do
      :ok ->
        case File.ls(store_root) do
          {:ok, files} ->
            sessions =
              files
              |> Enum.filter(&String.ends_with?(&1, ".json"))
              |> Enum.flat_map(&load_file(store_root, &1))
              |> Enum.filter(fn session ->
                project_matches?(session, project_fingerprint) and
                  workspace_matches?(session, workspace_id)
              end)
              |> Enum.sort_by(& &1["updated_at"], :desc)

            {:ok, sessions}

          {:error, reason} ->
            {:error, reason}
        end

      {:error, reason} ->
        {:error, reason}
    end
  end

  @impl true
  def load(session_id) do
    store_root = store_root()
    path = Path.join(store_root, "#{session_id}.json")

    case File.read(path) do
      {:ok, content} ->
        case Jason.decode(content) do
          {:ok, session} -> {:ok, session}
          {:error, reason} -> {:error, {:invalid_json, reason}}
        end

      {:error, :enoent} ->
        {:ok, nil}

      {:error, reason} ->
        {:error, reason}
    end
  end

  @impl true
  def create(attrs) do
    session_id = Map.fetch!(attrs, :id)
    store_root = store_root()

    case File.mkdir_p(store_root) do
      :ok ->
        now = DateTime.utc_now() |> DateTime.to_iso8601()

        session = %{
          "id" => session_id,
          "workspace_id" => Map.fetch!(attrs, :workspace_id),
          "project_fingerprint" => Map.fetch!(attrs, :project_fingerprint),
          "title" => Map.get(attrs, :title, "New session"),
          "messages" => Map.get(attrs, :messages, []),
          "session_digest" => Map.get(attrs, :session_digest, %{}),
          "last_message_preview" => Map.get(attrs, :last_message_preview),
          "created_at" => now,
          "updated_at" => now,
          "model" => Map.get(attrs, :model),
          "selected_model_id" => Map.get(attrs, :selected_model_id),
          "selected_provider" => Map.get(attrs, :selected_provider)
        }

        write_session(store_root, session_id, session)

      {:error, reason} ->
        {:error, reason}
    end
  end

  @impl true
  def update(session_id, attrs) do
    store_root = store_root()

    with {:ok, session} <- load(session_id) do
      if session do
        now = DateTime.utc_now() |> DateTime.to_iso8601()
        updated_session = Map.merge(session, attrs) |> Map.put("updated_at", now)
        write_session(store_root, session_id, updated_session)
      else
        {:error, :not_found}
      end
    end
  end

  @impl true
  def rename(session_id, title) do
    update(session_id, %{"title" => title})
  end

  @impl true
  def delete(session_id) do
    store_root = store_root()
    path = Path.join(store_root, "#{session_id}.json")

    case File.rm(path) do
      :ok -> :ok
      {:error, :enoent} -> :ok
      {:error, reason} -> {:error, reason}
    end
  end

  defp write_session(store_root, session_id, session) do
    path = Path.join(store_root, "#{session_id}.json")
    tmp_path = "#{path}.tmp"

    case Jason.encode(session) do
      {:ok, content} ->
        case File.write(tmp_path, content) do
          :ok ->
            case File.rename(tmp_path, path) do
              :ok -> {:ok, session}
              {:error, reason} -> {:error, reason}
            end

          {:error, reason} ->
            {:error, reason}
        end

      {:error, reason} ->
        {:error, reason}
    end
  end

  defp load_file(store_root, filename) do
    path = Path.join(store_root, filename)

    case File.read(path) do
      {:ok, content} ->
        case Jason.decode(content) do
          {:ok, session} -> [session]
          {:error, _reason} -> []
        end

      {:error, _reason} ->
        []
    end
  end

  defp store_root do
    project_root =
      Application.get_env(
        :foundry_web,
        :current_project_root,
        Application.get_env(
          :foundry_web,
          :igaming_project_root,
          Path.expand("../../reference_projects/igaming", __DIR__)
        )
      )
      |> Path.expand()

    Path.join([project_root, ".foundry", "local", "chat_sessions"])
  end

  defp workspace_matches?(_session, nil), do: true
  defp workspace_matches?(_session, ""), do: true
  defp workspace_matches?(session, workspace_id), do: session["workspace_id"] == workspace_id

  defp project_matches?(_session, nil), do: true
  defp project_matches?(_session, ""), do: true

  defp project_matches?(session, project_fingerprint),
    do: session["project_fingerprint"] == project_fingerprint
end