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 lunatask-mcp-server configuration.
  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 lunatask-mcp-server configuration file structure.
 21type Config struct {
 22	Server   ServerConfig `toml:"server"`
 23	Tools    ToolsConfig  `toml:"tools"`
 24	Timezone string       `toml:"timezone"`
 25	Areas    []Area       `toml:"areas"`
 26	Habits   []Habit      `toml:"habits"`
 27}
 28
 29// ServerConfig holds server-related settings.
 30type ServerConfig struct {
 31	Host      string `toml:"host"`
 32	Port      int    `toml:"port"`
 33	Transport string `toml:"transport"`
 34}
 35
 36// ToolsConfig controls which MCP tools are enabled.
 37type ToolsConfig struct {
 38	GetTimestamp       bool `toml:"get_timestamp"`
 39	CreateTask         bool `toml:"create_task"`
 40	UpdateTask         bool `toml:"update_task"`
 41	DeleteTask         bool `toml:"delete_task"`
 42	TrackHabitActivity bool `toml:"track_habit_activity"`
 43}
 44
 45// Area represents a Lunatask area of life with its goals.
 46type Area struct {
 47	ID    string `json:"id"    toml:"id"`
 48	Name  string `json:"name"  toml:"name"`
 49	Key   string `json:"key"   toml:"key"`
 50	Goals []Goal `json:"goals" toml:"goals"`
 51}
 52
 53// Goal represents a goal within an area.
 54type Goal struct {
 55	ID   string `json:"id"   toml:"id"`
 56	Name string `json:"name" toml:"name"`
 57	Key  string `json:"key"  toml:"key"`
 58}
 59
 60// Habit represents a trackable habit.
 61type Habit struct {
 62	ID   string `json:"id"   toml:"id"`
 63	Name string `json:"name" toml:"name"`
 64	Key  string `json:"key"  toml:"key"`
 65}
 66
 67// Defaults holds default selections.
 68type Defaults struct {
 69	Area string `toml:"area"`
 70}
 71
 72// Path returns the path to the config file.
 73func Path() (string, error) {
 74	configDir, err := os.UserConfigDir()
 75	if err != nil {
 76		return "", fmt.Errorf("getting config dir: %w", err)
 77	}
 78
 79	return filepath.Join(configDir, "lunatask-mcp-server", "config.toml"), nil
 80}
 81
 82// Load reads the config file. Returns ErrNotFound if the file doesn't exist.
 83func Load() (*Config, error) {
 84	path, err := Path()
 85	if err != nil {
 86		return nil, err
 87	}
 88
 89	return LoadFrom(path)
 90}
 91
 92// LoadFrom reads the config from the specified path.
 93func LoadFrom(path string) (*Config, error) {
 94	var cfg Config
 95	if _, err := toml.DecodeFile(path, &cfg); err != nil {
 96		if errors.Is(err, os.ErrNotExist) {
 97			return nil, ErrNotFound
 98		}
 99
100		return nil, fmt.Errorf("decoding config: %w", err)
101	}
102
103	cfg.applyDefaults()
104
105	return &cfg, nil
106}
107
108// Save writes the config to the default path.
109func (c *Config) Save() error {
110	path, err := Path()
111	if err != nil {
112		return err
113	}
114
115	return c.SaveTo(path)
116}
117
118// SaveTo writes the config to the specified path.
119func (c *Config) SaveTo(path string) error {
120	if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
121		return fmt.Errorf("creating config dir: %w", err)
122	}
123
124	//nolint:gosec,mnd // path is from user config dir; 0o600 is intentional
125	f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
126	if err != nil {
127		return fmt.Errorf("creating config file: %w", err)
128	}
129
130	if err := toml.NewEncoder(f).Encode(c); err != nil {
131		_ = f.Close()
132
133		return fmt.Errorf("encoding config: %w", err)
134	}
135
136	if err := f.Close(); err != nil {
137		return fmt.Errorf("closing config file: %w", err)
138	}
139
140	return nil
141}
142
143// AreaByKey finds an area by its key.
144func (c *Config) AreaByKey(key string) *Area {
145	for i := range c.Areas {
146		if c.Areas[i].Key == key {
147			return &c.Areas[i]
148		}
149	}
150
151	return nil
152}
153
154// AreaByID finds an area by its ID.
155func (c *Config) AreaByID(id string) *Area {
156	for i := range c.Areas {
157		if c.Areas[i].ID == id {
158			return &c.Areas[i]
159		}
160	}
161
162	return nil
163}
164
165// HabitByKey finds a habit by its key.
166func (c *Config) HabitByKey(key string) *Habit {
167	for i := range c.Habits {
168		if c.Habits[i].Key == key {
169			return &c.Habits[i]
170		}
171	}
172
173	return nil
174}
175
176// HabitByID finds a habit by its ID.
177func (c *Config) HabitByID(id string) *Habit {
178	for i := range c.Habits {
179		if c.Habits[i].ID == id {
180			return &c.Habits[i]
181		}
182	}
183
184	return nil
185}
186
187// GoalByKey finds a goal within this area by its key.
188func (a *Area) GoalByKey(key string) *Goal {
189	for i := range a.Goals {
190		if a.Goals[i].Key == key {
191			return &a.Goals[i]
192		}
193	}
194
195	return nil
196}
197
198// GoalByID finds a goal within this area by its ID.
199func (a *Area) GoalByID(id string) *Goal {
200	for i := range a.Goals {
201		if a.Goals[i].ID == id {
202			return &a.Goals[i]
203		}
204	}
205
206	return nil
207}
208
209// GoalMatch pairs a goal with its parent area.
210type GoalMatch struct {
211	Goal *Goal
212	Area *Area
213}
214
215// FindGoalsByKey returns all goals matching the given key across all areas.
216func (c *Config) FindGoalsByKey(key string) []GoalMatch {
217	var matches []GoalMatch
218
219	for i := range c.Areas {
220		area := &c.Areas[i]
221
222		for j := range area.Goals {
223			if area.Goals[j].Key == key {
224				matches = append(matches, GoalMatch{
225					Goal: &area.Goals[j],
226					Area: area,
227				})
228			}
229		}
230	}
231
232	return matches
233}
234
235// GoalByID finds a goal by its ID across all areas.
236func (c *Config) GoalByID(id string) *GoalMatch {
237	for i := range c.Areas {
238		area := &c.Areas[i]
239
240		for j := range area.Goals {
241			if area.Goals[j].ID == id {
242				return &GoalMatch{
243					Goal: &area.Goals[j],
244					Area: area,
245				}
246			}
247		}
248	}
249
250	return nil
251}
252
253// applyDefaults sets default values for unset fields.
254func (c *Config) applyDefaults() {
255	if c.Server.Host == "" {
256		c.Server.Host = "localhost"
257	}
258
259	if c.Server.Port == 0 {
260		c.Server.Port = 8080
261	}
262
263	if c.Server.Transport == "" {
264		c.Server.Transport = "stdio"
265	}
266
267	if c.Timezone == "" {
268		c.Timezone = "UTC"
269	}
270
271	c.Tools.ApplyDefaults()
272}
273
274// ApplyDefaults enables all tools by default.
275func (t *ToolsConfig) ApplyDefaults() {
276	// Use a marker to detect if tools section was present in config.
277	// If all are false, apply defaults (all true).
278	if !t.GetTimestamp && !t.CreateTask && !t.UpdateTask && !t.DeleteTask && !t.TrackHabitActivity {
279		t.GetTimestamp = true
280		t.CreateTask = true
281		t.UpdateTask = true
282		t.DeleteTask = true
283		t.TrackHabitActivity = true
284	}
285}