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}