mirror of
https://github.com/cheat/cheat.git
synced 2026-03-07 03:03:32 +01:00
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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
Style string `yaml:"style"`
|
||||
Formatter string `yaml:"formatter"`
|
||||
Pager string `yaml:"pager"`
|
||||
Colorize bool `yaml:"colorize"`
|
||||
Editor string `yaml:"editor"`
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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
147
internal/config/new.go
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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",
|
||||
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,
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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:]
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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") {
|
||||
|
||||
58
internal/installer/template.go
Normal file
58
internal/installer/template.go
Normal 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",
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
package repo
|
||||
@@ -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 := "[38;2;181;137;0mecho[0m[38;2;147;161;161m"
|
||||
want += " [0m[38;2;42;161;152m'foo'[0m"
|
||||
// assert that the text was modified (colorization applied)
|
||||
if s.Text == original {
|
||||
t.Error("Colorize did not modify sheet text")
|
||||
}
|
||||
|
||||
// assert
|
||||
if s.Text != want {
|
||||
t.Errorf("failed to colorize sheet: want: %s, got: %s", want, s.Text)
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// Create a config with invalid formatter/style
|
||||
conf := config.Config{
|
||||
Formatter: "invalidformatter",
|
||||
Style: "invalidstyle",
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -10,15 +10,12 @@ import (
|
||||
// TestCopyErrors tests error cases for the Copy method
|
||||
func TestCopyErrors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func() (*Sheet, string, func())
|
||||
wantErr bool
|
||||
errMsg string
|
||||
name string
|
||||
setup func() (*Sheet, string, func())
|
||||
}{
|
||||
{
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
40
internal/sheet/validate.go
Normal file
40
internal/sheet/validate.go
Normal 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
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
@@ -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"}},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}()
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user