chore: housekeeping and refactoring (bump to 4.7.1)

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Christopher Allen Lane
2026-02-15 15:09:30 -05:00
parent d4a8a79628
commit 5ad1a3c39f
68 changed files with 605 additions and 1578 deletions

View File

@@ -85,7 +85,11 @@ The `cheat` command-line tool is organized into several key packages:
- Writes to stdout or pager
- Handles text formatting and indentation
6. **`internal/repo`**: Git repository management
6. **`internal/installer`**: First-run installer
- Prompts user for initial configuration choices
- Generates default `conf.yml` and downloads community cheatsheets
7. **`internal/repo`**: Git repository management
- Clones community cheatsheet repositories
- Updates existing repositories
@@ -95,6 +99,7 @@ The `cheat` command-line tool is organized into several key packages:
- **Override mechanism**: Local sheets override community sheets with same name
- **Tag system**: Sheets can be categorized with tags in frontmatter
- **Multiple cheatpaths**: Supports personal, community, and directory-scoped sheets
- **Directory-scoped discovery**: Walks up from cwd to find the nearest `.cheat` directory (like `.git` discovery)
### Sheet Format
@@ -114,4 +119,4 @@ ssh -L 8080:localhost:80 user@remote
- Use `go-git` for repository operations, not exec'ing git commands
- Platform-specific paths are handled in `internal/config/paths.go`
- 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

@@ -1,5 +1,4 @@
Contributing
============
# Contributing
Thank you for your interest in `cheat`.
@@ -11,4 +10,8 @@ Bug reports are still welcome. If you've found a bug, please open an issue in
the [issue tracker][issues]. Before doing so, please search through the
existing open issues to make sure it hasn't already been reported.
Feature requests may be filed, but are unlikely to be implemented. The project
is now mature and the maintainer considers its feature set to be essentially
complete.
[issues]: https://github.com/cheat/cheat/issues

View File

@@ -88,7 +88,7 @@ The main configuration structure:
type Config struct {
Colorize bool `yaml:"colorize"`
Editor string `yaml:"editor"`
Cheatpaths []cp.Cheatpath `yaml:"cheatpaths"`
Cheatpaths []cp.Path `yaml:"cheatpaths"`
Style string `yaml:"style"`
Formatter string `yaml:"formatter"`
Pager string `yaml:"pager"`
@@ -97,7 +97,7 @@ type Config struct {
```
Key functions:
- `New(opts, confPath, resolve)` - Load config from file
- `New(confPath, resolve)` - Load config from file
- `Validate()` - Validate configuration values
- `Editor()` - Get editor 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:
```go
type Cheatpath struct {
type Path struct {
Name string // Friendly name (e.g., "personal")
Path string // Filesystem 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.go` files in same package
- Table-driven tests for multiple scenarios
- Mock data in `internal/mock` package
- Mock data in `mocks` package
## Error Handling

View File

@@ -1,30 +1,29 @@
Installing
==========
# Installing
`cheat` has no runtime dependencies. As such, installing it is generally
straightforward. There are a few methods available:
### Install manually
#### Unix-like
## Install manually
### Unix-like
On Unix-like systems, you may simply paste the following snippet into your terminal:
```sh
cd /tmp \
&& wget https://github.com/cheat/cheat/releases/download/4.5.1/cheat-linux-amd64.gz \
&& wget https://github.com/cheat/cheat/releases/download/4.7.0/cheat-linux-amd64.gz \
&& gunzip cheat-linux-amd64.gz \
&& chmod +x cheat-linux-amd64 \
&& sudo mv cheat-linux-amd64 /usr/local/bin/cheat
```
You may need to need to change the version number (`4.5.1`) and the archive
You may need to need to change the version number (`4.7.0`) and the archive
(`cheat-linux-amd64.gz`) depending on your platform.
See the [releases page][releases] for a list of supported platforms.
#### Windows
### Windows
On Windows, download the appropriate binary from the [releases page][releases],
unzip the archive, and place the `cheat.exe` executable on your `PATH`.
### Install via `go install`
## Install via `go install`
If you have `go` version `>=1.17` available on your `PATH`, you can install
`cheat` via `go install`:
@@ -32,7 +31,7 @@ If you have `go` version `>=1.17` available on your `PATH`, you can install
go install github.com/cheat/cheat/cmd/cheat@latest
```
### Install via package manager
## Install via package manager
Several community-maintained packages are also available:
Package manager | Package(s)
@@ -43,8 +42,6 @@ docker | [docker-cheat][pkg-docker]
nix | [nixos.cheat][pkg-nix]
snap | [cheat][pkg-snap]
<!--[pacman][] |-->
## Configuring
Three things must be done before you can use `cheat`:
1. A config file must be generated
@@ -56,7 +53,7 @@ automatically. After the installer is complete, it is strongly advised that you
view the configuration file that was generated, as you may want to change some
of its default values (to enable colorization, change the paginator, etc).
### conf.yml ###
### conf.yml
`cheat` is configured by a YAML file that will be auto-generated on first run.
By default, the config file is assumed to exist on an XDG-compliant

View File

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

View File

@@ -1,8 +1,6 @@
![Workflow status](https://github.com/cheat/cheat/actions/workflows/build.yml/badge.svg)
cheat
=====
# cheat
`cheat` allows you to create and view interactive cheatsheets on the
command-line. It was designed to help remind \*nix system administrators of
@@ -13,9 +11,7 @@ remember.
Use `cheat` with [cheatsheets][].
Example
-------
## Example
The next time you're forced to disarm a nuclear weapon without consulting
Google, you may run:
@@ -42,8 +38,10 @@ tar -xjvf '/path/to/foo.tgz'
tar -cjvf '/path/to/foo.tgz' '/path/to/foo/'
```
Usage
-----
## Installing
For installation and configuration instructions, see [INSTALLING.md][].
## Usage
To view a cheatsheet:
```sh
@@ -107,14 +105,7 @@ Flags may be combined in intuitive ways. Example: to search sheets on the
cheat -p personal -t networking --regex -s '(?:[0-9]{1,3}\.){3}[0-9]{1,3}'
```
Installing
----------
For installation and configuration instructions, see [INSTALLING.md][].
Cheatsheets
-----------
## Cheatsheets
Cheatsheets are plain-text files with no file extension, and are named
according to the command used to view them:
@@ -143,8 +134,7 @@ The `cheat` executable includes no cheatsheets, but [community-sourced
cheatsheets are available][cheatsheets]. You will be asked if you would like to
install the community-sourced cheatsheets the first time you run `cheat`.
Cheatpaths
----------
## Cheatpaths
Cheatsheets are stored on "cheatpaths", which are directories that contain
cheatsheets. Cheatpaths are specified in the `conf.yml` file.
@@ -176,7 +166,7 @@ If a user attempts to edit a cheatsheet on a read-only cheatpath, `cheat` will
transparently copy that sheet to a writeable directory before opening it for
editing.
### Directory-scoped Cheatpaths ###
### 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` directory
in the current working directory and its ancestors (similar to how `git` locates
@@ -184,8 +174,7 @@ in the current working directory and its ancestors (similar to how `git` locates
added to the cheatpaths. This means you can place a `.cheat` directory at your
project root and it will be available from any subdirectory within that project.
Autocompletion
--------------
## Autocompletion
Shell autocompletion is currently available for `bash`, `fish`, and `zsh`. Copy
the relevant [completion script][completions] into the appropriate directory on
your filesystem to enable autocompletion. (This directory will vary depending
@@ -204,4 +193,3 @@ Additionally, `cheat` supports enhanced autocompletion via integration with
[Chroma]: https://github.com/alecthomas/chroma
[supported languages]: https://github.com/alecthomas/chroma#supported-languages
[fzf]: https://github.com/junegunn/fzf
[go]: https://golang.org

View File

@@ -38,10 +38,10 @@ The validation is performed at the application layer before any file operations
### Validation Function
The validation is implemented in `internal/cheatpath/validate.go`:
The validation is implemented in `internal/sheet/validate.go`:
```go
func ValidateSheetName(name string) error {
func Validate(name string) error {
// Reject empty names
if name == "" {
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:
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
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/config"
"github.com/cheat/cheat/internal/sheet"
"github.com/cheat/cheat/internal/sheets"
)
@@ -18,7 +19,7 @@ func cmdEdit(opts map[string]interface{}, conf config.Config) {
cheatsheet := opts["--edit"].(string)
// 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)
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)
os.Exit(1)
}
// filter cheatcheats by tag if --tag was provided
if opts["--tag"] != nil {
cheatsheets = sheets.Filter(
cheatsheets,
@@ -52,50 +51,30 @@ func cmdEdit(opts map[string]interface{}, conf config.Config) {
// if the sheet exists and is not read-only, edit it in place
if ok && !sheet.ReadOnly {
editpath = sheet.Path
// if the sheet exists but is read-only, copy it before editing
} else if ok && sheet.ReadOnly {
// compute the new edit path
// begin by getting a writeable cheatpath
} else {
// for read-only or non-existent sheets, resolve a writeable path
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, sheet.Title)
// 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)
}
// use the existing title for read-only copies, the requested name otherwise
title := cheatsheet
if ok {
title = sheet.Title
}
editpath = filepath.Join(writepath.Path, title)
// copy the sheet to the new edit path
err = sheet.Copy(editpath)
if err != nil {
if ok {
// copy the read-only sheet to the writeable path
// (Copy handles MkdirAll internally)
if err := sheet.Copy(editpath); err != nil {
fmt.Fprintf(os.Stderr, "failed to copy cheatsheet: %v\n", err)
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
// create any necessary subdirectories for the new sheet
dirs := filepath.Dir(editpath)
if dirs != "." {
if err := os.MkdirAll(dirs, 0755); err != nil {
@@ -104,6 +83,7 @@ func cmdEdit(opts map[string]interface{}, conf config.Config) {
}
}
}
}
// split `conf.Editor` into parts to separate the editor's executable from
// any arguments it may have been passed. If this is not done, the nearby

View File

@@ -3,78 +3,27 @@ package main
import (
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"github.com/mitchellh/go-homedir"
"github.com/cheat/cheat/internal/config"
"github.com/cheat/cheat/internal/installer"
)
// cmdInit displays an example config file.
func cmdInit() {
func cmdInit(home string, envvars map[string]string) {
// get the user's home directory
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
// identify the os-specific paths at which configs may be located
confpaths, err := config.Paths(runtime.GOOS, home, envvars)
if err != nil {
fmt.Fprintf(os.Stderr, "failed to read config paths: %v\n", err)
os.Exit(1)
}
// determine the appropriate paths for config data and (optional) community
// cheatsheets based on the user's platform
confpath := confpaths[0]
confdir := filepath.Dir(confpath)
// create paths for community, personal, and work cheatsheets
community := filepath.Join(confdir, "cheatsheets", "community")
personal := filepath.Join(confdir, "cheatsheets", "personal")
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,
)
// expand template placeholders and comment out community cheatpath
configs := installer.ExpandTemplate(configs(), confpath)
configs = installer.CommentCommunity(configs, confpath)
// output the templated 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)
os.Exit(1)
}
// filter cheatsheets by tag if --tag was provided
if opts["--tag"] != nil {
cheatsheets = sheets.Filter(
cheatsheets,

View File

@@ -5,8 +5,8 @@ import (
"os"
"strings"
"github.com/cheat/cheat/internal/cheatpath"
"github.com/cheat/cheat/internal/config"
"github.com/cheat/cheat/internal/sheet"
"github.com/cheat/cheat/internal/sheets"
)
@@ -16,7 +16,7 @@ func cmdRemove(opts map[string]interface{}, conf config.Config) {
cheatsheet := opts["--rm"].(string)
// 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)
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)
os.Exit(1)
}
// filter cheatcheats by tag if --tag was provided
if opts["--tag"] != nil {
cheatsheets = sheets.Filter(
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)
os.Exit(1)
}
// filter cheatcheats by tag if --tag was provided
if opts["--tag"] != nil {
cheatsheets = sheets.Filter(
cheatsheets,
@@ -80,7 +78,7 @@ func cmdSearch(opts map[string]interface{}, conf config.Config) {
// append the cheatsheet title
sheet.Title,
// 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
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)
os.Exit(1)
}
// filter cheatcheats by tag if --tag was provided
if opts["--tag"] != nil {
cheatsheets = sheets.Filter(
cheatsheets,
@@ -42,7 +40,7 @@ func cmdView(opts map[string]interface{}, conf config.Config) {
// identify the matching cheatsheet
out += fmt.Sprintf("%s %s\n",
sheet.Title,
display.Faint(fmt.Sprintf("(%s)", sheet.CheatPath), conf),
display.Faint(fmt.Sprintf("(%s)", sheet.CheatPath), conf.Color(opts)),
)
// apply colorization if requested

View File

@@ -15,7 +15,7 @@ import (
"github.com/cheat/cheat/internal/installer"
)
const version = "4.7.0"
const version = "4.7.1"
func main() {
@@ -26,13 +26,6 @@ func main() {
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
home, err := homedir.Dir()
if err != nil {
@@ -51,6 +44,13 @@ func main() {
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
confpaths, err := config.Paths(runtime.GOOS, home, envvars)
if err != nil {
@@ -92,7 +92,7 @@ func main() {
}
// initialize the configs
conf, err := config.New(opts, confpath, true)
conf, err := config.New(confpath, true)
if err != nil {
fmt.Fprintf(os.Stderr, "failed to load config: %v\n", err)
os.Exit(1)

View File

@@ -2,27 +2,10 @@
// management.
package cheatpath
import "fmt"
// Cheatpath encapsulates cheatsheet path information
type Cheatpath struct {
// Path encapsulates cheatsheet path information
type Path struct {
Name string `yaml:"name"`
Path string `yaml:"path"`
ReadOnly bool `yaml:"readonly"`
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) {
tests := []struct {
name string
cheatpath Cheatpath
cheatpath Path
wantErr bool
errMsg string
}{
{
name: "valid cheatpath",
cheatpath: Cheatpath{
cheatpath: Path{
Name: "personal",
Path: "/home/user/.config/cheat/personal",
ReadOnly: false,
@@ -24,7 +24,7 @@ func TestCheatpathValidate(t *testing.T) {
},
{
name: "empty name",
cheatpath: Cheatpath{
cheatpath: Path{
Name: "",
Path: "/home/user/.config/cheat/personal",
ReadOnly: false,
@@ -35,7 +35,7 @@ func TestCheatpathValidate(t *testing.T) {
},
{
name: "empty path",
cheatpath: Cheatpath{
cheatpath: Path{
Name: "personal",
Path: "",
ReadOnly: false,
@@ -46,7 +46,7 @@ func TestCheatpathValidate(t *testing.T) {
},
{
name: "both empty",
cheatpath: Cheatpath{
cheatpath: Path{
Name: "",
Path: "",
ReadOnly: true,
@@ -57,7 +57,7 @@ func TestCheatpathValidate(t *testing.T) {
},
{
name: "minimal valid",
cheatpath: Cheatpath{
cheatpath: Path{
Name: "x",
Path: "/",
},
@@ -65,7 +65,7 @@ func TestCheatpathValidate(t *testing.T) {
},
{
name: "with readonly and tags",
cheatpath: Cheatpath{
cheatpath: Path{
Name: "community",
Path: "/usr/share/cheat",
ReadOnly: true,
@@ -88,26 +88,3 @@ func TestCheatpathValidate(t *testing.T) {
})
}
}
func TestCheatpathStruct(t *testing.T) {
// Test that the struct fields work as expected
cp := Cheatpath{
Name: "test",
Path: "/test/path",
ReadOnly: true,
Tags: []string{"tag1", "tag2"},
}
if cp.Name != "test" {
t.Errorf("expected Name to be 'test', got %q", cp.Name)
}
if cp.Path != "/test/path" {
t.Errorf("expected Path to be '/test/path', got %q", cp.Path)
}
if !cp.ReadOnly {
t.Error("expected ReadOnly to be true")
}
if len(cp.Tags) != 2 || cp.Tags[0] != "tag1" || cp.Tags[1] != "tag2" {
t.Errorf("expected Tags to be [tag1 tag2], got %v", cp.Tags)
}
}

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`
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
for _, path := range paths {
if path.Name == name {
return []Cheatpath{path}, nil
return []Path{path}, nil
}
}
// 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) {
// init cheatpaths
paths := []Cheatpath{
Cheatpath{Name: "foo"},
Cheatpath{Name: "bar"},
Cheatpath{Name: "baz"},
paths := []Path{
Path{Name: "foo"},
Path{Name: "bar"},
Path{Name: "baz"},
}
// filter the paths
@@ -39,10 +39,10 @@ func TestFilterSuccess(t *testing.T) {
func TestFilterFailure(t *testing.T) {
// init cheatpaths
paths := []Cheatpath{
Cheatpath{Name: "foo"},
Cheatpath{Name: "bar"},
Cheatpath{Name: "baz"},
paths := []Path{
Path{Name: "foo"},
Path{Name: "bar"},
Path{Name: "baz"},
}
// filter the paths

View File

@@ -2,39 +2,15 @@ package cheatpath
import (
"fmt"
"path/filepath"
"strings"
)
// ValidateSheetName ensures that a cheatsheet name does not contain
// directory traversal sequences or other potentially dangerous patterns.
func ValidateSheetName(name string) error {
// Reject empty names
if name == "" {
return fmt.Errorf("cheatsheet name cannot be empty")
// Validate ensures that the Path is valid
func (c Path) Validate() error {
if c.Name == "" {
return fmt.Errorf("cheatpath name cannot be empty")
}
// Reject names containing directory traversal
if strings.Contains(name, "..") {
return fmt.Errorf("cheatsheet name cannot contain '..'")
if c.Path == "" {
return fmt.Errorf("cheatpath path cannot be empty")
}
// 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

@@ -4,8 +4,8 @@ import (
"fmt"
)
// Writeable returns a writeable Cheatpath
func Writeable(cheatpaths []Cheatpath) (Cheatpath, error) {
// Writeable returns a writeable Path
func Writeable(cheatpaths []Path) (Path, error) {
// iterate backwards over the cheatpaths
// 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
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) {
// initialize some cheatpaths
cheatpaths := []Cheatpath{
Cheatpath{Path: "/foo", ReadOnly: true},
Cheatpath{Path: "/bar", ReadOnly: false},
Cheatpath{Path: "/baz", ReadOnly: true},
cheatpaths := []Path{
Path{Path: "/foo", ReadOnly: true},
Path{Path: "/bar", ReadOnly: false},
Path{Path: "/baz", ReadOnly: true},
}
// get the writeable cheatpath
@@ -34,10 +34,10 @@ func TestWriteableOK(t *testing.T) {
func TestWriteableNotOK(t *testing.T) {
// initialize some cheatpaths
cheatpaths := []Cheatpath{
Cheatpath{Path: "/foo", ReadOnly: true},
Cheatpath{Path: "/bar", ReadOnly: true},
Cheatpath{Path: "/baz", ReadOnly: true},
cheatpaths := []Path{
Path{Path: "/foo", ReadOnly: true},
Path{Path: "/bar", ReadOnly: true},
Path{Path: "/baz", ReadOnly: true},
}
// get the writeable cheatpath

View File

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

View File

@@ -3,10 +3,9 @@ package config
import (
"os"
"path/filepath"
"runtime"
"testing"
"github.com/cheat/cheat/internal/mock"
"github.com/cheat/cheat/mocks"
)
// TestConfigYAMLErrors tests YAML parsing errors
@@ -19,258 +18,22 @@ func TestConfigYAMLErrors(t *testing.T) {
defer os.RemoveAll(tempDir)
invalidYAML := filepath.Join(tempDir, "invalid.yml")
err = os.WriteFile(invalidYAML, []byte("invalid: yaml: content:\n - no closing"), 0644)
err = os.WriteFile(invalidYAML, []byte("cheatpaths: [{unclosed\n"), 0644)
if err != nil {
t.Fatalf("failed to write invalid yaml: %v", err)
}
// Attempt to load invalid YAML
_, err = New(map[string]interface{}{}, invalidYAML, false)
_, err = New(invalidYAML, false)
if err == nil {
t.Error("expected error for invalid YAML, got nil")
}
}
// TestConfigLocalCheatpath tests local .cheat directory detection
func TestConfigLocalCheatpath(t *testing.T) {
// Create a temporary directory to act as working directory
tempDir, err := os.MkdirTemp("", "cheat-config-test-*")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
// Resolve symlinks in temp dir path (macOS /var -> /private/var)
tempDir, err = filepath.EvalSymlinks(tempDir)
if err != nil {
t.Fatalf("failed to resolve temp dir symlinks: %v", err)
}
// Save current working directory
oldCwd, err := os.Getwd()
if err != nil {
t.Fatalf("failed to get cwd: %v", err)
}
defer os.Chdir(oldCwd)
// Change to temp directory
err = os.Chdir(tempDir)
if err != nil {
t.Fatalf("failed to change dir: %v", err)
}
// Create .cheat directory
localCheat := filepath.Join(tempDir, ".cheat")
err = os.Mkdir(localCheat, 0755)
if err != nil {
t.Fatalf("failed to create .cheat dir: %v", err)
}
// Load config
conf, err := New(map[string]interface{}{}, mock.Path("conf/empty.yml"), false)
if err != nil {
t.Errorf("failed to load config: %v", err)
}
// Check that local cheatpath was added
found := false
for _, cp := range conf.Cheatpaths {
if cp.Name == "cwd" && cp.Path == localCheat {
found = true
break
}
}
if !found {
t.Error("local .cheat directory was not added to cheatpaths")
}
}
// TestConfigLocalCheatpathInParent tests that .cheat in a parent directory is found
func TestConfigLocalCheatpathInParent(t *testing.T) {
tempDir, err := os.MkdirTemp("", "cheat-config-test-*")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
// Resolve symlinks in temp dir path (macOS /var -> /private/var)
tempDir, err = filepath.EvalSymlinks(tempDir)
if err != nil {
t.Fatalf("failed to resolve temp dir symlinks: %v", err)
}
oldCwd, err := os.Getwd()
if err != nil {
t.Fatalf("failed to get cwd: %v", err)
}
defer os.Chdir(oldCwd)
// Create .cheat in the root of the temp dir
localCheat := filepath.Join(tempDir, ".cheat")
if err := os.Mkdir(localCheat, 0755); err != nil {
t.Fatalf("failed to create .cheat dir: %v", err)
}
// Create a subdirectory and cd into it
subDir := filepath.Join(tempDir, "sub")
if err := os.Mkdir(subDir, 0755); err != nil {
t.Fatalf("failed to create sub dir: %v", err)
}
if err := os.Chdir(subDir); err != nil {
t.Fatalf("failed to chdir: %v", err)
}
conf, err := New(map[string]interface{}{}, mock.Path("conf/empty.yml"), false)
if err != nil {
t.Errorf("failed to load config: %v", err)
}
found := false
for _, cp := range conf.Cheatpaths {
if cp.Name == "cwd" && cp.Path == localCheat {
found = true
break
}
}
if !found {
t.Error("parent .cheat directory was not added to cheatpaths")
}
}
// TestConfigLocalCheatpathNearestWins tests that the nearest .cheat wins
func TestConfigLocalCheatpathNearestWins(t *testing.T) {
tempDir, err := os.MkdirTemp("", "cheat-config-test-*")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
// Resolve symlinks in temp dir path (macOS /var -> /private/var)
tempDir, err = filepath.EvalSymlinks(tempDir)
if err != nil {
t.Fatalf("failed to resolve temp dir symlinks: %v", err)
}
oldCwd, err := os.Getwd()
if err != nil {
t.Fatalf("failed to get cwd: %v", err)
}
defer os.Chdir(oldCwd)
// Create .cheat at root
if err := os.Mkdir(filepath.Join(tempDir, ".cheat"), 0755); err != nil {
t.Fatalf("failed to create root .cheat dir: %v", err)
}
// Create sub/.cheat (the nearer one)
subDir := filepath.Join(tempDir, "sub")
if err := os.Mkdir(subDir, 0755); err != nil {
t.Fatalf("failed to create sub dir: %v", err)
}
nearCheat := filepath.Join(subDir, ".cheat")
if err := os.Mkdir(nearCheat, 0755); err != nil {
t.Fatalf("failed to create near .cheat dir: %v", err)
}
// cd into sub/deep/
deepDir := filepath.Join(subDir, "deep")
if err := os.Mkdir(deepDir, 0755); err != nil {
t.Fatalf("failed to create deep dir: %v", err)
}
if err := os.Chdir(deepDir); err != nil {
t.Fatalf("failed to chdir: %v", err)
}
conf, err := New(map[string]interface{}{}, mock.Path("conf/empty.yml"), false)
if err != nil {
t.Errorf("failed to load config: %v", err)
}
found := false
for _, cp := range conf.Cheatpaths {
if cp.Name == "cwd" {
if cp.Path != nearCheat {
t.Errorf("expected nearest .cheat %s, got %s", nearCheat, cp.Path)
}
found = true
break
}
}
if !found {
t.Error("no cwd cheatpath found")
}
}
// TestConfigNoLocalCheatpath tests that no cwd cheatpath is added when no .cheat exists
func TestConfigNoLocalCheatpath(t *testing.T) {
tempDir, err := os.MkdirTemp("", "cheat-config-test-*")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
oldCwd, err := os.Getwd()
if err != nil {
t.Fatalf("failed to get cwd: %v", err)
}
defer os.Chdir(oldCwd)
if err := os.Chdir(tempDir); err != nil {
t.Fatalf("failed to chdir: %v", err)
}
conf, err := New(map[string]interface{}{}, mock.Path("conf/empty.yml"), false)
if err != nil {
t.Errorf("failed to load config: %v", err)
}
for _, cp := range conf.Cheatpaths {
if cp.Name == "cwd" {
t.Error("cwd cheatpath should not be added when no .cheat exists")
}
}
}
// TestConfigLocalCheatpathFileSkipped tests that a .cheat file (not dir) is skipped
func TestConfigLocalCheatpathFileSkipped(t *testing.T) {
tempDir, err := os.MkdirTemp("", "cheat-config-test-*")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
oldCwd, err := os.Getwd()
if err != nil {
t.Fatalf("failed to get cwd: %v", err)
}
defer os.Chdir(oldCwd)
// Create .cheat as a file, not a directory
if err := os.WriteFile(filepath.Join(tempDir, ".cheat"), []byte("not a dir"), 0644); err != nil {
t.Fatalf("failed to create .cheat file: %v", err)
}
if err := os.Chdir(tempDir); err != nil {
t.Fatalf("failed to chdir: %v", err)
}
conf, err := New(map[string]interface{}{}, mock.Path("conf/empty.yml"), false)
if err != nil {
t.Errorf("failed to load config: %v", err)
}
for _, cp := range conf.Cheatpaths {
if cp.Name == "cwd" {
t.Error("cwd cheatpath should not be added for a .cheat file")
}
}
}
// TestConfigDefaults tests default values
func TestConfigDefaults(t *testing.T) {
// Load empty config
conf, err := New(map[string]interface{}{}, mock.Path("conf/empty.yml"), false)
conf, err := New(mocks.Path("conf/empty.yml"), false)
if err != nil {
t.Errorf("failed to load config: %v", err)
}
@@ -329,13 +92,16 @@ cheatpaths:
}
// Load config with symlink resolution
conf, err := New(map[string]interface{}{}, configFile, true)
conf, err := New(configFile, true)
if err != nil {
t.Errorf("failed to load config: %v", err)
}
// Verify symlink was resolved
if len(conf.Cheatpaths) > 0 && conf.Cheatpaths[0].Path != targetDir {
if len(conf.Cheatpaths) == 0 {
t.Fatal("expected at least one cheatpath, got none")
}
if conf.Cheatpaths[0].Path != targetDir {
t.Errorf("expected symlink to be resolved to %s, got %s", targetDir, conf.Cheatpaths[0].Path)
}
}
@@ -372,7 +138,7 @@ cheatpaths:
// Load config with symlink resolution should skip the broken cheatpath
// (warn to stderr) rather than hard-error
conf, err := New(map[string]interface{}{}, configFile, true)
conf, err := New(configFile, true)
if err != nil {
t.Errorf("expected no error for broken symlink (should skip), got: %v", err)
}
@@ -380,70 +146,3 @@ cheatpaths:
t.Errorf("expected broken cheatpath to be filtered out, got %d cheatpaths", len(conf.Cheatpaths))
}
}
// TestConfigTildeExpansionError tests tilde expansion error handling
func TestConfigTildeExpansionError(t *testing.T) {
// This is tricky to test without mocking homedir.Expand
// We'll create a config with an invalid home reference
tempDir, err := os.MkdirTemp("", "cheat-config-test-*")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
// Create config with user that likely doesn't exist
configContent := `---
editor: vim
cheatpaths:
- name: test
path: ~nonexistentuser12345/cheat
readonly: true
`
configFile := filepath.Join(tempDir, "config.yml")
err = os.WriteFile(configFile, []byte(configContent), 0644)
if err != nil {
t.Fatalf("failed to write config: %v", err)
}
// Load config - this may or may not fail depending on the system
// but we're testing that it doesn't panic
_, _ = New(map[string]interface{}{}, configFile, false)
}
// TestConfigGetCwdError tests error handling when os.Getwd fails
func TestConfigGetCwdError(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Windows does not allow removing the current directory")
}
// This is difficult to test without being able to break os.Getwd
// We'll create a scenario where the current directory is removed
// Create and enter a temp directory
tempDir, err := os.MkdirTemp("", "cheat-config-test-*")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
oldCwd, err := os.Getwd()
if err != nil {
t.Fatalf("failed to get cwd: %v", err)
}
defer os.Chdir(oldCwd)
err = os.Chdir(tempDir)
if err != nil {
t.Fatalf("failed to change dir: %v", err)
}
// Remove the directory we're in
err = os.RemoveAll(tempDir)
if err != nil {
t.Fatalf("failed to remove temp dir: %v", err)
}
// Now os.Getwd should fail
_, err = New(map[string]interface{}{}, mock.Path("conf/empty.yml"), false)
// This might not fail on all systems, so we just ensure no panic
_ = err
}

View File

@@ -65,58 +65,3 @@ func FuzzFindLocalCheatpath(f *testing.F) {
}
})
}
// FuzzFindLocalCheatpathNearestWins verifies that when two .cheat directories
// exist at different levels of the ancestor chain, the nearest one is returned.
func FuzzFindLocalCheatpathNearestWins(f *testing.F) {
f.Add(uint8(5), uint8(1), uint8(3))
f.Add(uint8(8), uint8(0), uint8(7))
f.Add(uint8(3), uint8(0), uint8(2))
f.Add(uint8(10), uint8(2), uint8(8))
f.Fuzz(func(t *testing.T, totalDepth, shallowRaw, deepRaw uint8) {
depth := int(totalDepth%12) + 2 // 2..13 (need room for two placements)
s := int(shallowRaw) % depth
d := int(deepRaw) % depth
// Need two distinct levels
if s == d {
d = (d + 1) % depth
}
// Ensure s < d (shallow is higher in tree, deep is closer to search dir)
if s > d {
s, d = d, s
}
tempDir := t.TempDir()
// Build chain
dirs := make([]string, 0, depth+1)
dirs = append(dirs, tempDir)
current := tempDir
for i := 0; i < depth; i++ {
current = filepath.Join(current, fmt.Sprintf("d%d", i))
if err := os.Mkdir(current, 0755); err != nil {
t.Fatalf("mkdir: %v", err)
}
dirs = append(dirs, current)
}
// Place .cheat at both levels
shallowCheat := filepath.Join(dirs[s], ".cheat")
deepCheat := filepath.Join(dirs[d], ".cheat")
if err := os.Mkdir(shallowCheat, 0755); err != nil {
t.Fatalf("mkdir shallow .cheat: %v", err)
}
if err := os.Mkdir(deepCheat, 0755); err != nil {
t.Fatalf("mkdir deep .cheat: %v", err)
}
// Search from the deepest directory — should find the deeper (nearer) .cheat
result := findLocalCheatpath(current)
if result != deepCheat {
t.Errorf("depth=%d shallow=%d deep=%d: expected nearest %s, got %s",
depth, s, d, deepCheat, result)
}
})
}

View File

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

View File

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

View File

@@ -4,7 +4,6 @@ import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
@@ -90,9 +89,6 @@ func TestInitWriteError(t *testing.T) {
if err == nil {
t.Error("expected error when writing to invalid path, got nil")
}
if err != nil && !strings.Contains(err.Error(), "failed to create") {
t.Errorf("expected 'failed to create' error, got: %v", err)
}
}
// TestInitExistingFile tests that Init overwrites existing files

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

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

View File

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

View File

@@ -2,6 +2,7 @@ package config
import (
"os"
"path/filepath"
"runtime"
"testing"
)
@@ -44,29 +45,20 @@ func TestPager(t *testing.T) {
os.Setenv("PAGER", "")
pager := Pager()
// Should find one of the fallback pagers or return empty string
if pager == "" {
return // no pager found is acceptable
}
// Should find one of the known fallback pagers
validPagers := map[string]bool{
"": true, // no pager found
"pager": true,
"less": true,
"more": true,
}
// Check if it's a path to one of these
found := false
for p := range validPagers {
if p == "" && pager == "" {
found = true
break
}
if p != "" && (pager == p || len(pager) >= len(p) && pager[len(pager)-len(p):] == p) {
found = true
break
}
}
if !found {
t.Errorf("unexpected pager value: %s", pager)
base := filepath.Base(pager)
if !validPagers[base] {
t.Errorf("unexpected pager value: %s (base: %s)", pager, base)
}
})

View File

@@ -14,8 +14,8 @@ func TestValidateCorrect(t *testing.T) {
Colorize: true,
Editor: "vim",
Formatter: "terminal16m",
Cheatpaths: []cheatpath.Cheatpath{
cheatpath.Cheatpath{
Cheatpaths: []cheatpath.Path{
cheatpath.Path{
Name: "foo",
Path: "/foo",
ReadOnly: false,
@@ -38,8 +38,8 @@ func TestInvalidateMissingEditor(t *testing.T) {
conf := Config{
Colorize: true,
Formatter: "terminal16m",
Cheatpaths: []cheatpath.Cheatpath{
cheatpath.Cheatpath{
Cheatpaths: []cheatpath.Path{
cheatpath.Path{
Name: "foo",
Path: "/foo",
ReadOnly: false,
@@ -71,19 +71,28 @@ func TestInvalidateMissingCheatpaths(t *testing.T) {
}
}
// TestMissingInvalidFormatters asserts that configs which contain invalid
// TestInvalidateInvalidFormatter asserts that configs which contain invalid
// formatters are invalidated
func TestMissingInvalidFormatters(t *testing.T) {
func TestInvalidateInvalidFormatter(t *testing.T) {
// mock a config
// mock a config with a valid editor and cheatpaths but invalid formatter
conf := Config{
Colorize: true,
Editor: "vim",
Formatter: "html",
Cheatpaths: []cheatpath.Path{
cheatpath.Path{
Name: "foo",
Path: "/foo",
ReadOnly: false,
Tags: []string{},
},
},
}
// assert that no errors are returned
// assert that the config is invalidated due to the formatter
if err := conf.Validate(); err == nil {
t.Errorf("failed to invalidate config without formatter")
t.Errorf("failed to invalidate config with invalid formatter")
}
}
@@ -96,14 +105,14 @@ func TestInvalidateDuplicateCheatpathNames(t *testing.T) {
Colorize: true,
Editor: "vim",
Formatter: "terminal16m",
Cheatpaths: []cheatpath.Cheatpath{
cheatpath.Cheatpath{
Cheatpaths: []cheatpath.Path{
cheatpath.Path{
Name: "foo",
Path: "/foo",
ReadOnly: false,
Tags: []string{},
},
cheatpath.Cheatpath{
cheatpath.Path{
Name: "foo",
Path: "/bar",
ReadOnly: false,
@@ -127,14 +136,14 @@ func TestInvalidateDuplicateCheatpathPaths(t *testing.T) {
Colorize: true,
Editor: "vim",
Formatter: "terminal16m",
Cheatpaths: []cheatpath.Cheatpath{
cheatpath.Cheatpath{
Cheatpaths: []cheatpath.Path{
cheatpath.Path{
Name: "foo",
Path: "/foo",
ReadOnly: false,
Tags: []string{},
},
cheatpath.Cheatpath{
cheatpath.Path{
Name: "bar",
Path: "/foo",
ReadOnly: false,

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.
package display
import (
"fmt"
"github.com/cheat/cheat/internal/config"
)
import "fmt"
// Faint returns a faintly-colored string that's used to de-prioritize text
// 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
if conf.Colorize {
if colorize {
return fmt.Sprintf("\033[2m%s\033[0m", str)
}

View File

@@ -1,26 +1,20 @@
package display
import (
"testing"
"github.com/cheat/cheat/internal/config"
)
import "testing"
// TestFaint asserts that Faint applies faint formatting
func TestFaint(t *testing.T) {
// case: apply colorization
conf := config.Config{Colorize: true}
want := "\033[2mfoo\033[0m"
got := Faint("foo", conf)
got := Faint("foo", true)
if want != got {
t.Errorf("failed to faint: want: %s, got: %s", want, got)
}
// case: do not apply colorization
conf.Colorize = false
want = "foo"
got = Faint("foo", conf)
got = Faint("foo", false)
if 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
func writeToPager(out string, conf config.Config) {
parts := strings.Split(conf.Pager, " ")
parts := strings.Fields(conf.Pager)
pager := parts[0]
args := parts[1:]

View File

@@ -2,7 +2,6 @@ package installer
import (
"bytes"
"fmt"
"io"
"os"
"strings"
@@ -158,23 +157,3 @@ func TestPromptError(t *testing.T) {
t.Errorf("expected 'failed to prompt' error, got: %v", err)
}
}
// TestPromptIntegration provides a simple integration test
func TestPromptIntegration(t *testing.T) {
// This demonstrates how the prompt would be used in practice
// It's skipped by default since it requires actual user input
if os.Getenv("TEST_INTERACTIVE") != "1" {
t.Skip("Skipping interactive test - set TEST_INTERACTIVE=1 to run")
}
fmt.Println("\n=== Interactive Prompt Test ===")
fmt.Println("You will be prompted to answer a question.")
fmt.Println("Try different inputs: y, n, Y, N, empty (just press Enter)")
result, err := Prompt("Would you like to continue? [Y/n]", true)
if err != nil {
t.Fatalf("Prompt failed: %v", err)
}
fmt.Printf("You answered: %v\n", result)
}

View File

@@ -3,8 +3,6 @@ package installer
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/cheat/cheat/internal/config"
"github.com/cheat/cheat/internal/repo"
@@ -13,27 +11,11 @@ import (
// Run runs the installer
func Run(configs string, confpath string) error {
// determine the appropriate paths for config data and (optional) community
// cheatsheets based on the user's platform
confdir := filepath.Dir(confpath)
// expand template placeholders with platform-appropriate paths
configs = ExpandTemplate(configs, confpath)
// create paths for community, personal, and work cheatsheets
community := filepath.Join(confdir, "cheatsheets", "community")
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)
}
// determine cheatsheet directory paths
community, personal, work := cheatsheetDirs(confpath)
// prompt the user to download the community cheatsheets
yes, err := Prompt(
@@ -51,19 +33,7 @@ func Run(configs string, confpath string) error {
return fmt.Errorf("failed to clone cheatsheets: %v", err)
}
} else {
// comment out the community cheatpath in the config since
// 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,
)
configs = CommentCommunity(configs, confpath)
}
// always create personal and work directories

View File

@@ -1,7 +1,6 @@
package installer
import (
"fmt"
"io"
"os"
"path/filepath"
@@ -245,10 +244,10 @@ cheatpaths:
if strings.Contains(contentStr, "PERSONAL_PATH") {
t.Error("PERSONAL_PATH was not replaced")
}
if strings.Contains(contentStr, "EDITOR_PATH") && !strings.Contains(contentStr, fmt.Sprintf("editor: %s", "")) {
if strings.Contains(contentStr, "EDITOR_PATH") {
t.Error("EDITOR_PATH was not replaced")
}
if strings.Contains(contentStr, "PAGER_PATH") && !strings.Contains(contentStr, fmt.Sprintf("pager: %s", "")) {
if strings.Contains(contentStr, "PAGER_PATH") {
t.Error("PAGER_PATH was not replaced")
}
if strings.Contains(contentStr, "WORK_PATH") {

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,6 +1,7 @@
package sheet
import (
"strings"
"testing"
"github.com/cheat/cheat/internal/config"
@@ -16,45 +17,26 @@ func TestColorize(t *testing.T) {
}
// mock a sheet
original := "echo 'foo'"
s := Sheet{
Text: "echo 'foo'",
Text: original,
}
// colorize the sheet text
s.Colorize(conf)
// initialize expectations
want := "echo"
want += " 'foo'"
// assert
if s.Text != want {
t.Errorf("failed to colorize sheet: want: %s, got: %s", want, s.Text)
}
// assert that the text was modified (colorization applied)
if s.Text == original {
t.Error("Colorize did not modify sheet text")
}
// TestColorizeError tests the error handling in Colorize
func TestColorizeError(_ *testing.T) {
// Create a sheet with content
sheet := Sheet{
Text: "some text",
Syntax: "invalidlexer12345", // Use an invalid lexer that might cause issues
// assert that ANSI escape codes are present
if !strings.Contains(s.Text, "\x1b[") && !strings.Contains(s.Text, "[0m") {
t.Errorf("colorized text does not contain ANSI escape codes: %q", s.Text)
}
// Create a config with invalid formatter/style
conf := config.Config{
Formatter: "invalidformatter",
Style: "invalidstyle",
// assert that the original content is still present within the colorized output
if !strings.Contains(s.Text, "echo") || !strings.Contains(s.Text, "foo") {
t.Errorf("colorized text lost original content: %q", s.Text)
}
// Store original text
originalText := sheet.Text
// Colorize should not panic even with invalid settings
sheet.Colorize(conf)
// The text might be unchanged if there was an error, or it might be colorized
// We're mainly testing that it doesn't panic
_ = sheet.Text
_ = originalText
}

View File

@@ -12,13 +12,10 @@ func TestCopyErrors(t *testing.T) {
tests := []struct {
name string
setup func() (*Sheet, string, func())
wantErr bool
errMsg string
}{
{
name: "source file does not exist",
setup: func() (*Sheet, string, func()) {
// Create a sheet with non-existent path
sheet := &Sheet{
Title: "test",
Path: "/non/existent/file.txt",
@@ -30,13 +27,10 @@ func TestCopyErrors(t *testing.T) {
}
return sheet, dest, cleanup
},
wantErr: true,
errMsg: "failed to open cheatsheet",
},
{
name: "destination directory creation fails",
setup: func() (*Sheet, string, func()) {
// Create a source file
src, err := os.CreateTemp("", "copy-test-src-*")
if err != nil {
t.Fatalf("failed to create temp file: %v", err)
@@ -50,13 +44,11 @@ func TestCopyErrors(t *testing.T) {
CheatPath: "test",
}
// Create a file where we want a directory
blockerFile := filepath.Join(os.TempDir(), "copy-blocker-file")
if err := os.WriteFile(blockerFile, []byte("blocker"), 0644); err != nil {
t.Fatalf("failed to create blocker file: %v", err)
}
// Try to create dest under the blocker file (will fail)
dest := filepath.Join(blockerFile, "subdir", "dest.txt")
cleanup := func() {
@@ -65,13 +57,10 @@ func TestCopyErrors(t *testing.T) {
}
return sheet, dest, cleanup
},
wantErr: true,
errMsg: "failed to create directory",
},
{
name: "destination file creation fails",
setup: func() (*Sheet, string, func()) {
// Create a source file
src, err := os.CreateTemp("", "copy-test-src-*")
if err != nil {
t.Fatalf("failed to create temp file: %v", err)
@@ -85,7 +74,6 @@ func TestCopyErrors(t *testing.T) {
CheatPath: "test",
}
// Create a directory where we want the file
destDir := filepath.Join(os.TempDir(), "copy-test-dir")
if err := os.Mkdir(destDir, 0755); err != nil && !os.IsExist(err) {
t.Fatalf("failed to create dest dir: %v", err)
@@ -97,8 +85,6 @@ func TestCopyErrors(t *testing.T) {
}
return sheet, destDir, cleanup
},
wantErr: true,
errMsg: "failed to create outfile",
},
}
@@ -108,43 +94,27 @@ func TestCopyErrors(t *testing.T) {
defer cleanup()
err := sheet.Copy(dest)
if (err != nil) != tt.wantErr {
t.Errorf("Copy() error = %v, wantErr %v", err, tt.wantErr)
return
}
if err != nil && tt.errMsg != "" {
if !contains(err.Error(), tt.errMsg) {
t.Errorf("Copy() error = %v, want error containing %q", err, tt.errMsg)
}
if err == nil {
t.Error("Copy() expected error, got nil")
}
})
}
}
// TestCopyIOError tests the io.Copy error case
func TestCopyIOError(t *testing.T) {
// This is difficult to test without mocking io.Copy
// The error case would occur if the source file is modified
// or removed after opening but before copying
t.Skip("Skipping io.Copy error test - requires file system race condition")
}
// TestCopyCleanupOnError verifies that partially written files are cleaned up on error
func TestCopyCleanupOnError(t *testing.T) {
// TestCopyUnreadableSource verifies that Copy returns an error when the source
// file cannot be opened (e.g., permission denied).
func TestCopyUnreadableSource(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("chmod does not restrict reads on Windows")
}
// Create a source file that we'll make unreadable after opening
src, err := os.CreateTemp("", "copy-test-cleanup-*")
src, err := os.CreateTemp("", "copy-test-unreadable-*")
if err != nil {
t.Fatalf("failed to create temp file: %v", err)
}
defer os.Remove(src.Name())
// Write some content
content := "test content for cleanup"
if _, err := src.WriteString(content); err != nil {
if _, err := src.WriteString("test content"); err != nil {
t.Fatalf("failed to write content: %v", err)
}
src.Close()
@@ -155,38 +125,21 @@ func TestCopyCleanupOnError(t *testing.T) {
CheatPath: "test",
}
// Destination path
dest := filepath.Join(os.TempDir(), "copy-cleanup-test.txt")
defer os.Remove(dest) // Clean up if test fails
dest := filepath.Join(os.TempDir(), "copy-unreadable-test.txt")
defer os.Remove(dest)
// Make the source file unreadable (simulating a read error during copy)
// This is platform-specific, but should work on Unix-like systems
if err := os.Chmod(src.Name(), 0000); err != nil {
t.Skip("Cannot change file permissions on this platform")
}
defer os.Chmod(src.Name(), 0644) // Restore permissions for cleanup
defer os.Chmod(src.Name(), 0644)
// Attempt to copy - this should fail during io.Copy
err = sheet.Copy(dest)
if err == nil {
t.Error("Expected Copy to fail with permission error")
t.Error("expected Copy to fail with permission error")
}
// Verify the destination file was cleaned up
// Destination should not exist since the error occurs before it is created
if _, err := os.Stat(dest); !os.IsNotExist(err) {
t.Error("Destination file should have been removed after copy failure")
t.Error("destination file should not exist after open failure")
}
}
func contains(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsHelper(s, substr))
}
func containsHelper(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}

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

@@ -27,22 +27,3 @@ func TestParseWindowsLineEndings(t *testing.T) {
t.Errorf("failed to parse syntax: want: %s, got: %s", want, fm.Syntax)
}
}
// TestParseInvalidYAML tests parsing with invalid YAML in frontmatter
func TestParseInvalidYAML(t *testing.T) {
// stub our cheatsheet content with invalid YAML
markdown := `---
syntax: go
tags: [ test
unclosed bracket
---
To foo the bar: baz`
// parse the frontmatter
_, _, err := parse(markdown)
// assert that an error was returned for invalid YAML
if err == nil {
t.Error("expected error for invalid YAML, got nil")
}
}

View File

@@ -38,7 +38,7 @@ To foo the bar: baz`
t.Errorf("failed to parse tags: want: %s, got: %s", want, fm.Tags[0])
}
if len(fm.Tags) != 1 {
t.Errorf("failed to parse tags: want: len 0, got: len %d", len(fm.Tags))
t.Errorf("failed to parse tags: want: len 1, got: len %d", len(fm.Tags))
}
}

View File

@@ -122,69 +122,3 @@ func FuzzSearchRegex(f *testing.F) {
}
})
}
// FuzzSearchCatastrophicBacktracking specifically tests for regex patterns
// that could cause performance issues
func FuzzSearchCatastrophicBacktracking(f *testing.F) {
// Seed with patterns known to potentially cause issues
f.Add("a", 10, 5)
f.Add("x", 20, 3)
f.Fuzz(func(t *testing.T, char string, repeats int, groups int) {
// Limit the size to avoid memory issues in the test
if repeats > 30 || repeats < 0 || groups > 10 || groups < 0 || len(char) > 5 {
t.Skip("Skipping invalid or overly large test case")
}
// Construct patterns that might cause backtracking
patterns := []string{
strings.Repeat(char, repeats),
"(" + char + "+)+",
"(" + char + "*)*",
"(" + char + "|" + char + ")*",
}
// Add nested groups
if groups > 0 && groups < 10 {
nested := char
for i := 0; i < groups; i++ {
nested = "(" + nested + ")+"
}
patterns = append(patterns, nested)
}
// Test text that might trigger backtracking
testText := strings.Repeat(char, repeats) + "x"
for _, pattern := range patterns {
// Try to compile the pattern
reg, err := regexp.Compile(pattern)
if err != nil {
// Invalid pattern, skip
continue
}
// Test with timeout
done := make(chan bool, 1)
go func() {
defer func() {
if r := recover(); r != nil {
t.Errorf("Search panicked with backtracking pattern %q: %v", pattern, r)
}
done <- true
}()
sheet := Sheet{Text: testText}
_ = sheet.Search(reg)
}()
select {
case <-done:
// Completed successfully
case <-time.After(50 * time.Millisecond):
t.Logf("Warning: potential backtracking issue with pattern %q (completed slowly)", pattern)
}
}
})
}

View File

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

View File

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

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 (
"strings"
@@ -6,9 +6,9 @@ import (
"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
func FuzzValidateSheetName(f *testing.F) {
func FuzzValidate(f *testing.F) {
// Add seed corpus with various valid and malicious inputs
// Valid names
f.Add("docker")
@@ -84,11 +84,11 @@ func FuzzValidateSheetName(f *testing.F) {
func() {
defer func() {
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
if err == nil {
@@ -129,8 +129,8 @@ func FuzzValidateSheetName(f *testing.F) {
})
}
// FuzzValidateSheetNamePathTraversal specifically targets path traversal bypasses
func FuzzValidateSheetNamePathTraversal(f *testing.F) {
// FuzzValidatePathTraversal specifically targets path traversal bypasses
func FuzzValidatePathTraversal(f *testing.F) {
// Seed corpus focusing on path traversal variations
f.Add("..", "/", "")
f.Add("", "..", "/")
@@ -153,11 +153,11 @@ func FuzzValidateSheetNamePathTraversal(f *testing.F) {
func() {
defer func() {
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 strings.Contains(input, "..") && err == nil {

View File

@@ -1,4 +1,4 @@
package cheatpath
package sheet
import (
"runtime"
@@ -6,7 +6,7 @@ import (
"testing"
)
func TestValidateSheetName(t *testing.T) {
func TestValidate(t *testing.T) {
tests := []struct {
name string
input string
@@ -98,14 +98,14 @@ func TestValidateSheetName(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateSheetName(tt.input)
err := Validate(tt.input)
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
}
if err != nil && 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

@@ -18,28 +18,26 @@ func TestFilterSingleTag(t *testing.T) {
map[string]sheet.Sheet{
"foo": sheet.Sheet{Title: "foo", Tags: []string{"alpha", "bravo"}},
"bar": sheet.Sheet{Title: "bar", Tags: []string{"bravo", "charlie"}},
"bar": sheet.Sheet{Title: "bar", Tags: []string{"charlie"}},
},
map[string]sheet.Sheet{
"baz": sheet.Sheet{Title: "baz", Tags: []string{"alpha", "bravo"}},
"baz": sheet.Sheet{Title: "baz", Tags: []string{"alpha"}},
"bat": sheet.Sheet{Title: "bat", Tags: []string{"bravo", "charlie"}},
},
}
// filter the cheatsheets
filtered := Filter(cheatpaths, []string{"bravo"})
filtered := Filter(cheatpaths, []string{"alpha"})
// assert that the expect results were returned
want := []map[string]sheet.Sheet{
map[string]sheet.Sheet{
"foo": sheet.Sheet{Title: "foo", Tags: []string{"alpha", "bravo"}},
"bar": sheet.Sheet{Title: "bar", Tags: []string{"bravo", "charlie"}},
},
map[string]sheet.Sheet{
"baz": sheet.Sheet{Title: "baz", Tags: []string{"alpha", "bravo"}},
"bat": sheet.Sheet{Title: "bat", Tags: []string{"bravo", "charlie"}},
"baz": sheet.Sheet{Title: "baz", Tags: []string{"alpha"}},
},
}

View File

@@ -8,12 +8,11 @@ import (
"strings"
cp "github.com/cheat/cheat/internal/cheatpath"
"github.com/cheat/cheat/internal/repo"
"github.com/cheat/cheat/internal/sheet"
)
// 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
// 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
// encountered along the way
err := filepath.Walk(
err := filepath.WalkDir(
cheatpath.Path, func(
path string,
info os.FileInfo,
d fs.DirEntry,
err error) error {
// 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)
}
// don't register directories as cheatsheets
if info.IsDir() {
if d.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
}
@@ -63,17 +66,6 @@ func Load(cheatpaths []cp.Cheatpath) ([]map[string]sheet.Sheet, error) {
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
s, err := sheet.New(
title,

View File

@@ -5,22 +5,22 @@ import (
"testing"
"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
func TestLoad(t *testing.T) {
// mock cheatpaths
cheatpaths := []cheatpath.Cheatpath{
cheatpaths := []cheatpath.Path{
{
Name: "community",
Path: path.Join(mock.Path("cheatsheets"), "community"),
Path: path.Join(mocks.Path("cheatsheets"), "community"),
ReadOnly: true,
},
{
Name: "personal",
Path: path.Join(mock.Path("cheatsheets"), "personal"),
Path: path.Join(mocks.Path("cheatsheets"), "personal"),
ReadOnly: false,
},
}
@@ -54,7 +54,7 @@ func TestLoad(t *testing.T) {
func TestLoadBadPath(t *testing.T) {
// mock a bad cheatpath
cheatpaths := []cheatpath.Cheatpath{
cheatpaths := []cheatpath.Path{
{
Name: "badpath",
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.Slice(sorted, func(i, j int) bool {
return sorted[i] < sorted[j]
})
sort.Strings(sorted)
return sorted
}

View File

@@ -127,64 +127,3 @@ func FuzzTags(f *testing.F) {
}()
})
}
// FuzzTagsStress tests Tags function with large numbers of tags
func FuzzTagsStress(f *testing.F) {
// Seed: number of unique tags, number of sheets, tags per sheet
f.Add(10, 10, 5)
f.Add(100, 50, 10)
f.Add(1000, 100, 20)
f.Fuzz(func(t *testing.T, numUniqueTags int, numSheets int, tagsPerSheet int) {
// Limit to reasonable values
if numUniqueTags > 1000 || numUniqueTags < 0 ||
numSheets > 1000 || numSheets < 0 ||
tagsPerSheet > 100 || tagsPerSheet < 0 {
t.Skip("Skipping unreasonable test case")
}
// Generate unique tags
uniqueTags := make([]string, numUniqueTags)
for i := 0; i < numUniqueTags; i++ {
uniqueTags[i] = "tag" + string(rune(i))
}
// Create sheets with random tags
cheatpaths := []map[string]sheet.Sheet{
make(map[string]sheet.Sheet),
}
for i := 0; i < numSheets; i++ {
// Select random tags for this sheet
sheetTags := make([]string, 0, tagsPerSheet)
for j := 0; j < tagsPerSheet && j < numUniqueTags; j++ {
// Distribute tags across sheets
tagIndex := (i*tagsPerSheet + j) % numUniqueTags
sheetTags = append(sheetTags, uniqueTags[tagIndex])
}
cheatpaths[0]["sheet"+string(rune(i))] = sheet.Sheet{
Title: "sheet" + string(rune(i)),
Tags: sheetTags,
}
}
// Should handle large numbers efficiently
func() {
defer func() {
if r := recover(); r != nil {
t.Errorf("Tags panicked with %d unique tags, %d sheets, %d tags/sheet: %v",
numUniqueTags, numSheets, tagsPerSheet, r)
}
}()
result := Tags(cheatpaths)
// Should have at most numUniqueTags in result
if len(result) > numUniqueTags {
t.Errorf("More tags in result (%d) than unique tags created (%d)",
len(result), numUniqueTags)
}
}()
})
}

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"
TESTS=(
"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"
"FuzzSearchCatastrophicBacktracking:./internal/sheet:catastrophic backtracking"
"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)..."

View File

@@ -1,4 +1,4 @@
package main
package integration
import (
"fmt"
@@ -18,7 +18,8 @@ func TestBriefFlagIntegration(t *testing.T) {
// Build the cheat binary once for all sub-tests.
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 {
t.Fatalf("failed to build cheat: %v\nOutput: %s", err, output)
}

View File

@@ -1,4 +1,4 @@
package main
package integration
import (
"fmt"
@@ -32,7 +32,8 @@ func TestLocalCheatpathIntegration(t *testing.T) {
// Build the cheat binary once for all sub-tests.
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 {
t.Fatalf("failed to build cheat: %v\nOutput: %s", err, output)
}

View File

@@ -1,4 +1,4 @@
package main
package integration
import (
"os"
@@ -19,7 +19,8 @@ func TestFirstRunIntegration(t *testing.T) {
binName += ".exe"
}
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 {
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 (
"fmt"
@@ -19,7 +19,9 @@ func TestPathTraversalIntegration(t *testing.T) {
// Build the cheat binary
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)
}
@@ -159,7 +161,9 @@ func TestPathTraversalRealWorld(t *testing.T) {
// Build cheat
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)
}

View File

@@ -1,6 +1,6 @@
//go:build integration
package main
package integration
import (
"bytes"
@@ -9,7 +9,6 @@ import (
"os/exec"
"path/filepath"
"testing"
"time"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
@@ -17,12 +16,10 @@ import (
// BenchmarkSearchCommand benchmarks the actual cheat search command
func BenchmarkSearchCommand(b *testing.B) {
root := repoRootBench(b)
// Build the cheat binary in .tmp (using absolute path)
rootDir, err := filepath.Abs(filepath.Join("..", ".."))
if err != nil {
b.Fatalf("Failed to get root dir: %v", err)
}
tmpDir := filepath.Join(rootDir, ".tmp", "bench-test")
tmpDir := filepath.Join(root, ".tmp", "bench-test")
if err := os.MkdirAll(tmpDir, 0755); err != nil {
b.Fatalf("Failed to create temp dir: %v", err)
}
@@ -35,7 +32,7 @@ func BenchmarkSearchCommand(b *testing.B) {
})
cmd := exec.Command("go", "build", "-o", cheatBin, "./cmd/cheat")
cmd.Dir = rootDir
cmd.Dir = root
if output, err := cmd.CombinedOutput(); err != nil {
b.Fatalf("Failed to build cheat: %v\nOutput: %s", err, output)
}
@@ -108,23 +105,15 @@ cheatpaths:
cmd := exec.Command(cheatBin, tc.args...)
cmd.Env = env
// Capture output to prevent spamming
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
start := time.Now()
err := cmd.Run()
elapsed := time.Since(start)
if err != nil {
b.Fatalf("Command failed: %v\nStderr: %s", err, stderr.String())
}
// Report custom metric
b.ReportMetric(float64(elapsed.Nanoseconds())/1e6, "ms/op")
// Ensure we got some results
if stdout.Len() == 0 {
b.Fatal("No output from search")
}
@@ -135,12 +124,10 @@ cheatpaths:
// BenchmarkListCommand benchmarks the list command for comparison
func BenchmarkListCommand(b *testing.B) {
root := repoRootBench(b)
// Build the cheat binary in .tmp (using absolute path)
rootDir, err := filepath.Abs(filepath.Join("..", ".."))
if err != nil {
b.Fatalf("Failed to get root dir: %v", err)
}
tmpDir := filepath.Join(rootDir, ".tmp", "bench-test")
tmpDir := filepath.Join(root, ".tmp", "bench-test")
if err := os.MkdirAll(tmpDir, 0755); err != nil {
b.Fatalf("Failed to create temp dir: %v", err)
}
@@ -153,7 +140,7 @@ func BenchmarkListCommand(b *testing.B) {
})
cmd := exec.Command("go", "build", "-o", cheatBin, "./cmd/cheat")
cmd.Dir = rootDir
cmd.Dir = root
if output, err := cmd.CombinedOutput(); err != nil {
b.Fatalf("Failed to build cheat: %v\nOutput: %s", err, output)
}