Skip to content

Commit ed22a3a

Browse files
authored
Merge pull request #2 from nsumbadze/install-search
Install search
2 parents 4cdedc2 + ea1be07 commit ed22a3a

15 files changed

Lines changed: 1349 additions & 144 deletions

internal/config/paths.go

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
package config
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"os"
7+
"path/filepath"
8+
)
9+
10+
const userOnlyDirPerm os.FileMode = 0700
11+
12+
var (
13+
ErrHomeDirRequired = errors.New("config: home directory required")
14+
ErrConfigPathNotRegularFile = errors.New("config: config path is not a regular file")
15+
)
16+
17+
type FilePath struct {
18+
value string
19+
}
20+
21+
func (p FilePath) String() string {
22+
return p.value
23+
}
24+
25+
type DirPath struct {
26+
value string
27+
}
28+
29+
func (p DirPath) String() string {
30+
return p.value
31+
}
32+
33+
type ConfigSource string
34+
35+
const (
36+
ConfigSourceCLIOverride ConfigSource = "cli-override"
37+
ConfigSourceProject ConfigSource = "project"
38+
ConfigSourceXDG ConfigSource = "xdg"
39+
ConfigSourceLegacyMigration ConfigSource = "legacy-migration"
40+
ConfigSourceDefaults ConfigSource = "defaults"
41+
ConfigSourceNone ConfigSource = ConfigSourceDefaults
42+
)
43+
44+
type PathOptions struct {
45+
Env map[string]string
46+
}
47+
48+
type PathSet struct {
49+
WriteConfigFile FilePath
50+
StateDir DirPath
51+
CacheDir DirPath
52+
LegacyMigrationConfigFile FilePath
53+
}
54+
55+
type ConfigReadPath struct {
56+
Source ConfigSource
57+
Path FilePath
58+
}
59+
60+
type ConfigReadOptions struct {
61+
CLIOverridePath string
62+
ProjectConfigPath string
63+
}
64+
65+
func DiscoverPaths(options PathOptions) (PathSet, error) {
66+
home, err := options.homeDir()
67+
if err != nil {
68+
return PathSet{}, err
69+
}
70+
71+
configRoot := options.xdgRoot("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
72+
stateRoot := options.xdgRoot("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
73+
cacheRoot := options.xdgRoot("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
74+
75+
return PathSet{
76+
WriteConfigFile: newFilePath(filepath.Join(configRoot, "lazynpm", "config.yaml")),
77+
StateDir: newDirPath(filepath.Join(stateRoot, "lazynpm")),
78+
CacheDir: newDirPath(filepath.Join(cacheRoot, "lazynpm")),
79+
LegacyMigrationConfigFile: newFilePath(filepath.Join(home, ".config", "jesseduffield", "lazynpm", "config.yml")),
80+
}, nil
81+
}
82+
83+
func (p PathSet) SelectConfigReadPath(options ConfigReadOptions) (ConfigReadPath, error) {
84+
candidates := []ConfigReadPath{
85+
{Source: ConfigSourceCLIOverride, Path: newFilePath(options.CLIOverridePath)},
86+
{Source: ConfigSourceProject, Path: newFilePath(options.ProjectConfigPath)},
87+
}
88+
for _, candidate := range candidates {
89+
if candidate.Path.String() == "" {
90+
continue
91+
}
92+
exists, err := fileExists(candidate.Path)
93+
if err != nil {
94+
return ConfigReadPath{}, err
95+
}
96+
if exists {
97+
return candidate, nil
98+
}
99+
}
100+
101+
return p.selectUserConfigReadPath()
102+
}
103+
104+
func (p PathSet) selectUserConfigReadPath() (ConfigReadPath, error) {
105+
if exists, err := fileExists(p.WriteConfigFile); err != nil {
106+
return ConfigReadPath{}, err
107+
} else if exists {
108+
return ConfigReadPath{Source: ConfigSourceXDG, Path: p.WriteConfigFile}, nil
109+
}
110+
111+
if exists, err := fileExists(p.LegacyMigrationConfigFile); err != nil {
112+
return ConfigReadPath{}, err
113+
} else if exists {
114+
return ConfigReadPath{Source: ConfigSourceLegacyMigration, Path: p.LegacyMigrationConfigFile}, nil
115+
}
116+
117+
return ConfigReadPath{Source: ConfigSourceDefaults}, nil
118+
}
119+
120+
func (p PathSet) EnsureDirectories() error {
121+
dirs := []DirPath{
122+
newDirPath(filepath.Dir(p.WriteConfigFile.String())),
123+
p.StateDir,
124+
p.CacheDir,
125+
}
126+
for _, dir := range dirs {
127+
if err := os.MkdirAll(dir.String(), userOnlyDirPerm); err != nil {
128+
return fmt.Errorf("create config directory %s: %w", dir.String(), err)
129+
}
130+
}
131+
return nil
132+
}
133+
134+
func (options PathOptions) homeDir() (string, error) {
135+
if value, ok := options.lookupEnv("HOME"); ok && value != "" {
136+
return value, nil
137+
}
138+
if options.Env != nil {
139+
return "", ErrHomeDirRequired
140+
}
141+
home, err := os.UserHomeDir()
142+
if err != nil {
143+
return "", fmt.Errorf("discover home directory: %w", err)
144+
}
145+
if home == "" {
146+
return "", ErrHomeDirRequired
147+
}
148+
return home, nil
149+
}
150+
151+
func (options PathOptions) xdgRoot(name string, fallback string) string {
152+
if value, ok := options.lookupEnv(name); ok && value != "" {
153+
return value
154+
}
155+
return fallback
156+
}
157+
158+
func (options PathOptions) lookupEnv(name string) (string, bool) {
159+
if options.Env != nil {
160+
value, ok := options.Env[name]
161+
return value, ok
162+
}
163+
return os.LookupEnv(name)
164+
}
165+
166+
func fileExists(path FilePath) (bool, error) {
167+
info, err := os.Stat(path.String())
168+
if err == nil {
169+
if !info.Mode().IsRegular() {
170+
return false, fmt.Errorf("%w: %s", ErrConfigPathNotRegularFile, path.String())
171+
}
172+
return true, nil
173+
}
174+
if errors.Is(err, os.ErrNotExist) {
175+
return false, nil
176+
}
177+
return false, fmt.Errorf("inspect config file %s: %w", path.String(), err)
178+
}
179+
180+
func newFilePath(value string) FilePath {
181+
return FilePath{value: value}
182+
}
183+
184+
func newDirPath(value string) DirPath {
185+
return DirPath{value: value}
186+
}

internal/config/paths_test.go

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
package config
2+
3+
import (
4+
"errors"
5+
"os"
6+
"path/filepath"
7+
"runtime"
8+
"testing"
9+
10+
"github.com/stretchr/testify/assert"
11+
)
12+
13+
func TestDiscoverPaths_usesXDGDefaults_whenOnlyHomeProvided(t *testing.T) {
14+
// Given: an isolated HOME with no XDG override variables.
15+
home := t.TempDir()
16+
17+
// When: config paths are discovered from the injected environment.
18+
got, err := DiscoverPaths(PathOptions{Env: map[string]string{"HOME": home}})
19+
20+
// Then: lazynpm uses XDG defaults plus the legacy read-only migration input.
21+
assert.NoError(t, err)
22+
assert.Equal(t, filepath.Join(home, ".config", "lazynpm", "config.yaml"), got.WriteConfigFile.String())
23+
assert.Equal(t, filepath.Join(home, ".local", "state", "lazynpm"), got.StateDir.String())
24+
assert.Equal(t, filepath.Join(home, ".cache", "lazynpm"), got.CacheDir.String())
25+
readPath, err := got.SelectConfigReadPath(ConfigReadOptions{})
26+
assert.NoError(t, err)
27+
assert.Equal(t, ConfigSourceNone, readPath.Source)
28+
assert.Equal(t, filepath.Join(home, ".config", "jesseduffield", "lazynpm", "config.yml"), got.LegacyMigrationConfigFile.String())
29+
}
30+
31+
func TestDiscoverPaths_usesProcessEnvironment_whenXDGVariablesAreUnset(t *testing.T) {
32+
// Given: an isolated process environment with no XDG override variables.
33+
home := t.TempDir()
34+
t.Setenv("HOME", home)
35+
t.Setenv("XDG_CONFIG_HOME", "")
36+
t.Setenv("XDG_STATE_HOME", "")
37+
t.Setenv("XDG_CACHE_HOME", "")
38+
39+
// When: config paths are discovered from the process environment.
40+
got, err := DiscoverPaths(PathOptions{})
41+
42+
// Then: discovery never consults the real user's config locations.
43+
assert.NoError(t, err)
44+
assert.Equal(t, filepath.Join(home, ".config", "lazynpm", "config.yaml"), got.WriteConfigFile.String())
45+
assert.Equal(t, filepath.Join(home, ".local", "state", "lazynpm"), got.StateDir.String())
46+
assert.Equal(t, filepath.Join(home, ".cache", "lazynpm"), got.CacheDir.String())
47+
}
48+
49+
func TestDiscoverPaths_usesExplicitXDGEnv_whenProvided(t *testing.T) {
50+
// Given: isolated XDG roots and HOME for the legacy migration path.
51+
home := t.TempDir()
52+
configRoot := filepath.Join(t.TempDir(), "config-root")
53+
stateRoot := filepath.Join(t.TempDir(), "state-root")
54+
cacheRoot := filepath.Join(t.TempDir(), "cache-root")
55+
56+
// When: config paths are discovered from explicit XDG variables.
57+
got, err := DiscoverPaths(PathOptions{Env: map[string]string{
58+
"HOME": home,
59+
"XDG_CONFIG_HOME": configRoot,
60+
"XDG_STATE_HOME": stateRoot,
61+
"XDG_CACHE_HOME": cacheRoot,
62+
}})
63+
64+
// Then: the XDG roots override HOME-derived defaults without changing legacy input.
65+
assert.NoError(t, err)
66+
assert.Equal(t, filepath.Join(configRoot, "lazynpm", "config.yaml"), got.WriteConfigFile.String())
67+
assert.Equal(t, filepath.Join(stateRoot, "lazynpm"), got.StateDir.String())
68+
assert.Equal(t, filepath.Join(cacheRoot, "lazynpm"), got.CacheDir.String())
69+
assert.Equal(t, filepath.Join(home, ".config", "jesseduffield", "lazynpm", "config.yml"), got.LegacyMigrationConfigFile.String())
70+
}
71+
72+
func TestPathSet_SelectConfigReadPath_prefersXDG_whenBothConfigsExist(t *testing.T) {
73+
// Given: both the new XDG config and the legacy migration file exist.
74+
paths := mustDiscoverTempPaths(t)
75+
writeFile(t, paths.WriteConfigFile.String())
76+
writeFile(t, paths.LegacyMigrationConfigFile.String())
77+
78+
// When: the read path is selected.
79+
got, err := paths.SelectConfigReadPath(ConfigReadOptions{})
80+
81+
// Then: the new XDG config wins over the legacy migration input.
82+
assert.NoError(t, err)
83+
assert.Equal(t, ConfigSourceXDG, got.Source)
84+
assert.Equal(t, paths.WriteConfigFile.String(), got.Path.String())
85+
}
86+
87+
func TestPathSet_SelectConfigReadPath_usesLegacy_whenOnlyLegacyExists(t *testing.T) {
88+
// Given: only the read-only legacy migration file exists.
89+
paths := mustDiscoverTempPaths(t)
90+
writeFile(t, paths.LegacyMigrationConfigFile.String())
91+
92+
// When: the read path is selected.
93+
got, err := paths.SelectConfigReadPath(ConfigReadOptions{})
94+
95+
// Then: the legacy file is available only as a migration source.
96+
assert.NoError(t, err)
97+
assert.Equal(t, ConfigSourceLegacyMigration, got.Source)
98+
assert.Equal(t, paths.LegacyMigrationConfigFile.String(), got.Path.String())
99+
}
100+
101+
func TestPathSet_SelectConfigReadPath_characterizesExistingUserSourceFallback(t *testing.T) {
102+
// Given: an isolated user environment with only the legacy migration file.
103+
paths := mustDiscoverTempPaths(t)
104+
writeFile(t, paths.LegacyMigrationConfigFile.String())
105+
106+
// When: the existing user-source selector runs.
107+
got, err := paths.SelectConfigReadPath(ConfigReadOptions{})
108+
109+
// Then: legacy configuration remains the selected read-only fallback.
110+
assert.NoError(t, err)
111+
assert.Equal(t, ConfigSourceLegacyMigration, got.Source)
112+
assert.Equal(t, paths.LegacyMigrationConfigFile.String(), got.Path.String())
113+
}
114+
115+
func TestPathSet_SelectConfigReadPath_returnsNone_whenNoConfigExists(t *testing.T) {
116+
// Given: no config file exists in either the XDG or legacy location.
117+
paths := mustDiscoverTempPaths(t)
118+
119+
// When: the read path is selected.
120+
got, err := paths.SelectConfigReadPath(ConfigReadOptions{})
121+
122+
// Then: discovery succeeds without requiring any user config file.
123+
assert.NoError(t, err)
124+
assert.Equal(t, ConfigSourceNone, got.Source)
125+
assert.Equal(t, "", got.Path.String())
126+
}
127+
128+
func TestPathSet_SelectConfigReadPath_returnsError_whenXDGConfigPathIsDirectory(t *testing.T) {
129+
// Given: the XDG config file location is occupied by a directory.
130+
paths := mustDiscoverTempPaths(t)
131+
assert.NoError(t, os.MkdirAll(paths.WriteConfigFile.String(), 0700))
132+
133+
// When: the read path is selected.
134+
_, err := paths.SelectConfigReadPath(ConfigReadOptions{})
135+
136+
// Then: malformed filesystem input is not treated as a usable config source.
137+
assert.True(t, errors.Is(err, ErrConfigPathNotRegularFile))
138+
}
139+
140+
func TestPathSet_EnsureDirectories_createsXDGDirs_whenMissing(t *testing.T) {
141+
// Given: discovered paths under isolated temp roots.
142+
paths := mustDiscoverTempPaths(t)
143+
144+
// When: directory creation is requested.
145+
err := paths.EnsureDirectories()
146+
147+
// Then: config, state, and cache directories exist with user-only permissions where supported.
148+
assert.NoError(t, err)
149+
assertUserOnlyDir(t, filepath.Dir(paths.WriteConfigFile.String()))
150+
assertUserOnlyDir(t, paths.StateDir.String())
151+
assertUserOnlyDir(t, paths.CacheDir.String())
152+
_, err = os.Stat(filepath.Dir(paths.LegacyMigrationConfigFile.String()))
153+
assert.True(t, os.IsNotExist(err))
154+
}
155+
156+
func mustDiscoverTempPaths(t *testing.T) PathSet {
157+
t.Helper()
158+
home := t.TempDir()
159+
paths, err := DiscoverPaths(PathOptions{Env: map[string]string{
160+
"HOME": home,
161+
"XDG_CONFIG_HOME": filepath.Join(t.TempDir(), "config-root"),
162+
"XDG_STATE_HOME": filepath.Join(t.TempDir(), "state-root"),
163+
"XDG_CACHE_HOME": filepath.Join(t.TempDir(), "cache-root"),
164+
}})
165+
assert.NoError(t, err)
166+
return paths
167+
}
168+
169+
func writeFile(t *testing.T, path string) {
170+
t.Helper()
171+
assert.NoError(t, os.MkdirAll(filepath.Dir(path), 0700))
172+
assert.NoError(t, os.WriteFile(path, []byte("config: true\n"), 0600))
173+
}
174+
175+
func assertUserOnlyDir(t *testing.T, path string) {
176+
t.Helper()
177+
info, err := os.Stat(path)
178+
if !assert.NoError(t, err) {
179+
return
180+
}
181+
assert.True(t, info.IsDir())
182+
if runtime.GOOS == "windows" {
183+
return
184+
}
185+
assert.Equal(t, os.FileMode(0700), info.Mode().Perm())
186+
}

0 commit comments

Comments
 (0)