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