]> git.giacomolli.uk - onechat/blob - config.go
Quote long text in issues as a blockquote
[onechat] / config.go
1 package main
2
3 import (
4 "bytes"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "io"
9 "os"
10 "path/filepath"
11 )
12
13 const (
14 // endpointChatGPT names the ChatGPT backend with the subscription login.
15 endpointChatGPT = "chatgpt"
16
17 // endpointAPI names the OpenAI API with an API key.
18 endpointAPI = "api"
19 )
20
21 // Config holds the settings of config.json in the data directory.
22 type Config struct {
23 Endpoint string `json:"endpoint"`
24 Compactor ModelConfig `json:"compactor"`
25 }
26
27 // ModelConfig names a model and its reasoning effort.
28 type ModelConfig struct {
29 Model string `json:"model"`
30 Effort string `json:"effort"`
31 }
32
33 // defaultConfig returns the settings that apply when config.json does not
34 // set them.
35 func defaultConfig() Config {
36 return Config{
37 Endpoint: endpointChatGPT,
38 Compactor: ModelConfig{
39 Model: "gpt-6-luna",
40 Effort: "high",
41 },
42 }
43 }
44
45 // loadConfig reads config.json in dir over the defaults. A missing file gives
46 // the defaults. An unknown field is an error, so a typo does not become a
47 // silent default.
48 func loadConfig(dir string) (Config, error) {
49 config := defaultConfig()
50 data, err := os.ReadFile(filepath.Join(dir, "config.json"))
51 if errors.Is(err, os.ErrNotExist) {
52 return config, nil
53 }
54
55 if err != nil {
56 return Config{}, fmt.Errorf("read config: %w", err)
57 }
58
59 decoder := json.NewDecoder(bytes.NewReader(data))
60 decoder.DisallowUnknownFields()
61 err = decoder.Decode(&config)
62 if err == nil && !errors.Is(decoder.Decode(&struct{}{}), io.EOF) {
63 err = errors.New("data after the JSON object")
64 }
65
66 if err == nil {
67 err = config.validate()
68 }
69
70 if err != nil {
71 return Config{}, fmt.Errorf("config.json: %w", err)
72 }
73
74 return config, nil
75 }
76
77 // validate makes sure that each setting has a valid value.
78 func (c Config) validate() error {
79 if c.Endpoint != endpointChatGPT && c.Endpoint != endpointAPI {
80 return fmt.Errorf("endpoint %q is not %q or %q", c.Endpoint, endpointChatGPT, endpointAPI)
81 }
82
83 if c.Compactor.Model == "" || c.Compactor.Effort == "" {
84 return errors.New("compactor needs a model and an effort")
85 }
86
87 return nil
88 }
89
90 // dataDir returns the data directory and creates it with mode 0700 when it
91 // does not exist. It is ONECHAT_DIR when that is set. Otherwise it is
92 // $XDG_DATA_HOME/onechat when XDG_DATA_HOME is an absolute path, or else
93 // ~/.local/share/onechat.
94 func dataDir() (string, error) {
95 dir := os.Getenv("ONECHAT_DIR")
96 if dir == "" {
97 var err error
98 dir, err = defaultDataDir()
99 if err != nil {
100 return "", err
101 }
102 }
103
104 err := os.MkdirAll(dir, 0o700)
105 if err != nil {
106 return "", fmt.Errorf("create data directory: %w", err)
107 }
108
109 return dir, nil
110 }
111
112 // defaultDataDir returns the data directory for an unset ONECHAT_DIR. The XDG
113 // specification says to ignore a relative XDG_DATA_HOME.
114 func defaultDataDir() (string, error) {
115 xdg := os.Getenv("XDG_DATA_HOME")
116 if filepath.IsAbs(xdg) {
117 return filepath.Join(xdg, "onechat"), nil
118 }
119
120 home, err := os.UserHomeDir()
121 if err != nil {
122 return "", fmt.Errorf("find data directory: set ONECHAT_DIR: %w", err)
123 }
124
125 return filepath.Join(home, ".local", "share", "onechat"), nil
126 }