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