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 Tools ToolsConfig `toml:"tools"`
24 Timezone string `toml:"timezone"`
25 Areas []Area `toml:"areas"`
26 Habits []Habit `toml:"habits"`
27}
28
29// ServerConfig holds server-related settings.
30type ServerConfig struct {
31 Host string `toml:"host"`
32 Port int `toml:"port"`
33 Transport string `toml:"transport"`
34}
35
36// ToolsConfig controls which MCP tools are enabled.
37type ToolsConfig struct {
38 GetTimestamp bool `toml:"get_timestamp"`
39 CreateTask bool `toml:"create_task"`
40 UpdateTask bool `toml:"update_task"`
41 DeleteTask bool `toml:"delete_task"`
42 TrackHabitActivity bool `toml:"track_habit_activity"`
43}
44
45// Area represents a Lunatask area of life with its goals.
46type Area struct {
47 ID string `json:"id" toml:"id"`
48 Name string `json:"name" toml:"name"`
49 Key string `json:"key" toml:"key"`
50 Goals []Goal `json:"goals" toml:"goals"`
51}
52
53// Goal represents a goal within an area.
54type Goal struct {
55 ID string `json:"id" toml:"id"`
56 Name string `json:"name" toml:"name"`
57 Key string `json:"key" toml:"key"`
58}
59
60// Habit represents a trackable habit.
61type Habit struct {
62 ID string `json:"id" toml:"id"`
63 Name string `json:"name" toml:"name"`
64 Key string `json:"key" toml:"key"`
65}
66
67// Defaults holds default selections.
68type Defaults struct {
69 Area string `toml:"area"`
70}
71
72// Path returns the path to the config file.
73func Path() (string, error) {
74 configDir, err := os.UserConfigDir()
75 if err != nil {
76 return "", fmt.Errorf("getting config dir: %w", err)
77 }
78
79 return filepath.Join(configDir, "lunatask-mcp-server", "config.toml"), nil
80}
81
82// Load reads the config file. Returns ErrNotFound if the file doesn't exist.
83func Load() (*Config, error) {
84 path, err := Path()
85 if err != nil {
86 return nil, err
87 }
88
89 return LoadFrom(path)
90}
91
92// LoadFrom reads the config from the specified path.
93func LoadFrom(path string) (*Config, error) {
94 var cfg Config
95 if _, err := toml.DecodeFile(path, &cfg); err != nil {
96 if errors.Is(err, os.ErrNotExist) {
97 return nil, ErrNotFound
98 }
99
100 return nil, fmt.Errorf("decoding config: %w", err)
101 }
102
103 cfg.applyDefaults()
104
105 return &cfg, nil
106}
107
108// Save writes the config to the default path.
109func (c *Config) Save() error {
110 path, err := Path()
111 if err != nil {
112 return err
113 }
114
115 return c.SaveTo(path)
116}
117
118// SaveTo writes the config to the specified path.
119func (c *Config) SaveTo(path string) error {
120 if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
121 return fmt.Errorf("creating config dir: %w", err)
122 }
123
124 //nolint:gosec,mnd // path is from user config dir; 0o600 is intentional
125 f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
126 if err != nil {
127 return fmt.Errorf("creating config file: %w", err)
128 }
129
130 if err := toml.NewEncoder(f).Encode(c); err != nil {
131 _ = f.Close()
132
133 return fmt.Errorf("encoding config: %w", err)
134 }
135
136 if err := f.Close(); err != nil {
137 return fmt.Errorf("closing config file: %w", err)
138 }
139
140 return nil
141}
142
143// AreaByKey finds an area by its key.
144func (c *Config) AreaByKey(key string) *Area {
145 for i := range c.Areas {
146 if c.Areas[i].Key == key {
147 return &c.Areas[i]
148 }
149 }
150
151 return nil
152}
153
154// AreaByID finds an area by its ID.
155func (c *Config) AreaByID(id string) *Area {
156 for i := range c.Areas {
157 if c.Areas[i].ID == id {
158 return &c.Areas[i]
159 }
160 }
161
162 return nil
163}
164
165// HabitByKey finds a habit by its key.
166func (c *Config) HabitByKey(key string) *Habit {
167 for i := range c.Habits {
168 if c.Habits[i].Key == key {
169 return &c.Habits[i]
170 }
171 }
172
173 return nil
174}
175
176// HabitByID finds a habit by its ID.
177func (c *Config) HabitByID(id string) *Habit {
178 for i := range c.Habits {
179 if c.Habits[i].ID == id {
180 return &c.Habits[i]
181 }
182 }
183
184 return nil
185}
186
187// GoalByKey finds a goal within this area by its key.
188func (a *Area) GoalByKey(key string) *Goal {
189 for i := range a.Goals {
190 if a.Goals[i].Key == key {
191 return &a.Goals[i]
192 }
193 }
194
195 return nil
196}
197
198// GoalByID finds a goal within this area by its ID.
199func (a *Area) GoalByID(id string) *Goal {
200 for i := range a.Goals {
201 if a.Goals[i].ID == id {
202 return &a.Goals[i]
203 }
204 }
205
206 return nil
207}
208
209// GoalMatch pairs a goal with its parent area.
210type GoalMatch struct {
211 Goal *Goal
212 Area *Area
213}
214
215// FindGoalsByKey returns all goals matching the given key across all areas.
216func (c *Config) FindGoalsByKey(key string) []GoalMatch {
217 var matches []GoalMatch
218
219 for i := range c.Areas {
220 area := &c.Areas[i]
221
222 for j := range area.Goals {
223 if area.Goals[j].Key == key {
224 matches = append(matches, GoalMatch{
225 Goal: &area.Goals[j],
226 Area: area,
227 })
228 }
229 }
230 }
231
232 return matches
233}
234
235// GoalByID finds a goal by its ID across all areas.
236func (c *Config) GoalByID(id string) *GoalMatch {
237 for i := range c.Areas {
238 area := &c.Areas[i]
239
240 for j := range area.Goals {
241 if area.Goals[j].ID == id {
242 return &GoalMatch{
243 Goal: &area.Goals[j],
244 Area: area,
245 }
246 }
247 }
248 }
249
250 return nil
251}
252
253// applyDefaults sets default values for unset fields.
254func (c *Config) applyDefaults() {
255 if c.Server.Host == "" {
256 c.Server.Host = "localhost"
257 }
258
259 if c.Server.Port == 0 {
260 c.Server.Port = 8080
261 }
262
263 if c.Server.Transport == "" {
264 c.Server.Transport = "stdio"
265 }
266
267 if c.Timezone == "" {
268 c.Timezone = "UTC"
269 }
270
271 c.Tools.ApplyDefaults()
272}
273
274// ApplyDefaults enables all tools by default.
275func (t *ToolsConfig) ApplyDefaults() {
276 // Use a marker to detect if tools section was present in config.
277 // If all are false, apply defaults (all true).
278 if !t.GetTimestamp && !t.CreateTask && !t.UpdateTask && !t.DeleteTask && !t.TrackHabitActivity {
279 t.GetTimestamp = true
280 t.CreateTask = true
281 t.UpdateTask = true
282 t.DeleteTask = true
283 t.TrackHabitActivity = true
284 }
285}