]> git.giacomolli.uk - onechat/commitdiff
Read the data directory and the configuration
authorAnderson A. Giacomolli <agiacomolli@gmail.com>
Fri, 6 Nov 2026 17:12:51 +0000 (14:12 -0300)
committerAnderson A. Giacomolli <agiacomolli@gmail.com>
Fri, 6 Nov 2026 17:12:51 +0000 (14:12 -0300)
OneChat finds its data directory in ONECHAT_DIR, then in an absolute
XDG_DATA_HOME, then in ~/.local/share/onechat, and creates it with mode
0700. config.json in that directory selects the model endpoint and the
compactor model and effort. A missing file or field takes the default,
and an unknown field or an invalid value is an error, so a typo never
becomes a silent default.

The endpoint is a name, chatgpt or api, not a URL, so the configuration
cannot send a token to another host.

Issue: b14d537

README.rst
config.go [new file with mode: 0644]
config_test.go [new file with mode: 0644]

index df03c4f647b5575ada6afa6b73fe133490eef73d..362eeebd7d3d807a38dbeb2ce08d50a02e7569f1 100644 (file)
@@ -5,6 +5,46 @@
 OneChat is a Go program. It prints its name. It is in early development.
 Linux is supported.
 
+****************
+ Data directory
+****************
+
+OneChat keeps its data and its token file in one directory. It uses the
+first of these:
+
+#. ``ONECHAT_DIR``, when it is set.
+#. ``$XDG_DATA_HOME/onechat``, when ``XDG_DATA_HOME`` is an absolute
+   path. A relative value is ignored.
+#. ``~/.local/share/onechat``. With no home directory, OneChat exits
+   with an error.
+
+OneChat creates the directory with mode 0700.
+
+***************
+ Configuration
+***************
+
+``config.json`` in the data directory holds the settings. OneChat does
+not create it. A missing file or field takes the default, and an unknown
+field is an error.
+
+.. code:: json
+
+   {
+     "endpoint": "chatgpt",
+     "compactor": {"model": "gpt-6-luna", "effort": "high"}
+   }
+
+``endpoint``
+   ``chatgpt`` (the default) sends model calls to the ChatGPT backend
+   with the subscription login. ``api`` sends them to the OpenAI API
+   with the key in ``OPENAI_API_KEY``, which must then be set. With
+   ``chatgpt``, OneChat ignores ``OPENAI_API_KEY``.
+
+``compactor``
+   The model and the reasoning effort of the summaries. The defaults are
+   ``gpt-6-luna`` and ``high``.
+
 *************
  Development
 *************
diff --git a/config.go b/config.go
new file mode 100644 (file)
index 0000000..23c330f
--- /dev/null
+++ b/config.go
@@ -0,0 +1,126 @@
+package main
+
+import (
+       "bytes"
+       "encoding/json"
+       "errors"
+       "fmt"
+       "io"
+       "os"
+       "path/filepath"
+)
+
+const (
+       // endpointChatGPT names the ChatGPT backend with the subscription login.
+       endpointChatGPT = "chatgpt"
+
+       // endpointAPI names the OpenAI API with an API key.
+       endpointAPI = "api"
+)
+
+// Config holds the settings of config.json in the data directory.
+type Config struct {
+       Endpoint  string      `json:"endpoint"`
+       Compactor ModelConfig `json:"compactor"`
+}
+
+// ModelConfig names a model and its reasoning effort.
+type ModelConfig struct {
+       Model  string `json:"model"`
+       Effort string `json:"effort"`
+}
+
+// defaultConfig returns the settings that apply when config.json does not
+// set them.
+func defaultConfig() Config {
+       return Config{
+               Endpoint: endpointChatGPT,
+               Compactor: ModelConfig{
+                       Model:  "gpt-6-luna",
+                       Effort: "high",
+               },
+       }
+}
+
+// loadConfig reads config.json in dir over the defaults. A missing file gives
+// the defaults. An unknown field is an error, so a typo does not become a
+// silent default.
+func loadConfig(dir string) (Config, error) {
+       config := defaultConfig()
+       data, err := os.ReadFile(filepath.Join(dir, "config.json"))
+       if errors.Is(err, os.ErrNotExist) {
+               return config, nil
+       }
+
+       if err != nil {
+               return Config{}, fmt.Errorf("read config: %w", err)
+       }
+
+       decoder := json.NewDecoder(bytes.NewReader(data))
+       decoder.DisallowUnknownFields()
+       err = decoder.Decode(&config)
+       if err == nil && !errors.Is(decoder.Decode(&struct{}{}), io.EOF) {
+               err = errors.New("data after the JSON object")
+       }
+
+       if err == nil {
+               err = config.validate()
+       }
+
+       if err != nil {
+               return Config{}, fmt.Errorf("config.json: %w", err)
+       }
+
+       return config, nil
+}
+
+// validate makes sure that each setting has a valid value.
+func (c Config) validate() error {
+       if c.Endpoint != endpointChatGPT && c.Endpoint != endpointAPI {
+               return fmt.Errorf("endpoint %q is not %q or %q", c.Endpoint, endpointChatGPT, endpointAPI)
+       }
+
+       if c.Compactor.Model == "" || c.Compactor.Effort == "" {
+               return errors.New("compactor needs a model and an effort")
+       }
+
+       return nil
+}
+
+// dataDir returns the data directory and creates it with mode 0700 when it
+// does not exist. It is ONECHAT_DIR when that is set. Otherwise it is
+// $XDG_DATA_HOME/onechat when XDG_DATA_HOME is an absolute path, or else
+// ~/.local/share/onechat.
+func dataDir() (string, error) {
+       dir := os.Getenv("ONECHAT_DIR")
+       if dir == "" {
+               var err error
+               dir, err = defaultDataDir()
+               if err != nil {
+                       return "", err
+               }
+       }
+
+       err := os.MkdirAll(dir, 0o700)
+       if err != nil {
+               return "", fmt.Errorf("create data directory: %w", err)
+       }
+
+       return dir, nil
+}
+
+// defaultDataDir returns the data directory for an unset ONECHAT_DIR. The XDG
+// specification says to ignore a relative XDG_DATA_HOME.
+func defaultDataDir() (string, error) {
+       xdg := os.Getenv("XDG_DATA_HOME")
+       if filepath.IsAbs(xdg) {
+               return filepath.Join(xdg, "onechat"), nil
+       }
+
+       home, err := os.UserHomeDir()
+       if err != nil {
+               return "", fmt.Errorf("find data directory: set ONECHAT_DIR: %w", err)
+       }
+
+       return filepath.Join(home, ".local", "share", "onechat"), nil
+}
diff --git a/config_test.go b/config_test.go
new file mode 100644 (file)
index 0000000..ae84c96
--- /dev/null
@@ -0,0 +1,104 @@
+package main
+
+import (
+       "os"
+       "path/filepath"
+       "testing"
+
+       "github.com/stretchr/testify/require"
+)
+
+func TestLoadConfigGivesDefaultsWithoutAFile(t *testing.T) {
+       dir := t.TempDir()
+       config, err := loadConfig(dir)
+       require.NoError(t, err)
+       require.Equal(t, defaultConfig(), config)
+       require.NoFileExists(t, filepath.Join(dir, "config.json"))
+}
+
+func TestLoadConfigKeepsDefaultsForMissingFields(t *testing.T) {
+       dir := t.TempDir()
+       writeConfig(t, dir, `{"endpoint": "api", "compactor": {"effort": "xhigh"}}`)
+
+       config, err := loadConfig(dir)
+       require.NoError(t, err)
+       require.Equal(t, Config{
+               Endpoint: endpointAPI,
+               Compactor: ModelConfig{
+                       Model:  "gpt-6-luna",
+                       Effort: "xhigh",
+               },
+       }, config)
+}
+
+func TestLoadConfigRejectsInvalidFiles(t *testing.T) {
+       testCases := []struct {
+               name string
+               text string
+       }{
+               {"unknown field", `{"endpont": "api"}`},
+               {"unknown nested field", `{"compactor": {"modle": "x"}}`},
+               {"endpoint URL", `{"endpoint": "https://example.com/v1/responses"}`},
+               {"empty model", `{"compactor": {"model": ""}}`},
+               {"second object", `{} {}`},
+               {"broken JSON", `{"endpoint": `},
+       }
+
+       for _, tc := range testCases {
+               t.Run(tc.name, func(t *testing.T) {
+                       dir := t.TempDir()
+                       writeConfig(t, dir, tc.text)
+
+                       _, err := loadConfig(dir)
+                       require.Error(t, err)
+               })
+       }
+}
+
+func TestDataDirFollowsTheOrder(t *testing.T) {
+       root := t.TempDir()
+       testCases := []struct {
+               name string
+               dir  string
+               xdg  string
+               home string
+               want string
+       }{
+               {"ONECHAT_DIR first", filepath.Join(root, "a"), filepath.Join(root, "xdg"), root, filepath.Join(root, "a")},
+               {"absolute XDG_DATA_HOME", "", filepath.Join(root, "xdg"), root, filepath.Join(root, "xdg", "onechat")},
+               {"relative XDG_DATA_HOME ignored", "", "xdg", root, filepath.Join(root, ".local", "share", "onechat")},
+               {"home directory last", "", "", root, filepath.Join(root, ".local", "share", "onechat")},
+       }
+
+       for _, tc := range testCases {
+               t.Run(tc.name, func(t *testing.T) {
+                       t.Setenv("ONECHAT_DIR", tc.dir)
+                       t.Setenv("XDG_DATA_HOME", tc.xdg)
+                       t.Setenv("HOME", tc.home)
+
+                       dir, err := dataDir()
+                       require.NoError(t, err)
+                       require.Equal(t, tc.want, dir)
+
+                       info, err := os.Stat(dir)
+                       require.NoError(t, err)
+                       require.Equal(t, os.FileMode(0o700), info.Mode().Perm())
+               })
+       }
+}
+
+func TestDataDirFailsWithoutAHome(t *testing.T) {
+       t.Setenv("ONECHAT_DIR", "")
+       t.Setenv("XDG_DATA_HOME", "")
+       t.Setenv("HOME", "")
+
+       _, err := dataDir()
+       require.ErrorContains(t, err, "ONECHAT_DIR")
+}
+
+func writeConfig(t *testing.T, dir, text string) {
+       t.Helper()
+
+       err := os.WriteFile(filepath.Join(dir, "config.json"), []byte(text), 0o600)
+       require.NoError(t, err)
+}