Skip to content

Commit 88da351

Browse files
authored
Add unified persistent preferences (#11)
1 parent a20f166 commit 88da351

15 files changed

Lines changed: 1000 additions & 4 deletions

File tree

README.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ bible books
3939
bible search "living water"
4040
bible random
4141
bible translations
42+
bible config show
4243
```
4344

4445
`bible books` lists all canonical names, source codes, and accepted aliases.
@@ -85,6 +86,31 @@ bible translations
8586
`bible translations` reports the bundled text edition, language, canon, source,
8687
public-domain rights notice, trademark notice, and publisher text policy.
8788

89+
## Configuration
90+
91+
Bible Terminal uses the same configuration convention on macOS and Linux. The
92+
path is resolved in this order:
93+
94+
1. `$BIBLE_TERMINAL_CONFIG_HOME/config.json`
95+
2. `$XDG_CONFIG_HOME/bible-terminal/config.json`
96+
3. `~/.config/bible-terminal/config.json`
97+
98+
The first two environment variables must contain absolute paths. Inspect and
99+
change preferences with the CLI instead of editing JSON directly:
100+
101+
```console
102+
bible config path
103+
bible config show
104+
bible config set plain true
105+
bible config set color false
106+
bible config set translation webp
107+
bible config reset
108+
```
109+
110+
Saved preferences provide defaults. Explicit command-line flags take priority,
111+
including `--plain=false` and `--no-color=false`. Redirected output remains
112+
plain even when the saved plain preference is false.
113+
88114
The first release should:
89115

90116
- work completely offline;

cmd/bible/main.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,22 @@ import (
77

88
"github.com/vmrocha/bible-terminal/internal/buildinfo"
99
"github.com/vmrocha/bible-terminal/internal/cli"
10+
"github.com/vmrocha/bible-terminal/internal/config"
1011
"github.com/vmrocha/bible-terminal/internal/storage"
1112
)
1213

1314
func main() {
15+
configurationPath, err := config.DefaultPath()
16+
if err != nil {
17+
fmt.Fprintln(os.Stderr, err)
18+
os.Exit(1)
19+
}
20+
preferenceStore, err := config.NewStore(configurationPath)
21+
if err != nil {
22+
fmt.Fprintln(os.Stderr, err)
23+
os.Exit(1)
24+
}
25+
1426
command := cli.New(
1527
buildinfo.Current(),
1628
cli.WithReaderFactory(func(ctx context.Context) (cli.PassageReader, error) {
@@ -25,6 +37,7 @@ func main() {
2537
cli.WithRandomReaderFactory(func(ctx context.Context) (cli.RandomReader, error) {
2638
return storage.OpenEmbedded(ctx)
2739
}),
40+
cli.WithPreferenceStore(preferenceStore),
2841
)
2942
if err := command.Execute(); err != nil {
3043
fmt.Fprintln(os.Stderr, err)

docs/PLAN.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -159,11 +159,13 @@ returns results within an agreed performance budget.
159159

160160
Progress: tagged release automation builds checksummed macOS and Linux archives
161161
for AMD64 and ARM64, installation instructions are documented, and the CLI
162-
generates Bash, Zsh, Fish, and PowerShell completion scripts. Platform-specific
163-
configuration paths and persistent display preferences remain.
162+
generates Bash, Zsh, Fish, and PowerShell completion scripts. macOS and Linux
163+
share an XDG-compatible configuration path, and versioned persistent
164+
preferences remember translation and display defaults. Release validation is
165+
the remaining work.
164166

165-
- Add platform-appropriate configuration paths.
166-
- Remember the preferred translation and optional display preferences.
167+
- Add a consistent, documented configuration path. (complete)
168+
- Remember the preferred translation and optional display preferences. (complete)
167169
- Produce checksummed release binaries.
168170
- Add installation documentation and shell completion.
169171

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# ADR 0002: Use one XDG-compatible configuration path
2+
3+
- Status: accepted
4+
- Date: 2026-07-16
5+
6+
## Context
7+
8+
Operating-system-native conventions would normally place application
9+
configuration under `~/Library/Application Support` on macOS and under
10+
`$XDG_CONFIG_HOME` on Linux. That difference makes shell scripts, dotfile
11+
management, documentation, and switching between macOS and Linux less
12+
predictable for a terminal-first application.
13+
14+
Bible Terminal also needs a stable format for persistent defaults without
15+
allowing saved values to silently defeat explicit command-line arguments.
16+
17+
## Decision
18+
19+
Use the same resolution order on macOS and Linux:
20+
21+
1. `$BIBLE_TERMINAL_CONFIG_HOME/config.json`
22+
2. `$XDG_CONFIG_HOME/bible-terminal/config.json`
23+
3. `~/.config/bible-terminal/config.json`
24+
25+
Environment-provided directories must be absolute. The JSON document has an
26+
explicit schema version and is decoded strictly. Writes use a temporary file,
27+
owner-only permissions, and an atomic rename.
28+
29+
Saved preferences are defaults. Explicit flags take precedence, including
30+
boolean negation such as `--plain=false` and `--no-color=false`. Commands for
31+
finding and resetting the configuration remain available when the stored file
32+
cannot be decoded.
33+
34+
## Consequences
35+
36+
- macOS and Linux users see identical paths and can share setup instructions.
37+
- The default differs from the macOS graphical-application convention, which is
38+
acceptable for a terminal-focused program.
39+
- `BIBLE_TERMINAL_CONFIG_HOME` gives tests, portable installations, and users a
40+
direct application-specific override.
41+
- Future schema changes must preserve version compatibility or provide a clear
42+
migration and error message.

internal/cli/config.go

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
package cli
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"io"
7+
"strings"
8+
9+
"github.com/spf13/cobra"
10+
"github.com/vmrocha/bible-terminal/internal/config"
11+
"github.com/vmrocha/bible-terminal/internal/render"
12+
)
13+
14+
// PreferenceStore persists CLI defaults.
15+
type PreferenceStore interface {
16+
Path() string
17+
Load() (config.Preferences, error)
18+
Save(config.Preferences) error
19+
Reset() error
20+
}
21+
22+
// WithPreferenceStore enables persistent CLI preferences.
23+
func WithPreferenceStore(store PreferenceStore) Option {
24+
return func(configuration *configuration) {
25+
configuration.preferenceStore = store
26+
}
27+
}
28+
29+
func newConfigCommand(
30+
store PreferenceStore,
31+
settings *outputSettings,
32+
isTerminal func(io.Writer) bool,
33+
) *cobra.Command {
34+
command := &cobra.Command{
35+
Use: "config",
36+
Short: "Inspect and update persistent preferences",
37+
}
38+
command.AddCommand(newConfigPathCommand(store))
39+
command.AddCommand(newConfigShowCommand(store, settings, isTerminal))
40+
command.AddCommand(newConfigSetCommand(store))
41+
command.AddCommand(newConfigResetCommand(store))
42+
return command
43+
}
44+
45+
func newConfigPathCommand(store PreferenceStore) *cobra.Command {
46+
return &cobra.Command{
47+
Use: "path",
48+
Short: "Print the effective configuration path",
49+
Args: cobra.NoArgs,
50+
RunE: func(command *cobra.Command, _ []string) error {
51+
if store == nil {
52+
return errors.New("configuration is unavailable")
53+
}
54+
_, err := fmt.Fprintln(command.OutOrStdout(), store.Path())
55+
return err
56+
},
57+
}
58+
}
59+
60+
func newConfigShowCommand(
61+
store PreferenceStore,
62+
settings *outputSettings,
63+
isTerminal func(io.Writer) bool,
64+
) *cobra.Command {
65+
return &cobra.Command{
66+
Use: "show",
67+
Short: "Show the effective persistent preferences",
68+
Args: cobra.NoArgs,
69+
RunE: func(command *cobra.Command, _ []string) error {
70+
if store == nil {
71+
return errors.New("configuration is unavailable")
72+
}
73+
preferences, err := store.Load()
74+
if err != nil {
75+
return err
76+
}
77+
return render.Preferences(
78+
command.OutOrStdout(),
79+
store.Path(),
80+
preferences,
81+
renderOptions(command, settings, isTerminal),
82+
)
83+
},
84+
}
85+
}
86+
87+
func newConfigSetCommand(store PreferenceStore) *cobra.Command {
88+
return &cobra.Command{
89+
Use: "set <preference> <value>",
90+
Short: "Save a persistent preference",
91+
Args: cobra.ExactArgs(2),
92+
ValidArgs: []string{"translation", "plain", "color"},
93+
RunE: func(command *cobra.Command, args []string) error {
94+
if store == nil {
95+
return errors.New("configuration is unavailable")
96+
}
97+
preferences, err := store.Load()
98+
if err != nil {
99+
return err
100+
}
101+
key := strings.ToLower(args[0])
102+
value, err := updatePreference(&preferences, key, args[1])
103+
if err != nil {
104+
return err
105+
}
106+
if err := store.Save(preferences); err != nil {
107+
return err
108+
}
109+
_, err = fmt.Fprintf(command.OutOrStdout(), "saved %s=%s\n", key, value)
110+
return err
111+
},
112+
}
113+
}
114+
115+
func newConfigResetCommand(store PreferenceStore) *cobra.Command {
116+
return &cobra.Command{
117+
Use: "reset",
118+
Short: "Remove saved preferences",
119+
Args: cobra.NoArgs,
120+
RunE: func(command *cobra.Command, _ []string) error {
121+
if store == nil {
122+
return errors.New("configuration is unavailable")
123+
}
124+
if err := store.Reset(); err != nil {
125+
return err
126+
}
127+
_, err := fmt.Fprintln(command.OutOrStdout(), "configuration reset")
128+
return err
129+
},
130+
}
131+
}
132+
133+
func updatePreference(preferences *config.Preferences, key, rawValue string) (string, error) {
134+
switch key {
135+
case "translation":
136+
value := strings.ToLower(rawValue)
137+
if value == "webp" {
138+
value = "engwebp"
139+
}
140+
if value != "engwebp" {
141+
return "", fmt.Errorf("translation is not available: %s", rawValue)
142+
}
143+
preferences.Translation = value
144+
return value, nil
145+
case "plain":
146+
value, err := strictBoolean(rawValue)
147+
if err != nil {
148+
return "", fmt.Errorf("plain: %w", err)
149+
}
150+
preferences.Plain = value
151+
return fmt.Sprint(value), nil
152+
case "color":
153+
value, err := strictBoolean(rawValue)
154+
if err != nil {
155+
return "", fmt.Errorf("color: %w", err)
156+
}
157+
preferences.Color = value
158+
return fmt.Sprint(value), nil
159+
default:
160+
return "", fmt.Errorf("unknown preference %q; expected translation, plain, or color", key)
161+
}
162+
}
163+
164+
func strictBoolean(value string) (bool, error) {
165+
switch strings.ToLower(value) {
166+
case "true":
167+
return true, nil
168+
case "false":
169+
return false, nil
170+
default:
171+
return false, fmt.Errorf("expected true or false, got %q", value)
172+
}
173+
}

0 commit comments

Comments
 (0)