From c1551683a3a41d7557562c439be4de72f954f1fc Mon Sep 17 00:00:00 2001 From: Christopher Allen Lane Date: Sun, 15 Feb 2026 09:06:25 -0500 Subject: [PATCH 1/2] 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 --- README.md | 8 +- build/fuzz.sh | 2 + cmd/cheat/cheatpath_integration_test.go | 245 +++++++++++++++++ .../004-recursive-cheat-directory-search.md | 80 ++++++ internal/cheatpath/doc.go | 5 +- internal/config/config.go | 25 +- internal/config/config_extended_test.go | 181 ++++++++++++ internal/config/config_fuzz_test.go | 122 +++++++++ internal/config/config_test.go | 259 ++++++++++++++++++ 9 files changed, 918 insertions(+), 9 deletions(-) create mode 100644 cmd/cheat/cheatpath_integration_test.go create mode 100644 doc/adr/004-recursive-cheat-directory-search.md create mode 100644 internal/config/config_fuzz_test.go diff --git a/README.md b/README.md index 49af518..732fb8d 100644 --- a/README.md +++ b/README.md @@ -172,9 +172,11 @@ editing. ### Directory-scoped Cheatpaths ### 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 -the current working directory. If found, the `.cheat` directory will -(temporarily) be added to the cheatpaths. +your filesystem. `cheat` facilitates this by searching for a `.cheat` directory +in the current working directory and its ancestors (similar to how `git` locates +`.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 -------------- diff --git a/build/fuzz.sh b/build/fuzz.sh index aac9668..ccc9502 100755 --- a/build/fuzz.sh +++ b/build/fuzz.sh @@ -22,6 +22,8 @@ TESTS=( "FuzzTagged:./internal/sheet:tag matching with malicious input" "FuzzFilter:./internal/sheets:tag filtering operations" "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)..." diff --git a/cmd/cheat/cheatpath_integration_test.go b/cmd/cheat/cheatpath_integration_test.go new file mode 100644 index 0000000..309330a --- /dev/null +++ b/cmd/cheat/cheatpath_integration_test.go @@ -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) + } + }) +} diff --git a/doc/adr/004-recursive-cheat-directory-search.md b/doc/adr/004-recursive-cheat-directory-search.md new file mode 100644 index 0000000..c152408 --- /dev/null +++ b/doc/adr/004-recursive-cheat-directory-search.md @@ -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` diff --git a/internal/cheatpath/doc.go b/internal/cheatpath/doc.go index 8c2a030..6a848c2 100644 --- a/internal/cheatpath/doc.go +++ b/internal/cheatpath/doc.go @@ -27,8 +27,9 @@ // # Directory-Scoped Cheatpaths // // The package supports directory-scoped cheatpaths via `.cheat` directories. -// When running cheat from a directory containing a `.cheat` subdirectory, -// that directory is temporarily added to the available cheatpaths. +// When running cheat, the tool walks upward from the current working directory +// to the filesystem root, stopping at the first `.cheat` directory found. That +// directory is temporarily added to the available cheatpaths. // // # Precedence and Overrides // diff --git a/internal/config/config.go b/internal/config/config.go index d87bfb5..ea52e33 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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) } - // 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() if err != nil { return Config{}, fmt.Errorf("failed to get cwd: %v", err) } - local := filepath.Join(cwd, ".cheat") - if _, err := os.Stat(local); err == nil { + if local := findLocalCheatpath(cwd); local != "" { path := cp.Cheatpath{ Name: "cwd", Path: local, ReadOnly: false, Tags: []string{}, } - conf.Cheatpaths = append(conf.Cheatpaths, path) } @@ -140,3 +139,21 @@ func New(_ map[string]interface{}, confPath string, resolve bool) (Config, error 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 + } +} diff --git a/internal/config/config_extended_test.go b/internal/config/config_extended_test.go index 96f1322..c997030 100644 --- a/internal/config/config_extended_test.go +++ b/internal/config/config_extended_test.go @@ -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 func TestConfigDefaults(t *testing.T) { // Load empty config diff --git a/internal/config/config_fuzz_test.go b/internal/config/config_fuzz_test.go new file mode 100644 index 0000000..801795d --- /dev/null +++ b/internal/config/config_fuzz_test.go @@ -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) + } + }) +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 9795118..ca07a13 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -4,6 +4,7 @@ import ( "os" "path/filepath" "reflect" + "runtime" "testing" "github.com/davecgh/go-spew/spew" @@ -13,9 +14,267 @@ import ( "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 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 oldVisual := os.Getenv("VISUAL") oldEditor := os.Getenv("EDITOR") From 366d63afdcfe7a74572efb4b600127117432a454 Mon Sep 17 00:00:00 2001 From: Christopher Allen Lane Date: Sun, 15 Feb 2026 11:23:31 -0500 Subject: [PATCH 2/2] chore: bump version to 4.6.0 Co-Authored-By: Claude Opus 4.6 --- cmd/cheat/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/cheat/main.go b/cmd/cheat/main.go index 6541eb8..90531ee 100755 --- a/cmd/cheat/main.go +++ b/cmd/cheat/main.go @@ -15,7 +15,7 @@ import ( "github.com/cheat/cheat/internal/installer" ) -const version = "4.5.2" +const version = "4.6.0" func main() {