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	"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}