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// Note: "show" tools and config-based "list" tools are default-disabled
88// because they are fallbacks for agents that don't support MCP resources.
89//
90//nolint:cyclop // Complexity from repetitive boolean checks; structurally simple.
91func (t *ToolsConfig) ApplyDefaults() {
92 // If all are false (zero value), enable everything except resource fallbacks
93 if !t.GetTimestamp && !t.CreateTask && !t.UpdateTask && !t.DeleteTask &&
94 !t.ListTasks && !t.ShowTask && !t.CreateNote && !t.UpdateNote &&
95 !t.DeleteNote && !t.ListNotes && !t.ShowNote && !t.CreatePerson &&
96 !t.UpdatePerson && !t.DeletePerson && !t.ListPeople && !t.ShowPerson &&
97 !t.PersonTimeline && !t.TrackHabit && !t.ListHabits && !t.CreateJournal {
98 t.GetTimestamp = true
99 t.CreateTask = true
100 t.UpdateTask = true
101 t.DeleteTask = true
102 // ListTasks: default-disabled (fallback for lunatask://tasks/* resources)
103 // ShowTask: default-disabled (fallback for lunatask://task/{id} resource)
104 t.CreateNote = true
105 t.UpdateNote = true
106 t.DeleteNote = true
107 t.ListNotes = true
108 // ShowNote: default-disabled (fallback for lunatask://note/{id} resource)
109 t.CreatePerson = true
110 t.UpdatePerson = true
111 t.DeletePerson = true
112 t.ListPeople = true
113 // ShowPerson: default-disabled (fallback for lunatask://person/{id} resource)
114 t.PersonTimeline = true
115 t.TrackHabit = true
116 // ListHabits: default-disabled (fallback for lunatask://habits resource)
117 t.CreateJournal = true
118 }
119}
120
121// UIConfig holds user interface preferences.
122type UIConfig struct {
123 Color string `toml:"color"` // "always", "never", "auto"
124}
125
126// Defaults holds default selections for commands.
127type Defaults struct {
128 Area string `toml:"area"`
129 Notebook string `toml:"notebook"`
130}
131
132// Area represents a Lunatask area of life with its goals.
133//
134//nolint:recvcheck // Value receivers for Keyed interface; pointer receiver for GoalByKey is intentional.
135type Area struct {
136 ID string `json:"id" toml:"id"`
137 Name string `json:"name" toml:"name"`
138 Key string `json:"key" toml:"key"`
139 Workflow lunatask.Workflow `json:"workflow" toml:"workflow"`
140 Goals []Goal `json:"goals" toml:"goals"`
141}
142
143// Goal represents a goal within an area.
144type Goal struct {
145 ID string `json:"id" toml:"id"`
146 Name string `json:"name" toml:"name"`
147 Key string `json:"key" toml:"key"`
148}
149
150// Notebook represents a Lunatask notebook for notes.
151type Notebook struct {
152 ID string `json:"id" toml:"id"`
153 Name string `json:"name" toml:"name"`
154 Key string `json:"key" toml:"key"`
155}
156
157// Habit represents a trackable habit.
158type Habit struct {
159 ID string `json:"id" toml:"id"`
160 Name string `json:"name" toml:"name"`
161 Key string `json:"key" toml:"key"`
162}
163
164// GetID returns the area ID.
165func (a Area) GetID() string { return a.ID }
166
167// GetName returns the area name.
168func (a Area) GetName() string { return a.Name }
169
170// GetKey returns the area key.
171func (a Area) GetKey() string { return a.Key }
172
173// GetID returns the goal ID.
174func (g Goal) GetID() string { return g.ID }
175
176// GetName returns the goal name.
177func (g Goal) GetName() string { return g.Name }
178
179// GetKey returns the goal key.
180func (g Goal) GetKey() string { return g.Key }
181
182// GetID returns the habit ID.
183func (h Habit) GetID() string { return h.ID }
184
185// GetName returns the habit name.
186func (h Habit) GetName() string { return h.Name }
187
188// GetKey returns the habit key.
189func (h Habit) GetKey() string { return h.Key }
190
191// GetID returns the notebook ID.
192func (n Notebook) GetID() string { return n.ID }
193
194// GetName returns the notebook name.
195func (n Notebook) GetName() string { return n.Name }
196
197// GetKey returns the notebook key.
198func (n Notebook) GetKey() string { return n.Key }
199
200// Path returns the path to the config file.
201func Path() (string, error) {
202 configDir, err := os.UserConfigDir()
203 if err != nil {
204 return "", fmt.Errorf("getting config dir: %w", err)
205 }
206
207 return filepath.Join(configDir, "lune", "config.toml"), nil
208}
209
210// Load reads the config file. Returns ErrNotFound if the file doesn't exist.
211func Load() (*Config, error) {
212 path, err := Path()
213 if err != nil {
214 return nil, err
215 }
216
217 var cfg Config
218 if _, err := toml.DecodeFile(path, &cfg); err != nil {
219 if errors.Is(err, os.ErrNotExist) {
220 return nil, ErrNotFound
221 }
222
223 return nil, fmt.Errorf("decoding config: %w", err)
224 }
225
226 return &cfg, nil
227}
228
229// Save writes the config to disk.
230func (c *Config) Save() error {
231 path, err := Path()
232 if err != nil {
233 return err
234 }
235
236 if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
237 return fmt.Errorf("creating config dir: %w", err)
238 }
239
240 //nolint:gosec,mnd // path is from user config dir; 0o600 is intentional
241 f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
242 if err != nil {
243 return fmt.Errorf("creating config file: %w", err)
244 }
245
246 if err := toml.NewEncoder(f).Encode(c); err != nil {
247 _ = f.Close()
248
249 return fmt.Errorf("encoding config: %w", err)
250 }
251
252 if err := f.Close(); err != nil {
253 return fmt.Errorf("closing config file: %w", err)
254 }
255
256 return nil
257}
258
259// AreaByKey finds an area by its key.
260func (c *Config) AreaByKey(key string) *Area {
261 for i := range c.Areas {
262 if c.Areas[i].Key == key {
263 return &c.Areas[i]
264 }
265 }
266
267 return nil
268}
269
270// NotebookByKey finds a notebook by its key.
271func (c *Config) NotebookByKey(key string) *Notebook {
272 for i := range c.Notebooks {
273 if c.Notebooks[i].Key == key {
274 return &c.Notebooks[i]
275 }
276 }
277
278 return nil
279}
280
281// HabitByKey finds a habit by its key.
282func (c *Config) HabitByKey(key string) *Habit {
283 for i := range c.Habits {
284 if c.Habits[i].Key == key {
285 return &c.Habits[i]
286 }
287 }
288
289 return nil
290}
291
292// GoalByKey finds a goal within this area by its key.
293func (a *Area) GoalByKey(key string) *Goal {
294 for i := range a.Goals {
295 if a.Goals[i].Key == key {
296 return &a.Goals[i]
297 }
298 }
299
300 return nil
301}
302
303// GoalMatch pairs a goal with its parent area.
304type GoalMatch struct {
305 Goal *Goal
306 Area *Area
307}
308
309// FindGoalsByKey returns all goals matching the given key across all areas.
310func (c *Config) FindGoalsByKey(key string) []GoalMatch {
311 var matches []GoalMatch
312
313 for i := range c.Areas {
314 area := &c.Areas[i]
315
316 for j := range area.Goals {
317 if area.Goals[j].Key == key {
318 matches = append(matches, GoalMatch{
319 Goal: &area.Goals[j],
320 Area: area,
321 })
322 }
323 }
324 }
325
326 return matches
327}
328
329// AreaByID finds an area by its ID.
330func (c *Config) AreaByID(id string) *Area {
331 for i := range c.Areas {
332 if c.Areas[i].ID == id {
333 return &c.Areas[i]
334 }
335 }
336
337 return nil
338}
339
340// GoalByID finds a goal by its ID across all areas.
341func (c *Config) GoalByID(id string) *GoalMatch {
342 for i := range c.Areas {
343 area := &c.Areas[i]
344
345 for j := range area.Goals {
346 if area.Goals[j].ID == id {
347 return &GoalMatch{
348 Goal: &area.Goals[j],
349 Area: area,
350 }
351 }
352 }
353 }
354
355 return nil
356}
357
358// NotebookByID finds a notebook by its ID.
359func (c *Config) NotebookByID(id string) *Notebook {
360 for i := range c.Notebooks {
361 if c.Notebooks[i].ID == id {
362 return &c.Notebooks[i]
363 }
364 }
365
366 return nil
367}