1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
2//
3// SPDX-License-Identifier: AGPL-3.0-or-later
4
5// Package config handles loading and saving lunatask-mcp-server configuration.
6package config
7
8import (
9 "errors"
10 "fmt"
11 "os"
12 "path/filepath"
13
14 "github.com/BurntSushi/toml"
15)
16
17// ErrNotFound indicates the config file doesn't exist.
18var ErrNotFound = errors.New("config file not found")
19
20// Config represents the lunatask-mcp-server configuration file structure.
21type Config struct {
22 Server ServerConfig `toml:"server"`
23 Timezone string `toml:"timezone"`
24 Areas []Area `toml:"areas"`
25 Habits []Habit `toml:"habits"`
26}
27
28// ServerConfig holds server-related settings.
29type ServerConfig struct {
30 Host string `toml:"host"`
31 Port int `toml:"port"`
32 Transport string `toml:"transport"`
33}
34
35// Area represents a Lunatask area of life with its goals.
36type Area struct {
37 ID string `json:"id" toml:"id"`
38 Name string `json:"name" toml:"name"`
39 Key string `json:"key" toml:"key"`
40 Goals []Goal `json:"goals" toml:"goals"`
41}
42
43// Goal represents a goal within an area.
44type Goal struct {
45 ID string `json:"id" toml:"id"`
46 Name string `json:"name" toml:"name"`
47 Key string `json:"key" toml:"key"`
48}
49
50// Habit represents a trackable habit.
51type Habit struct {
52 ID string `json:"id" toml:"id"`
53 Name string `json:"name" toml:"name"`
54 Key string `json:"key" toml:"key"`
55}
56
57// Defaults holds default selections.
58type Defaults struct {
59 Area string `toml:"area"`
60}
61
62// Path returns the path to the config file.
63func Path() (string, error) {
64 configDir, err := os.UserConfigDir()
65 if err != nil {
66 return "", fmt.Errorf("getting config dir: %w", err)
67 }
68
69 return filepath.Join(configDir, "lunatask-mcp-server", "config.toml"), nil
70}
71
72// Load reads the config file. Returns ErrNotFound if the file doesn't exist.
73func Load() (*Config, error) {
74 path, err := Path()
75 if err != nil {
76 return nil, err
77 }
78
79 return LoadFrom(path)
80}
81
82// LoadFrom reads the config from the specified path.
83func LoadFrom(path string) (*Config, error) {
84 var cfg Config
85 if _, err := toml.DecodeFile(path, &cfg); err != nil {
86 if errors.Is(err, os.ErrNotExist) {
87 return nil, ErrNotFound
88 }
89
90 return nil, fmt.Errorf("decoding config: %w", err)
91 }
92
93 cfg.applyDefaults()
94
95 return &cfg, nil
96}
97
98// Save writes the config to the default path.
99func (c *Config) Save() error {
100 path, err := Path()
101 if err != nil {
102 return err
103 }
104
105 return c.SaveTo(path)
106}
107
108// SaveTo writes the config to the specified path.
109func (c *Config) SaveTo(path string) error {
110 if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
111 return fmt.Errorf("creating config dir: %w", err)
112 }
113
114 //nolint:gosec,mnd // path is from user config dir; 0o600 is intentional
115 f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
116 if err != nil {
117 return fmt.Errorf("creating config file: %w", err)
118 }
119
120 if err := toml.NewEncoder(f).Encode(c); err != nil {
121 _ = f.Close()
122
123 return fmt.Errorf("encoding config: %w", err)
124 }
125
126 if err := f.Close(); err != nil {
127 return fmt.Errorf("closing config file: %w", err)
128 }
129
130 return nil
131}
132
133// AreaByKey finds an area by its key.
134func (c *Config) AreaByKey(key string) *Area {
135 for i := range c.Areas {
136 if c.Areas[i].Key == key {
137 return &c.Areas[i]
138 }
139 }
140
141 return nil
142}
143
144// AreaByID finds an area by its ID.
145func (c *Config) AreaByID(id string) *Area {
146 for i := range c.Areas {
147 if c.Areas[i].ID == id {
148 return &c.Areas[i]
149 }
150 }
151
152 return nil
153}
154
155// HabitByKey finds a habit by its key.
156func (c *Config) HabitByKey(key string) *Habit {
157 for i := range c.Habits {
158 if c.Habits[i].Key == key {
159 return &c.Habits[i]
160 }
161 }
162
163 return nil
164}
165
166// HabitByID finds a habit by its ID.
167func (c *Config) HabitByID(id string) *Habit {
168 for i := range c.Habits {
169 if c.Habits[i].ID == id {
170 return &c.Habits[i]
171 }
172 }
173
174 return nil
175}
176
177// GoalByKey finds a goal within this area by its key.
178func (a *Area) GoalByKey(key string) *Goal {
179 for i := range a.Goals {
180 if a.Goals[i].Key == key {
181 return &a.Goals[i]
182 }
183 }
184
185 return nil
186}
187
188// GoalByID finds a goal within this area by its ID.
189func (a *Area) GoalByID(id string) *Goal {
190 for i := range a.Goals {
191 if a.Goals[i].ID == id {
192 return &a.Goals[i]
193 }
194 }
195
196 return nil
197}
198
199// GoalMatch pairs a goal with its parent area.
200type GoalMatch struct {
201 Goal *Goal
202 Area *Area
203}
204
205// FindGoalsByKey returns all goals matching the given key across all areas.
206func (c *Config) FindGoalsByKey(key string) []GoalMatch {
207 var matches []GoalMatch
208
209 for i := range c.Areas {
210 area := &c.Areas[i]
211
212 for j := range area.Goals {
213 if area.Goals[j].Key == key {
214 matches = append(matches, GoalMatch{
215 Goal: &area.Goals[j],
216 Area: area,
217 })
218 }
219 }
220 }
221
222 return matches
223}
224
225// GoalByID finds a goal by its ID across all areas.
226func (c *Config) GoalByID(id string) *GoalMatch {
227 for i := range c.Areas {
228 area := &c.Areas[i]
229
230 for j := range area.Goals {
231 if area.Goals[j].ID == id {
232 return &GoalMatch{
233 Goal: &area.Goals[j],
234 Area: area,
235 }
236 }
237 }
238 }
239
240 return nil
241}
242
243// applyDefaults sets default values for unset fields.
244func (c *Config) applyDefaults() {
245 if c.Server.Host == "" {
246 c.Server.Host = "localhost"
247 }
248
249 if c.Server.Port == 0 {
250 c.Server.Port = 8080
251 }
252
253 if c.Server.Transport == "" {
254 c.Server.Transport = "stdio"
255 }
256
257 if c.Timezone == "" {
258 c.Timezone = "UTC"
259 }
260}