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