diff --git a/go.mod b/go.mod index 0034bb2..2101b5d 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,17 @@ module github.com/stig/goaichat go 1.24.6 -require gopkg.in/yaml.v3 v3.0.1 // indirect +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect + golang.org/x/sys v0.34.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + modernc.org/libc v1.66.3 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.39.0 // indirect +) diff --git a/go.sum b/go.sum index 4bc0337..62ead42 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,26 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= +golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ= +modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.39.0 h1:6bwu9Ooim0yVYA7IZn9demiQk/Ejp0BtTjBWFLymSeY= +modernc.org/sqlite v1.39.0/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E= diff --git a/internal/config/config.go b/internal/config/config.go index d2c6acb..2c62aaa 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -16,10 +16,11 @@ const ( // Config captures runtime configuration for the Goaichat application. type Config struct { - API APIConfig `yaml:"api"` - Model ModelConfig `yaml:"model"` + API APIConfig `yaml:"api"` + Model ModelConfig `yaml:"model"` Logging LoggingConfig `yaml:"logging"` - UI UIConfig `yaml:"ui"` + UI UIConfig `yaml:"ui"` + Storage StorageConfig `yaml:"storage"` } // APIConfig holds settings for connecting to the OpenAI-compatible API. @@ -45,6 +46,13 @@ type UIConfig struct { ShowTimestamps bool `yaml:"show_timestamps"` } +// StorageConfig controls persistence locations and metadata. +type StorageConfig struct { + Path string `yaml:"path"` + BaseDir string `yaml:"base_dir"` + Username string `yaml:"username"` +} + // Load reads configuration from the provided path, falling back to defaults and // environment overrides. func Load(path string) (*Config, error) { @@ -123,5 +131,6 @@ func defaultConfig() Config { UI: UIConfig{ ShowTimestamps: true, }, + Storage: StorageConfig{}, } } diff --git a/internal/storage/storage.go b/internal/storage/storage.go new file mode 100644 index 0000000..77bea44 --- /dev/null +++ b/internal/storage/storage.go @@ -0,0 +1,114 @@ +package storage + +import ( + "database/sql" + "errors" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + "sync" + + _ "modernc.org/sqlite" + + "github.com/stig/goaichat/internal/config" +) + +// Manager handles access to the SQLite database storing chat sessions and metadata. +type Manager struct { + path string + cfg config.StorageConfig + mu sync.Mutex + db *sql.DB +} + +// NewManager constructs a Manager and ensures the data directory exists. +func NewManager(cfg config.Config) (*Manager, error) { + path, err := resolvePath(cfg.Storage) + if err != nil { + return nil, err + } + + if err := ensureDir(filepath.Dir(path)); err != nil { + return nil, err + } + + return &Manager{path: path, cfg: cfg.Storage}, nil +} + +// Open lazily opens the SQLite database connection. +func (m *Manager) Open() (*sql.DB, error) { + m.mu.Lock() + defer m.mu.Unlock() + + if m.db != nil { + return m.db, nil + } + + dsn := fmt.Sprintf("file:%s?_pragma=journal_mode(WAL)&_busy_timeout=5000", m.path) + db, err := sql.Open("sqlite", dsn) + if err != nil { + return nil, fmt.Errorf("open sqlite: %w", err) + } + + if err := db.Ping(); err != nil { + _ = db.Close() + return nil, fmt.Errorf("ping sqlite: %w", err) + } + + m.db = db + return db, nil +} + +// Close releases the underlying database connection. +func (m *Manager) Close() error { + m.mu.Lock() + defer m.mu.Unlock() + + if m.db == nil { + return nil + } + + err := m.db.Close() + m.db = nil + return err +} + +func resolvePath(cfg config.StorageConfig) (string, error) { + if strings.TrimSpace(cfg.Path) != "" { + return cfg.Path, nil + } + + username := cfg.Username + if username == "" { + username = os.Getenv("USER") + if username == "" { + username = "default" + } + } + + base := filepath.Join("/var/log/goaichat", username) + if cfg.BaseDir != "" { + base = cfg.BaseDir + } + + return filepath.Join(base, "goaichat.db"), nil +} + +func ensureDir(dir string) error { + if dir == "" { + return errors.New("directory path cannot be empty") + } + if err := os.MkdirAll(dir, 0o700); err != nil { + return fmt.Errorf("create data directory: %w", err) + } + + if runtime.GOOS != "windows" { + if err := os.Chmod(dir, 0o700); err != nil { + return fmt.Errorf("chmod data directory: %w", err) + } + } + + return nil +}