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 TokenHash string `toml:"token_hash,omitempty"`
37 Tools ToolsConfig `toml:"tools"`
38}
39
40// ToolsConfig controls which MCP tools are enabled.
41// All tools default to enabled when not explicitly set.
42type ToolsConfig struct {
43 GetTimestamp bool `toml:"get_timestamp"`
44 AddTimelineNote bool `toml:"add_timeline_note"`
45 TrackHabit bool `toml:"track_habit"`
46
47 Create bool `toml:"create"` // task, note, person, journal
48 Update bool `toml:"update"` // task, note, person
49 Delete bool `toml:"delete"` // task, note, person
50 Query bool `toml:"query"` // all entities; default-disabled fallback for resources
51}
52
53// MCPDefaults applies default values to MCP config.
54func (c *MCPConfig) MCPDefaults() {
55 if c.Host == "" {
56 c.Host = "localhost"
57 }
58
59 if c.Port == 0 {
60 c.Port = 8080
61 }
62
63 if c.Timezone == "" {
64 c.Timezone = "UTC"
65 }
66
67 c.Tools.ApplyDefaults()
68}
69
70// ApplyDefaults enables all tools if none are explicitly configured.
71// Note: Query is default-disabled because it's a fallback for agents
72// that don't support MCP resources.
73func (t *ToolsConfig) ApplyDefaults() {
74 // If all are false (zero value), enable everything except Query
75 if !t.GetTimestamp && !t.AddTimelineNote && !t.TrackHabit &&
76 !t.Create && !t.Update && !t.Delete && !t.Query {
77 t.GetTimestamp = true
78 t.AddTimelineNote = true
79 t.TrackHabit = true
80 t.Create = true
81 t.Update = true
82 t.Delete = true
83 // Query: default-disabled (fallback for agents without resource support)
84 }
85}
86
87// UIConfig holds user interface preferences.
88type UIConfig struct {
89 Color string `toml:"color"` // "always", "never", "auto"
90}
91
92// Defaults holds default selections for commands.
93type Defaults struct {
94 Area string `toml:"area"`
95 Notebook string `toml:"notebook"`
96}
97
98// Area represents a Lunatask area of life with its goals.
99//
100//nolint:recvcheck // Value receivers for Keyed interface; pointer receiver for GoalByKey is intentional.
101type Area struct {
102 ID string `json:"id" toml:"id"`
103 Name string `json:"name" toml:"name"`
104 Key string `json:"key" toml:"key"`
105 Workflow lunatask.Workflow `json:"workflow" toml:"workflow"`
106 Goals []Goal `json:"goals" toml:"goals"`
107}
108
109// Goal represents a goal within an area.
110type Goal struct {
111 ID string `json:"id" toml:"id"`
112 Name string `json:"name" toml:"name"`
113 Key string `json:"key" toml:"key"`
114}
115
116// Notebook represents a Lunatask notebook for notes.
117type Notebook struct {
118 ID string `json:"id" toml:"id"`
119 Name string `json:"name" toml:"name"`
120 Key string `json:"key" toml:"key"`
121}
122
123// Habit represents a trackable habit.
124type Habit struct {
125 ID string `json:"id" toml:"id"`
126 Name string `json:"name" toml:"name"`
127 Key string `json:"key" toml:"key"`
128}
129
130// GetID returns the area ID.
131func (a Area) GetID() string { return a.ID }
132
133// GetName returns the area name.
134func (a Area) GetName() string { return a.Name }
135
136// GetKey returns the area key.
137func (a Area) GetKey() string { return a.Key }
138
139// GetID returns the goal ID.
140func (g Goal) GetID() string { return g.ID }
141
142// GetName returns the goal name.
143func (g Goal) GetName() string { return g.Name }
144
145// GetKey returns the goal key.
146func (g Goal) GetKey() string { return g.Key }
147
148// GetID returns the habit ID.
149func (h Habit) GetID() string { return h.ID }
150
151// GetName returns the habit name.
152func (h Habit) GetName() string { return h.Name }
153
154// GetKey returns the habit key.
155func (h Habit) GetKey() string { return h.Key }
156
157// GetID returns the notebook ID.
158func (n Notebook) GetID() string { return n.ID }
159
160// GetName returns the notebook name.
161func (n Notebook) GetName() string { return n.Name }
162
163// GetKey returns the notebook key.
164func (n Notebook) GetKey() string { return n.Key }
165
166// Path returns the path to the config file.
167func Path() (string, error) {
168 configDir, err := os.UserConfigDir()
169 if err != nil {
170 return "", fmt.Errorf("getting config dir: %w", err)
171 }
172
173 return filepath.Join(configDir, "lune", "config.toml"), nil
174}
175
176// Load reads the config file. Returns ErrNotFound if the file doesn't exist.
177func Load() (*Config, error) {
178 path, err := Path()
179 if err != nil {
180 return nil, err
181 }
182
183 var cfg Config
184 if _, err := toml.DecodeFile(path, &cfg); err != nil {
185 if errors.Is(err, os.ErrNotExist) {
186 return nil, ErrNotFound
187 }
188
189 return nil, fmt.Errorf("decoding config: %w", err)
190 }
191
192 return &cfg, nil
193}
194
195// Save writes the config to disk.
196func (c *Config) Save() error {
197 path, err := Path()
198 if err != nil {
199 return err
200 }
201
202 if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
203 return fmt.Errorf("creating config dir: %w", err)
204 }
205
206 //nolint:gosec,mnd // path is from user config dir; 0o600 is intentional
207 f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
208 if err != nil {
209 return fmt.Errorf("creating config file: %w", err)
210 }
211
212 if err := toml.NewEncoder(f).Encode(c); err != nil {
213 _ = f.Close()
214
215 return fmt.Errorf("encoding config: %w", err)
216 }
217
218 if err := f.Close(); err != nil {
219 return fmt.Errorf("closing config file: %w", err)
220 }
221
222 return nil
223}
224
225// AreaByKey finds an area by its key.
226func (c *Config) AreaByKey(key string) *Area {
227 for i := range c.Areas {
228 if c.Areas[i].Key == key {
229 return &c.Areas[i]
230 }
231 }
232
233 return nil
234}
235
236// NotebookByKey finds a notebook by its key.
237func (c *Config) NotebookByKey(key string) *Notebook {
238 for i := range c.Notebooks {
239 if c.Notebooks[i].Key == key {
240 return &c.Notebooks[i]
241 }
242 }
243
244 return nil
245}
246
247// HabitByKey finds a habit by its key.
248func (c *Config) HabitByKey(key string) *Habit {
249 for i := range c.Habits {
250 if c.Habits[i].Key == key {
251 return &c.Habits[i]
252 }
253 }
254
255 return nil
256}
257
258// GoalByKey finds a goal within this area by its key.
259func (a *Area) GoalByKey(key string) *Goal {
260 for i := range a.Goals {
261 if a.Goals[i].Key == key {
262 return &a.Goals[i]
263 }
264 }
265
266 return nil
267}
268
269// GoalMatch pairs a goal with its parent area.
270type GoalMatch struct {
271 Goal *Goal
272 Area *Area
273}
274
275// FindGoalsByKey returns all goals matching the given key across all areas.
276func (c *Config) FindGoalsByKey(key string) []GoalMatch {
277 var matches []GoalMatch
278
279 for i := range c.Areas {
280 area := &c.Areas[i]
281
282 for j := range area.Goals {
283 if area.Goals[j].Key == key {
284 matches = append(matches, GoalMatch{
285 Goal: &area.Goals[j],
286 Area: area,
287 })
288 }
289 }
290 }
291
292 return matches
293}
294
295// AreaByID finds an area by its ID.
296func (c *Config) AreaByID(id string) *Area {
297 for i := range c.Areas {
298 if c.Areas[i].ID == id {
299 return &c.Areas[i]
300 }
301 }
302
303 return nil
304}
305
306// GoalByID finds a goal by its ID across all areas.
307func (c *Config) GoalByID(id string) *GoalMatch {
308 for i := range c.Areas {
309 area := &c.Areas[i]
310
311 for j := range area.Goals {
312 if area.Goals[j].ID == id {
313 return &GoalMatch{
314 Goal: &area.Goals[j],
315 Area: area,
316 }
317 }
318 }
319 }
320
321 return nil
322}
323
324// NotebookByID finds a notebook by its ID.
325func (c *Config) NotebookByID(id string) *Notebook {
326 for i := range c.Notebooks {
327 if c.Notebooks[i].ID == id {
328 return &c.Notebooks[i]
329 }
330 }
331
332 return nil
333}