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:
Christopher Allen Lane
2026-02-15 15:09:30 -05:00
parent d4a8a79628
commit 5ad1a3c39f
68 changed files with 605 additions and 1578 deletions

View File

@@ -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
}
}

View File

@@ -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
}

View File

@@ -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)
}
})
}

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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
View 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
}
}

View File

@@ -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)
}

View File

@@ -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)
}
})

View File

@@ -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,