mirror of
https://github.com/cheat/cheat.git
synced 2026-03-07 03:03:32 +01:00
feat: walk up directory tree to find .cheat directory (#602)
Previously cheat only checked the current working directory for a .cheat subdirectory. Now it walks upward through ancestor directories, stopping at the first .cheat directory found. This mirrors how git discovers .git directories, so users can place .cheat at their project root and have it work from any subdirectory. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -172,9 +172,11 @@ editing.
|
|||||||
|
|
||||||
### Directory-scoped Cheatpaths ###
|
### Directory-scoped Cheatpaths ###
|
||||||
At times, it can be useful to closely associate cheatsheets with a directory on
|
At times, it can be useful to closely associate cheatsheets with a directory on
|
||||||
your filesystem. `cheat` facilitates this by searching for a `.cheat` folder in
|
your filesystem. `cheat` facilitates this by searching for a `.cheat` directory
|
||||||
the current working directory. If found, the `.cheat` directory will
|
in the current working directory and its ancestors (similar to how `git` locates
|
||||||
(temporarily) be added to the cheatpaths.
|
`.git` directories). The nearest `.cheat` directory found will (temporarily) be
|
||||||
|
added to the cheatpaths. This means you can place a `.cheat` directory at your
|
||||||
|
project root and it will be available from any subdirectory within that project.
|
||||||
|
|
||||||
Autocompletion
|
Autocompletion
|
||||||
--------------
|
--------------
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ TESTS=(
|
|||||||
"FuzzTagged:./internal/sheet:tag matching with malicious input"
|
"FuzzTagged:./internal/sheet:tag matching with malicious input"
|
||||||
"FuzzFilter:./internal/sheets:tag filtering operations"
|
"FuzzFilter:./internal/sheets:tag filtering operations"
|
||||||
"FuzzTags:./internal/sheets:tag aggregation and sorting"
|
"FuzzTags:./internal/sheets:tag aggregation and sorting"
|
||||||
|
"FuzzFindLocalCheatpath:./internal/config:recursive .cheat directory discovery"
|
||||||
|
"FuzzFindLocalCheatpathNearestWins:./internal/config:nearest .cheat wins invariant"
|
||||||
)
|
)
|
||||||
|
|
||||||
echo "Running fuzz tests ($DURATION each)..."
|
echo "Running fuzz tests ($DURATION each)..."
|
||||||
|
|||||||
245
cmd/cheat/cheatpath_integration_test.go
Normal file
245
cmd/cheat/cheatpath_integration_test.go
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// hasCwdCheatpath checks whether the --directories output contains a
|
||||||
|
// cheatpath named "cwd". The output format is "name: path\n" per line
|
||||||
|
// (tabwriter-aligned), so we look for a line beginning with "cwd".
|
||||||
|
func hasCwdCheatpath(output string) bool {
|
||||||
|
for _, line := range strings.Split(output, "\n") {
|
||||||
|
if strings.HasPrefix(line, "cwd") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLocalCheatpathIntegration exercises the recursive .cheat directory
|
||||||
|
// discovery end-to-end: it builds the real cheat binary, sets up filesystem
|
||||||
|
// layouts, and verifies behaviour from the user's perspective.
|
||||||
|
func TestLocalCheatpathIntegration(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("integration test uses Unix-specific env vars")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the cheat binary once for all sub-tests.
|
||||||
|
binPath := filepath.Join(t.TempDir(), "cheat_test")
|
||||||
|
build := exec.Command("go", "build", "-o", binPath, ".")
|
||||||
|
if output, err := build.CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("failed to build cheat: %v\nOutput: %s", err, output)
|
||||||
|
}
|
||||||
|
|
||||||
|
// cheatEnv returns a minimal environment for the cheat binary.
|
||||||
|
cheatEnv := func(confPath, home string) []string {
|
||||||
|
return []string{
|
||||||
|
"CHEAT_CONFIG_PATH=" + confPath,
|
||||||
|
"HOME=" + home,
|
||||||
|
"PATH=" + os.Getenv("PATH"),
|
||||||
|
"EDITOR=vi",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeConfig writes a minimal valid config file referencing sheetsDir.
|
||||||
|
writeConfig := func(t *testing.T, dir, sheetsDir string) string {
|
||||||
|
t.Helper()
|
||||||
|
conf := fmt.Sprintf("---\neditor: vi\ncolorize: false\ncheatpaths:\n - name: base\n path: %s\n readonly: true\n", sheetsDir)
|
||||||
|
confPath := filepath.Join(dir, "conf.yml")
|
||||||
|
if err := os.WriteFile(confPath, []byte(conf), 0644); err != nil {
|
||||||
|
t.Fatalf("failed to write config: %v", err)
|
||||||
|
}
|
||||||
|
return confPath
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("parent .cheat is discovered from subdirectory", func(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
|
||||||
|
// Configured cheatpath (empty but must exist for validation)
|
||||||
|
sheetsDir := filepath.Join(root, "sheets")
|
||||||
|
os.MkdirAll(sheetsDir, 0755)
|
||||||
|
|
||||||
|
// .cheat at root with a cheatsheet
|
||||||
|
dotCheat := filepath.Join(root, ".cheat")
|
||||||
|
os.Mkdir(dotCheat, 0755)
|
||||||
|
os.WriteFile(
|
||||||
|
filepath.Join(dotCheat, "localsheet"),
|
||||||
|
[]byte("---\nsyntax: bash\n---\necho hello from local\n"),
|
||||||
|
0644,
|
||||||
|
)
|
||||||
|
|
||||||
|
confPath := writeConfig(t, root, sheetsDir)
|
||||||
|
|
||||||
|
// Work from a subdirectory
|
||||||
|
workDir := filepath.Join(root, "src", "pkg")
|
||||||
|
os.MkdirAll(workDir, 0755)
|
||||||
|
env := cheatEnv(confPath, root)
|
||||||
|
|
||||||
|
// --directories should list "cwd" cheatpath
|
||||||
|
cmd := exec.Command(binPath, "--directories")
|
||||||
|
cmd.Dir = workDir
|
||||||
|
cmd.Env = env
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("cheat --directories failed: %v\nOutput: %s", err, output)
|
||||||
|
}
|
||||||
|
if !hasCwdCheatpath(string(output)) {
|
||||||
|
t.Errorf("expected 'cwd' cheatpath in --directories output:\n%s", output)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Viewing the cheatsheet should show its content
|
||||||
|
cmd2 := exec.Command(binPath, "localsheet")
|
||||||
|
cmd2.Dir = workDir
|
||||||
|
cmd2.Env = env
|
||||||
|
output2, err := cmd2.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("cheat localsheet failed: %v\nOutput: %s", err, output2)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(output2), "echo hello from local") {
|
||||||
|
t.Errorf("expected cheatsheet content, got:\n%s", output2)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("grandparent .cheat is discovered from deep subdirectory", func(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
|
||||||
|
sheetsDir := filepath.Join(root, "sheets")
|
||||||
|
os.MkdirAll(sheetsDir, 0755)
|
||||||
|
|
||||||
|
dotCheat := filepath.Join(root, ".cheat")
|
||||||
|
os.Mkdir(dotCheat, 0755)
|
||||||
|
os.WriteFile(
|
||||||
|
filepath.Join(dotCheat, "deepsheet"),
|
||||||
|
[]byte("---\nsyntax: bash\n---\ndeep discovery works\n"),
|
||||||
|
0644,
|
||||||
|
)
|
||||||
|
|
||||||
|
confPath := writeConfig(t, root, sheetsDir)
|
||||||
|
|
||||||
|
deepDir := filepath.Join(root, "a", "b", "c", "d", "e")
|
||||||
|
os.MkdirAll(deepDir, 0755)
|
||||||
|
|
||||||
|
cmd := exec.Command(binPath, "deepsheet")
|
||||||
|
cmd.Dir = deepDir
|
||||||
|
cmd.Env = cheatEnv(confPath, root)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("cheat deepsheet failed: %v\nOutput: %s", err, output)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(output), "deep discovery works") {
|
||||||
|
t.Errorf("expected cheatsheet content, got:\n%s", output)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("nearest .cheat wins over ancestor .cheat", func(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
|
||||||
|
sheetsDir := filepath.Join(root, "sheets")
|
||||||
|
os.MkdirAll(sheetsDir, 0755)
|
||||||
|
|
||||||
|
// .cheat at root
|
||||||
|
rootCheat := filepath.Join(root, ".cheat")
|
||||||
|
os.Mkdir(rootCheat, 0755)
|
||||||
|
os.WriteFile(
|
||||||
|
filepath.Join(rootCheat, "shared"),
|
||||||
|
[]byte("---\nsyntax: bash\n---\nfrom root\n"),
|
||||||
|
0644,
|
||||||
|
)
|
||||||
|
|
||||||
|
// .cheat at project/ (nearer)
|
||||||
|
projectDir := filepath.Join(root, "project")
|
||||||
|
os.MkdirAll(projectDir, 0755)
|
||||||
|
projectCheat := filepath.Join(projectDir, ".cheat")
|
||||||
|
os.Mkdir(projectCheat, 0755)
|
||||||
|
os.WriteFile(
|
||||||
|
filepath.Join(projectCheat, "shared"),
|
||||||
|
[]byte("---\nsyntax: bash\n---\nfrom project nearest\n"),
|
||||||
|
0644,
|
||||||
|
)
|
||||||
|
|
||||||
|
confPath := writeConfig(t, root, sheetsDir)
|
||||||
|
|
||||||
|
workDir := filepath.Join(projectDir, "src")
|
||||||
|
os.MkdirAll(workDir, 0755)
|
||||||
|
env := cheatEnv(confPath, root)
|
||||||
|
|
||||||
|
// --directories should list the nearer cheatpath
|
||||||
|
cmd := exec.Command(binPath, "--directories")
|
||||||
|
cmd.Dir = workDir
|
||||||
|
cmd.Env = env
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("cheat --directories failed: %v\nOutput: %s", err, output)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(output), projectCheat) {
|
||||||
|
t.Errorf("expected project .cheat path in output, got:\n%s", output)
|
||||||
|
}
|
||||||
|
|
||||||
|
// "shared" sheet should come from the nearer .cheat
|
||||||
|
cmd2 := exec.Command(binPath, "shared")
|
||||||
|
cmd2.Dir = workDir
|
||||||
|
cmd2.Env = env
|
||||||
|
output2, err := cmd2.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("cheat shared failed: %v\nOutput: %s", err, output2)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(output2), "from project nearest") {
|
||||||
|
t.Errorf("expected nearest .cheat content, got:\n%s", output2)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("no .cheat directory means no cwd cheatpath", func(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
|
||||||
|
sheetsDir := filepath.Join(root, "sheets")
|
||||||
|
os.MkdirAll(sheetsDir, 0755)
|
||||||
|
// Need at least one sheet for --directories to work without error
|
||||||
|
os.WriteFile(filepath.Join(sheetsDir, "placeholder"),
|
||||||
|
[]byte("---\nsyntax: bash\n---\nplaceholder\n"), 0644)
|
||||||
|
|
||||||
|
confPath := writeConfig(t, root, sheetsDir)
|
||||||
|
|
||||||
|
// No .cheat anywhere under root
|
||||||
|
cmd := exec.Command(binPath, "--directories")
|
||||||
|
cmd.Dir = root
|
||||||
|
cmd.Env = cheatEnv(confPath, root)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("cheat --directories failed: %v\nOutput: %s", err, output)
|
||||||
|
}
|
||||||
|
if hasCwdCheatpath(string(output)) {
|
||||||
|
t.Errorf("'cwd' cheatpath should not appear when no .cheat exists:\n%s", output)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run(".cheat file (not directory) is ignored", func(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
|
||||||
|
sheetsDir := filepath.Join(root, "sheets")
|
||||||
|
os.MkdirAll(sheetsDir, 0755)
|
||||||
|
os.WriteFile(filepath.Join(sheetsDir, "placeholder"),
|
||||||
|
[]byte("---\nsyntax: bash\n---\nplaceholder\n"), 0644)
|
||||||
|
|
||||||
|
// Create .cheat as a regular file
|
||||||
|
os.WriteFile(filepath.Join(root, ".cheat"), []byte("not a dir"), 0644)
|
||||||
|
|
||||||
|
confPath := writeConfig(t, root, sheetsDir)
|
||||||
|
|
||||||
|
cmd := exec.Command(binPath, "--directories")
|
||||||
|
cmd.Dir = root
|
||||||
|
cmd.Env = cheatEnv(confPath, root)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("cheat --directories failed: %v\nOutput: %s", err, output)
|
||||||
|
}
|
||||||
|
if hasCwdCheatpath(string(output)) {
|
||||||
|
t.Errorf("'cwd' should not appear for a .cheat file:\n%s", output)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
80
doc/adr/004-recursive-cheat-directory-search.md
Normal file
80
doc/adr/004-recursive-cheat-directory-search.md
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
# ADR-004: Recursive `.cheat` Directory Search
|
||||||
|
|
||||||
|
Date: 2026-02-15
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Previously, `cheat` only checked the current working directory for a `.cheat`
|
||||||
|
subdirectory to use as a directory-scoped cheatpath. If a user was in
|
||||||
|
`~/projects/myapp/src/handlers/` but the `.cheat` directory lived at
|
||||||
|
`~/projects/myapp/.cheat`, it would not be found. Users requested (#602) that
|
||||||
|
`cheat` walk up the directory hierarchy to find the nearest `.cheat`
|
||||||
|
directory, mirroring the discovery pattern used by `git` for `.git`
|
||||||
|
directories.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Walk upward from the current working directory to the filesystem root, and
|
||||||
|
stop at the first `.cheat` directory found. Only directories are matched (a
|
||||||
|
file named `.cheat` is ignored).
|
||||||
|
|
||||||
|
### Stop at first `.cheat` found
|
||||||
|
|
||||||
|
Rather than collecting multiple `.cheat` directories from ancestor directories:
|
||||||
|
|
||||||
|
- Matches `.git` discovery semantics, which users already understand
|
||||||
|
- Fits the existing single-cheatpath-named-`"cwd"` code without structural
|
||||||
|
changes
|
||||||
|
- Avoids precedence and naming complexity when multiple `.cheat` directories
|
||||||
|
exist in the ancestor chain
|
||||||
|
- `cheat` already supports multiple cheatpaths via `conf.yml` for users who
|
||||||
|
want that; directory-scoped `.cheat` serves the project-context use case
|
||||||
|
|
||||||
|
### Walk to filesystem root (not `$HOME`)
|
||||||
|
|
||||||
|
Rather than stopping the search at `$HOME`:
|
||||||
|
|
||||||
|
- Simpler implementation with no platform-specific home-directory detection
|
||||||
|
- Supports sysadmins working in `/etc`, `/srv`, `/var`, or other paths
|
||||||
|
outside `$HOME`
|
||||||
|
- The boundary only matters on the failure path (no `.cheat` found anywhere),
|
||||||
|
where the cost is a few extra `stat` calls
|
||||||
|
- Security is not a concern since cheatsheets are display-only text, not
|
||||||
|
executable code
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
### Positive
|
||||||
|
- Users can place `.cheat` at their project root and it works from any
|
||||||
|
subdirectory, matching their mental model
|
||||||
|
- No configuration changes needed; existing `.cheat` directories continue to
|
||||||
|
work identically
|
||||||
|
- Minimal code change (one small helper function)
|
||||||
|
|
||||||
|
### Negative
|
||||||
|
- A `.cheat` directory in an unexpected ancestor could be picked up
|
||||||
|
unintentionally, though this is unlikely in practice and matches how `.git`
|
||||||
|
works
|
||||||
|
|
||||||
|
### Neutral
|
||||||
|
- The cheatpath name remains `"cwd"` regardless of which ancestor the `.cheat`
|
||||||
|
was found in
|
||||||
|
|
||||||
|
## Alternatives Considered
|
||||||
|
|
||||||
|
### 1. Stop at `$HOME`
|
||||||
|
**Rejected**: Adds platform-specific complexity for minimal benefit. The only
|
||||||
|
downside of walking to root is a few extra `stat` calls on the failure path.
|
||||||
|
|
||||||
|
### 2. Collect multiple `.cheat` directories
|
||||||
|
**Rejected**: Introduces precedence and naming complexity. Users who want
|
||||||
|
multiple cheatpaths can configure them in `conf.yml`.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- GitHub issue: #602
|
||||||
|
- Implementation: `findLocalCheatpath()` in `internal/config/config.go`
|
||||||
@@ -27,8 +27,9 @@
|
|||||||
// # Directory-Scoped Cheatpaths
|
// # Directory-Scoped Cheatpaths
|
||||||
//
|
//
|
||||||
// The package supports directory-scoped cheatpaths via `.cheat` directories.
|
// The package supports directory-scoped cheatpaths via `.cheat` directories.
|
||||||
// When running cheat from a directory containing a `.cheat` subdirectory,
|
// When running cheat, the tool walks upward from the current working directory
|
||||||
// that directory is temporarily added to the available cheatpaths.
|
// to the filesystem root, stopping at the first `.cheat` directory found. That
|
||||||
|
// directory is temporarily added to the available cheatpaths.
|
||||||
//
|
//
|
||||||
// # Precedence and Overrides
|
// # Precedence and Overrides
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -45,21 +45,20 @@ func New(_ map[string]interface{}, confPath string, resolve bool) (Config, error
|
|||||||
return Config{}, fmt.Errorf("could not unmarshal yaml: %v", err)
|
return Config{}, fmt.Errorf("could not unmarshal yaml: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// if a .cheat directory exists locally, append it to the cheatpaths
|
// if a .cheat directory exists in the current directory or any ancestor,
|
||||||
|
// append it to the cheatpaths
|
||||||
cwd, err := os.Getwd()
|
cwd, err := os.Getwd()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Config{}, fmt.Errorf("failed to get cwd: %v", err)
|
return Config{}, fmt.Errorf("failed to get cwd: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
local := filepath.Join(cwd, ".cheat")
|
if local := findLocalCheatpath(cwd); local != "" {
|
||||||
if _, err := os.Stat(local); err == nil {
|
|
||||||
path := cp.Cheatpath{
|
path := cp.Cheatpath{
|
||||||
Name: "cwd",
|
Name: "cwd",
|
||||||
Path: local,
|
Path: local,
|
||||||
ReadOnly: false,
|
ReadOnly: false,
|
||||||
Tags: []string{},
|
Tags: []string{},
|
||||||
}
|
}
|
||||||
|
|
||||||
conf.Cheatpaths = append(conf.Cheatpaths, path)
|
conf.Cheatpaths = append(conf.Cheatpaths, path)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,3 +139,21 @@ func New(_ map[string]interface{}, confPath string, resolve bool) (Config, error
|
|||||||
|
|
||||||
return conf, nil
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -86,6 +86,187 @@ func TestConfigLocalCheatpath(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
// TestConfigDefaults tests default values
|
||||||
func TestConfigDefaults(t *testing.T) {
|
func TestConfigDefaults(t *testing.T) {
|
||||||
// Load empty config
|
// Load empty config
|
||||||
|
|||||||
122
internal/config/config_fuzz_test.go
Normal file
122
internal/config/config_fuzz_test.go
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FuzzFindLocalCheatpath exercises findLocalCheatpath with randomised
|
||||||
|
// directory depths and .cheat placements. For each fuzz input it builds a
|
||||||
|
// temporary directory hierarchy, places a single .cheat directory at a
|
||||||
|
// computed level, and asserts that the function always returns it.
|
||||||
|
func FuzzFindLocalCheatpath(f *testing.F) {
|
||||||
|
// Seed corpus: (totalDepth, cheatPlacement)
|
||||||
|
f.Add(uint8(1), uint8(0)) // depth 1, .cheat at root
|
||||||
|
f.Add(uint8(3), uint8(0)) // depth 3, .cheat at root
|
||||||
|
f.Add(uint8(5), uint8(3)) // depth 5, .cheat at level 3
|
||||||
|
f.Add(uint8(1), uint8(1)) // depth 1, .cheat at same level as search dir
|
||||||
|
f.Add(uint8(10), uint8(5)) // deep hierarchy
|
||||||
|
|
||||||
|
f.Fuzz(func(t *testing.T, totalDepth uint8, cheatPlacement uint8) {
|
||||||
|
// Clamp to reasonable values to keep I/O bounded
|
||||||
|
depth := int(totalDepth%15) + 1 // 1..15
|
||||||
|
cheatAt := int(cheatPlacement) % (depth + 1) // 0..depth (0 = tempDir itself)
|
||||||
|
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
|
// Build chain: tempDir/d0/d1/…/d{depth-1}
|
||||||
|
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 dirs[cheatAt]
|
||||||
|
cheatDir := filepath.Join(dirs[cheatAt], ".cheat")
|
||||||
|
if err := os.Mkdir(cheatDir, 0755); err != nil {
|
||||||
|
t.Fatalf("mkdir .cheat: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search from the deepest directory
|
||||||
|
result := findLocalCheatpath(current)
|
||||||
|
|
||||||
|
// Invariant 1: must find the .cheat we placed
|
||||||
|
if result != cheatDir {
|
||||||
|
t.Errorf("depth=%d cheatAt=%d: expected %s, got %s",
|
||||||
|
depth, cheatAt, cheatDir, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invariant 2: result must end with /.cheat
|
||||||
|
if !strings.HasSuffix(result, string(filepath.Separator)+".cheat") {
|
||||||
|
t.Errorf("result %q does not end with /.cheat", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invariant 3: result must be under tempDir
|
||||||
|
if !strings.HasPrefix(result, tempDir) {
|
||||||
|
t.Errorf("result %q is not under tempDir %s", result, tempDir)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"runtime"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/davecgh/go-spew/spew"
|
"github.com/davecgh/go-spew/spew"
|
||||||
@@ -13,9 +14,267 @@ import (
|
|||||||
"github.com/cheat/cheat/internal/mock"
|
"github.com/cheat/cheat/internal/mock"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// TestFindLocalCheatpathInCurrentDir tests that .cheat in the given dir is found
|
||||||
|
func TestFindLocalCheatpathInCurrentDir(t *testing.T) {
|
||||||
|
tempDir, err := os.MkdirTemp("", "cheat-test-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create temp dir: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tempDir)
|
||||||
|
|
||||||
|
cheatDir := filepath.Join(tempDir, ".cheat")
|
||||||
|
if err := os.Mkdir(cheatDir, 0755); err != nil {
|
||||||
|
t.Fatalf("failed to create .cheat dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := findLocalCheatpath(tempDir)
|
||||||
|
if result != cheatDir {
|
||||||
|
t.Errorf("expected %s, got %s", cheatDir, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFindLocalCheatpathInParent tests walking up to a parent directory
|
||||||
|
func TestFindLocalCheatpathInParent(t *testing.T) {
|
||||||
|
tempDir, err := os.MkdirTemp("", "cheat-test-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create temp dir: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tempDir)
|
||||||
|
|
||||||
|
cheatDir := filepath.Join(tempDir, ".cheat")
|
||||||
|
if err := os.Mkdir(cheatDir, 0755); err != nil {
|
||||||
|
t.Fatalf("failed to create .cheat dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
subDir := filepath.Join(tempDir, "sub")
|
||||||
|
if err := os.Mkdir(subDir, 0755); err != nil {
|
||||||
|
t.Fatalf("failed to create sub dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := findLocalCheatpath(subDir)
|
||||||
|
if result != cheatDir {
|
||||||
|
t.Errorf("expected %s, got %s", cheatDir, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFindLocalCheatpathInGrandparent tests walking up multiple levels
|
||||||
|
func TestFindLocalCheatpathInGrandparent(t *testing.T) {
|
||||||
|
tempDir, err := os.MkdirTemp("", "cheat-test-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create temp dir: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tempDir)
|
||||||
|
|
||||||
|
cheatDir := filepath.Join(tempDir, ".cheat")
|
||||||
|
if err := os.Mkdir(cheatDir, 0755); err != nil {
|
||||||
|
t.Fatalf("failed to create .cheat dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
deepDir := filepath.Join(tempDir, "a", "b", "c")
|
||||||
|
if err := os.MkdirAll(deepDir, 0755); err != nil {
|
||||||
|
t.Fatalf("failed to create deep dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := findLocalCheatpath(deepDir)
|
||||||
|
if result != cheatDir {
|
||||||
|
t.Errorf("expected %s, got %s", cheatDir, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFindLocalCheatpathNearestWins tests that the closest .cheat is returned
|
||||||
|
func TestFindLocalCheatpathNearestWins(t *testing.T) {
|
||||||
|
tempDir, err := os.MkdirTemp("", "cheat-test-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create temp dir: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tempDir)
|
||||||
|
|
||||||
|
// Create .cheat at root level
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
nearCheatDir := filepath.Join(subDir, ".cheat")
|
||||||
|
if err := os.Mkdir(nearCheatDir, 0755); err != nil {
|
||||||
|
t.Fatalf("failed to create sub .cheat dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search from sub/deep/
|
||||||
|
deepDir := filepath.Join(subDir, "deep")
|
||||||
|
if err := os.Mkdir(deepDir, 0755); err != nil {
|
||||||
|
t.Fatalf("failed to create deep dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := findLocalCheatpath(deepDir)
|
||||||
|
if result != nearCheatDir {
|
||||||
|
t.Errorf("expected nearest %s, got %s", nearCheatDir, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFindLocalCheatpathNotFound tests that empty string is returned when no .cheat exists
|
||||||
|
func TestFindLocalCheatpathNotFound(t *testing.T) {
|
||||||
|
tempDir, err := os.MkdirTemp("", "cheat-test-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create temp dir: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tempDir)
|
||||||
|
|
||||||
|
result := findLocalCheatpath(tempDir)
|
||||||
|
if result != "" {
|
||||||
|
t.Errorf("expected empty string, got %s", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFindLocalCheatpathSkipsFile tests that a file named .cheat is not matched
|
||||||
|
func TestFindLocalCheatpathSkipsFile(t *testing.T) {
|
||||||
|
tempDir, err := os.MkdirTemp("", "cheat-test-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create temp dir: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tempDir)
|
||||||
|
|
||||||
|
// Create .cheat as a file, not a directory
|
||||||
|
cheatFile := filepath.Join(tempDir, ".cheat")
|
||||||
|
if err := os.WriteFile(cheatFile, []byte("not a directory"), 0644); err != nil {
|
||||||
|
t.Fatalf("failed to create .cheat file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := findLocalCheatpath(tempDir)
|
||||||
|
if result != "" {
|
||||||
|
t.Errorf("expected empty string for .cheat file, got %s", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFindLocalCheatpathSymlink tests that a .cheat symlink to a directory is found
|
||||||
|
func TestFindLocalCheatpathSymlink(t *testing.T) {
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
|
// Create the real directory
|
||||||
|
realDir := filepath.Join(tempDir, "real-cheat")
|
||||||
|
if err := os.Mkdir(realDir, 0755); err != nil {
|
||||||
|
t.Fatalf("failed to create real dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Symlink .cheat -> real-cheat
|
||||||
|
cheatLink := filepath.Join(tempDir, ".cheat")
|
||||||
|
if err := os.Symlink(realDir, cheatLink); err != nil {
|
||||||
|
t.Fatalf("failed to create symlink: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := findLocalCheatpath(tempDir)
|
||||||
|
if result != cheatLink {
|
||||||
|
t.Errorf("expected %s, got %s", cheatLink, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFindLocalCheatpathSymlinkInAncestor tests discovery through a symlinked
|
||||||
|
// ancestor directory. When the cwd is reached via a symlink, filepath.Dir
|
||||||
|
// walks the symlinked path (not the real path), so .cheat must be findable
|
||||||
|
// through that chain.
|
||||||
|
func TestFindLocalCheatpathSymlinkInAncestor(t *testing.T) {
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
|
// Create real/project/.cheat
|
||||||
|
realProject := filepath.Join(tempDir, "real", "project")
|
||||||
|
if err := os.MkdirAll(realProject, 0755); err != nil {
|
||||||
|
t.Fatalf("failed to create real project dir: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.Mkdir(filepath.Join(realProject, ".cheat"), 0755); err != nil {
|
||||||
|
t.Fatalf("failed to create .cheat dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create symlink: linked -> real/project
|
||||||
|
linkedProject := filepath.Join(tempDir, "linked")
|
||||||
|
if err := os.Symlink(realProject, linkedProject); err != nil {
|
||||||
|
t.Fatalf("failed to create symlink: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create sub inside the symlinked path
|
||||||
|
subDir := filepath.Join(linkedProject, "sub")
|
||||||
|
if err := os.Mkdir(subDir, 0755); err != nil {
|
||||||
|
t.Fatalf("failed to create sub dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search from linked/sub — should find linked/.cheat
|
||||||
|
// (os.Stat follows symlinks, so linked/.cheat resolves to real/project/.cheat)
|
||||||
|
result := findLocalCheatpath(subDir)
|
||||||
|
expected := filepath.Join(linkedProject, ".cheat")
|
||||||
|
if result != expected {
|
||||||
|
t.Errorf("expected %s, got %s", expected, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFindLocalCheatpathPermissionDenied tests that unreadable ancestor
|
||||||
|
// directories are skipped and the walk continues upward.
|
||||||
|
func TestFindLocalCheatpathPermissionDenied(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("Unix permissions do not apply on Windows")
|
||||||
|
}
|
||||||
|
if os.Getuid() == 0 {
|
||||||
|
t.Skip("test requires non-root user")
|
||||||
|
}
|
||||||
|
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
|
// Resolve symlinks (macOS /var -> /private/var)
|
||||||
|
tempDir, err := filepath.EvalSymlinks(tempDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to resolve symlinks: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create tempDir/.cheat (the target we want found)
|
||||||
|
cheatDir := filepath.Join(tempDir, ".cheat")
|
||||||
|
if err := os.Mkdir(cheatDir, 0755); err != nil {
|
||||||
|
t.Fatalf("failed to create .cheat dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create tempDir/restricted/ with its own .cheat and sub/
|
||||||
|
restricted := filepath.Join(tempDir, "restricted")
|
||||||
|
if err := os.Mkdir(restricted, 0755); err != nil {
|
||||||
|
t.Fatalf("failed to create restricted dir: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.Mkdir(filepath.Join(restricted, ".cheat"), 0755); err != nil {
|
||||||
|
t.Fatalf("failed to create restricted .cheat dir: %v", err)
|
||||||
|
}
|
||||||
|
subDir := filepath.Join(restricted, "sub")
|
||||||
|
if err := os.Mkdir(subDir, 0755); err != nil {
|
||||||
|
t.Fatalf("failed to create sub dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make restricted/ unreadable — blocks stat of children
|
||||||
|
if err := os.Chmod(restricted, 0000); err != nil {
|
||||||
|
t.Fatalf("failed to chmod: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { os.Chmod(restricted, 0755) })
|
||||||
|
|
||||||
|
// Walk from restricted/sub: stat("restricted/sub/.cheat") fails (EACCES),
|
||||||
|
// stat("restricted/.cheat") fails (EACCES), walk continues to tempDir/.cheat
|
||||||
|
result := findLocalCheatpath(subDir)
|
||||||
|
if result != cheatDir {
|
||||||
|
t.Errorf("expected %s (walked past restricted dir), got %s", cheatDir, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestConfig asserts that the configs are loaded correctly
|
// TestConfig asserts that the configs are loaded correctly
|
||||||
func TestConfigSuccessful(t *testing.T) {
|
func TestConfigSuccessful(t *testing.T) {
|
||||||
|
|
||||||
|
// Chdir into a temp directory so no ancestor .cheat directory can
|
||||||
|
// leak into the cheatpaths (findLocalCheatpath walks the full
|
||||||
|
// ancestor chain).
|
||||||
|
oldCwd, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to get cwd: %v", err)
|
||||||
|
}
|
||||||
|
defer os.Chdir(oldCwd)
|
||||||
|
if err := os.Chdir(t.TempDir()); err != nil {
|
||||||
|
t.Fatalf("failed to chdir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
// clear env vars so they don't override the config file value
|
// clear env vars so they don't override the config file value
|
||||||
oldVisual := os.Getenv("VISUAL")
|
oldVisual := os.Getenv("VISUAL")
|
||||||
oldEditor := os.Getenv("EDITOR")
|
oldEditor := os.Getenv("EDITOR")
|
||||||
|
|||||||
Reference in New Issue
Block a user