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")