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