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