From 8e8da466ded3b39188862e4b46f40220d65f316e Mon Sep 17 00:00:00 2001 From: "Anderson A. Giacomolli" Date: Fri, 6 Nov 2026 14:12:51 -0300 Subject: [PATCH] Read the data directory and the configuration 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 | 40 ++++++++++++++++ config.go | 126 +++++++++++++++++++++++++++++++++++++++++++++++++ config_test.go | 104 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 270 insertions(+) create mode 100644 config.go create mode 100644 config_test.go diff --git a/README.rst b/README.rst index df03c4f..362eeeb 100644 --- a/README.rst +++ b/README.rst @@ -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 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 index 0000000..ae84c96 --- /dev/null +++ b/config_test.go @@ -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) +} -- 2.53.0