config.go

  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}