package statedir
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
)
// Conversations are kept out of the repository, in one directory per working
// root under ~/.config/chats (or wherever XDG_CONFIG_HOME puts it). A chat is
// yours rather than the project's: it should not be scattered across every
// checkout you have ever opened, should not vanish with a `git clean -dfx`,
// and should not sit inside a tree that might be copied, archived or shared.
// The event log and the agent's scratch space stay in Dir, because those are
// about the repository.
const (
ChatsDir = "chats"
// SessionsSubdir and HistoryFile are what the chats used to be called
// inside Dir, before they moved out. They are named here only so the
// move can find them.
SessionsSubdir = "sessions"
HistoryFile = "history"
)
// Chats is the directory a working root's saved sessions and prompt history
// live in: ~/.config/chats/<root>, with the root's path flattened into one
// directory name the way Claude Code names its projects, so the directory
// can be found by eye. Two roots that flatten alike would share it, which is
// why every session also records its root and is checked against it when it
// is listed or resumed.
func Chats(root string) (string, error) {
dir, err := os.UserConfigDir()
if err != nil {
return "", fmt.Errorf("chats: no config directory: %w", err)
}
return filepath.Join(dir, ChatsDir, chatKey(root)), nil
}
func chatKey(root string) string {
return strings.Map(func(r rune) rune {
if r == '/' || r == '\\' || r == ':' {
return '-'
}
return r
}, filepath.Clean(root))
}
// MigrateChats moves the sessions and the prompt history an older build left
// in <root>/.codey into dir, reporting how many files it moved. It is called
// at startup, after Migrate and before anything opens a session.
//
// A file already present at the destination wins and the old copy is left
// where it was: both existing means both places have been written to, and
// overwriting either could only lose a conversation. Nothing to move is the
// common case, and must not create dir.
func MigrateChats(root, dir string) (int, error) {
moved := 0
oldSessions := filepath.Join(root, Dir, SessionsSubdir)
entries, err := os.ReadDir(oldSessions)
if err != nil && !errors.Is(err, os.ErrNotExist) {
return 0, fmt.Errorf("chats: %w", err)
}
var errs []error
for _, e := range entries {
if e.IsDir() || filepath.Ext(e.Name()) != ".json" {
continue
}
ok, err := moveFile(filepath.Join(oldSessions, e.Name()), filepath.Join(dir, e.Name()))
if err != nil {
errs = append(errs, err)
}
if ok {
moved++
}
}
// Removed only once empty, so anything left behind stays visible.
if len(entries) > 0 {
os.Remove(oldSessions)
}
oldHistory := filepath.Join(root, Dir, HistoryFile)
if info, err := os.Lstat(oldHistory); err == nil && info.Mode().IsRegular() {
ok, err := moveFile(oldHistory, filepath.Join(dir, HistoryFile))
if err != nil {
errs = append(errs, err)
}
if ok {
moved++
}
}
if err := errors.Join(errs...); err != nil {
return moved, fmt.Errorf("chats: %w", err)
}
return moved, nil
}
// moveFile renames src to dst unless dst exists, falling back to copy and
// remove when the two are on different filesystems — the config directory
// and a checkout often are.
func moveFile(src, dst string) (bool, error) {
if _, err := os.Lstat(dst); err == nil {
return false, nil
}
if err := os.MkdirAll(filepath.Dir(dst), 0o700); err != nil {
return false, err
}
if err := os.Rename(src, dst); err == nil {
return true, nil
}
in, err := os.Open(src)
if err != nil {
return false, err
}
defer in.Close()
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
return false, err
}
if _, err := io.Copy(out, in); err != nil {
out.Close()
os.Remove(dst)
return false, err
}
if err := out.Close(); err != nil {
os.Remove(dst)
return false, err
}
return true, os.Remove(src)
}