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 lune configuration from TOML.
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 lune configuration file structure.
21type Config struct {
22 UI UIConfig `toml:"ui"`
23 Defaults Defaults `toml:"defaults"`
24 MCP MCPConfig `toml:"mcp"`
25 Areas []Area `toml:"areas"`
26 Notebooks []Notebook `toml:"notebooks"`
27 Habits []Habit `toml:"habits"`
28}
29
30// MCPConfig holds MCP server settings.
31type MCPConfig struct {
32 Host string `toml:"host"`
33 Port int `toml:"port"`
34 Timezone string `toml:"timezone"`
35 Tools ToolsConfig `toml:"tools"`
36}
37
38// ToolsConfig controls which MCP tools are enabled.
39// All tools default to enabled when not explicitly set.
40type ToolsConfig struct {
41 GetTimestamp bool `toml:"get_timestamp"`
42
43 CreateTask bool `toml:"create_task"`
44 UpdateTask bool `toml:"update_task"`
45 DeleteTask bool `toml:"delete_task"`
46 ListTasks bool `toml:"list_tasks"`
47 ShowTask bool `toml:"show_task"`
48
49 CreateNote bool `toml:"create_note"`
50 UpdateNote bool `toml:"update_note"`
51 DeleteNote bool `toml:"delete_note"`
52 ListNotes bool `toml:"list_notes"`
53 ShowNote bool `toml:"show_note"`
54
55 CreatePerson bool `toml:"create_person"`
56 UpdatePerson bool `toml:"update_person"`
57 DeletePerson bool `toml:"delete_person"`
58 ListPeople bool `toml:"list_people"`
59 ShowPerson bool `toml:"show_person"`
60 PersonTimeline bool `toml:"person_timeline"`
61
62 TrackHabit bool `toml:"track_habit"`
63 ListHabits bool `toml:"list_habits"`
64
65 CreateJournal bool `toml:"create_journal"`
66}
67
68// MCPDefaults applies default values to MCP config.
69func (c *MCPConfig) MCPDefaults() {
70 if c.Host == "" {
71 c.Host = "localhost"
72 }
73
74 if c.Port == 0 {
75 c.Port = 8080
76 }
77
78 if c.Timezone == "" {
79 c.Timezone = "UTC"
80 }
81
82 c.Tools.ApplyDefaults()
83}
84
85// ApplyDefaults enables all tools if none are explicitly configured.
86//
87//nolint:cyclop // Complexity from repetitive boolean checks; structurally simple.
88func (t *ToolsConfig) ApplyDefaults() {
89 // If all are false (zero value), enable everything
90 if !t.GetTimestamp && !t.CreateTask && !t.UpdateTask && !t.DeleteTask &&
91 !t.ListTasks && !t.ShowTask && !t.CreateNote && !t.UpdateNote &&
92 !t.DeleteNote && !t.ListNotes && !t.ShowNote && !t.CreatePerson &&
93 !t.UpdatePerson && !t.DeletePerson && !t.ListPeople && !t.ShowPerson &&
94 !t.PersonTimeline && !t.TrackHabit && !t.ListHabits && !t.CreateJournal {
95 t.GetTimestamp = true
96 t.CreateTask = true
97 t.UpdateTask = true
98 t.DeleteTask = true
99 t.ListTasks = true
100 t.ShowTask = true
101 t.CreateNote = true
102 t.UpdateNote = true
103 t.DeleteNote = true
104 t.ListNotes = true
105 t.ShowNote = true
106 t.CreatePerson = true
107 t.UpdatePerson = true
108 t.DeletePerson = true
109 t.ListPeople = true
110 t.ShowPerson = true
111 t.PersonTimeline = true
112 t.TrackHabit = true
113 t.ListHabits = true
114 t.CreateJournal = true
115 }
116}
117
118// UIConfig holds user interface preferences.
119type UIConfig struct {
120 Color string `toml:"color"` // "always", "never", "auto"
121}
122
123// Defaults holds default selections for commands.
124type Defaults struct {
125 Area string `toml:"area"`
126 Notebook string `toml:"notebook"`
127}
128
129// Area represents a Lunatask area of life with its goals.
130//
131//nolint:recvcheck // Value receivers for Keyed interface; pointer receiver for GoalByKey is intentional.
132type Area struct {
133 ID string `json:"id" toml:"id"`
134 Name string `json:"name" toml:"name"`
135 Key string `json:"key" toml:"key"`
136 Goals []Goal `json:"goals" toml:"goals"`
137}
138
139// Goal represents a goal within an area.
140type Goal struct {
141 ID string `json:"id" toml:"id"`
142 Name string `json:"name" toml:"name"`
143 Key string `json:"key" toml:"key"`
144}
145
146// Notebook represents a Lunatask notebook for notes.
147type Notebook struct {
148 ID string `json:"id" toml:"id"`
149 Name string `json:"name" toml:"name"`
150 Key string `json:"key" toml:"key"`
151}
152
153// Habit represents a trackable habit.
154type Habit struct {
155 ID string `json:"id" toml:"id"`
156 Name string `json:"name" toml:"name"`
157 Key string `json:"key" toml:"key"`
158}
159
160// GetID returns the area ID.
161func (a Area) GetID() string { return a.ID }
162
163// GetName returns the area name.
164func (a Area) GetName() string { return a.Name }
165
166// GetKey returns the area key.
167func (a Area) GetKey() string { return a.Key }
168
169// GetID returns the goal ID.
170func (g Goal) GetID() string { return g.ID }
171
172// GetName returns the goal name.
173func (g Goal) GetName() string { return g.Name }
174
175// GetKey returns the goal key.
176func (g Goal) GetKey() string { return g.Key }
177
178// GetID returns the habit ID.
179func (h Habit) GetID() string { return h.ID }
180
181// GetName returns the habit name.
182func (h Habit) GetName() string { return h.Name }
183
184// GetKey returns the habit key.
185func (h Habit) GetKey() string { return h.Key }
186
187// GetID returns the notebook ID.
188func (n Notebook) GetID() string { return n.ID }
189
190// GetName returns the notebook name.
191func (n Notebook) GetName() string { return n.Name }
192
193// GetKey returns the notebook key.
194func (n Notebook) GetKey() string { return n.Key }
195
196// Path returns the path to the config file.
197func Path() (string, error) {
198 configDir, err := os.UserConfigDir()
199 if err != nil {
200 return "", fmt.Errorf("getting config dir: %w", err)
201 }
202
203 return filepath.Join(configDir, "lune", "config.toml"), nil
204}
205
206// Load reads the config file. Returns ErrNotFound if the file doesn't exist.
207func Load() (*Config, error) {
208 path, err := Path()
209 if err != nil {
210 return nil, err
211 }
212
213 var cfg Config
214 if _, err := toml.DecodeFile(path, &cfg); err != nil {
215 if errors.Is(err, os.ErrNotExist) {
216 return nil, ErrNotFound
217 }
218
219 return nil, fmt.Errorf("decoding config: %w", err)
220 }
221
222 return &cfg, nil
223}
224
225// Save writes the config to disk.
226func (c *Config) Save() error {
227 path, err := Path()
228 if err != nil {
229 return err
230 }
231
232 if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
233 return fmt.Errorf("creating config dir: %w", err)
234 }
235
236 //nolint:gosec,mnd // path is from user config dir; 0o600 is intentional
237 f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
238 if err != nil {
239 return fmt.Errorf("creating config file: %w", err)
240 }
241
242 if err := toml.NewEncoder(f).Encode(c); err != nil {
243 _ = f.Close()
244
245 return fmt.Errorf("encoding config: %w", err)
246 }
247
248 if err := f.Close(); err != nil {
249 return fmt.Errorf("closing config file: %w", err)
250 }
251
252 return nil
253}
254
255// AreaByKey finds an area by its key.
256func (c *Config) AreaByKey(key string) *Area {
257 for i := range c.Areas {
258 if c.Areas[i].Key == key {
259 return &c.Areas[i]
260 }
261 }
262
263 return nil
264}
265
266// NotebookByKey finds a notebook by its key.
267func (c *Config) NotebookByKey(key string) *Notebook {
268 for i := range c.Notebooks {
269 if c.Notebooks[i].Key == key {
270 return &c.Notebooks[i]
271 }
272 }
273
274 return nil
275}
276
277// HabitByKey finds a habit by its key.
278func (c *Config) HabitByKey(key string) *Habit {
279 for i := range c.Habits {
280 if c.Habits[i].Key == key {
281 return &c.Habits[i]
282 }
283 }
284
285 return nil
286}
287
288// GoalByKey finds a goal within this area by its key.
289func (a *Area) GoalByKey(key string) *Goal {
290 for i := range a.Goals {
291 if a.Goals[i].Key == key {
292 return &a.Goals[i]
293 }
294 }
295
296 return nil
297}
298
299// GoalMatch pairs a goal with its parent area.
300type GoalMatch struct {
301 Goal *Goal
302 Area *Area
303}
304
305// FindGoalsByKey returns all goals matching the given key across all areas.
306func (c *Config) FindGoalsByKey(key string) []GoalMatch {
307 var matches []GoalMatch
308
309 for i := range c.Areas {
310 area := &c.Areas[i]
311
312 for j := range area.Goals {
313 if area.Goals[j].Key == key {
314 matches = append(matches, GoalMatch{
315 Goal: &area.Goals[j],
316 Area: area,
317 })
318 }
319 }
320 }
321
322 return matches
323}
324
325// AreaByID finds an area by its ID.
326func (c *Config) AreaByID(id string) *Area {
327 for i := range c.Areas {
328 if c.Areas[i].ID == id {
329 return &c.Areas[i]
330 }
331 }
332
333 return nil
334}
335
336// GoalByID finds a goal by its ID across all areas.
337func (c *Config) GoalByID(id string) *GoalMatch {
338 for i := range c.Areas {
339 area := &c.Areas[i]
340
341 for j := range area.Goals {
342 if area.Goals[j].ID == id {
343 return &GoalMatch{
344 Goal: &area.Goals[j],
345 Area: area,
346 }
347 }
348 }
349 }
350
351 return nil
352}
353
354// NotebookByID finds a notebook by its ID.
355func (c *Config) NotebookByID(id string) *Notebook {
356 for i := range c.Notebooks {
357 if c.Notebooks[i].ID == id {
358 return &c.Notebooks[i]
359 }
360 }
361
362 return nil
363}