mirror of
https://github.com/cheat/cheat.git
synced 2026-03-07 03:03:32 +01:00
chore: housekeeping and refactoring (bump to 4.7.1)
- Remove unused parameters, dead files, and inaccurate doc.go files - Extract shared helpers, eliminate duplication - Rename cheatpath.Cheatpath to cheatpath.Path - Optimize filesystem walks (WalkDir, skip .git) - Move sheet name validation to sheet.Validate - Move integration tests to test/integration/ - Consolidate internal/mock into mocks/ - Move fuzz.sh to test/ - Inline loadSheets helper into command callers - Extract config.New into its own file - Fix stale references in HACKING.md and CLAUDE.md - Restore plan9 build target - Remove redundant and low-value tests - Clean up project documentation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,158 +2,16 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
cp "github.com/cheat/cheat/internal/cheatpath"
|
||||
|
||||
"github.com/mitchellh/go-homedir"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Config encapsulates configuration parameters
|
||||
type Config struct {
|
||||
Colorize bool `yaml:"colorize"`
|
||||
Editor string `yaml:"editor"`
|
||||
Cheatpaths []cp.Cheatpath `yaml:"cheatpaths"`
|
||||
Style string `yaml:"style"`
|
||||
Formatter string `yaml:"formatter"`
|
||||
Pager string `yaml:"pager"`
|
||||
Colorize bool `yaml:"colorize"`
|
||||
Editor string `yaml:"editor"`
|
||||
Cheatpaths []cp.Path `yaml:"cheatpaths"`
|
||||
Style string `yaml:"style"`
|
||||
Formatter string `yaml:"formatter"`
|
||||
Pager string `yaml:"pager"`
|
||||
Path string
|
||||
}
|
||||
|
||||
// New returns a new Config struct
|
||||
func New(_ map[string]interface{}, confPath string, resolve bool) (Config, error) {
|
||||
|
||||
// read the config file
|
||||
buf, err := os.ReadFile(confPath)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("could not read config file: %v", err)
|
||||
}
|
||||
|
||||
// initialize a config object
|
||||
conf := Config{}
|
||||
|
||||
// store the config path
|
||||
conf.Path = confPath
|
||||
|
||||
// unmarshal the yaml
|
||||
err = yaml.Unmarshal(buf, &conf)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("could not unmarshal yaml: %v", err)
|
||||
}
|
||||
|
||||
// if a .cheat directory exists in the current directory or any ancestor,
|
||||
// append it to the cheatpaths
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("failed to get cwd: %v", err)
|
||||
}
|
||||
|
||||
if local := findLocalCheatpath(cwd); local != "" {
|
||||
path := cp.Cheatpath{
|
||||
Name: "cwd",
|
||||
Path: local,
|
||||
ReadOnly: false,
|
||||
Tags: []string{},
|
||||
}
|
||||
conf.Cheatpaths = append(conf.Cheatpaths, path)
|
||||
}
|
||||
|
||||
// process cheatpaths
|
||||
var validPaths []cp.Cheatpath
|
||||
for _, cheatpath := range conf.Cheatpaths {
|
||||
|
||||
// expand ~ in config paths
|
||||
expanded, err := homedir.Expand(cheatpath.Path)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("failed to expand ~: %v", err)
|
||||
}
|
||||
|
||||
// follow symlinks
|
||||
//
|
||||
// NB: `resolve` is an ugly kludge that exists for the sake of unit-tests.
|
||||
// It's necessary because `EvalSymlinks` will error if the symlink points
|
||||
// to a non-existent location on the filesystem. When unit-testing,
|
||||
// however, we don't want to have dependencies on the filesystem. As such,
|
||||
// `resolve` is a switch that allows us to turn off symlink resolution when
|
||||
// running the config tests.
|
||||
if resolve {
|
||||
evaled, err := filepath.EvalSymlinks(expanded)
|
||||
if err != nil {
|
||||
// if the path simply doesn't exist, warn and skip it
|
||||
if os.IsNotExist(err) {
|
||||
fmt.Fprintf(os.Stderr,
|
||||
"WARNING: cheatpath '%s' does not exist, skipping\n",
|
||||
expanded,
|
||||
)
|
||||
continue
|
||||
}
|
||||
return Config{}, fmt.Errorf(
|
||||
"failed to resolve symlink: %s: %v",
|
||||
expanded,
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
expanded = evaled
|
||||
}
|
||||
|
||||
cheatpath.Path = expanded
|
||||
validPaths = append(validPaths, cheatpath)
|
||||
}
|
||||
conf.Cheatpaths = validPaths
|
||||
|
||||
// determine the editor: env vars override the config file value,
|
||||
// following standard Unix convention (see #589)
|
||||
if v := os.Getenv("VISUAL"); v != "" {
|
||||
conf.Editor = v
|
||||
} else if v := os.Getenv("EDITOR"); v != "" {
|
||||
conf.Editor = v
|
||||
} else {
|
||||
conf.Editor = strings.TrimSpace(conf.Editor)
|
||||
}
|
||||
|
||||
// if an editor was still not determined, attempt to choose one
|
||||
// that's appropriate for the environment
|
||||
if conf.Editor == "" {
|
||||
if conf.Editor, err = Editor(); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
}
|
||||
|
||||
// if a chroma style was not provided, set a default
|
||||
if conf.Style == "" {
|
||||
conf.Style = "bw"
|
||||
}
|
||||
|
||||
// if a chroma formatter was not provided, set a default
|
||||
if conf.Formatter == "" {
|
||||
conf.Formatter = "terminal"
|
||||
}
|
||||
|
||||
// load the pager
|
||||
conf.Pager = strings.TrimSpace(conf.Pager)
|
||||
|
||||
return conf, nil
|
||||
}
|
||||
|
||||
// findLocalCheatpath walks upward from dir looking for a .cheat directory.
|
||||
// It returns the path to the first .cheat directory found, or an empty string
|
||||
// if none exists. This mirrors the discovery pattern used by git for .git
|
||||
// directories.
|
||||
func findLocalCheatpath(dir string) string {
|
||||
for {
|
||||
candidate := filepath.Join(dir, ".cheat")
|
||||
if info, err := os.Stat(candidate); err == nil && info.IsDir() {
|
||||
return candidate
|
||||
}
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
return ""
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,9 @@ package config
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/cheat/cheat/internal/mock"
|
||||
"github.com/cheat/cheat/mocks"
|
||||
)
|
||||
|
||||
// TestConfigYAMLErrors tests YAML parsing errors
|
||||
@@ -19,258 +18,22 @@ func TestConfigYAMLErrors(t *testing.T) {
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
invalidYAML := filepath.Join(tempDir, "invalid.yml")
|
||||
err = os.WriteFile(invalidYAML, []byte("invalid: yaml: content:\n - no closing"), 0644)
|
||||
err = os.WriteFile(invalidYAML, []byte("cheatpaths: [{unclosed\n"), 0644)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to write invalid yaml: %v", err)
|
||||
}
|
||||
|
||||
// Attempt to load invalid YAML
|
||||
_, err = New(map[string]interface{}{}, invalidYAML, false)
|
||||
_, err = New(invalidYAML, false)
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid YAML, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfigLocalCheatpath tests local .cheat directory detection
|
||||
func TestConfigLocalCheatpath(t *testing.T) {
|
||||
// Create a temporary directory to act as working directory
|
||||
tempDir, err := os.MkdirTemp("", "cheat-config-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
// Resolve symlinks in temp dir path (macOS /var -> /private/var)
|
||||
tempDir, err = filepath.EvalSymlinks(tempDir)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to resolve temp dir symlinks: %v", err)
|
||||
}
|
||||
|
||||
// Save current working directory
|
||||
oldCwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get cwd: %v", err)
|
||||
}
|
||||
defer os.Chdir(oldCwd)
|
||||
|
||||
// Change to temp directory
|
||||
err = os.Chdir(tempDir)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to change dir: %v", err)
|
||||
}
|
||||
|
||||
// Create .cheat directory
|
||||
localCheat := filepath.Join(tempDir, ".cheat")
|
||||
err = os.Mkdir(localCheat, 0755)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create .cheat dir: %v", err)
|
||||
}
|
||||
|
||||
// Load config
|
||||
conf, err := New(map[string]interface{}{}, mock.Path("conf/empty.yml"), false)
|
||||
if err != nil {
|
||||
t.Errorf("failed to load config: %v", err)
|
||||
}
|
||||
|
||||
// Check that local cheatpath was added
|
||||
found := false
|
||||
for _, cp := range conf.Cheatpaths {
|
||||
if cp.Name == "cwd" && cp.Path == localCheat {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
t.Error("local .cheat directory was not added to cheatpaths")
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfigLocalCheatpathInParent tests that .cheat in a parent directory is found
|
||||
func TestConfigLocalCheatpathInParent(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "cheat-config-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
// Resolve symlinks in temp dir path (macOS /var -> /private/var)
|
||||
tempDir, err = filepath.EvalSymlinks(tempDir)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to resolve temp dir symlinks: %v", err)
|
||||
}
|
||||
|
||||
oldCwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get cwd: %v", err)
|
||||
}
|
||||
defer os.Chdir(oldCwd)
|
||||
|
||||
// Create .cheat in the root of the temp dir
|
||||
localCheat := filepath.Join(tempDir, ".cheat")
|
||||
if err := os.Mkdir(localCheat, 0755); err != nil {
|
||||
t.Fatalf("failed to create .cheat dir: %v", err)
|
||||
}
|
||||
|
||||
// Create a subdirectory and cd into it
|
||||
subDir := filepath.Join(tempDir, "sub")
|
||||
if err := os.Mkdir(subDir, 0755); err != nil {
|
||||
t.Fatalf("failed to create sub dir: %v", err)
|
||||
}
|
||||
if err := os.Chdir(subDir); err != nil {
|
||||
t.Fatalf("failed to chdir: %v", err)
|
||||
}
|
||||
|
||||
conf, err := New(map[string]interface{}{}, mock.Path("conf/empty.yml"), false)
|
||||
if err != nil {
|
||||
t.Errorf("failed to load config: %v", err)
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, cp := range conf.Cheatpaths {
|
||||
if cp.Name == "cwd" && cp.Path == localCheat {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("parent .cheat directory was not added to cheatpaths")
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfigLocalCheatpathNearestWins tests that the nearest .cheat wins
|
||||
func TestConfigLocalCheatpathNearestWins(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "cheat-config-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
// Resolve symlinks in temp dir path (macOS /var -> /private/var)
|
||||
tempDir, err = filepath.EvalSymlinks(tempDir)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to resolve temp dir symlinks: %v", err)
|
||||
}
|
||||
|
||||
oldCwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get cwd: %v", err)
|
||||
}
|
||||
defer os.Chdir(oldCwd)
|
||||
|
||||
// Create .cheat at root
|
||||
if err := os.Mkdir(filepath.Join(tempDir, ".cheat"), 0755); err != nil {
|
||||
t.Fatalf("failed to create root .cheat dir: %v", err)
|
||||
}
|
||||
|
||||
// Create sub/.cheat (the nearer one)
|
||||
subDir := filepath.Join(tempDir, "sub")
|
||||
if err := os.Mkdir(subDir, 0755); err != nil {
|
||||
t.Fatalf("failed to create sub dir: %v", err)
|
||||
}
|
||||
nearCheat := filepath.Join(subDir, ".cheat")
|
||||
if err := os.Mkdir(nearCheat, 0755); err != nil {
|
||||
t.Fatalf("failed to create near .cheat dir: %v", err)
|
||||
}
|
||||
|
||||
// cd into sub/deep/
|
||||
deepDir := filepath.Join(subDir, "deep")
|
||||
if err := os.Mkdir(deepDir, 0755); err != nil {
|
||||
t.Fatalf("failed to create deep dir: %v", err)
|
||||
}
|
||||
if err := os.Chdir(deepDir); err != nil {
|
||||
t.Fatalf("failed to chdir: %v", err)
|
||||
}
|
||||
|
||||
conf, err := New(map[string]interface{}{}, mock.Path("conf/empty.yml"), false)
|
||||
if err != nil {
|
||||
t.Errorf("failed to load config: %v", err)
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, cp := range conf.Cheatpaths {
|
||||
if cp.Name == "cwd" {
|
||||
if cp.Path != nearCheat {
|
||||
t.Errorf("expected nearest .cheat %s, got %s", nearCheat, cp.Path)
|
||||
}
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("no cwd cheatpath found")
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfigNoLocalCheatpath tests that no cwd cheatpath is added when no .cheat exists
|
||||
func TestConfigNoLocalCheatpath(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "cheat-config-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
oldCwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get cwd: %v", err)
|
||||
}
|
||||
defer os.Chdir(oldCwd)
|
||||
|
||||
if err := os.Chdir(tempDir); err != nil {
|
||||
t.Fatalf("failed to chdir: %v", err)
|
||||
}
|
||||
|
||||
conf, err := New(map[string]interface{}{}, mock.Path("conf/empty.yml"), false)
|
||||
if err != nil {
|
||||
t.Errorf("failed to load config: %v", err)
|
||||
}
|
||||
|
||||
for _, cp := range conf.Cheatpaths {
|
||||
if cp.Name == "cwd" {
|
||||
t.Error("cwd cheatpath should not be added when no .cheat exists")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfigLocalCheatpathFileSkipped tests that a .cheat file (not dir) is skipped
|
||||
func TestConfigLocalCheatpathFileSkipped(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "cheat-config-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
oldCwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get cwd: %v", err)
|
||||
}
|
||||
defer os.Chdir(oldCwd)
|
||||
|
||||
// Create .cheat as a file, not a directory
|
||||
if err := os.WriteFile(filepath.Join(tempDir, ".cheat"), []byte("not a dir"), 0644); err != nil {
|
||||
t.Fatalf("failed to create .cheat file: %v", err)
|
||||
}
|
||||
|
||||
if err := os.Chdir(tempDir); err != nil {
|
||||
t.Fatalf("failed to chdir: %v", err)
|
||||
}
|
||||
|
||||
conf, err := New(map[string]interface{}{}, mock.Path("conf/empty.yml"), false)
|
||||
if err != nil {
|
||||
t.Errorf("failed to load config: %v", err)
|
||||
}
|
||||
|
||||
for _, cp := range conf.Cheatpaths {
|
||||
if cp.Name == "cwd" {
|
||||
t.Error("cwd cheatpath should not be added for a .cheat file")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfigDefaults tests default values
|
||||
func TestConfigDefaults(t *testing.T) {
|
||||
// Load empty config
|
||||
conf, err := New(map[string]interface{}{}, mock.Path("conf/empty.yml"), false)
|
||||
conf, err := New(mocks.Path("conf/empty.yml"), false)
|
||||
if err != nil {
|
||||
t.Errorf("failed to load config: %v", err)
|
||||
}
|
||||
@@ -329,13 +92,16 @@ cheatpaths:
|
||||
}
|
||||
|
||||
// Load config with symlink resolution
|
||||
conf, err := New(map[string]interface{}{}, configFile, true)
|
||||
conf, err := New(configFile, true)
|
||||
if err != nil {
|
||||
t.Errorf("failed to load config: %v", err)
|
||||
}
|
||||
|
||||
// Verify symlink was resolved
|
||||
if len(conf.Cheatpaths) > 0 && conf.Cheatpaths[0].Path != targetDir {
|
||||
if len(conf.Cheatpaths) == 0 {
|
||||
t.Fatal("expected at least one cheatpath, got none")
|
||||
}
|
||||
if conf.Cheatpaths[0].Path != targetDir {
|
||||
t.Errorf("expected symlink to be resolved to %s, got %s", targetDir, conf.Cheatpaths[0].Path)
|
||||
}
|
||||
}
|
||||
@@ -372,7 +138,7 @@ cheatpaths:
|
||||
|
||||
// Load config with symlink resolution should skip the broken cheatpath
|
||||
// (warn to stderr) rather than hard-error
|
||||
conf, err := New(map[string]interface{}{}, configFile, true)
|
||||
conf, err := New(configFile, true)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error for broken symlink (should skip), got: %v", err)
|
||||
}
|
||||
@@ -380,70 +146,3 @@ cheatpaths:
|
||||
t.Errorf("expected broken cheatpath to be filtered out, got %d cheatpaths", len(conf.Cheatpaths))
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfigTildeExpansionError tests tilde expansion error handling
|
||||
func TestConfigTildeExpansionError(t *testing.T) {
|
||||
// This is tricky to test without mocking homedir.Expand
|
||||
// We'll create a config with an invalid home reference
|
||||
tempDir, err := os.MkdirTemp("", "cheat-config-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
// Create config with user that likely doesn't exist
|
||||
configContent := `---
|
||||
editor: vim
|
||||
cheatpaths:
|
||||
- name: test
|
||||
path: ~nonexistentuser12345/cheat
|
||||
readonly: true
|
||||
`
|
||||
configFile := filepath.Join(tempDir, "config.yml")
|
||||
err = os.WriteFile(configFile, []byte(configContent), 0644)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to write config: %v", err)
|
||||
}
|
||||
|
||||
// Load config - this may or may not fail depending on the system
|
||||
// but we're testing that it doesn't panic
|
||||
_, _ = New(map[string]interface{}{}, configFile, false)
|
||||
}
|
||||
|
||||
// TestConfigGetCwdError tests error handling when os.Getwd fails
|
||||
func TestConfigGetCwdError(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Windows does not allow removing the current directory")
|
||||
}
|
||||
|
||||
// This is difficult to test without being able to break os.Getwd
|
||||
// We'll create a scenario where the current directory is removed
|
||||
|
||||
// Create and enter a temp directory
|
||||
tempDir, err := os.MkdirTemp("", "cheat-config-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
|
||||
oldCwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get cwd: %v", err)
|
||||
}
|
||||
defer os.Chdir(oldCwd)
|
||||
|
||||
err = os.Chdir(tempDir)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to change dir: %v", err)
|
||||
}
|
||||
|
||||
// Remove the directory we're in
|
||||
err = os.RemoveAll(tempDir)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to remove temp dir: %v", err)
|
||||
}
|
||||
|
||||
// Now os.Getwd should fail
|
||||
_, err = New(map[string]interface{}{}, mock.Path("conf/empty.yml"), false)
|
||||
// This might not fail on all systems, so we just ensure no panic
|
||||
_ = err
|
||||
}
|
||||
|
||||
@@ -65,58 +65,3 @@ func FuzzFindLocalCheatpath(f *testing.F) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// FuzzFindLocalCheatpathNearestWins verifies that when two .cheat directories
|
||||
// exist at different levels of the ancestor chain, the nearest one is returned.
|
||||
func FuzzFindLocalCheatpathNearestWins(f *testing.F) {
|
||||
f.Add(uint8(5), uint8(1), uint8(3))
|
||||
f.Add(uint8(8), uint8(0), uint8(7))
|
||||
f.Add(uint8(3), uint8(0), uint8(2))
|
||||
f.Add(uint8(10), uint8(2), uint8(8))
|
||||
|
||||
f.Fuzz(func(t *testing.T, totalDepth, shallowRaw, deepRaw uint8) {
|
||||
depth := int(totalDepth%12) + 2 // 2..13 (need room for two placements)
|
||||
s := int(shallowRaw) % depth
|
||||
d := int(deepRaw) % depth
|
||||
|
||||
// Need two distinct levels
|
||||
if s == d {
|
||||
d = (d + 1) % depth
|
||||
}
|
||||
// Ensure s < d (shallow is higher in tree, deep is closer to search dir)
|
||||
if s > d {
|
||||
s, d = d, s
|
||||
}
|
||||
|
||||
tempDir := t.TempDir()
|
||||
|
||||
// Build chain
|
||||
dirs := make([]string, 0, depth+1)
|
||||
dirs = append(dirs, tempDir)
|
||||
current := tempDir
|
||||
for i := 0; i < depth; i++ {
|
||||
current = filepath.Join(current, fmt.Sprintf("d%d", i))
|
||||
if err := os.Mkdir(current, 0755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
dirs = append(dirs, current)
|
||||
}
|
||||
|
||||
// Place .cheat at both levels
|
||||
shallowCheat := filepath.Join(dirs[s], ".cheat")
|
||||
deepCheat := filepath.Join(dirs[d], ".cheat")
|
||||
if err := os.Mkdir(shallowCheat, 0755); err != nil {
|
||||
t.Fatalf("mkdir shallow .cheat: %v", err)
|
||||
}
|
||||
if err := os.Mkdir(deepCheat, 0755); err != nil {
|
||||
t.Fatalf("mkdir deep .cheat: %v", err)
|
||||
}
|
||||
|
||||
// Search from the deepest directory — should find the deeper (nearer) .cheat
|
||||
result := findLocalCheatpath(current)
|
||||
if result != deepCheat {
|
||||
t.Errorf("depth=%d shallow=%d deep=%d: expected nearest %s, got %s",
|
||||
depth, s, d, deepCheat, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"github.com/mitchellh/go-homedir"
|
||||
|
||||
"github.com/cheat/cheat/internal/cheatpath"
|
||||
"github.com/cheat/cheat/internal/mock"
|
||||
"github.com/cheat/cheat/mocks"
|
||||
)
|
||||
|
||||
// TestFindLocalCheatpathInCurrentDir tests that .cheat in the given dir is found
|
||||
@@ -286,7 +286,7 @@ func TestConfigSuccessful(t *testing.T) {
|
||||
}()
|
||||
|
||||
// initialize a config
|
||||
conf, err := New(map[string]interface{}{}, mock.Path("conf/conf.yml"), false)
|
||||
conf, err := New(mocks.Path("conf/conf.yml"), false)
|
||||
if err != nil {
|
||||
t.Errorf("failed to parse config file: %v", err)
|
||||
}
|
||||
@@ -306,18 +306,18 @@ func TestConfigSuccessful(t *testing.T) {
|
||||
}
|
||||
|
||||
// assert that the cheatpaths are correct
|
||||
want := []cheatpath.Cheatpath{
|
||||
cheatpath.Cheatpath{
|
||||
want := []cheatpath.Path{
|
||||
cheatpath.Path{
|
||||
Path: filepath.Join(home, ".dotfiles", "cheat", "community"),
|
||||
ReadOnly: true,
|
||||
Tags: []string{"community"},
|
||||
},
|
||||
cheatpath.Cheatpath{
|
||||
cheatpath.Path{
|
||||
Path: filepath.Join(home, ".dotfiles", "cheat", "work"),
|
||||
ReadOnly: false,
|
||||
Tags: []string{"work"},
|
||||
},
|
||||
cheatpath.Cheatpath{
|
||||
cheatpath.Path{
|
||||
Path: filepath.Join(home, ".dotfiles", "cheat", "personal"),
|
||||
ReadOnly: false,
|
||||
Tags: []string{"personal"},
|
||||
@@ -338,7 +338,7 @@ func TestConfigSuccessful(t *testing.T) {
|
||||
func TestConfigFailure(t *testing.T) {
|
||||
|
||||
// attempt to read a non-existent config file
|
||||
_, err := New(map[string]interface{}{}, "/does-not-exit", false)
|
||||
_, err := New("/does-not-exit", false)
|
||||
if err == nil {
|
||||
t.Errorf("failed to error on unreadable config")
|
||||
}
|
||||
@@ -358,7 +358,7 @@ func TestEditorEnvOverride(t *testing.T) {
|
||||
// with no env vars, the config file value should be used
|
||||
os.Unsetenv("VISUAL")
|
||||
os.Unsetenv("EDITOR")
|
||||
conf, err := New(map[string]interface{}{}, mock.Path("conf/conf.yml"), false)
|
||||
conf, err := New(mocks.Path("conf/conf.yml"), false)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to init configs: %v", err)
|
||||
}
|
||||
@@ -368,7 +368,7 @@ func TestEditorEnvOverride(t *testing.T) {
|
||||
|
||||
// $EDITOR should override the config file value
|
||||
os.Setenv("EDITOR", "nano")
|
||||
conf, err = New(map[string]interface{}{}, mock.Path("conf/conf.yml"), false)
|
||||
conf, err = New(mocks.Path("conf/conf.yml"), false)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to init configs: %v", err)
|
||||
}
|
||||
@@ -378,7 +378,7 @@ func TestEditorEnvOverride(t *testing.T) {
|
||||
|
||||
// $VISUAL should override both $EDITOR and the config file value
|
||||
os.Setenv("VISUAL", "emacs")
|
||||
conf, err = New(map[string]interface{}{}, mock.Path("conf/conf.yml"), false)
|
||||
conf, err = New(mocks.Path("conf/conf.yml"), false)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to init configs: %v", err)
|
||||
}
|
||||
@@ -401,7 +401,7 @@ func TestEditorEnvFallback(t *testing.T) {
|
||||
// set $EDITOR and assert it's used when config has no editor
|
||||
os.Unsetenv("VISUAL")
|
||||
os.Setenv("EDITOR", "foo")
|
||||
conf, err := New(map[string]interface{}{}, mock.Path("conf/empty.yml"), false)
|
||||
conf, err := New(mocks.Path("conf/empty.yml"), false)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to init configs: %v", err)
|
||||
}
|
||||
@@ -411,7 +411,7 @@ func TestEditorEnvFallback(t *testing.T) {
|
||||
|
||||
// set $VISUAL and assert it takes precedence over $EDITOR
|
||||
os.Setenv("VISUAL", "bar")
|
||||
conf, err = New(map[string]interface{}{}, mock.Path("conf/empty.yml"), false)
|
||||
conf, err = New(mocks.Path("conf/empty.yml"), false)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to init configs: %v", err)
|
||||
}
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
// Package config manages application configuration and settings.
|
||||
//
|
||||
// The config package provides functionality to:
|
||||
// - Load configuration from YAML files
|
||||
// - Validate configuration values
|
||||
// - Manage platform-specific configuration paths
|
||||
// - Handle editor and pager settings
|
||||
// - Configure colorization and formatting options
|
||||
//
|
||||
// # Configuration Structure
|
||||
//
|
||||
// The main configuration file (conf.yml) contains:
|
||||
// - Editor preferences
|
||||
// - Pager settings
|
||||
// - Colorization options
|
||||
// - Cheatpath definitions
|
||||
// - Formatting preferences
|
||||
//
|
||||
// Example configuration:
|
||||
//
|
||||
// ---
|
||||
// editor: vim
|
||||
// colorize: true
|
||||
// style: monokai
|
||||
// formatter: terminal256
|
||||
// pager: less -FRX
|
||||
// cheatpaths:
|
||||
// - name: personal
|
||||
// path: ~/cheat
|
||||
// tags: []
|
||||
// readonly: false
|
||||
// - name: community
|
||||
// path: ~/cheat/.cheat
|
||||
// tags: [community]
|
||||
// readonly: true
|
||||
//
|
||||
// # Platform-Specific Paths
|
||||
//
|
||||
// The package automatically detects configuration paths based on the operating system:
|
||||
// - Linux/Unix: $XDG_CONFIG_HOME/cheat/conf.yml or ~/.config/cheat/conf.yml
|
||||
// - macOS: ~/Library/Application Support/cheat/conf.yml
|
||||
// - Windows: %APPDATA%\cheat\conf.yml
|
||||
//
|
||||
// # Environment Variables
|
||||
//
|
||||
// The following environment variables are respected:
|
||||
// - CHEAT_CONFIG_PATH: Override the configuration file location
|
||||
// - CHEAT_USE_FZF: Enable fzf integration when set to "true"
|
||||
// - EDITOR: Default editor if not specified in config
|
||||
// - VISUAL: Fallback editor if EDITOR is not set
|
||||
// - PAGER: Default pager if not specified in config
|
||||
package config
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -90,9 +89,6 @@ func TestInitWriteError(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Error("expected error when writing to invalid path, got nil")
|
||||
}
|
||||
if err != nil && !strings.Contains(err.Error(), "failed to create") {
|
||||
t.Errorf("expected 'failed to create' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInitExistingFile tests that Init overwrites existing files
|
||||
|
||||
147
internal/config/new.go
Normal file
147
internal/config/new.go
Normal file
@@ -0,0 +1,147 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
cp "github.com/cheat/cheat/internal/cheatpath"
|
||||
|
||||
"github.com/mitchellh/go-homedir"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// New returns a new Config struct
|
||||
func New(confPath string, resolve bool) (Config, error) {
|
||||
|
||||
// read the config file
|
||||
buf, err := os.ReadFile(confPath)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("could not read config file: %v", err)
|
||||
}
|
||||
|
||||
// initialize a config object
|
||||
conf := Config{}
|
||||
|
||||
// store the config path
|
||||
conf.Path = confPath
|
||||
|
||||
// unmarshal the yaml
|
||||
err = yaml.Unmarshal(buf, &conf)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("could not unmarshal yaml: %v", err)
|
||||
}
|
||||
|
||||
// if a .cheat directory exists in the current directory or any ancestor,
|
||||
// append it to the cheatpaths
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("failed to get cwd: %v", err)
|
||||
}
|
||||
|
||||
if local := findLocalCheatpath(cwd); local != "" {
|
||||
path := cp.Path{
|
||||
Name: "cwd",
|
||||
Path: local,
|
||||
ReadOnly: false,
|
||||
Tags: []string{},
|
||||
}
|
||||
conf.Cheatpaths = append(conf.Cheatpaths, path)
|
||||
}
|
||||
|
||||
// process cheatpaths
|
||||
var validPaths []cp.Path
|
||||
for _, cheatpath := range conf.Cheatpaths {
|
||||
|
||||
// expand ~ in config paths
|
||||
expanded, err := homedir.Expand(cheatpath.Path)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("failed to expand ~: %v", err)
|
||||
}
|
||||
|
||||
// follow symlinks
|
||||
//
|
||||
// NB: `resolve` is an ugly kludge that exists for the sake of unit-tests.
|
||||
// It's necessary because `EvalSymlinks` will error if the symlink points
|
||||
// to a non-existent location on the filesystem. When unit-testing,
|
||||
// however, we don't want to have dependencies on the filesystem. As such,
|
||||
// `resolve` is a switch that allows us to turn off symlink resolution when
|
||||
// running the config tests.
|
||||
if resolve {
|
||||
evaled, err := filepath.EvalSymlinks(expanded)
|
||||
if err != nil {
|
||||
// if the path simply doesn't exist, warn and skip it
|
||||
if os.IsNotExist(err) {
|
||||
fmt.Fprintf(os.Stderr,
|
||||
"WARNING: cheatpath '%s' does not exist, skipping\n",
|
||||
expanded,
|
||||
)
|
||||
continue
|
||||
}
|
||||
return Config{}, fmt.Errorf(
|
||||
"failed to resolve symlink: %s: %v",
|
||||
expanded,
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
expanded = evaled
|
||||
}
|
||||
|
||||
cheatpath.Path = expanded
|
||||
validPaths = append(validPaths, cheatpath)
|
||||
}
|
||||
conf.Cheatpaths = validPaths
|
||||
|
||||
// determine the editor: env vars override the config file value,
|
||||
// following standard Unix convention (see #589)
|
||||
if v := os.Getenv("VISUAL"); v != "" {
|
||||
conf.Editor = v
|
||||
} else if v := os.Getenv("EDITOR"); v != "" {
|
||||
conf.Editor = v
|
||||
} else {
|
||||
conf.Editor = strings.TrimSpace(conf.Editor)
|
||||
}
|
||||
|
||||
// if an editor was still not determined, attempt to choose one
|
||||
// that's appropriate for the environment
|
||||
if conf.Editor == "" {
|
||||
if conf.Editor, err = Editor(); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
}
|
||||
|
||||
// if a chroma style was not provided, set a default
|
||||
if conf.Style == "" {
|
||||
conf.Style = "bw"
|
||||
}
|
||||
|
||||
// if a chroma formatter was not provided, set a default
|
||||
if conf.Formatter == "" {
|
||||
conf.Formatter = "terminal"
|
||||
}
|
||||
|
||||
// load the pager
|
||||
conf.Pager = strings.TrimSpace(conf.Pager)
|
||||
|
||||
return conf, nil
|
||||
}
|
||||
|
||||
// findLocalCheatpath walks upward from dir looking for a .cheat directory.
|
||||
// It returns the path to the first .cheat directory found, or an empty string
|
||||
// if none exists. This mirrors the discovery pattern used by git for .git
|
||||
// directories.
|
||||
func findLocalCheatpath(dir string) string {
|
||||
for {
|
||||
candidate := filepath.Join(dir, ".cheat")
|
||||
if info, err := os.Stat(candidate); err == nil && info.IsDir() {
|
||||
return candidate
|
||||
}
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
return ""
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,7 @@ cheatpaths:
|
||||
}
|
||||
|
||||
// Load the config
|
||||
conf, err := New(map[string]interface{}{}, configPath, false)
|
||||
conf, err := New(configPath, false)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to load config: %v", err)
|
||||
}
|
||||
@@ -88,7 +88,7 @@ cheatpaths:
|
||||
}
|
||||
|
||||
// Load the config
|
||||
conf, err := New(map[string]interface{}{}, configPath, false)
|
||||
conf, err := New(configPath, false)
|
||||
if err != nil {
|
||||
// It's OK if this fails due to no editor being found
|
||||
// The important thing is it doesn't panic
|
||||
@@ -123,7 +123,7 @@ cheatpaths:
|
||||
}
|
||||
|
||||
// Load the config
|
||||
conf, err := New(map[string]interface{}{}, configPath, false)
|
||||
conf, err := New(configPath, false)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to load config: %v", err)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
@@ -44,29 +45,20 @@ func TestPager(t *testing.T) {
|
||||
os.Setenv("PAGER", "")
|
||||
pager := Pager()
|
||||
|
||||
// Should find one of the fallback pagers or return empty string
|
||||
if pager == "" {
|
||||
return // no pager found is acceptable
|
||||
}
|
||||
|
||||
// Should find one of the known fallback pagers
|
||||
validPagers := map[string]bool{
|
||||
"": true, // no pager found
|
||||
"pager": true,
|
||||
"less": true,
|
||||
"more": true,
|
||||
}
|
||||
|
||||
// Check if it's a path to one of these
|
||||
found := false
|
||||
for p := range validPagers {
|
||||
if p == "" && pager == "" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
if p != "" && (pager == p || len(pager) >= len(p) && pager[len(pager)-len(p):] == p) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
t.Errorf("unexpected pager value: %s", pager)
|
||||
base := filepath.Base(pager)
|
||||
if !validPagers[base] {
|
||||
t.Errorf("unexpected pager value: %s (base: %s)", pager, base)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -14,8 +14,8 @@ func TestValidateCorrect(t *testing.T) {
|
||||
Colorize: true,
|
||||
Editor: "vim",
|
||||
Formatter: "terminal16m",
|
||||
Cheatpaths: []cheatpath.Cheatpath{
|
||||
cheatpath.Cheatpath{
|
||||
Cheatpaths: []cheatpath.Path{
|
||||
cheatpath.Path{
|
||||
Name: "foo",
|
||||
Path: "/foo",
|
||||
ReadOnly: false,
|
||||
@@ -38,8 +38,8 @@ func TestInvalidateMissingEditor(t *testing.T) {
|
||||
conf := Config{
|
||||
Colorize: true,
|
||||
Formatter: "terminal16m",
|
||||
Cheatpaths: []cheatpath.Cheatpath{
|
||||
cheatpath.Cheatpath{
|
||||
Cheatpaths: []cheatpath.Path{
|
||||
cheatpath.Path{
|
||||
Name: "foo",
|
||||
Path: "/foo",
|
||||
ReadOnly: false,
|
||||
@@ -71,19 +71,28 @@ func TestInvalidateMissingCheatpaths(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestMissingInvalidFormatters asserts that configs which contain invalid
|
||||
// TestInvalidateInvalidFormatter asserts that configs which contain invalid
|
||||
// formatters are invalidated
|
||||
func TestMissingInvalidFormatters(t *testing.T) {
|
||||
func TestInvalidateInvalidFormatter(t *testing.T) {
|
||||
|
||||
// mock a config
|
||||
// mock a config with a valid editor and cheatpaths but invalid formatter
|
||||
conf := Config{
|
||||
Colorize: true,
|
||||
Editor: "vim",
|
||||
Colorize: true,
|
||||
Editor: "vim",
|
||||
Formatter: "html",
|
||||
Cheatpaths: []cheatpath.Path{
|
||||
cheatpath.Path{
|
||||
Name: "foo",
|
||||
Path: "/foo",
|
||||
ReadOnly: false,
|
||||
Tags: []string{},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// assert that no errors are returned
|
||||
// assert that the config is invalidated due to the formatter
|
||||
if err := conf.Validate(); err == nil {
|
||||
t.Errorf("failed to invalidate config without formatter")
|
||||
t.Errorf("failed to invalidate config with invalid formatter")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,14 +105,14 @@ func TestInvalidateDuplicateCheatpathNames(t *testing.T) {
|
||||
Colorize: true,
|
||||
Editor: "vim",
|
||||
Formatter: "terminal16m",
|
||||
Cheatpaths: []cheatpath.Cheatpath{
|
||||
cheatpath.Cheatpath{
|
||||
Cheatpaths: []cheatpath.Path{
|
||||
cheatpath.Path{
|
||||
Name: "foo",
|
||||
Path: "/foo",
|
||||
ReadOnly: false,
|
||||
Tags: []string{},
|
||||
},
|
||||
cheatpath.Cheatpath{
|
||||
cheatpath.Path{
|
||||
Name: "foo",
|
||||
Path: "/bar",
|
||||
ReadOnly: false,
|
||||
@@ -127,14 +136,14 @@ func TestInvalidateDuplicateCheatpathPaths(t *testing.T) {
|
||||
Colorize: true,
|
||||
Editor: "vim",
|
||||
Formatter: "terminal16m",
|
||||
Cheatpaths: []cheatpath.Cheatpath{
|
||||
cheatpath.Cheatpath{
|
||||
Cheatpaths: []cheatpath.Path{
|
||||
cheatpath.Path{
|
||||
Name: "foo",
|
||||
Path: "/foo",
|
||||
ReadOnly: false,
|
||||
Tags: []string{},
|
||||
},
|
||||
cheatpath.Cheatpath{
|
||||
cheatpath.Path{
|
||||
Name: "bar",
|
||||
Path: "/foo",
|
||||
ReadOnly: false,
|
||||
|
||||
Reference in New Issue
Block a user