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