Compare commits

..

1 Commits

Author SHA1 Message Date
Christopher Allen Lane
5ad1a3c39f chore: housekeeping and refactoring (bump to 4.7.1)
- Remove unused parameters, dead files, and inaccurate doc.go files
- Extract shared helpers, eliminate duplication
- Rename cheatpath.Cheatpath to cheatpath.Path
- Optimize filesystem walks (WalkDir, skip .git)
- Move sheet name validation to sheet.Validate
- Move integration tests to test/integration/
- Consolidate internal/mock into mocks/
- Move fuzz.sh to test/
- Inline loadSheets helper into command callers
- Extract config.New into its own file
- Fix stale references in HACKING.md and CLAUDE.md
- Restore plan9 build target
- Remove redundant and low-value tests
- Clean up project documentation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 15:11:19 -05:00
53 changed files with 508 additions and 848 deletions

View File

@@ -119,4 +119,4 @@ ssh -L 8080:localhost:80 user@remote
- Use `go-git` for repository operations, not exec'ing git commands - Use `go-git` for repository operations, not exec'ing git commands
- Platform-specific paths are handled in `internal/config/paths.go` - Platform-specific paths are handled in `internal/config/paths.go`
- Color output uses ANSI codes via the Chroma library - Color output uses ANSI codes via the Chroma library
- Test files use the `internal/mock` package for test data - Test files use the `mocks` package for test data

View File

@@ -88,7 +88,7 @@ The main configuration structure:
type Config struct { type Config struct {
Colorize bool `yaml:"colorize"` Colorize bool `yaml:"colorize"`
Editor string `yaml:"editor"` Editor string `yaml:"editor"`
Cheatpaths []cp.Cheatpath `yaml:"cheatpaths"` Cheatpaths []cp.Path `yaml:"cheatpaths"`
Style string `yaml:"style"` Style string `yaml:"style"`
Formatter string `yaml:"formatter"` Formatter string `yaml:"formatter"`
Pager string `yaml:"pager"` Pager string `yaml:"pager"`
@@ -97,7 +97,7 @@ type Config struct {
``` ```
Key functions: Key functions:
- `New(opts, confPath, resolve)` - Load config from file - `New(confPath, resolve)` - Load config from file
- `Validate()` - Validate configuration values - `Validate()` - Validate configuration values
- `Editor()` - Get editor from environment or defaults (package-level function) - `Editor()` - Get editor from environment or defaults (package-level function)
- `Pager()` - Get pager from environment or defaults (package-level function) - `Pager()` - Get pager from environment or defaults (package-level function)
@@ -107,7 +107,7 @@ Key functions:
Represents a directory containing cheatsheets: Represents a directory containing cheatsheets:
```go ```go
type Cheatpath struct { type Path struct {
Name string // Friendly name (e.g., "personal") Name string // Friendly name (e.g., "personal")
Path string // Filesystem path Path string // Filesystem path
Tags []string // Tags applied to all sheets in this path Tags []string // Tags applied to all sheets in this path
@@ -202,7 +202,7 @@ go test ./... # Go test directly
Test files follow Go conventions: Test files follow Go conventions:
- `*_test.go` files in same package - `*_test.go` files in same package
- Table-driven tests for multiple scenarios - Table-driven tests for multiple scenarios
- Mock data in `internal/mock` package - Mock data in `mocks` package
## Error Handling ## Error Handling

View File

@@ -44,6 +44,7 @@ releases := \
$(dist_dir)/cheat-linux-arm7 \ $(dist_dir)/cheat-linux-arm7 \
$(dist_dir)/cheat-netbsd-amd64 \ $(dist_dir)/cheat-netbsd-amd64 \
$(dist_dir)/cheat-openbsd-amd64 \ $(dist_dir)/cheat-openbsd-amd64 \
$(dist_dir)/cheat-plan9-amd64 \
$(dist_dir)/cheat-solaris-amd64 \ $(dist_dir)/cheat-solaris-amd64 \
$(dist_dir)/cheat-windows-amd64.exe $(dist_dir)/cheat-windows-amd64.exe
@@ -213,12 +214,12 @@ test-all: test test-integration
## test-fuzz: run quick fuzz tests for security-critical functions ## test-fuzz: run quick fuzz tests for security-critical functions
.PHONY: test-fuzz .PHONY: test-fuzz
test-fuzz: test-fuzz:
@./build/fuzz.sh 15s @./test/fuzz.sh 15s
## test-fuzz-long: run extended fuzz tests (10 minutes each) ## test-fuzz-long: run extended fuzz tests (10 minutes each)
.PHONY: test-fuzz-long .PHONY: test-fuzz-long
test-fuzz-long: test-fuzz-long:
@./build/fuzz.sh 10m @./test/fuzz.sh 10m
## coverage: generate a test coverage report ## coverage: generate a test coverage report
.PHONY: coverage .PHONY: coverage
@@ -240,22 +241,22 @@ coverage-text: .tmp
## benchmark: run performance benchmarks ## benchmark: run performance benchmarks
.PHONY: benchmark .PHONY: benchmark
benchmark: .tmp benchmark: .tmp
$(GO) test -tags=integration -bench=. -benchtime=10s -benchmem ./cmd/cheat | tee .tmp/benchmark-latest.txt && \ $(GO) test -tags=integration -bench=. -benchtime=10s -benchmem ./test/integration | tee .tmp/benchmark-latest.txt && \
$(RM) -f cheat.test $(RM) -f integration.test
## benchmark-cpu: run benchmarks with CPU profiling ## benchmark-cpu: run benchmarks with CPU profiling
.PHONY: benchmark-cpu .PHONY: benchmark-cpu
benchmark-cpu: .tmp benchmark-cpu: .tmp
$(GO) test -tags=integration -bench=. -benchtime=10s -cpuprofile=.tmp/cpu.prof ./cmd/cheat && \ $(GO) test -tags=integration -bench=. -benchtime=10s -cpuprofile=.tmp/cpu.prof ./test/integration && \
$(RM) -f cheat.test && \ $(RM) -f integration.test && \
echo "CPU profile saved to .tmp/cpu.prof" && \ echo "CPU profile saved to .tmp/cpu.prof" && \
echo "View with: go tool pprof -http=:8080 .tmp/cpu.prof" echo "View with: go tool pprof -http=:8080 .tmp/cpu.prof"
## benchmark-mem: run benchmarks with memory profiling ## benchmark-mem: run benchmarks with memory profiling
.PHONY: benchmark-mem .PHONY: benchmark-mem
benchmark-mem: .tmp benchmark-mem: .tmp
$(GO) test -tags=integration -bench=. -benchtime=10s -benchmem -memprofile=.tmp/mem.prof ./cmd/cheat && \ $(GO) test -tags=integration -bench=. -benchtime=10s -benchmem -memprofile=.tmp/mem.prof ./test/integration && \
$(RM) -f cheat.test && \ $(RM) -f integration.test && \
echo "Memory profile saved to .tmp/mem.prof" && \ echo "Memory profile saved to .tmp/mem.prof" && \
echo "View with: go tool pprof -http=:8080 .tmp/mem.prof" echo "View with: go tool pprof -http=:8080 .tmp/mem.prof"

View File

@@ -38,10 +38,10 @@ The validation is performed at the application layer before any file operations
### Validation Function ### Validation Function
The validation is implemented in `internal/cheatpath/validate.go`: The validation is implemented in `internal/sheet/validate.go`:
```go ```go
func ValidateSheetName(name string) error { func Validate(name string) error {
// Reject empty names // Reject empty names
if name == "" { if name == "" {
return fmt.Errorf("cheatsheet name cannot be empty") return fmt.Errorf("cheatsheet name cannot be empty")
@@ -133,7 +133,7 @@ The following patterns are explicitly allowed:
Comprehensive tests ensure the validation works correctly: Comprehensive tests ensure the validation works correctly:
1. **Unit tests** (`internal/cheatpath/validate_test.go`) verify the validation logic 1. **Unit tests** (`internal/sheet/validate_test.go`) verify the validation logic
2. **Integration tests** verify the actual binary blocks malicious inputs 2. **Integration tests** verify the actual binary blocks malicious inputs
3. **No system files are accessed** during testing - all tests use isolated directories 3. **No system files are accessed** during testing - all tests use isolated directories

View File

@@ -9,6 +9,7 @@ import (
"github.com/cheat/cheat/internal/cheatpath" "github.com/cheat/cheat/internal/cheatpath"
"github.com/cheat/cheat/internal/config" "github.com/cheat/cheat/internal/config"
"github.com/cheat/cheat/internal/sheet"
"github.com/cheat/cheat/internal/sheets" "github.com/cheat/cheat/internal/sheets"
) )
@@ -18,7 +19,7 @@ func cmdEdit(opts map[string]interface{}, conf config.Config) {
cheatsheet := opts["--edit"].(string) cheatsheet := opts["--edit"].(string)
// validate the cheatsheet name // validate the cheatsheet name
if err := cheatpath.ValidateSheetName(cheatsheet); err != nil { if err := sheet.Validate(cheatsheet); err != nil {
fmt.Fprintf(os.Stderr, "invalid cheatsheet name: %v\n", err) fmt.Fprintf(os.Stderr, "invalid cheatsheet name: %v\n", err)
os.Exit(1) os.Exit(1)
} }
@@ -29,8 +30,6 @@ func cmdEdit(opts map[string]interface{}, conf config.Config) {
fmt.Fprintf(os.Stderr, "failed to list cheatsheets: %v\n", err) fmt.Fprintf(os.Stderr, "failed to list cheatsheets: %v\n", err)
os.Exit(1) os.Exit(1)
} }
// filter cheatcheats by tag if --tag was provided
if opts["--tag"] != nil { if opts["--tag"] != nil {
cheatsheets = sheets.Filter( cheatsheets = sheets.Filter(
cheatsheets, cheatsheets,
@@ -52,55 +51,36 @@ func cmdEdit(opts map[string]interface{}, conf config.Config) {
// if the sheet exists and is not read-only, edit it in place // if the sheet exists and is not read-only, edit it in place
if ok && !sheet.ReadOnly { if ok && !sheet.ReadOnly {
editpath = sheet.Path editpath = sheet.Path
} else {
// if the sheet exists but is read-only, copy it before editing // for read-only or non-existent sheets, resolve a writeable path
} else if ok && sheet.ReadOnly {
// compute the new edit path
// begin by getting a writeable cheatpath
writepath, err := cheatpath.Writeable(conf.Cheatpaths) writepath, err := cheatpath.Writeable(conf.Cheatpaths)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "failed to get writeable path: %v\n", err) fmt.Fprintf(os.Stderr, "failed to get writeable path: %v\n", err)
os.Exit(1) os.Exit(1)
} }
// compute the new edit path // use the existing title for read-only copies, the requested name otherwise
editpath = filepath.Join(writepath.Path, sheet.Title) title := cheatsheet
if ok {
title = sheet.Title
}
editpath = filepath.Join(writepath.Path, title)
// create any necessary subdirectories if ok {
dirs := filepath.Dir(editpath) // copy the read-only sheet to the writeable path
if dirs != "." { // (Copy handles MkdirAll internally)
if err := os.MkdirAll(dirs, 0755); err != nil { if err := sheet.Copy(editpath); err != nil {
fmt.Fprintf(os.Stderr, "failed to create directory: %s, %v\n", dirs, err) fmt.Fprintf(os.Stderr, "failed to copy cheatsheet: %v\n", err)
os.Exit(1) os.Exit(1)
} }
} } else {
// create any necessary subdirectories for the new sheet
// copy the sheet to the new edit path dirs := filepath.Dir(editpath)
err = sheet.Copy(editpath) if dirs != "." {
if err != nil { if err := os.MkdirAll(dirs, 0755); err != nil {
fmt.Fprintf(os.Stderr, "failed to copy cheatsheet: %v\n", err) fmt.Fprintf(os.Stderr, "failed to create directory: %s, %v\n", dirs, err)
os.Exit(1) os.Exit(1)
} }
// if the sheet does not exist, create it
} else {
// compute the new edit path
// begin by getting a writeable cheatpath
writepath, err := cheatpath.Writeable(conf.Cheatpaths)
if err != nil {
fmt.Fprintf(os.Stderr, "failed to get writeable path: %v\n", err)
os.Exit(1)
}
// compute the new edit path
editpath = filepath.Join(writepath.Path, cheatsheet)
// create any necessary subdirectories
dirs := filepath.Dir(editpath)
if dirs != "." {
if err := os.MkdirAll(dirs, 0755); err != nil {
fmt.Fprintf(os.Stderr, "failed to create directory: %s, %v\n", dirs, err)
os.Exit(1)
} }
} }
} }

View File

@@ -3,78 +3,27 @@ package main
import ( import (
"fmt" "fmt"
"os" "os"
"path/filepath"
"runtime" "runtime"
"strings"
"github.com/mitchellh/go-homedir"
"github.com/cheat/cheat/internal/config" "github.com/cheat/cheat/internal/config"
"github.com/cheat/cheat/internal/installer"
) )
// cmdInit displays an example config file. // cmdInit displays an example config file.
func cmdInit() { func cmdInit(home string, envvars map[string]string) {
// get the user's home directory // identify the os-specific paths at which configs may be located
home, err := homedir.Dir()
if err != nil {
fmt.Fprintf(os.Stderr, "failed to get user home directory: %v\n", err)
os.Exit(1)
}
// read the envvars into a map of strings
envvars := map[string]string{}
for _, e := range os.Environ() {
pair := strings.SplitN(e, "=", 2)
envvars[pair[0]] = pair[1]
}
// load the config template
configs := configs()
// identify the os-specifc paths at which configs may be located
confpaths, err := config.Paths(runtime.GOOS, home, envvars) confpaths, err := config.Paths(runtime.GOOS, home, envvars)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "failed to read config paths: %v\n", err) fmt.Fprintf(os.Stderr, "failed to read config paths: %v\n", err)
os.Exit(1) os.Exit(1)
} }
// determine the appropriate paths for config data and (optional) community
// cheatsheets based on the user's platform
confpath := confpaths[0] confpath := confpaths[0]
confdir := filepath.Dir(confpath)
// create paths for community, personal, and work cheatsheets // expand template placeholders and comment out community cheatpath
community := filepath.Join(confdir, "cheatsheets", "community") configs := installer.ExpandTemplate(configs(), confpath)
personal := filepath.Join(confdir, "cheatsheets", "personal") configs = installer.CommentCommunity(configs, confpath)
work := filepath.Join(confdir, "cheatsheets", "work")
// template the above paths into the default configs
configs = strings.Replace(configs, "COMMUNITY_PATH", community, -1)
configs = strings.Replace(configs, "PERSONAL_PATH", personal, -1)
configs = strings.Replace(configs, "WORK_PATH", work, -1)
// locate and set a default pager
configs = strings.Replace(configs, "PAGER_PATH", config.Pager(), -1)
// locate and set a default editor
if editor, err := config.Editor(); err == nil {
configs = strings.Replace(configs, "EDITOR_PATH", editor, -1)
}
// comment out the community cheatpath by default, since the directory
// won't exist until the user clones it
configs = strings.Replace(configs,
" - name: community\n"+
" path: "+community+"\n"+
" tags: [ community ]\n"+
" readonly: true",
" #- name: community\n"+
" # path: "+community+"\n"+
" # tags: [ community ]\n"+
" # readonly: true",
-1,
)
// output the templated configs // output the templated configs
fmt.Println(configs) fmt.Println(configs)

View File

@@ -24,8 +24,6 @@ func cmdList(opts map[string]interface{}, conf config.Config) {
fmt.Fprintf(os.Stderr, "failed to list cheatsheets: %v\n", err) fmt.Fprintf(os.Stderr, "failed to list cheatsheets: %v\n", err)
os.Exit(1) os.Exit(1)
} }
// filter cheatsheets by tag if --tag was provided
if opts["--tag"] != nil { if opts["--tag"] != nil {
cheatsheets = sheets.Filter( cheatsheets = sheets.Filter(
cheatsheets, cheatsheets,

View File

@@ -5,8 +5,8 @@ import (
"os" "os"
"strings" "strings"
"github.com/cheat/cheat/internal/cheatpath"
"github.com/cheat/cheat/internal/config" "github.com/cheat/cheat/internal/config"
"github.com/cheat/cheat/internal/sheet"
"github.com/cheat/cheat/internal/sheets" "github.com/cheat/cheat/internal/sheets"
) )
@@ -16,7 +16,7 @@ func cmdRemove(opts map[string]interface{}, conf config.Config) {
cheatsheet := opts["--rm"].(string) cheatsheet := opts["--rm"].(string)
// validate the cheatsheet name // validate the cheatsheet name
if err := cheatpath.ValidateSheetName(cheatsheet); err != nil { if err := sheet.Validate(cheatsheet); err != nil {
fmt.Fprintf(os.Stderr, "invalid cheatsheet name: %v\n", err) fmt.Fprintf(os.Stderr, "invalid cheatsheet name: %v\n", err)
os.Exit(1) os.Exit(1)
} }
@@ -27,8 +27,6 @@ func cmdRemove(opts map[string]interface{}, conf config.Config) {
fmt.Fprintf(os.Stderr, "failed to list cheatsheets: %v\n", err) fmt.Fprintf(os.Stderr, "failed to list cheatsheets: %v\n", err)
os.Exit(1) os.Exit(1)
} }
// filter cheatcheats by tag if --tag was provided
if opts["--tag"] != nil { if opts["--tag"] != nil {
cheatsheets = sheets.Filter( cheatsheets = sheets.Filter(
cheatsheets, cheatsheets,

View File

@@ -22,8 +22,6 @@ func cmdSearch(opts map[string]interface{}, conf config.Config) {
fmt.Fprintf(os.Stderr, "failed to list cheatsheets: %v\n", err) fmt.Fprintf(os.Stderr, "failed to list cheatsheets: %v\n", err)
os.Exit(1) os.Exit(1)
} }
// filter cheatcheats by tag if --tag was provided
if opts["--tag"] != nil { if opts["--tag"] != nil {
cheatsheets = sheets.Filter( cheatsheets = sheets.Filter(
cheatsheets, cheatsheets,
@@ -80,7 +78,7 @@ func cmdSearch(opts map[string]interface{}, conf config.Config) {
// append the cheatsheet title // append the cheatsheet title
sheet.Title, sheet.Title,
// append the cheatsheet path // append the cheatsheet path
display.Faint(fmt.Sprintf("(%s)", sheet.CheatPath), conf), display.Faint(fmt.Sprintf("(%s)", sheet.CheatPath), conf.Color(opts)),
// indent each line of content // indent each line of content
display.Indent(sheet.Text), display.Indent(sheet.Text),
) )

View File

@@ -21,8 +21,6 @@ func cmdView(opts map[string]interface{}, conf config.Config) {
fmt.Fprintf(os.Stderr, "failed to list cheatsheets: %v\n", err) fmt.Fprintf(os.Stderr, "failed to list cheatsheets: %v\n", err)
os.Exit(1) os.Exit(1)
} }
// filter cheatcheats by tag if --tag was provided
if opts["--tag"] != nil { if opts["--tag"] != nil {
cheatsheets = sheets.Filter( cheatsheets = sheets.Filter(
cheatsheets, cheatsheets,
@@ -42,7 +40,7 @@ func cmdView(opts map[string]interface{}, conf config.Config) {
// identify the matching cheatsheet // identify the matching cheatsheet
out += fmt.Sprintf("%s %s\n", out += fmt.Sprintf("%s %s\n",
sheet.Title, sheet.Title,
display.Faint(fmt.Sprintf("(%s)", sheet.CheatPath), conf), display.Faint(fmt.Sprintf("(%s)", sheet.CheatPath), conf.Color(opts)),
) )
// apply colorization if requested // apply colorization if requested

View File

@@ -15,7 +15,7 @@ import (
"github.com/cheat/cheat/internal/installer" "github.com/cheat/cheat/internal/installer"
) )
const version = "4.7.0" const version = "4.7.1"
func main() { func main() {
@@ -26,13 +26,6 @@ func main() {
panic(fmt.Errorf("docopt failed to parse: %v", err)) panic(fmt.Errorf("docopt failed to parse: %v", err))
} }
// if --init was passed, we don't want to attempt to load a config file.
// Instead, just execute cmd_init and exit
if opts["--init"] != nil && opts["--init"] == true {
cmdInit()
os.Exit(0)
}
// get the user's home directory // get the user's home directory
home, err := homedir.Dir() home, err := homedir.Dir()
if err != nil { if err != nil {
@@ -51,6 +44,13 @@ func main() {
envvars[pair[0]] = pair[1] envvars[pair[0]] = pair[1]
} }
// if --init was passed, we don't want to attempt to load a config file.
// Instead, just execute cmd_init and exit
if opts["--init"] == true {
cmdInit(home, envvars)
os.Exit(0)
}
// identify the os-specifc paths at which configs may be located // identify the os-specifc paths at which configs may be located
confpaths, err := config.Paths(runtime.GOOS, home, envvars) confpaths, err := config.Paths(runtime.GOOS, home, envvars)
if err != nil { if err != nil {
@@ -92,7 +92,7 @@ func main() {
} }
// initialize the configs // initialize the configs
conf, err := config.New(opts, confpath, true) conf, err := config.New(confpath, true)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "failed to load config: %v\n", err) fmt.Fprintf(os.Stderr, "failed to load config: %v\n", err)
os.Exit(1) os.Exit(1)

View File

@@ -2,27 +2,10 @@
// management. // management.
package cheatpath package cheatpath
import "fmt" // Path encapsulates cheatsheet path information
type Path struct {
// Cheatpath encapsulates cheatsheet path information
type Cheatpath struct {
Name string `yaml:"name"` Name string `yaml:"name"`
Path string `yaml:"path"` Path string `yaml:"path"`
ReadOnly bool `yaml:"readonly"` ReadOnly bool `yaml:"readonly"`
Tags []string `yaml:"tags"` Tags []string `yaml:"tags"`
} }
// Validate ensures that the Cheatpath is valid
func (c Cheatpath) Validate() error {
// Check that name is not empty
if c.Name == "" {
return fmt.Errorf("cheatpath name cannot be empty")
}
// Check that path is not empty
if c.Path == "" {
return fmt.Errorf("cheatpath path cannot be empty")
}
return nil
}

View File

@@ -8,13 +8,13 @@ import (
func TestCheatpathValidate(t *testing.T) { func TestCheatpathValidate(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
cheatpath Cheatpath cheatpath Path
wantErr bool wantErr bool
errMsg string errMsg string
}{ }{
{ {
name: "valid cheatpath", name: "valid cheatpath",
cheatpath: Cheatpath{ cheatpath: Path{
Name: "personal", Name: "personal",
Path: "/home/user/.config/cheat/personal", Path: "/home/user/.config/cheat/personal",
ReadOnly: false, ReadOnly: false,
@@ -24,7 +24,7 @@ func TestCheatpathValidate(t *testing.T) {
}, },
{ {
name: "empty name", name: "empty name",
cheatpath: Cheatpath{ cheatpath: Path{
Name: "", Name: "",
Path: "/home/user/.config/cheat/personal", Path: "/home/user/.config/cheat/personal",
ReadOnly: false, ReadOnly: false,
@@ -35,7 +35,7 @@ func TestCheatpathValidate(t *testing.T) {
}, },
{ {
name: "empty path", name: "empty path",
cheatpath: Cheatpath{ cheatpath: Path{
Name: "personal", Name: "personal",
Path: "", Path: "",
ReadOnly: false, ReadOnly: false,
@@ -46,7 +46,7 @@ func TestCheatpathValidate(t *testing.T) {
}, },
{ {
name: "both empty", name: "both empty",
cheatpath: Cheatpath{ cheatpath: Path{
Name: "", Name: "",
Path: "", Path: "",
ReadOnly: true, ReadOnly: true,
@@ -57,7 +57,7 @@ func TestCheatpathValidate(t *testing.T) {
}, },
{ {
name: "minimal valid", name: "minimal valid",
cheatpath: Cheatpath{ cheatpath: Path{
Name: "x", Name: "x",
Path: "/", Path: "/",
}, },
@@ -65,7 +65,7 @@ func TestCheatpathValidate(t *testing.T) {
}, },
{ {
name: "with readonly and tags", name: "with readonly and tags",
cheatpath: Cheatpath{ cheatpath: Path{
Name: "community", Name: "community",
Path: "/usr/share/cheat", Path: "/usr/share/cheat",
ReadOnly: true, ReadOnly: true,

View File

@@ -1,64 +0,0 @@
// Package cheatpath manages collections of cheat sheets organized in filesystem directories.
//
// A Cheatpath represents a directory containing cheat sheets, with associated
// metadata such as tags and read-only status. Multiple cheatpaths can be
// configured to organize sheets from different sources (personal, community, work, etc.).
//
// # Cheatpath Structure
//
// Each cheatpath has:
// - Name: A friendly identifier (e.g., "personal", "community")
// - Path: The filesystem path to the directory
// - Tags: Tags automatically applied to all sheets in this path
// - ReadOnly: Whether sheets in this path can be modified
//
// Example configuration:
//
// cheatpaths:
// - name: personal
// path: ~/cheat
// tags: []
// readonly: false
// - name: community
// path: ~/cheat/community
// tags: [community]
// readonly: true
//
// # Directory-Scoped Cheatpaths
//
// The package supports directory-scoped cheatpaths via `.cheat` directories.
// 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
//
// When multiple cheatpaths contain a sheet with the same name, the sheet
// from the most "local" cheatpath takes precedence. This allows users to
// override community sheets with personal versions.
//
// Key Functions
//
// - Filter: Filters cheatpaths by name
// - Validate: Ensures cheatpath configuration is valid
// - Writeable: Returns the first writeable cheatpath
//
// Example Usage
//
// // Filter cheatpaths to only "personal"
// filtered, err := cheatpath.Filter(paths, "personal")
// if err != nil {
// log.Fatal(err)
// }
//
// // Find a writeable cheatpath
// writeable, err := cheatpath.Writeable(paths)
// if err != nil {
// log.Fatal(err)
// }
//
// // Validate cheatpath configuration
// if err := cheatpath.Validate(paths); err != nil {
// log.Fatal(err)
// }
package cheatpath

View File

@@ -5,15 +5,15 @@ import (
) )
// Filter filters all cheatpaths that are not named `name` // Filter filters all cheatpaths that are not named `name`
func Filter(paths []Cheatpath, name string) ([]Cheatpath, error) { func Filter(paths []Path, name string) ([]Path, error) {
// if a path of the given name exists, return it // if a path of the given name exists, return it
for _, path := range paths { for _, path := range paths {
if path.Name == name { if path.Name == name {
return []Cheatpath{path}, nil return []Path{path}, nil
} }
} }
// otherwise, return an error // otherwise, return an error
return []Cheatpath{}, fmt.Errorf("cheatpath does not exist: %s", name) return []Path{}, fmt.Errorf("cheatpath does not exist: %s", name)
} }

View File

@@ -9,10 +9,10 @@ import (
func TestFilterSuccess(t *testing.T) { func TestFilterSuccess(t *testing.T) {
// init cheatpaths // init cheatpaths
paths := []Cheatpath{ paths := []Path{
Cheatpath{Name: "foo"}, Path{Name: "foo"},
Cheatpath{Name: "bar"}, Path{Name: "bar"},
Cheatpath{Name: "baz"}, Path{Name: "baz"},
} }
// filter the paths // filter the paths
@@ -39,10 +39,10 @@ func TestFilterSuccess(t *testing.T) {
func TestFilterFailure(t *testing.T) { func TestFilterFailure(t *testing.T) {
// init cheatpaths // init cheatpaths
paths := []Cheatpath{ paths := []Path{
Cheatpath{Name: "foo"}, Path{Name: "foo"},
Cheatpath{Name: "bar"}, Path{Name: "bar"},
Cheatpath{Name: "baz"}, Path{Name: "baz"},
} }
// filter the paths // filter the paths

View File

@@ -2,39 +2,15 @@ package cheatpath
import ( import (
"fmt" "fmt"
"path/filepath"
"strings"
) )
// ValidateSheetName ensures that a cheatsheet name does not contain // Validate ensures that the Path is valid
// directory traversal sequences or other potentially dangerous patterns. func (c Path) Validate() error {
func ValidateSheetName(name string) error { if c.Name == "" {
// Reject empty names return fmt.Errorf("cheatpath name cannot be empty")
if name == "" {
return fmt.Errorf("cheatsheet name cannot be empty")
} }
if c.Path == "" {
// Reject names containing directory traversal return fmt.Errorf("cheatpath path cannot be empty")
if strings.Contains(name, "..") {
return fmt.Errorf("cheatsheet name cannot contain '..'")
} }
// Reject absolute paths
if filepath.IsAbs(name) {
return fmt.Errorf("cheatsheet name cannot be an absolute path")
}
// Reject names that start with ~ (home directory expansion)
if strings.HasPrefix(name, "~") {
return fmt.Errorf("cheatsheet name cannot start with '~'")
}
// Reject hidden files (files that start with a dot)
// We don't display hidden files, so we shouldn't create them
filename := filepath.Base(name)
if strings.HasPrefix(filename, ".") {
return fmt.Errorf("cheatsheet name cannot start with '.' (hidden files are not supported)")
}
return nil return nil
} }

View File

@@ -4,8 +4,8 @@ import (
"fmt" "fmt"
) )
// Writeable returns a writeable Cheatpath // Writeable returns a writeable Path
func Writeable(cheatpaths []Cheatpath) (Cheatpath, error) { func Writeable(cheatpaths []Path) (Path, error) {
// iterate backwards over the cheatpaths // iterate backwards over the cheatpaths
// NB: we're going backwards because we assume that the most "local" // NB: we're going backwards because we assume that the most "local"
@@ -18,5 +18,5 @@ func Writeable(cheatpaths []Cheatpath) (Cheatpath, error) {
} }
// otherwise, return an error // otherwise, return an error
return Cheatpath{}, fmt.Errorf("no writeable cheatpaths found") return Path{}, fmt.Errorf("no writeable cheatpaths found")
} }

View File

@@ -9,10 +9,10 @@ import (
func TestWriteableOK(t *testing.T) { func TestWriteableOK(t *testing.T) {
// initialize some cheatpaths // initialize some cheatpaths
cheatpaths := []Cheatpath{ cheatpaths := []Path{
Cheatpath{Path: "/foo", ReadOnly: true}, Path{Path: "/foo", ReadOnly: true},
Cheatpath{Path: "/bar", ReadOnly: false}, Path{Path: "/bar", ReadOnly: false},
Cheatpath{Path: "/baz", ReadOnly: true}, Path{Path: "/baz", ReadOnly: true},
} }
// get the writeable cheatpath // get the writeable cheatpath
@@ -34,10 +34,10 @@ func TestWriteableOK(t *testing.T) {
func TestWriteableNotOK(t *testing.T) { func TestWriteableNotOK(t *testing.T) {
// initialize some cheatpaths // initialize some cheatpaths
cheatpaths := []Cheatpath{ cheatpaths := []Path{
Cheatpath{Path: "/foo", ReadOnly: true}, Path{Path: "/foo", ReadOnly: true},
Cheatpath{Path: "/bar", ReadOnly: true}, Path{Path: "/bar", ReadOnly: true},
Cheatpath{Path: "/baz", ReadOnly: true}, Path{Path: "/baz", ReadOnly: true},
} }
// get the writeable cheatpath // get the writeable cheatpath

View File

@@ -2,158 +2,16 @@
package config package config
import ( import (
"fmt"
"os"
"path/filepath"
"strings"
cp "github.com/cheat/cheat/internal/cheatpath" cp "github.com/cheat/cheat/internal/cheatpath"
"github.com/mitchellh/go-homedir"
"gopkg.in/yaml.v3"
) )
// Config encapsulates configuration parameters // Config encapsulates configuration parameters
type Config struct { type Config struct {
Colorize bool `yaml:"colorize"` Colorize bool `yaml:"colorize"`
Editor string `yaml:"editor"` Editor string `yaml:"editor"`
Cheatpaths []cp.Cheatpath `yaml:"cheatpaths"` Cheatpaths []cp.Path `yaml:"cheatpaths"`
Style string `yaml:"style"` Style string `yaml:"style"`
Formatter string `yaml:"formatter"` Formatter string `yaml:"formatter"`
Pager string `yaml:"pager"` Pager string `yaml:"pager"`
Path string Path string
} }
// New returns a new Config struct
func New(_ map[string]interface{}, confPath string, resolve bool) (Config, error) {
// read the config file
buf, err := os.ReadFile(confPath)
if err != nil {
return Config{}, fmt.Errorf("could not read config file: %v", err)
}
// initialize a config object
conf := Config{}
// store the config path
conf.Path = confPath
// unmarshal the yaml
err = yaml.Unmarshal(buf, &conf)
if err != nil {
return Config{}, fmt.Errorf("could not unmarshal yaml: %v", err)
}
// if a .cheat directory exists in the current directory or any ancestor,
// append it to the cheatpaths
cwd, err := os.Getwd()
if err != nil {
return Config{}, fmt.Errorf("failed to get cwd: %v", err)
}
if local := findLocalCheatpath(cwd); local != "" {
path := cp.Cheatpath{
Name: "cwd",
Path: local,
ReadOnly: false,
Tags: []string{},
}
conf.Cheatpaths = append(conf.Cheatpaths, path)
}
// process cheatpaths
var validPaths []cp.Cheatpath
for _, cheatpath := range conf.Cheatpaths {
// expand ~ in config paths
expanded, err := homedir.Expand(cheatpath.Path)
if err != nil {
return Config{}, fmt.Errorf("failed to expand ~: %v", err)
}
// follow symlinks
//
// NB: `resolve` is an ugly kludge that exists for the sake of unit-tests.
// It's necessary because `EvalSymlinks` will error if the symlink points
// to a non-existent location on the filesystem. When unit-testing,
// however, we don't want to have dependencies on the filesystem. As such,
// `resolve` is a switch that allows us to turn off symlink resolution when
// running the config tests.
if resolve {
evaled, err := filepath.EvalSymlinks(expanded)
if err != nil {
// if the path simply doesn't exist, warn and skip it
if os.IsNotExist(err) {
fmt.Fprintf(os.Stderr,
"WARNING: cheatpath '%s' does not exist, skipping\n",
expanded,
)
continue
}
return Config{}, fmt.Errorf(
"failed to resolve symlink: %s: %v",
expanded,
err,
)
}
expanded = evaled
}
cheatpath.Path = expanded
validPaths = append(validPaths, cheatpath)
}
conf.Cheatpaths = validPaths
// determine the editor: env vars override the config file value,
// following standard Unix convention (see #589)
if v := os.Getenv("VISUAL"); v != "" {
conf.Editor = v
} else if v := os.Getenv("EDITOR"); v != "" {
conf.Editor = v
} else {
conf.Editor = strings.TrimSpace(conf.Editor)
}
// if an editor was still not determined, attempt to choose one
// that's appropriate for the environment
if conf.Editor == "" {
if conf.Editor, err = Editor(); err != nil {
return Config{}, err
}
}
// if a chroma style was not provided, set a default
if conf.Style == "" {
conf.Style = "bw"
}
// if a chroma formatter was not provided, set a default
if conf.Formatter == "" {
conf.Formatter = "terminal"
}
// load the pager
conf.Pager = strings.TrimSpace(conf.Pager)
return conf, nil
}
// findLocalCheatpath walks upward from dir looking for a .cheat directory.
// It returns the path to the first .cheat directory found, or an empty string
// if none exists. This mirrors the discovery pattern used by git for .git
// directories.
func findLocalCheatpath(dir string) string {
for {
candidate := filepath.Join(dir, ".cheat")
if info, err := os.Stat(candidate); err == nil && info.IsDir() {
return candidate
}
parent := filepath.Dir(dir)
if parent == dir {
return ""
}
dir = parent
}
}

View File

@@ -5,7 +5,7 @@ import (
"path/filepath" "path/filepath"
"testing" "testing"
"github.com/cheat/cheat/internal/mock" "github.com/cheat/cheat/mocks"
) )
// TestConfigYAMLErrors tests YAML parsing errors // TestConfigYAMLErrors tests YAML parsing errors
@@ -24,7 +24,7 @@ func TestConfigYAMLErrors(t *testing.T) {
} }
// Attempt to load invalid YAML // Attempt to load invalid YAML
_, err = New(map[string]interface{}{}, invalidYAML, false) _, err = New(invalidYAML, false)
if err == nil { if err == nil {
t.Error("expected error for invalid YAML, got nil") t.Error("expected error for invalid YAML, got nil")
} }
@@ -33,7 +33,7 @@ func TestConfigYAMLErrors(t *testing.T) {
// TestConfigDefaults tests default values // TestConfigDefaults tests default values
func TestConfigDefaults(t *testing.T) { func TestConfigDefaults(t *testing.T) {
// Load empty config // Load empty config
conf, err := New(map[string]interface{}{}, mock.Path("conf/empty.yml"), false) conf, err := New(mocks.Path("conf/empty.yml"), false)
if err != nil { if err != nil {
t.Errorf("failed to load config: %v", err) t.Errorf("failed to load config: %v", err)
} }
@@ -92,7 +92,7 @@ cheatpaths:
} }
// Load config with symlink resolution // Load config with symlink resolution
conf, err := New(map[string]interface{}{}, configFile, true) conf, err := New(configFile, true)
if err != nil { if err != nil {
t.Errorf("failed to load config: %v", err) t.Errorf("failed to load config: %v", err)
} }
@@ -138,7 +138,7 @@ cheatpaths:
// Load config with symlink resolution should skip the broken cheatpath // Load config with symlink resolution should skip the broken cheatpath
// (warn to stderr) rather than hard-error // (warn to stderr) rather than hard-error
conf, err := New(map[string]interface{}{}, configFile, true) conf, err := New(configFile, true)
if err != nil { if err != nil {
t.Errorf("expected no error for broken symlink (should skip), got: %v", err) t.Errorf("expected no error for broken symlink (should skip), got: %v", err)
} }

View File

@@ -11,7 +11,7 @@ import (
"github.com/mitchellh/go-homedir" "github.com/mitchellh/go-homedir"
"github.com/cheat/cheat/internal/cheatpath" "github.com/cheat/cheat/internal/cheatpath"
"github.com/cheat/cheat/internal/mock" "github.com/cheat/cheat/mocks"
) )
// TestFindLocalCheatpathInCurrentDir tests that .cheat in the given dir is found // TestFindLocalCheatpathInCurrentDir tests that .cheat in the given dir is found
@@ -286,7 +286,7 @@ func TestConfigSuccessful(t *testing.T) {
}() }()
// initialize a config // initialize a config
conf, err := New(map[string]interface{}{}, mock.Path("conf/conf.yml"), false) conf, err := New(mocks.Path("conf/conf.yml"), false)
if err != nil { if err != nil {
t.Errorf("failed to parse config file: %v", err) t.Errorf("failed to parse config file: %v", err)
} }
@@ -306,18 +306,18 @@ func TestConfigSuccessful(t *testing.T) {
} }
// assert that the cheatpaths are correct // assert that the cheatpaths are correct
want := []cheatpath.Cheatpath{ want := []cheatpath.Path{
cheatpath.Cheatpath{ cheatpath.Path{
Path: filepath.Join(home, ".dotfiles", "cheat", "community"), Path: filepath.Join(home, ".dotfiles", "cheat", "community"),
ReadOnly: true, ReadOnly: true,
Tags: []string{"community"}, Tags: []string{"community"},
}, },
cheatpath.Cheatpath{ cheatpath.Path{
Path: filepath.Join(home, ".dotfiles", "cheat", "work"), Path: filepath.Join(home, ".dotfiles", "cheat", "work"),
ReadOnly: false, ReadOnly: false,
Tags: []string{"work"}, Tags: []string{"work"},
}, },
cheatpath.Cheatpath{ cheatpath.Path{
Path: filepath.Join(home, ".dotfiles", "cheat", "personal"), Path: filepath.Join(home, ".dotfiles", "cheat", "personal"),
ReadOnly: false, ReadOnly: false,
Tags: []string{"personal"}, Tags: []string{"personal"},
@@ -338,7 +338,7 @@ func TestConfigSuccessful(t *testing.T) {
func TestConfigFailure(t *testing.T) { func TestConfigFailure(t *testing.T) {
// attempt to read a non-existent config file // attempt to read a non-existent config file
_, err := New(map[string]interface{}{}, "/does-not-exit", false) _, err := New("/does-not-exit", false)
if err == nil { if err == nil {
t.Errorf("failed to error on unreadable config") t.Errorf("failed to error on unreadable config")
} }
@@ -358,7 +358,7 @@ func TestEditorEnvOverride(t *testing.T) {
// with no env vars, the config file value should be used // with no env vars, the config file value should be used
os.Unsetenv("VISUAL") os.Unsetenv("VISUAL")
os.Unsetenv("EDITOR") os.Unsetenv("EDITOR")
conf, err := New(map[string]interface{}{}, mock.Path("conf/conf.yml"), false) conf, err := New(mocks.Path("conf/conf.yml"), false)
if err != nil { if err != nil {
t.Fatalf("failed to init configs: %v", err) t.Fatalf("failed to init configs: %v", err)
} }
@@ -368,7 +368,7 @@ func TestEditorEnvOverride(t *testing.T) {
// $EDITOR should override the config file value // $EDITOR should override the config file value
os.Setenv("EDITOR", "nano") os.Setenv("EDITOR", "nano")
conf, err = New(map[string]interface{}{}, mock.Path("conf/conf.yml"), false) conf, err = New(mocks.Path("conf/conf.yml"), false)
if err != nil { if err != nil {
t.Fatalf("failed to init configs: %v", err) t.Fatalf("failed to init configs: %v", err)
} }
@@ -378,7 +378,7 @@ func TestEditorEnvOverride(t *testing.T) {
// $VISUAL should override both $EDITOR and the config file value // $VISUAL should override both $EDITOR and the config file value
os.Setenv("VISUAL", "emacs") os.Setenv("VISUAL", "emacs")
conf, err = New(map[string]interface{}{}, mock.Path("conf/conf.yml"), false) conf, err = New(mocks.Path("conf/conf.yml"), false)
if err != nil { if err != nil {
t.Fatalf("failed to init configs: %v", err) t.Fatalf("failed to init configs: %v", err)
} }
@@ -401,7 +401,7 @@ func TestEditorEnvFallback(t *testing.T) {
// set $EDITOR and assert it's used when config has no editor // set $EDITOR and assert it's used when config has no editor
os.Unsetenv("VISUAL") os.Unsetenv("VISUAL")
os.Setenv("EDITOR", "foo") os.Setenv("EDITOR", "foo")
conf, err := New(map[string]interface{}{}, mock.Path("conf/empty.yml"), false) conf, err := New(mocks.Path("conf/empty.yml"), false)
if err != nil { if err != nil {
t.Fatalf("failed to init configs: %v", err) t.Fatalf("failed to init configs: %v", err)
} }
@@ -411,7 +411,7 @@ func TestEditorEnvFallback(t *testing.T) {
// set $VISUAL and assert it takes precedence over $EDITOR // set $VISUAL and assert it takes precedence over $EDITOR
os.Setenv("VISUAL", "bar") os.Setenv("VISUAL", "bar")
conf, err = New(map[string]interface{}{}, mock.Path("conf/empty.yml"), false) conf, err = New(mocks.Path("conf/empty.yml"), false)
if err != nil { if err != nil {
t.Fatalf("failed to init configs: %v", err) t.Fatalf("failed to init configs: %v", err)
} }

View File

@@ -1,52 +0,0 @@
// Package config manages application configuration and settings.
//
// The config package provides functionality to:
// - Load configuration from YAML files
// - Validate configuration values
// - Manage platform-specific configuration paths
// - Handle editor and pager settings
// - Configure colorization and formatting options
//
// # Configuration Structure
//
// The main configuration file (conf.yml) contains:
// - Editor preferences
// - Pager settings
// - Colorization options
// - Cheatpath definitions
// - Formatting preferences
//
// Example configuration:
//
// ---
// editor: vim
// colorize: true
// style: monokai
// formatter: terminal256
// pager: less -FRX
// cheatpaths:
// - name: personal
// path: ~/cheat
// tags: []
// readonly: false
// - name: community
// path: ~/cheat/.cheat
// tags: [community]
// readonly: true
//
// # Platform-Specific Paths
//
// The package automatically detects configuration paths based on the operating system:
// - Linux/Unix: $XDG_CONFIG_HOME/cheat/conf.yml or ~/.config/cheat/conf.yml
// - macOS: ~/Library/Application Support/cheat/conf.yml
// - Windows: %APPDATA%\cheat\conf.yml
//
// # Environment Variables
//
// The following environment variables are respected:
// - CHEAT_CONFIG_PATH: Override the configuration file location
// - CHEAT_USE_FZF: Enable fzf integration when set to "true"
// - EDITOR: Default editor if not specified in config
// - VISUAL: Fallback editor if EDITOR is not set
// - PAGER: Default pager if not specified in config
package config

147
internal/config/new.go Normal file
View File

@@ -0,0 +1,147 @@
package config
import (
"fmt"
"os"
"path/filepath"
"strings"
cp "github.com/cheat/cheat/internal/cheatpath"
"github.com/mitchellh/go-homedir"
"gopkg.in/yaml.v3"
)
// New returns a new Config struct
func New(confPath string, resolve bool) (Config, error) {
// read the config file
buf, err := os.ReadFile(confPath)
if err != nil {
return Config{}, fmt.Errorf("could not read config file: %v", err)
}
// initialize a config object
conf := Config{}
// store the config path
conf.Path = confPath
// unmarshal the yaml
err = yaml.Unmarshal(buf, &conf)
if err != nil {
return Config{}, fmt.Errorf("could not unmarshal yaml: %v", err)
}
// if a .cheat directory exists in the current directory or any ancestor,
// append it to the cheatpaths
cwd, err := os.Getwd()
if err != nil {
return Config{}, fmt.Errorf("failed to get cwd: %v", err)
}
if local := findLocalCheatpath(cwd); local != "" {
path := cp.Path{
Name: "cwd",
Path: local,
ReadOnly: false,
Tags: []string{},
}
conf.Cheatpaths = append(conf.Cheatpaths, path)
}
// process cheatpaths
var validPaths []cp.Path
for _, cheatpath := range conf.Cheatpaths {
// expand ~ in config paths
expanded, err := homedir.Expand(cheatpath.Path)
if err != nil {
return Config{}, fmt.Errorf("failed to expand ~: %v", err)
}
// follow symlinks
//
// NB: `resolve` is an ugly kludge that exists for the sake of unit-tests.
// It's necessary because `EvalSymlinks` will error if the symlink points
// to a non-existent location on the filesystem. When unit-testing,
// however, we don't want to have dependencies on the filesystem. As such,
// `resolve` is a switch that allows us to turn off symlink resolution when
// running the config tests.
if resolve {
evaled, err := filepath.EvalSymlinks(expanded)
if err != nil {
// if the path simply doesn't exist, warn and skip it
if os.IsNotExist(err) {
fmt.Fprintf(os.Stderr,
"WARNING: cheatpath '%s' does not exist, skipping\n",
expanded,
)
continue
}
return Config{}, fmt.Errorf(
"failed to resolve symlink: %s: %v",
expanded,
err,
)
}
expanded = evaled
}
cheatpath.Path = expanded
validPaths = append(validPaths, cheatpath)
}
conf.Cheatpaths = validPaths
// determine the editor: env vars override the config file value,
// following standard Unix convention (see #589)
if v := os.Getenv("VISUAL"); v != "" {
conf.Editor = v
} else if v := os.Getenv("EDITOR"); v != "" {
conf.Editor = v
} else {
conf.Editor = strings.TrimSpace(conf.Editor)
}
// if an editor was still not determined, attempt to choose one
// that's appropriate for the environment
if conf.Editor == "" {
if conf.Editor, err = Editor(); err != nil {
return Config{}, err
}
}
// if a chroma style was not provided, set a default
if conf.Style == "" {
conf.Style = "bw"
}
// if a chroma formatter was not provided, set a default
if conf.Formatter == "" {
conf.Formatter = "terminal"
}
// load the pager
conf.Pager = strings.TrimSpace(conf.Pager)
return conf, nil
}
// findLocalCheatpath walks upward from dir looking for a .cheat directory.
// It returns the path to the first .cheat directory found, or an empty string
// if none exists. This mirrors the discovery pattern used by git for .git
// directories.
func findLocalCheatpath(dir string) string {
for {
candidate := filepath.Join(dir, ".cheat")
if info, err := os.Stat(candidate); err == nil && info.IsDir() {
return candidate
}
parent := filepath.Dir(dir)
if parent == dir {
return ""
}
dir = parent
}
}

View File

@@ -38,7 +38,7 @@ cheatpaths:
} }
// Load the config // Load the config
conf, err := New(map[string]interface{}{}, configPath, false) conf, err := New(configPath, false)
if err != nil { if err != nil {
t.Fatalf("failed to load config: %v", err) t.Fatalf("failed to load config: %v", err)
} }
@@ -88,7 +88,7 @@ cheatpaths:
} }
// Load the config // Load the config
conf, err := New(map[string]interface{}{}, configPath, false) conf, err := New(configPath, false)
if err != nil { if err != nil {
// It's OK if this fails due to no editor being found // It's OK if this fails due to no editor being found
// The important thing is it doesn't panic // The important thing is it doesn't panic
@@ -123,7 +123,7 @@ cheatpaths:
} }
// Load the config // Load the config
conf, err := New(map[string]interface{}{}, configPath, false) conf, err := New(configPath, false)
if err != nil { if err != nil {
t.Fatalf("failed to load config: %v", err) t.Fatalf("failed to load config: %v", err)
} }

View File

@@ -14,8 +14,8 @@ func TestValidateCorrect(t *testing.T) {
Colorize: true, Colorize: true,
Editor: "vim", Editor: "vim",
Formatter: "terminal16m", Formatter: "terminal16m",
Cheatpaths: []cheatpath.Cheatpath{ Cheatpaths: []cheatpath.Path{
cheatpath.Cheatpath{ cheatpath.Path{
Name: "foo", Name: "foo",
Path: "/foo", Path: "/foo",
ReadOnly: false, ReadOnly: false,
@@ -38,8 +38,8 @@ func TestInvalidateMissingEditor(t *testing.T) {
conf := Config{ conf := Config{
Colorize: true, Colorize: true,
Formatter: "terminal16m", Formatter: "terminal16m",
Cheatpaths: []cheatpath.Cheatpath{ Cheatpaths: []cheatpath.Path{
cheatpath.Cheatpath{ cheatpath.Path{
Name: "foo", Name: "foo",
Path: "/foo", Path: "/foo",
ReadOnly: false, ReadOnly: false,
@@ -80,8 +80,8 @@ func TestInvalidateInvalidFormatter(t *testing.T) {
Colorize: true, Colorize: true,
Editor: "vim", Editor: "vim",
Formatter: "html", Formatter: "html",
Cheatpaths: []cheatpath.Cheatpath{ Cheatpaths: []cheatpath.Path{
cheatpath.Cheatpath{ cheatpath.Path{
Name: "foo", Name: "foo",
Path: "/foo", Path: "/foo",
ReadOnly: false, ReadOnly: false,
@@ -105,14 +105,14 @@ func TestInvalidateDuplicateCheatpathNames(t *testing.T) {
Colorize: true, Colorize: true,
Editor: "vim", Editor: "vim",
Formatter: "terminal16m", Formatter: "terminal16m",
Cheatpaths: []cheatpath.Cheatpath{ Cheatpaths: []cheatpath.Path{
cheatpath.Cheatpath{ cheatpath.Path{
Name: "foo", Name: "foo",
Path: "/foo", Path: "/foo",
ReadOnly: false, ReadOnly: false,
Tags: []string{}, Tags: []string{},
}, },
cheatpath.Cheatpath{ cheatpath.Path{
Name: "foo", Name: "foo",
Path: "/bar", Path: "/bar",
ReadOnly: false, ReadOnly: false,
@@ -136,14 +136,14 @@ func TestInvalidateDuplicateCheatpathPaths(t *testing.T) {
Colorize: true, Colorize: true,
Editor: "vim", Editor: "vim",
Formatter: "terminal16m", Formatter: "terminal16m",
Cheatpaths: []cheatpath.Cheatpath{ Cheatpaths: []cheatpath.Path{
cheatpath.Cheatpath{ cheatpath.Path{
Name: "foo", Name: "foo",
Path: "/foo", Path: "/foo",
ReadOnly: false, ReadOnly: false,
Tags: []string{}, Tags: []string{},
}, },
cheatpath.Cheatpath{ cheatpath.Path{
Name: "bar", Name: "bar",
Path: "/foo", Path: "/foo",
ReadOnly: false, ReadOnly: false,

View File

@@ -1,45 +0,0 @@
// Package display handles output formatting and presentation for the cheat application.
//
// The display package provides utilities for:
// - Writing output to stdout or a pager
// - Formatting text with indentation
// - Creating faint (dimmed) text for de-emphasis
// - Managing colored output
//
// # Pager Integration
//
// The package integrates with system pagers (less, more, etc.) to handle
// long output. If a pager is configured and the output is to a terminal,
// content is automatically piped through the pager.
//
// # Text Formatting
//
// Various formatting utilities are provided:
// - Faint: Creates dimmed text using ANSI escape codes
// - Indent: Adds consistent indentation to text blocks
// - Write: Intelligent output that uses stdout or pager as appropriate
//
// Example Usage
//
// // Write output, using pager if configured
// if err := display.Write(output, config); err != nil {
// log.Fatal(err)
// }
//
// // Create faint text for de-emphasis
// fainted := display.Faint("(read-only)", config)
//
// // Indent a block of text
// indented := display.Indent(text, " ")
//
// # Color Support
//
// The package respects the colorization settings from the config.
// When colorization is disabled, formatting functions like Faint
// return unmodified text.
//
// # Terminal Detection
//
// The package uses isatty to detect if output is to a terminal,
// which affects decisions about using a pager and applying colors.
package display

View File

@@ -2,17 +2,13 @@
// cheatsheet content to stdout, or alternatively the system pager. // cheatsheet content to stdout, or alternatively the system pager.
package display package display
import ( import "fmt"
"fmt"
"github.com/cheat/cheat/internal/config"
)
// Faint returns a faintly-colored string that's used to de-prioritize text // Faint returns a faintly-colored string that's used to de-prioritize text
// written to stdout // written to stdout
func Faint(str string, conf config.Config) string { func Faint(str string, colorize bool) string {
// make `str` faint only if colorization has been requested // make `str` faint only if colorization has been requested
if conf.Colorize { if colorize {
return fmt.Sprintf("\033[2m%s\033[0m", str) return fmt.Sprintf("\033[2m%s\033[0m", str)
} }

View File

@@ -1,26 +1,20 @@
package display package display
import ( import "testing"
"testing"
"github.com/cheat/cheat/internal/config"
)
// TestFaint asserts that Faint applies faint formatting // TestFaint asserts that Faint applies faint formatting
func TestFaint(t *testing.T) { func TestFaint(t *testing.T) {
// case: apply colorization // case: apply colorization
conf := config.Config{Colorize: true}
want := "\033[2mfoo\033[0m" want := "\033[2mfoo\033[0m"
got := Faint("foo", conf) got := Faint("foo", true)
if want != got { if want != got {
t.Errorf("failed to faint: want: %s, got: %s", want, got) t.Errorf("failed to faint: want: %s, got: %s", want, got)
} }
// case: do not apply colorization // case: do not apply colorization
conf.Colorize = false
want = "foo" want = "foo"
got = Faint("foo", conf) got = Faint("foo", false)
if want != got { if want != got {
t.Errorf("failed to faint: want: %s, got: %s", want, got) t.Errorf("failed to faint: want: %s, got: %s", want, got)
} }

View File

@@ -24,7 +24,7 @@ func Write(out string, conf config.Config) {
// writeToPager writes output through a pager command // writeToPager writes output through a pager command
func writeToPager(out string, conf config.Config) { func writeToPager(out string, conf config.Config) {
parts := strings.Split(conf.Pager, " ") parts := strings.Fields(conf.Pager)
pager := parts[0] pager := parts[0]
args := parts[1:] args := parts[1:]

View File

@@ -3,8 +3,6 @@ package installer
import ( import (
"fmt" "fmt"
"os" "os"
"path/filepath"
"strings"
"github.com/cheat/cheat/internal/config" "github.com/cheat/cheat/internal/config"
"github.com/cheat/cheat/internal/repo" "github.com/cheat/cheat/internal/repo"
@@ -13,27 +11,11 @@ import (
// Run runs the installer // Run runs the installer
func Run(configs string, confpath string) error { func Run(configs string, confpath string) error {
// determine the appropriate paths for config data and (optional) community // expand template placeholders with platform-appropriate paths
// cheatsheets based on the user's platform configs = ExpandTemplate(configs, confpath)
confdir := filepath.Dir(confpath)
// create paths for community, personal, and work cheatsheets // determine cheatsheet directory paths
community := filepath.Join(confdir, "cheatsheets", "community") community, personal, work := cheatsheetDirs(confpath)
personal := filepath.Join(confdir, "cheatsheets", "personal")
work := filepath.Join(confdir, "cheatsheets", "work")
// set default cheatpaths
configs = strings.Replace(configs, "COMMUNITY_PATH", community, -1)
configs = strings.Replace(configs, "PERSONAL_PATH", personal, -1)
configs = strings.Replace(configs, "WORK_PATH", work, -1)
// locate and set a default pager
configs = strings.Replace(configs, "PAGER_PATH", config.Pager(), -1)
// locate and set a default editor
if editor, err := config.Editor(); err == nil {
configs = strings.Replace(configs, "EDITOR_PATH", editor, -1)
}
// prompt the user to download the community cheatsheets // prompt the user to download the community cheatsheets
yes, err := Prompt( yes, err := Prompt(
@@ -51,19 +33,7 @@ func Run(configs string, confpath string) error {
return fmt.Errorf("failed to clone cheatsheets: %v", err) return fmt.Errorf("failed to clone cheatsheets: %v", err)
} }
} else { } else {
// comment out the community cheatpath in the config since configs = CommentCommunity(configs, confpath)
// the directory won't exist
configs = strings.Replace(configs,
" - name: community\n"+
" path: "+community+"\n"+
" tags: [ community ]\n"+
" readonly: true",
" #- name: community\n"+
" # path: "+community+"\n"+
" # tags: [ community ]\n"+
" # readonly: true",
-1,
)
} }
// always create personal and work directories // always create personal and work directories

View File

@@ -0,0 +1,58 @@
package installer
import (
"path/filepath"
"strings"
"github.com/cheat/cheat/internal/config"
)
// cheatsheetDirs returns the community, personal, and work cheatsheet
// directory paths derived from a config file path.
func cheatsheetDirs(confpath string) (community, personal, work string) {
confdir := filepath.Dir(confpath)
community = filepath.Join(confdir, "cheatsheets", "community")
personal = filepath.Join(confdir, "cheatsheets", "personal")
work = filepath.Join(confdir, "cheatsheets", "work")
return
}
// ExpandTemplate replaces placeholder tokens in the config template with
// platform-appropriate paths derived from confpath.
func ExpandTemplate(configs string, confpath string) string {
community, personal, work := cheatsheetDirs(confpath)
// substitute paths
configs = strings.ReplaceAll(configs, "COMMUNITY_PATH", community)
configs = strings.ReplaceAll(configs, "PERSONAL_PATH", personal)
configs = strings.ReplaceAll(configs, "WORK_PATH", work)
// locate and set a default pager
configs = strings.ReplaceAll(configs, "PAGER_PATH", config.Pager())
// locate and set a default editor
if editor, err := config.Editor(); err == nil {
configs = strings.ReplaceAll(configs, "EDITOR_PATH", editor)
}
return configs
}
// CommentCommunity comments out the community cheatpath block in the config
// template. This is used when the community cheatsheets directory won't exist
// (either because the user declined to download them, or because the config
// is being output as an example).
func CommentCommunity(configs string, confpath string) string {
community, _, _ := cheatsheetDirs(confpath)
return strings.ReplaceAll(configs,
" - name: community\n"+
" path: "+community+"\n"+
" tags: [ community ]\n"+
" readonly: true",
" #- name: community\n"+
" # path: "+community+"\n"+
" # tags: [ community ]\n"+
" # readonly: true",
)
}

View File

@@ -1,29 +0,0 @@
// Package mock implements mock functions used in unit-tests.
package mock
import (
"fmt"
"path/filepath"
"runtime"
)
// Path returns the absolute path to the specified mock file.
func Path(filename string) string {
// determine the path of this file during runtime
_, thisfile, _, _ := runtime.Caller(0)
// compute the mock path
file, err := filepath.Abs(
filepath.Join(
filepath.Dir(thisfile),
"../../mocks",
filename,
),
)
if err != nil {
panic(fmt.Errorf("failed to resolve mock path: %v", err))
}
return file
}

View File

@@ -1 +0,0 @@
package repo

View File

@@ -1,65 +0,0 @@
// Package sheet provides functionality for parsing and managing individual cheat sheets.
//
// A sheet represents a single cheatsheet file containing helpful commands, notes,
// or documentation. Sheets can include optional YAML frontmatter for metadata
// such as tags and syntax highlighting preferences.
//
// # Sheet Format
//
// Sheets are plain text files that may begin with YAML frontmatter:
//
// ---
// syntax: bash
// tags: [networking, linux, ssh]
// ---
// # Connect to remote server
// ssh user@hostname
//
// # Copy files over SSH
// scp local_file user@hostname:/remote/path
//
// The frontmatter is optional. If omitted, the sheet will use default values.
//
// # Core Types
//
// The Sheet type contains:
// - Title: The sheet's name (derived from filename)
// - Path: Full filesystem path to the sheet
// - Text: The content of the sheet (without frontmatter)
// - Tags: Categories assigned to the sheet
// - Syntax: Language hint for syntax highlighting
// - ReadOnly: Whether the sheet can be modified
//
// Key Functions
//
// - New: Creates a new Sheet from a file path
// - Parse: Extracts frontmatter and content from sheet text
// - Search: Searches sheet content using regular expressions
// - Colorize: Applies syntax highlighting to sheet content
//
// # Syntax Highlighting
//
// The package integrates with the Chroma library to provide syntax highlighting.
// Supported languages include bash, python, go, javascript, and many others.
// The syntax can be specified in the frontmatter or auto-detected.
//
// Example Usage
//
// // Load a sheet from disk
// s, err := sheet.New("/path/to/sheet", []string{"personal"}, false)
// if err != nil {
// log.Fatal(err)
// }
//
// // Search for content
// matches, err := s.Search("ssh", false)
// if err != nil {
// log.Fatal(err)
// }
//
// // Apply syntax highlighting
// colorized, err := s.Colorize(config)
// if err != nil {
// log.Fatal(err)
// }
package sheet

View File

@@ -4,7 +4,7 @@ import (
"reflect" "reflect"
"testing" "testing"
"github.com/cheat/cheat/internal/mock" "github.com/cheat/cheat/mocks"
) )
// TestSheetSuccess asserts that sheets initialize properly // TestSheetSuccess asserts that sheets initialize properly
@@ -14,7 +14,7 @@ func TestSheetSuccess(t *testing.T) {
sheet, err := New( sheet, err := New(
"foo", "foo",
"community", "community",
mock.Path("sheet/foo"), mocks.Path("sheet/foo"),
[]string{"alpha", "bravo"}, []string{"alpha", "bravo"},
false, false,
) )
@@ -27,10 +27,10 @@ func TestSheetSuccess(t *testing.T) {
t.Errorf("failed to init title: want: foo, got: %s", sheet.Title) t.Errorf("failed to init title: want: foo, got: %s", sheet.Title)
} }
if sheet.Path != mock.Path("sheet/foo") { if sheet.Path != mocks.Path("sheet/foo") {
t.Errorf( t.Errorf(
"failed to init path: want: %s, got: %s", "failed to init path: want: %s, got: %s",
mock.Path("sheet/foo"), mocks.Path("sheet/foo"),
sheet.Path, sheet.Path,
) )
} }
@@ -63,7 +63,7 @@ func TestSheetFailure(t *testing.T) {
_, err := New( _, err := New(
"foo", "foo",
"community", "community",
mock.Path("/does-not-exist"), mocks.Path("/does-not-exist"),
[]string{"alpha", "bravo"}, []string{"alpha", "bravo"},
false, false,
) )
@@ -80,7 +80,7 @@ func TestSheetFrontMatterFailure(t *testing.T) {
_, err := New( _, err := New(
"foo", "foo",
"community", "community",
mock.Path("sheet/bad-fm"), mocks.Path("sheet/bad-fm"),
[]string{"alpha", "bravo"}, []string{"alpha", "bravo"},
false, false,
) )

View File

@@ -1,15 +1,8 @@
package sheet package sheet
import "slices"
// Tagged returns true if a sheet was tagged with `needle` // Tagged returns true if a sheet was tagged with `needle`
func (s *Sheet) Tagged(needle string) bool { func (s *Sheet) Tagged(needle string) bool {
return slices.Contains(s.Tags, needle)
// if any of the tags match `needle`, return `true`
for _, tag := range s.Tags {
if tag == needle {
return true
}
}
// otherwise, return `false`
return false
} }

View File

@@ -0,0 +1,40 @@
package sheet
import (
"fmt"
"path/filepath"
"strings"
)
// Validate ensures that a cheatsheet name does not contain
// directory traversal sequences or other potentially dangerous patterns.
func Validate(name string) error {
// Reject empty names
if name == "" {
return fmt.Errorf("cheatsheet name cannot be empty")
}
// Reject names containing directory traversal
if strings.Contains(name, "..") {
return fmt.Errorf("cheatsheet name cannot contain '..'")
}
// Reject absolute paths
if filepath.IsAbs(name) {
return fmt.Errorf("cheatsheet name cannot be an absolute path")
}
// Reject names that start with ~ (home directory expansion)
if strings.HasPrefix(name, "~") {
return fmt.Errorf("cheatsheet name cannot start with '~'")
}
// Reject hidden files (files that start with a dot)
// We don't display hidden files, so we shouldn't create them
filename := filepath.Base(name)
if strings.HasPrefix(filename, ".") {
return fmt.Errorf("cheatsheet name cannot start with '.' (hidden files are not supported)")
}
return nil
}

View File

@@ -1,4 +1,4 @@
package cheatpath package sheet
import ( import (
"strings" "strings"
@@ -6,9 +6,9 @@ import (
"unicode/utf8" "unicode/utf8"
) )
// FuzzValidateSheetName tests the ValidateSheetName function with fuzzing // FuzzValidate tests the Validate function with fuzzing
// to ensure it properly prevents path traversal and other security issues // to ensure it properly prevents path traversal and other security issues
func FuzzValidateSheetName(f *testing.F) { func FuzzValidate(f *testing.F) {
// Add seed corpus with various valid and malicious inputs // Add seed corpus with various valid and malicious inputs
// Valid names // Valid names
f.Add("docker") f.Add("docker")
@@ -84,11 +84,11 @@ func FuzzValidateSheetName(f *testing.F) {
func() { func() {
defer func() { defer func() {
if r := recover(); r != nil { if r := recover(); r != nil {
t.Errorf("ValidateSheetName panicked with input %q: %v", input, r) t.Errorf("Validate panicked with input %q: %v", input, r)
} }
}() }()
err := ValidateSheetName(input) err := Validate(input)
// Security invariants that must always hold // Security invariants that must always hold
if err == nil { if err == nil {
@@ -129,8 +129,8 @@ func FuzzValidateSheetName(f *testing.F) {
}) })
} }
// FuzzValidateSheetNamePathTraversal specifically targets path traversal bypasses // FuzzValidatePathTraversal specifically targets path traversal bypasses
func FuzzValidateSheetNamePathTraversal(f *testing.F) { func FuzzValidatePathTraversal(f *testing.F) {
// Seed corpus focusing on path traversal variations // Seed corpus focusing on path traversal variations
f.Add("..", "/", "") f.Add("..", "/", "")
f.Add("", "..", "/") f.Add("", "..", "/")
@@ -153,11 +153,11 @@ func FuzzValidateSheetNamePathTraversal(f *testing.F) {
func() { func() {
defer func() { defer func() {
if r := recover(); r != nil { if r := recover(); r != nil {
t.Errorf("ValidateSheetName panicked with constructed input %q: %v", input, r) t.Errorf("Validate panicked with constructed input %q: %v", input, r)
} }
}() }()
err := ValidateSheetName(input) err := Validate(input)
// If the input contains literal "..", it must be rejected // If the input contains literal "..", it must be rejected
if strings.Contains(input, "..") && err == nil { if strings.Contains(input, "..") && err == nil {

View File

@@ -1,4 +1,4 @@
package cheatpath package sheet
import ( import (
"runtime" "runtime"
@@ -6,7 +6,7 @@ import (
"testing" "testing"
) )
func TestValidateSheetName(t *testing.T) { func TestValidate(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
input string input string
@@ -98,14 +98,14 @@ func TestValidateSheetName(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
err := ValidateSheetName(tt.input) err := Validate(tt.input)
if (err != nil) != tt.wantErr { if (err != nil) != tt.wantErr {
t.Errorf("ValidateName(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr) t.Errorf("Validate(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
return return
} }
if err != nil && tt.errMsg != "" { if err != nil && tt.errMsg != "" {
if !strings.Contains(err.Error(), tt.errMsg) { if !strings.Contains(err.Error(), tt.errMsg) {
t.Errorf("ValidateName(%q) error = %v, want error containing %q", tt.input, err, tt.errMsg) t.Errorf("Validate(%q) error = %v, want error containing %q", tt.input, err, tt.errMsg)
} }
} }
}) })

View File

@@ -1,65 +0,0 @@
// Package sheets manages collections of cheat sheets across multiple cheatpaths.
//
// The sheets package provides functionality to:
// - Load sheets from multiple cheatpaths
// - Consolidate duplicate sheets (with precedence rules)
// - Filter sheets by tags
// - Sort sheets alphabetically
// - Extract unique tags across all sheets
//
// # Loading Sheets
//
// Sheets are loaded recursively from cheatpath directories, excluding:
// - Hidden files (starting with .)
// - Files in .git directories
// - Files with extensions (sheets have no extension)
//
// # Consolidation
//
// When multiple cheatpaths contain sheets with the same name, consolidation
// rules apply based on the order of cheatpaths. Sheets from earlier paths
// override those from later paths, allowing personal sheets to override
// community sheets.
//
// Example:
//
// cheatpaths:
// 1. personal: ~/cheat
// 2. community: ~/cheat/community
//
// If both contain "git", the version from "personal" is used.
//
// # Filtering
//
// Sheets can be filtered by:
// - Tags: Include only sheets with specific tags
// - Cheatpath: Include only sheets from specific paths
//
// Key Functions
//
// - Load: Loads all sheets from the given cheatpaths
// - Filter: Filters sheets by tag
// - Consolidate: Merges sheets from multiple paths with precedence
// - Sort: Sorts sheets alphabetically by title
// - Tags: Extracts all unique tags from sheets
//
// Example Usage
//
// // Load sheets from all cheatpaths
// allSheets, err := sheets.Load(cheatpaths)
// if err != nil {
// log.Fatal(err)
// }
//
// // Consolidate to handle duplicates
// consolidated := sheets.Consolidate(allSheets)
//
// // Filter by tag
// filtered := sheets.Filter(consolidated, "networking")
//
// // Sort alphabetically
// sheets.Sort(filtered)
//
// // Get all unique tags
// tags := sheets.Tags(consolidated)
package sheets

View File

@@ -8,12 +8,11 @@ import (
"strings" "strings"
cp "github.com/cheat/cheat/internal/cheatpath" cp "github.com/cheat/cheat/internal/cheatpath"
"github.com/cheat/cheat/internal/repo"
"github.com/cheat/cheat/internal/sheet" "github.com/cheat/cheat/internal/sheet"
) )
// Load produces a map of cheatsheet titles to filesystem paths // Load produces a map of cheatsheet titles to filesystem paths
func Load(cheatpaths []cp.Cheatpath) ([]map[string]sheet.Sheet, error) { func Load(cheatpaths []cp.Path) ([]map[string]sheet.Sheet, error) {
// create a slice of maps of sheets. This structure will store all sheets // create a slice of maps of sheets. This structure will store all sheets
// that are associated with each cheatpath. // that are associated with each cheatpath.
@@ -27,10 +26,10 @@ func Load(cheatpaths []cp.Cheatpath) ([]map[string]sheet.Sheet, error) {
// recursively iterate over the cheatpath, and load each cheatsheet // recursively iterate over the cheatpath, and load each cheatsheet
// encountered along the way // encountered along the way
err := filepath.Walk( err := filepath.WalkDir(
cheatpath.Path, func( cheatpath.Path, func(
path string, path string,
info os.FileInfo, d fs.DirEntry,
err error) error { err error) error {
// fail if an error occurred while walking the directory // fail if an error occurred while walking the directory
@@ -38,8 +37,12 @@ func Load(cheatpaths []cp.Cheatpath) ([]map[string]sheet.Sheet, error) {
return fmt.Errorf("failed to walk path: %v", err) return fmt.Errorf("failed to walk path: %v", err)
} }
// don't register directories as cheatsheets if d.IsDir() {
if info.IsDir() { // skip .git directories to avoid hundreds/thousands of
// needless syscalls (see repo.GitDir for full history)
if filepath.Base(path) == ".git" {
return fs.SkipDir
}
return nil return nil
} }
@@ -63,17 +66,6 @@ func Load(cheatpaths []cp.Cheatpath) ([]map[string]sheet.Sheet, error) {
string(os.PathSeparator), string(os.PathSeparator),
) )
// Don't walk the `.git` directory. Doing so creates
// hundreds/thousands of needless syscalls and could
// potentially harm performance on machines with slow disks.
skip, err := repo.GitDir(path)
if err != nil {
return fmt.Errorf("failed to identify .git directory: %v", err)
}
if skip {
return fs.SkipDir
}
// parse the cheatsheet file into a `sheet` struct // parse the cheatsheet file into a `sheet` struct
s, err := sheet.New( s, err := sheet.New(
title, title,

View File

@@ -5,22 +5,22 @@ import (
"testing" "testing"
"github.com/cheat/cheat/internal/cheatpath" "github.com/cheat/cheat/internal/cheatpath"
"github.com/cheat/cheat/internal/mock" "github.com/cheat/cheat/mocks"
) )
// TestLoad asserts that sheets on valid cheatpaths can be loaded successfully // TestLoad asserts that sheets on valid cheatpaths can be loaded successfully
func TestLoad(t *testing.T) { func TestLoad(t *testing.T) {
// mock cheatpaths // mock cheatpaths
cheatpaths := []cheatpath.Cheatpath{ cheatpaths := []cheatpath.Path{
{ {
Name: "community", Name: "community",
Path: path.Join(mock.Path("cheatsheets"), "community"), Path: path.Join(mocks.Path("cheatsheets"), "community"),
ReadOnly: true, ReadOnly: true,
}, },
{ {
Name: "personal", Name: "personal",
Path: path.Join(mock.Path("cheatsheets"), "personal"), Path: path.Join(mocks.Path("cheatsheets"), "personal"),
ReadOnly: false, ReadOnly: false,
}, },
} }
@@ -54,7 +54,7 @@ func TestLoad(t *testing.T) {
func TestLoadBadPath(t *testing.T) { func TestLoadBadPath(t *testing.T) {
// mock a bad cheatpath // mock a bad cheatpath
cheatpaths := []cheatpath.Cheatpath{ cheatpaths := []cheatpath.Path{
{ {
Name: "badpath", Name: "badpath",
Path: "/cheat/test/path/does/not/exist", Path: "/cheat/test/path/does/not/exist",

View File

@@ -32,9 +32,7 @@ func Tags(cheatpaths []map[string]sheet.Sheet) []string {
} }
// sort the slice // sort the slice
sort.Slice(sorted, func(i, j int) bool { sort.Strings(sorted)
return sorted[i] < sorted[j]
})
return sorted return sorted
} }

23
mocks/path.go Normal file
View File

@@ -0,0 +1,23 @@
// Package mocks provides test fixture data and helpers for unit tests.
package mocks
import (
"fmt"
"path/filepath"
"runtime"
)
// Path returns the absolute path to the specified mock file within
// the mocks/ directory.
func Path(filename string) string {
_, thisfile, _, _ := runtime.Caller(0)
file, err := filepath.Abs(
filepath.Join(filepath.Dir(thisfile), filename),
)
if err != nil {
panic(fmt.Errorf("failed to resolve mock path: %v", err))
}
return file
}

View File

@@ -16,14 +16,12 @@ DURATION="${1:-15s}"
# Define fuzz tests: "TestName:Package:Description" # Define fuzz tests: "TestName:Package:Description"
TESTS=( TESTS=(
"FuzzParse:./internal/sheet:YAML frontmatter parsing" "FuzzParse:./internal/sheet:YAML frontmatter parsing"
"FuzzValidateSheetName:./internal/cheatpath:sheet name validation (path traversal protection)" "FuzzValidate:./internal/sheet:sheet name validation (path traversal protection)"
"FuzzSearchRegex:./internal/sheet:regex search operations" "FuzzSearchRegex:./internal/sheet:regex search operations"
"FuzzSearchCatastrophicBacktracking:./internal/sheet:catastrophic backtracking"
"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" "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)..."

View File

@@ -1,4 +1,4 @@
package main package integration
import ( import (
"fmt" "fmt"
@@ -18,7 +18,8 @@ func TestBriefFlagIntegration(t *testing.T) {
// Build the cheat binary once for all sub-tests. // Build the cheat binary once for all sub-tests.
binPath := filepath.Join(t.TempDir(), "cheat_test") binPath := filepath.Join(t.TempDir(), "cheat_test")
build := exec.Command("go", "build", "-o", binPath, ".") build := exec.Command("go", "build", "-o", binPath, "./cmd/cheat")
build.Dir = repoRoot(t)
if output, err := build.CombinedOutput(); err != nil { if output, err := build.CombinedOutput(); err != nil {
t.Fatalf("failed to build cheat: %v\nOutput: %s", err, output) t.Fatalf("failed to build cheat: %v\nOutput: %s", err, output)
} }

View File

@@ -1,4 +1,4 @@
package main package integration
import ( import (
"fmt" "fmt"
@@ -32,7 +32,8 @@ func TestLocalCheatpathIntegration(t *testing.T) {
// Build the cheat binary once for all sub-tests. // Build the cheat binary once for all sub-tests.
binPath := filepath.Join(t.TempDir(), "cheat_test") binPath := filepath.Join(t.TempDir(), "cheat_test")
build := exec.Command("go", "build", "-o", binPath, ".") build := exec.Command("go", "build", "-o", binPath, "./cmd/cheat")
build.Dir = repoRoot(t)
if output, err := build.CombinedOutput(); err != nil { if output, err := build.CombinedOutput(); err != nil {
t.Fatalf("failed to build cheat: %v\nOutput: %s", err, output) t.Fatalf("failed to build cheat: %v\nOutput: %s", err, output)
} }

View File

@@ -1,4 +1,4 @@
package main package integration
import ( import (
"os" "os"
@@ -19,7 +19,8 @@ func TestFirstRunIntegration(t *testing.T) {
binName += ".exe" binName += ".exe"
} }
binPath := filepath.Join(t.TempDir(), binName) binPath := filepath.Join(t.TempDir(), binName)
build := exec.Command("go", "build", "-o", binPath, ".") build := exec.Command("go", "build", "-o", binPath, "./cmd/cheat")
build.Dir = repoRoot(t)
if output, err := build.CombinedOutput(); err != nil { if output, err := build.CombinedOutput(); err != nil {
t.Fatalf("failed to build cheat: %v\nOutput: %s", err, output) t.Fatalf("failed to build cheat: %v\nOutput: %s", err, output)
} }

View File

@@ -0,0 +1,30 @@
package integration
import (
"path/filepath"
"runtime"
"testing"
)
// repoRoot returns the absolute path to the repository root.
// It derives this from the known location of this source file
// (test/integration/) relative to the repo root.
func repoRoot(t *testing.T) string {
t.Helper()
_, file, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("failed to determine repo root via runtime.Caller")
}
// file is <repo>/test/integration/helpers_test.go → go up two dirs
return filepath.Dir(filepath.Dir(filepath.Dir(file)))
}
// repoRootBench is the same as repoRoot but for use in benchmarks.
func repoRootBench(b *testing.B) string {
b.Helper()
_, file, _, ok := runtime.Caller(0)
if !ok {
b.Fatal("failed to determine repo root via runtime.Caller")
}
return filepath.Dir(filepath.Dir(filepath.Dir(file)))
}

View File

@@ -1,4 +1,4 @@
package main package integration
import ( import (
"fmt" "fmt"
@@ -19,7 +19,9 @@ func TestPathTraversalIntegration(t *testing.T) {
// Build the cheat binary // Build the cheat binary
binPath := filepath.Join(t.TempDir(), "cheat_test") binPath := filepath.Join(t.TempDir(), "cheat_test")
if output, err := exec.Command("go", "build", "-o", binPath, ".").CombinedOutput(); err != nil { build := exec.Command("go", "build", "-o", binPath, "./cmd/cheat")
build.Dir = repoRoot(t)
if output, err := build.CombinedOutput(); err != nil {
t.Fatalf("Failed to build cheat: %v\nOutput: %s", err, output) t.Fatalf("Failed to build cheat: %v\nOutput: %s", err, output)
} }
@@ -159,7 +161,9 @@ func TestPathTraversalRealWorld(t *testing.T) {
// Build cheat // Build cheat
binPath := filepath.Join(t.TempDir(), "cheat_test") binPath := filepath.Join(t.TempDir(), "cheat_test")
if output, err := exec.Command("go", "build", "-o", binPath, ".").CombinedOutput(); err != nil { build := exec.Command("go", "build", "-o", binPath, "./cmd/cheat")
build.Dir = repoRoot(t)
if output, err := build.CombinedOutput(); err != nil {
t.Fatalf("Failed to build: %v\n%s", err, output) t.Fatalf("Failed to build: %v\n%s", err, output)
} }

View File

@@ -1,6 +1,6 @@
//go:build integration //go:build integration
package main package integration
import ( import (
"bytes" "bytes"
@@ -16,12 +16,10 @@ import (
// BenchmarkSearchCommand benchmarks the actual cheat search command // BenchmarkSearchCommand benchmarks the actual cheat search command
func BenchmarkSearchCommand(b *testing.B) { func BenchmarkSearchCommand(b *testing.B) {
root := repoRootBench(b)
// Build the cheat binary in .tmp (using absolute path) // Build the cheat binary in .tmp (using absolute path)
rootDir, err := filepath.Abs(filepath.Join("..", "..")) tmpDir := filepath.Join(root, ".tmp", "bench-test")
if err != nil {
b.Fatalf("Failed to get root dir: %v", err)
}
tmpDir := filepath.Join(rootDir, ".tmp", "bench-test")
if err := os.MkdirAll(tmpDir, 0755); err != nil { if err := os.MkdirAll(tmpDir, 0755); err != nil {
b.Fatalf("Failed to create temp dir: %v", err) b.Fatalf("Failed to create temp dir: %v", err)
} }
@@ -34,7 +32,7 @@ func BenchmarkSearchCommand(b *testing.B) {
}) })
cmd := exec.Command("go", "build", "-o", cheatBin, "./cmd/cheat") cmd := exec.Command("go", "build", "-o", cheatBin, "./cmd/cheat")
cmd.Dir = rootDir cmd.Dir = root
if output, err := cmd.CombinedOutput(); err != nil { if output, err := cmd.CombinedOutput(); err != nil {
b.Fatalf("Failed to build cheat: %v\nOutput: %s", err, output) b.Fatalf("Failed to build cheat: %v\nOutput: %s", err, output)
} }
@@ -126,12 +124,10 @@ cheatpaths:
// BenchmarkListCommand benchmarks the list command for comparison // BenchmarkListCommand benchmarks the list command for comparison
func BenchmarkListCommand(b *testing.B) { func BenchmarkListCommand(b *testing.B) {
root := repoRootBench(b)
// Build the cheat binary in .tmp (using absolute path) // Build the cheat binary in .tmp (using absolute path)
rootDir, err := filepath.Abs(filepath.Join("..", "..")) tmpDir := filepath.Join(root, ".tmp", "bench-test")
if err != nil {
b.Fatalf("Failed to get root dir: %v", err)
}
tmpDir := filepath.Join(rootDir, ".tmp", "bench-test")
if err := os.MkdirAll(tmpDir, 0755); err != nil { if err := os.MkdirAll(tmpDir, 0755); err != nil {
b.Fatalf("Failed to create temp dir: %v", err) b.Fatalf("Failed to create temp dir: %v", err)
} }
@@ -144,7 +140,7 @@ func BenchmarkListCommand(b *testing.B) {
}) })
cmd := exec.Command("go", "build", "-o", cheatBin, "./cmd/cheat") cmd := exec.Command("go", "build", "-o", cheatBin, "./cmd/cheat")
cmd.Dir = rootDir cmd.Dir = root
if output, err := cmd.CombinedOutput(); err != nil { if output, err := cmd.CombinedOutput(); err != nil {
b.Fatalf("Failed to build cheat: %v\nOutput: %s", err, output) b.Fatalf("Failed to build cheat: %v\nOutput: %s", err, output)
} }