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