diff --git a/browser/browse.go b/browser/browse.go new file mode 100644 index 0000000..6a15940 --- /dev/null +++ b/browser/browse.go @@ -0,0 +1,17 @@ +package browser + +import "os/exec" + +// browse starts the named program with the given arguments +// and waits for it to complete. +var browse = func(name string, args ...string) error { + // #nosec G204 + // executable names are hardcoded by this package + // and never originate from user input. + cmd := exec.Command(name, args...) + cmd.Stdin = nil + cmd.Stdout = nil + cmd.Stderr = nil + + return cmd.Run() +} diff --git a/browser/browse_test.go b/browser/browse_test.go new file mode 100644 index 0000000..455caf0 --- /dev/null +++ b/browser/browse_test.go @@ -0,0 +1,51 @@ +package browser + +import ( + "runtime" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestRun(t *testing.T) { + tests := []struct { + name string + command string + args []string + wantErr bool + }{ + { + name: "valid_command", + command: "echo", + args: []string{"hello"}, + wantErr: false, + }, + { + name: "invalid_command", + command: "nonexistent-command-xyz", + args: nil, + wantErr: true, + }, + { + name: "command_with_empty_args", + command: "echo", + args: []string{}, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if runtime.GOOS == "windows" && tt.command == "echo" { + t.Skip("echo is not a standalone executable on Windows") + } + err := browse(tt.command, tt.args...) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} diff --git a/browser/open.go b/browser/open.go new file mode 100644 index 0000000..b640d81 --- /dev/null +++ b/browser/open.go @@ -0,0 +1,50 @@ +package browser + +import ( + "fmt" + "os" + "runtime" +) + +// Open opens the given URL in the system browser. +// It detects the platform and chooses the appropriate command. +// +// On macOS and Windows it checks for remote/SSH sessions first. +// On Linux (including WSL) it checks for an active display server. +// Returns an error if no display server is available. +func Open(url string) error { + switch runtime.GOOS { + case "darwin": + return browse("open", url) + case "windows": + return browse("explorer.exe", url) + case "linux": + return openOnLinux(url) + default: + return fmt.Errorf("unsupported platform: %s", runtime.GOOS) + } +} + +// openOnLinux handles browser opening on Linux and WSL. +func openOnLinux(url string) error { + if runningOnWSL() { + return openOnWSL(url) + } + + if !hasDisplay() { + return fmt.Errorf("no display server detected") + } + + return browse("xdg-open", url) +} + +// openOnWSL handles browser opening inside Windows Subsystem for Linux. +func openOnWSL(url string) error { + return browse("explorer.exe", url) +} + +// hasDisplay checks whether a display server is available +// by testing the DISPLAY and WAYLAND_DISPLAY environment variables. +func hasDisplay() bool { + return os.Getenv("DISPLAY") != "" || os.Getenv("WAYLAND_DISPLAY") != "" +} diff --git a/browser/open_test.go b/browser/open_test.go new file mode 100644 index 0000000..bd40855 --- /dev/null +++ b/browser/open_test.go @@ -0,0 +1,177 @@ +package browser + +import ( + "runtime" + "testing" + + "github.com/stretchr/testify/assert" +) + +type call struct { + name string + args []string +} + +func TestOpenOnLinux(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("openOnLinux only runs on Linux") + } + if runningOnWSL() { + t.Skip("WSL routes to explorer.exe, tested separately") + } + + tests := []struct { + name string + display string + wayland string + wantErr bool + wantCalls []call + }{ + { + name: "with display", + display: ":0", + wantCalls: []call{ + {name: "xdg-open", args: []string{"https://example.com"}}, + }, + }, + { + name: "no display", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("DISPLAY", tt.display) + t.Setenv("WAYLAND_DISPLAY", tt.wayland) + + var calls []call + mockBrowse(t, func(name string, args ...string) error { + calls = append(calls, call{name, args}) + return nil + }) + + err := openOnLinux("https://example.com") + if tt.wantErr { + assert.Error(t, err) + assert.Contains(t, err.Error(), "no display server detected") + assert.Empty(t, calls, "browse should not be called on error") + return + } + + assert.NoError(t, err) + assert.Equal(t, tt.wantCalls, calls) + }) + } +} + +func TestOpenOnWSL(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("openOnWSL only runs on Linux") + } + if !runningOnWSL() { + t.Skip("not running on WSL") + } + + var calls []call + mockBrowse(t, func(name string, args ...string) error { + calls = append(calls, call{name, args}) + return nil + }) + + err := openOnWSL("https://example.com") + assert.NoError(t, err) + assert.Equal(t, []call{ + {name: "explorer.exe", args: []string{"https://example.com"}}, + }, calls) +} + +func TestOpen(t *testing.T) { + t.Setenv("DISPLAY", "") + t.Setenv("WAYLAND_DISPLAY", "") + + switch runtime.GOOS { + case "linux": + if runningOnWSL() { + var calls []call + mockBrowse(t, func(name string, args ...string) error { + calls = append(calls, call{name, args}) + return nil + }) + + err := Open("https://example.com") + assert.NoError(t, err) + assert.Equal( + t, + []call{ + { + name: "explorer.exe", + args: []string{"https://example.com"}, + }, + }, calls, + ) + } else { + err := Open("https://example.com") + assert.Error(t, err) + assert.Contains(t, err.Error(), "no display server detected") + } + case "darwin": + var calls []call + mockBrowse(t, func(name string, args ...string) error { + calls = append(calls, call{name, args}) + return nil + }) + + err := Open("https://example.com") + assert.NoError(t, err) + assert.Equal( + t, + []call{{name: "open", args: []string{"https://example.com"}}}, + calls, + ) + case "windows": + var calls []call + mockBrowse(t, func(name string, args ...string) error { + calls = append(calls, call{name, args}) + return nil + }) + + err := Open("https://example.com") + assert.NoError(t, err) + assert.Equal( + t, + []call{{name: "explorer.exe", args: []string{"https://example.com"}}}, + calls, + ) + } +} + +func TestHasDisplay(t *testing.T) { + tests := []struct { + name string + display string + wayland string + want bool + }{ + {name: "neither set", display: "", wayland: "", want: false}, + {name: "x11 only", display: ":0", wayland: "", want: true}, + {name: "wayland only", display: "", wayland: "wayland-0", want: true}, + {name: "both set", display: ":0", wayland: "wayland-0", want: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("DISPLAY", tt.display) + t.Setenv("WAYLAND_DISPLAY", tt.wayland) + assert.Equal(t, tt.want, hasDisplay()) + }) + } +} + +// mockBrowse replaces the package-level browse function for the duration of t. +func mockBrowse(t *testing.T, fn func(string, ...string) error) { + t.Helper() + oldBrowse := browse + browse = fn + t.Cleanup(func() { browse = oldBrowse }) +} diff --git a/browser/wsl.go b/browser/wsl.go new file mode 100644 index 0000000..c0107f4 --- /dev/null +++ b/browser/wsl.go @@ -0,0 +1,29 @@ +package browser + +import ( + "os" + "strings" +) + +// runningOnWSL reports whether the current process is running +// inside Windows Subsystem for Linux (WSL). +func runningOnWSL() bool { + return isWSL(os.ReadFile) +} + +// isWSL reports whether the system is running inside Windows Subsystem for Linux (WSL) +// by inspecting the Linux kernel version information. +// +// The readFile function is injected to allow the detection logic to be +// tested without reading from the real filesystem. +func isWSL(readFile func(string) ([]byte, error)) bool { + data, err := readFile("/proc/version") + if err != nil { + return false + } + + return strings.Contains( + strings.ToLower(string(data)), + "microsoft", + ) +} diff --git a/browser/wsl_test.go b/browser/wsl_test.go new file mode 100644 index 0000000..6dd7468 --- /dev/null +++ b/browser/wsl_test.go @@ -0,0 +1,70 @@ +package browser + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIsWSL(t *testing.T) { + tests := []struct { + name string + content string + want bool + }{ + { + name: "wsl2_marker", + content: "Linux version 5.10.16.3-microsoft-standard-WSL2", + want: true, + }, + { + name: "microsoft_mixed_case", + content: "Linux version 5.15.0 (Microsoft@Microsoft.com) (gcc 11.2.0) #1 SMP", + want: true, + }, + { + name: "no_microsoft_marker", + content: "Linux version 6.1.0-23-amd64 (debian-kernel@lists.debian.org)", + want: false, + }, + { + name: "empty_content", + content: "", + want: false, + }, + { + name: "partial_word_micah", + content: "Linux version 5.15.0 (micah@kernel.org)", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + reader := func(_ string) ([]byte, error) { + return []byte(tt.content), nil + } + assert.Equal(t, tt.want, isWSL(reader)) + }) + } +} + +func TestIsWSLReadError(t *testing.T) { + t.Parallel() + + reader := func(_ string) ([]byte, error) { + return nil, fmt.Errorf("permission denied") + } + + assert.False(t, isWSL(reader)) +} + +func TestRunningOnWSL(t *testing.T) { + t.Parallel() + + // smoke test: verify runningOnWSL doesn't panic and returns a bool. + result := runningOnWSL() + assert.IsType(t, false, result) +} diff --git a/cmd/cli.go b/cmd/cli.go index abd3337..b4bff04 100644 --- a/cmd/cli.go +++ b/cmd/cli.go @@ -19,6 +19,9 @@ type CLI struct { // Search requests a keyword search across pages. Search string + // Browse requests opening the page in the default web browser. + Browse bool + // ListPlatforms requests listing available platforms. ListPlatforms bool @@ -46,7 +49,7 @@ type CLI struct { // ShowHelp requests printing the help text. ShowHelp bool - // Options + // options // Platform overrides the platform used for page lookup. Platform string @@ -60,20 +63,19 @@ type CLI struct { // LongOptions requests displaying long option forms. LongOptions bool - // Edit requests displaying a GitHub edit link. - Edit bool - // Offline suppresses automatic cache updates. Offline bool // Compact strips empty lines from output. Compact bool + // NoCompact overrides --compact, preserving empty lines in output. NoCompact bool // Raw prints pages in raw markdown. Raw bool + // NoRaw overrides --raw, rendering pages instead of printing raw content. NoRaw bool // Quiet suppresses informational and warning messages. @@ -87,4 +89,9 @@ type CLI struct { // Config specifies an alternative config file path. Config string + + // page actions + + // Edit requests displaying a GitHub edit link. + Edit bool } diff --git a/cmd/diagnostic.go b/cmd/diagnostic.go new file mode 100644 index 0000000..7a2ccda --- /dev/null +++ b/cmd/diagnostic.go @@ -0,0 +1,117 @@ +package cmd + +import ( + "flag" + "fmt" + "strings" + + "github.com/TheRootDaemon/tlgc/termcolor" +) + +// fmtFlagError wraps flag parse errors with clap-style formatting. +func fmtFlagError(fs *flag.FlagSet, err error) error { + s := err.Error() + + switch { + case strings.HasPrefix(s, "flag provided but not defined: "): + raw := strings.TrimPrefix(s, "flag provided but not defined: ") + name := strings.TrimLeft(raw, "-") + var tip string + if sim := similarFlag(fs, name); sim != "" { + tip = fmt.Sprintf( + "\n\n %s a similar argument exists: %s", + termcolor.Sprint("bold green", "tip:"), + termcolor.Sprint("bold blue", flagDisplay(sim)), + ) + } + return fmtUsage( + "unexpected argument %s found%s", + termcolor.Sprint("bold cyan", flagDisplay(name)), + tip, + ) + case strings.HasPrefix(s, "flag needs an argument: "): + raw := strings.TrimPrefix(s, "flag needs an argument: ") + name := strings.TrimLeft(raw, "-") + return fmtUsage("flag %s requires an argument", termcolor.Sprint("bold blue", flagDisplay(name))) + default: + return fmtUsage("%s", s) + } +} + +// fmtConflictError builds a clap-style error for conflicting operations. +func fmtConflictError(cli *CLI) error { + ops := activeOps(cli) + if len(ops) < 2 { + return fmt.Errorf("only one operation can be specified at a time") + } + return fmtUsage( + "argument %s cannot be used with %s", + termcolor.Sprint("blue", ops[0]), + termcolor.Sprint("blue", ops[1]), + ) +} + +// fmtUsage wraps a formatted message with the standard usage footer. +func fmtUsage(format string, args ...any) error { + usage := fmt.Sprintf( + "\n\n%s %s [OPTIONS] [PAGE]...\n\nFor more information, try %s.", + termcolor.Sprint("bold underline", "Usage:"), + termcolor.Sprint("bold", "tlgc"), + termcolor.Sprint("bold blue", "--help"), + ) + return fmt.Errorf(format+usage, args...) +} + +// flagDisplay returns the display form of a flag name, +// "-x" for short, "--xxx" for long. +func flagDisplay(name string) string { + if len(name) == 1 { + return "-" + name + } + return "--" + name +} + +// activeOps returns display names for all active operations in cli. +func activeOps(cli *CLI) []string { + var ops []string + if len(cli.Page) > 0 && !cli.Browse { + ops = append(ops, "[PAGE]...") + } + if cli.Update { + ops = append(ops, "--update") + } + if cli.List { + ops = append(ops, "--list") + } + if cli.ListAll { + ops = append(ops, "--list-all") + } + if cli.Search != "" { + ops = append(ops, "--search ") + } + if cli.Browse { + ops = append(ops, "--browse [PAGE]...") + } + if cli.ListPlatforms { + ops = append(ops, "--list-platforms") + } + if cli.ListLanguages { + ops = append(ops, "--list-languages") + } + if cli.Info { + ops = append(ops, "--info") + } + if cli.Render != "" { + ops = append(ops, "--render ") + } + if cli.CleanCache { + ops = append(ops, "--clean-cache") + } + if cli.GenConfig { + ops = append(ops, "--gen-config") + } + if cli.ConfigPath { + ops = append(ops, "--config-path") + } + return ops +} diff --git a/cmd/diagnostic_test.go b/cmd/diagnostic_test.go new file mode 100644 index 0000000..f410cdf --- /dev/null +++ b/cmd/diagnostic_test.go @@ -0,0 +1,192 @@ +package cmd + +import ( + "flag" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestFmtFlagError(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + setup func(*flag.FlagSet) + args []string + contains []string + }{ + { + name: "undefined", + setup: func(fs *flag.FlagSet) { + fs.Bool("update", false, "") + fs.Bool("search", false, "") + }, + args: []string{"--bogus"}, + contains: []string{ + "unexpected argument", + }, + }, + { + name: "undefined_with_tip", + setup: func(fs *flag.FlagSet) { + fs.Bool("update", false, "") + fs.Bool("search", false, "") + }, + args: []string{"--searc"}, + contains: []string{ + "unexpected argument", + }, + }, + { + name: "missing_argument", + setup: func(fs *flag.FlagSet) { + fs.String("search", "", "") + }, + args: []string{"--search"}, + contains: []string{ + "requires an argument", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fs := flag.NewFlagSet("test", flag.ContinueOnError) + tt.setup(fs) + + err := fs.Parse(tt.args) + assert.Error(t, err) + + err = fmtFlagError(fs, err) + assert.Error(t, err) + + for _, s := range tt.contains { + assert.True(t, strings.Contains(err.Error(), s)) + } + }) + } +} + +func TestFmtConflictError(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + cli CLI + contains []string + }{ + { + name: "two_operations", + cli: CLI{Update: true, List: true}, + contains: []string{"cannot be used with", "--update", "--list"}, + }, + { + name: "page_and_search", + cli: CLI{Page: []string{"tar"}, Search: "foo"}, + contains: []string{"cannot be used with", "[PAGE]...", "--search"}, + }, + { + name: "one_operation_fallback", + cli: CLI{Update: true}, + contains: []string{"only one operation"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := fmtConflictError(&tt.cli) + assert.Error(t, err) + for _, s := range tt.contains { + assert.True(t, strings.Contains(err.Error(), s), + "error %q should contain %q", err.Error(), s) + } + }) + } +} + +func TestFmtUsage(t *testing.T) { + t.Parallel() + + err := fmtUsage("test message %d", 42) + assert.Error(t, err) + assert.Contains(t, err.Error(), "test message 42") + assert.Contains(t, err.Error(), "Usage:") + assert.Contains(t, err.Error(), "tlgc") + assert.Contains(t, err.Error(), "--help") +} + +func TestFlagDisplay(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + arg string + want string + }{ + {name: "short", arg: "x", want: "-x"}, + {name: "long", arg: "update", want: "--update"}, + {name: "single_char_short", arg: "u", want: "-u"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := flagDisplay(tt.arg) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestActiveOps(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + cli CLI + want []string + }{ + { + name: "empty", + cli: CLI{}, + want: nil, + }, + { + name: "page", + cli: CLI{Page: []string{"tar"}}, + want: []string{"[PAGE]..."}, + }, + { + name: "update", + cli: CLI{Update: true}, + want: []string{"--update"}, + }, + { + name: "browse", + cli: CLI{Browse: true, Page: []string{"tar"}}, + want: []string{"--browse [PAGE]..."}, + }, + { + name: "search", + cli: CLI{Search: "ngi"}, + want: []string{"--search "}, + }, + { + name: "render", + cli: CLI{Render: "file.md"}, + want: []string{"--render "}, + }, + { + name: "multiple", + cli: CLI{Update: true, Search: "foo"}, + want: []string{"--update", "--search "}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := activeOps(&tt.cli) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/cmd/help.go b/cmd/help.go index 413b4d0..c5c6622 100644 --- a/cmd/help.go +++ b/cmd/help.go @@ -8,12 +8,14 @@ import ( "github.com/TheRootDaemon/tlgc/version" ) +// help prints the full help text: usage, flags, and the footer. func help() { printUsage() printFlags() printFooter() } +// printUsage prints the version, usage line, and argument descriptions. func printUsage() { fmt.Printf( "tlgc %s (implementing client specification v2.3)\n\n", @@ -30,6 +32,7 @@ func printUsage() { fmt.Printf(" [PAGE]... The tldr page to show\n\n") } +// printFlags prints the aligned table of all available command-line options. func printFlags() { type flagEntry struct { short string @@ -192,10 +195,13 @@ func printFlags() { } } +// printFooter prints the project URL footer. func printFooter() { fmt.Printf("\nSee https://github.com/TheRootDaemon/tlgc for more information.\n") } +// updateColumnWidths updates the running maximum column widths +// for the short and long flag columns. func updateColumnWidths( maxShort, maxLong *int, @@ -220,6 +226,7 @@ func updateColumnWidths( *maxLong = max(*maxLong, longWidth) } +// printFlag prints a single colorized and aligned row of the flags table. func printFlag( maxShort, maxLong int, diff --git a/cmd/list_values_test.go b/cmd/list_values_test.go new file mode 100644 index 0000000..7e6eb0a --- /dev/null +++ b/cmd/list_values_test.go @@ -0,0 +1,139 @@ +package cmd + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCountValueNilPointer(t *testing.T) { + t.Parallel() + + v := &countValue{} + assert.Equal(t, "0", v.String()) +} + +func TestCountValue(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + setCount int + want string + wantBool bool + }{ + { + name: "zero_value", + want: "0", + wantBool: true, + }, + { + name: "single_increment", + setCount: 1, + want: "1", + wantBool: true, + }, + { + name: "double_increment", + setCount: 2, + want: "2", + wantBool: true, + }, + { + name: "triple_increment", + setCount: 3, + want: "3", + wantBool: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var count uint8 + v := &countValue{count: &count} + + for range tt.setCount { + require.NoError(t, v.Set("")) + } + + assert.Equal(t, tt.want, v.String()) + assert.Equal(t, tt.wantBool, v.IsBoolFlag()) + }) + } +} + +func TestStringListValueNilPointer(t *testing.T) { + t.Parallel() + + v := &stringListValue{} + assert.Equal(t, "", v.String()) +} + +func TestStringListValue(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + inputs []string + want string + wantS []string + }{ + { + name: "empty", + inputs: nil, + want: "", + wantS: nil, + }, + { + name: "single_value", + inputs: []string{"en"}, + want: "en", + wantS: []string{"en"}, + }, + { + name: "comma_separated", + inputs: []string{"de,pl"}, + want: "de,pl", + wantS: []string{"de", "pl"}, + }, + { + name: "whitespace_trimmed", + inputs: []string{" de , pl "}, + want: "de,pl", + wantS: []string{"de", "pl"}, + }, + { + name: "empty_parts_skipped", + inputs: []string{",en,,de,"}, + want: "en,de", + wantS: []string{"en", "de"}, + }, + { + name: "multiple_calls", + inputs: []string{"en", "de"}, + want: "en,de", + wantS: []string{"en", "de"}, + }, + { + name: "mixed_comma_and_repeated", + inputs: []string{"en,de", "fr"}, + want: "en,de,fr", + wantS: []string{"en", "de", "fr"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var values []string + v := &stringListValue{values: &values} + + for _, input := range tt.inputs { + require.NoError(t, v.Set(input)) + } + + assert.Equal(t, tt.want, v.String()) + assert.Equal(t, tt.wantS, values) + }) + } +} diff --git a/cmd/match_flag.go b/cmd/match_flag.go new file mode 100644 index 0000000..52fe453 --- /dev/null +++ b/cmd/match_flag.go @@ -0,0 +1,83 @@ +package cmd + +import ( + "flag" + "strings" +) + +// similarFlag finds the closest defined long flag to name, within threshold. +func similarFlag(fs *flag.FlagSet, name string) string { + if prefix := prefixFlag(fs, name); prefix != "" { + return prefix + } + return fuzzyFlag(fs, name) +} + +// prefixFlag returns the shortest defined long flag that starts with name. +func prefixFlag(fs *flag.FlagSet, name string) string { + var best string + + fs.VisitAll(func(f *flag.Flag) { + if len(f.Name) <= 1 { + return + } + + if strings.HasPrefix(f.Name, name) && + (best == "" || len(f.Name) < len(best)) { + best = f.Name + } + }) + + return best +} + +// fuzzyFlag returns the closest defined long flag to name by Levenshtein distance. +func fuzzyFlag(fs *flag.FlagSet, name string) string { + var best string + bestDist := 3 + + fs.VisitAll(func(f *flag.Flag) { + if len(f.Name) <= 1 { + return + } + + d := editDistance(name, f.Name) + if d < bestDist { + bestDist = d + best = f.Name + } + }) + + return best +} + +// editDistance returns the Levenshtein distance between a and b. +func editDistance(a, b string) int { + rows, cols := len(a)+1, len(b)+1 + distances := make([][]int, rows) + + for i := range distances { + distances[i] = make([]int, cols) + distances[i][0] = i + } + + for j := range cols { + distances[0][j] = j + } + + for i := 1; i < rows; i++ { + for j := 1; j < cols; j++ { + cost := 0 + if a[i-1] != b[j-1] { + cost = 1 + } + distances[i][j] = min( + distances[i-1][j]+1, + distances[i][j-1]+1, + distances[i-1][j-1]+cost, + ) + } + } + + return distances[rows-1][cols-1] +} diff --git a/cmd/match_flag_test.go b/cmd/match_flag_test.go new file mode 100644 index 0000000..235eb3e --- /dev/null +++ b/cmd/match_flag_test.go @@ -0,0 +1,176 @@ +package cmd + +import ( + "flag" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSimilarFlag(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + flagSet []string + query string + want string + }{ + { + name: "prefix_priority", + flagSet: []string{"search", "update"}, + query: "sea", + want: "search", + }, + { + name: "fuzzy_fallback", + flagSet: []string{"search", "update"}, + query: "searh", + want: "search", + }, + { + name: "no_match", + flagSet: []string{"update", "list"}, + query: "xyz", + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fs := flag.NewFlagSet("test", flag.ContinueOnError) + for _, name := range tt.flagSet { + fs.Bool(name, false, "") + } + got := similarFlag(fs, tt.query) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestPrefixFlag(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + flagSet []string + query string + want string + }{ + { + name: "exact_prefix", + flagSet: []string{"update", "list", "list-all"}, + query: "lis", + want: "list", + }, + { + name: "shortest_prefix_wins", + flagSet: []string{"list", "list-all", "list-platforms"}, + query: "list", + want: "list", + }, + { + name: "no_match", + flagSet: []string{"update", "list"}, + query: "bogus", + want: "", + }, + { + name: "skips_short_flags", + flagSet: []string{"u", "q"}, + query: "u", + want: "", + }, + { + name: "single_match", + flagSet: []string{"search"}, + query: "sea", + want: "search", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fs := flag.NewFlagSet("test", flag.ContinueOnError) + for _, name := range tt.flagSet { + fs.Bool(name, false, "") + } + got := prefixFlag(fs, tt.query) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestFuzzyFlag(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + flagSet []string + query string + want string + }{ + { + name: "distance_1", + flagSet: []string{"search", "update"}, + query: "searh", + want: "search", + }, + { + name: "distance_2", + flagSet: []string{"verbose", "offline"}, + query: "verbos", + want: "verbose", + }, + { + name: "no_match_too_far", + flagSet: []string{"update", "list"}, + query: "xyz", + want: "", + }, + { + name: "skips_short_flags", + flagSet: []string{"u", "update"}, + query: "updaet", + want: "update", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fs := flag.NewFlagSet("test", flag.ContinueOnError) + for _, name := range tt.flagSet { + fs.Bool(name, false, "") + } + got := fuzzyFlag(fs, tt.query) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestEditDistance(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + a, b string + want int + }{ + {name: "identical", a: "abc", b: "abc", want: 0}, + {name: "empty_both", a: "", b: "", want: 0}, + {name: "empty_a", a: "", b: "abc", want: 3}, + {name: "empty_b", a: "abc", b: "", want: 3}, + {name: "substitution", a: "abc", b: "axc", want: 1}, + {name: "insertion", a: "ac", b: "abc", want: 1}, + {name: "deletion", a: "abc", b: "ac", want: 1}, + {name: "full_mismatch", a: "abc", b: "xyz", want: 3}, + {name: "typo", a: "searc", b: "search", want: 1}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := editDistance(tt.a, tt.b) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/cmd/parse.go b/cmd/parse.go index d2d8b4a..bea7c20 100644 --- a/cmd/parse.go +++ b/cmd/parse.go @@ -2,11 +2,8 @@ package cmd import ( "flag" - "fmt" "os" "strings" - - "github.com/TheRootDaemon/tlgc/version" ) // Parse parses the process command-line arguments into a CLI value. @@ -59,6 +56,19 @@ func parse(args []string) (*CLI, error) { "search for pages containing a keyword", ) + fs.BoolVar( + &cli.Browse, + "b", + false, + "open page in default web browser", + ) + fs.BoolVar( + &cli.Browse, + "browse", + false, + "open page in default web browser", + ) + fs.BoolVar( &cli.ListPlatforms, "list-platforms", @@ -221,88 +231,7 @@ func parse(args []string) (*CLI, error) { "specify an alternative configuration file", ) - if err := fs.Parse(args); err != nil { - return nil, err - } - - switch cli.Color { - case "auto", "always", "never": - default: - return nil, fmt.Errorf("invalid value %q for --color (expected auto, always, never)", cli.Color) - } - - // show version - if cli.ShowVersion { - fmt.Printf( - "tlgc %s (implementing client specification v2.3)\n", - version.String(), - ) - return cli, nil - } - - // show help - if cli.ShowHelp { - help() - return cli, nil - } - - // positional arguments - cli.Page = fs.Args() - - // validate that exactly one operation is active - ops := cli.operationCount() - if ops == 0 { - help() - return cli, nil - } else if ops > 1 { - return nil, fmt.Errorf("only one operation can be specified at a time") - } - - return cli, nil -} - -// operationCount returns how many operation-group flags are active. -func (c *CLI) operationCount() int { - count := 0 - - if len(c.Page) > 0 { - count++ - } - if c.Update { - count++ - } - if c.List { - count++ - } - if c.ListAll { - count++ - } - if c.Search != "" { - count++ - } - if c.ListPlatforms { - count++ - } - if c.ListLanguages { - count++ - } - if c.Info { - count++ - } - if c.Render != "" { - count++ - } - if c.CleanCache { - count++ - } - if c.GenConfig { - count++ - } - if c.ConfigPath { - count++ - } - - return count + return Validate(cli, fs, args) } // reorderFlags moves all flags before positional arguments diff --git a/cmd/parse_test.go b/cmd/parse_test.go new file mode 100644 index 0000000..b153a62 --- /dev/null +++ b/cmd/parse_test.go @@ -0,0 +1,580 @@ +package cmd + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParse(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + args []string + check func(t *testing.T, cli *CLI) + wantErr bool + errCheck func(t *testing.T, err error) + }{ + // operations (short forms) + { + name: "update_short", + args: []string{"-u"}, + check: func(t *testing.T, cli *CLI) { + assert.True(t, cli.Update) + }, + }, + { + name: "list_short", + args: []string{"-l"}, + check: func(t *testing.T, cli *CLI) { + assert.True(t, cli.List) + }, + }, + { + name: "list_all_short", + args: []string{"-a"}, + check: func(t *testing.T, cli *CLI) { + assert.True(t, cli.ListAll) + }, + }, + { + name: "browse_short", + args: []string{"-b", "tar"}, + check: func(t *testing.T, cli *CLI) { + assert.True(t, cli.Browse) + assert.Equal(t, []string{"tar"}, cli.Page) + }, + }, + { + name: "browse_long", + args: []string{"--browse", "tar"}, + check: func(t *testing.T, cli *CLI) { + assert.True(t, cli.Browse) + assert.Equal(t, []string{"tar"}, cli.Page) + }, + }, + { + name: "search_short", + args: []string{"-s", "ngi"}, + check: func(t *testing.T, cli *CLI) { + assert.Equal(t, "ngi", cli.Search) + }, + }, + { + name: "info_short", + args: []string{"-i"}, + check: func(t *testing.T, cli *CLI) { + assert.True(t, cli.Info) + }, + }, + { + name: "render_short", + args: []string{"-r", "file.md"}, + check: func(t *testing.T, cli *CLI) { + assert.Equal(t, "file.md", cli.Render) + }, + }, + + // operations (long forms) + { + name: "update_long", + args: []string{"--update"}, + check: func(t *testing.T, cli *CLI) { + assert.True(t, cli.Update) + }, + }, + { + name: "list_long", + args: []string{"--list"}, + check: func(t *testing.T, cli *CLI) { + assert.True(t, cli.List) + }, + }, + { + name: "list_all_long", + args: []string{"--list-all"}, + check: func(t *testing.T, cli *CLI) { + assert.True(t, cli.ListAll) + }, + }, + { + name: "search_long", + args: []string{"--search", "nginx"}, + check: func(t *testing.T, cli *CLI) { + assert.Equal(t, "nginx", cli.Search) + }, + }, + { + name: "info_long", + args: []string{"--info"}, + check: func(t *testing.T, cli *CLI) { + assert.True(t, cli.Info) + }, + }, + { + name: "render_long", + args: []string{"--render", "file.md"}, + check: func(t *testing.T, cli *CLI) { + assert.Equal(t, "file.md", cli.Render) + }, + }, + { + name: "list_platforms", + args: []string{"--list-platforms"}, + check: func(t *testing.T, cli *CLI) { + assert.True(t, cli.ListPlatforms) + }, + }, + { + name: "list_languages", + args: []string{"--list-languages"}, + check: func(t *testing.T, cli *CLI) { + assert.True(t, cli.ListLanguages) + }, + }, + { + name: "clean_cache", + args: []string{"--clean-cache"}, + check: func(t *testing.T, cli *CLI) { + assert.True(t, cli.CleanCache) + }, + }, + { + name: "gen_config", + args: []string{"--gen-config"}, + check: func(t *testing.T, cli *CLI) { + assert.True(t, cli.GenConfig) + }, + }, + { + name: "config_path", + args: []string{"--config-path"}, + check: func(t *testing.T, cli *CLI) { + assert.True(t, cli.ConfigPath) + }, + }, + + // positional args + { + name: "single_page", + args: []string{"tar"}, + check: func(t *testing.T, cli *CLI) { + assert.Equal(t, []string{"tar"}, cli.Page) + }, + }, + { + name: "multiple_pages", + args: []string{"tar", "git"}, + check: func(t *testing.T, cli *CLI) { + assert.Equal(t, []string{"tar", "git"}, cli.Page) + }, + }, + + // options + { + name: "platform_short", + args: []string{"-p", "linux", "-u"}, + check: func(t *testing.T, cli *CLI) { + assert.Equal(t, "linux", cli.Platform) + }, + }, + { + name: "platform_long", + args: []string{"--platform", "osx", "-u"}, + check: func(t *testing.T, cli *CLI) { + assert.Equal(t, "osx", cli.Platform) + }, + }, + { + name: "language_single", + args: []string{"-L", "en", "-u"}, + check: func(t *testing.T, cli *CLI) { + assert.Equal(t, []string{"en"}, cli.Languages) + }, + }, + { + name: "language_repeat", + args: []string{"-L", "en", "-L", "de", "-u"}, + check: func(t *testing.T, cli *CLI) { + assert.Equal(t, []string{"en", "de"}, cli.Languages) + }, + }, + { + name: "language_comma", + args: []string{"-L", "en,de", "-u"}, + check: func(t *testing.T, cli *CLI) { + assert.Equal(t, []string{"en", "de"}, cli.Languages) + }, + }, + { + name: "language_long", + args: []string{"--language", "fr", "-u"}, + check: func(t *testing.T, cli *CLI) { + assert.Equal(t, []string{"fr"}, cli.Languages) + }, + }, + { + name: "offline_short", + args: []string{"-o", "-u"}, + check: func(t *testing.T, cli *CLI) { + assert.True(t, cli.Offline) + }, + }, + { + name: "offline_long", + args: []string{"--offline", "-u"}, + check: func(t *testing.T, cli *CLI) { + assert.True(t, cli.Offline) + }, + }, + { + name: "compact_short", + args: []string{"-c", "-u"}, + check: func(t *testing.T, cli *CLI) { + assert.True(t, cli.Compact) + }, + }, + { + name: "compact_long", + args: []string{"--compact", "-u"}, + check: func(t *testing.T, cli *CLI) { + assert.True(t, cli.Compact) + }, + }, + { + name: "no_compact", + args: []string{"--no-compact", "-u"}, + check: func(t *testing.T, cli *CLI) { + assert.True(t, cli.NoCompact) + }, + }, + { + name: "raw_short", + args: []string{"-R", "-u"}, + check: func(t *testing.T, cli *CLI) { + assert.True(t, cli.Raw) + }, + }, + { + name: "raw_long", + args: []string{"--raw", "-u"}, + check: func(t *testing.T, cli *CLI) { + assert.True(t, cli.Raw) + }, + }, + { + name: "no_raw", + args: []string{"--no-raw", "-u"}, + check: func(t *testing.T, cli *CLI) { + assert.True(t, cli.NoRaw) + }, + }, + { + name: "quiet_short", + args: []string{"-q", "-u"}, + check: func(t *testing.T, cli *CLI) { + assert.True(t, cli.Quiet) + }, + }, + { + name: "quiet_long", + args: []string{"--quiet", "-u"}, + check: func(t *testing.T, cli *CLI) { + assert.True(t, cli.Quiet) + }, + }, + { + name: "verbose_single", + args: []string{"--verbose", "-u"}, + check: func(t *testing.T, cli *CLI) { + assert.Equal(t, uint8(1), cli.Verbose) + }, + }, + { + name: "verbose_double", + args: []string{"--verbose", "--verbose", "-u"}, + check: func(t *testing.T, cli *CLI) { + assert.Equal(t, uint8(2), cli.Verbose) + }, + }, + { + name: "color_auto", + args: []string{"--color", "auto", "-u"}, + check: func(t *testing.T, cli *CLI) { + assert.Equal(t, "auto", cli.Color) + }, + }, + { + name: "color_always", + args: []string{"--color", "always", "-u"}, + check: func(t *testing.T, cli *CLI) { + assert.Equal(t, "always", cli.Color) + }, + }, + { + name: "color_never", + args: []string{"--color", "never", "-u"}, + check: func(t *testing.T, cli *CLI) { + assert.Equal(t, "never", cli.Color) + }, + }, + { + name: "color_default", + args: []string{"-u"}, + check: func(t *testing.T, cli *CLI) { + assert.Equal(t, "auto", cli.Color) + }, + }, + { + name: "config_path_option", + args: []string{"--config", "/tmp/cfg", "-u"}, + check: func(t *testing.T, cli *CLI) { + assert.Equal(t, "/tmp/cfg", cli.Config) + }, + }, + { + name: "edit", + args: []string{"--edit", "-u"}, + check: func(t *testing.T, cli *CLI) { + assert.True(t, cli.Edit) + }, + }, + { + name: "short_options", + args: []string{"--short-options", "-u"}, + check: func(t *testing.T, cli *CLI) { + assert.True(t, cli.ShortOptions) + }, + }, + { + name: "long_options", + args: []string{"--long-options", "-u"}, + check: func(t *testing.T, cli *CLI) { + assert.True(t, cli.LongOptions) + }, + }, + + // combined + { + name: "search_with_platform_and_language", + args: []string{"-s", "ngi", "-p", "linux", "-L", "en"}, + check: func(t *testing.T, cli *CLI) { + assert.Equal(t, "ngi", cli.Search) + assert.Equal(t, "linux", cli.Platform) + assert.Equal(t, []string{"en"}, cli.Languages) + }, + }, + { + name: "page_with_all_options", + args: []string{"tar", "-p", "linux", "-L", "en", "-o", "-c", "-q"}, + check: func(t *testing.T, cli *CLI) { + assert.Equal(t, []string{"tar"}, cli.Page) + assert.Equal(t, "linux", cli.Platform) + assert.Equal(t, []string{"en"}, cli.Languages) + assert.True(t, cli.Offline) + assert.True(t, cli.Compact) + assert.True(t, cli.Quiet) + }, + }, + + // special operations + { + name: "version", + args: []string{"-v"}, + check: func(t *testing.T, cli *CLI) { + assert.True(t, cli.ShowVersion) + }, + }, + { + name: "version_long", + args: []string{"--version"}, + check: func(t *testing.T, cli *CLI) { + assert.True(t, cli.ShowVersion) + }, + }, + { + name: "help", + args: []string{"-h"}, + check: func(t *testing.T, cli *CLI) { + assert.True(t, cli.ShowHelp) + }, + }, + { + name: "help_long", + args: []string{"--help"}, + check: func(t *testing.T, cli *CLI) { + assert.True(t, cli.ShowHelp) + }, + }, + { + name: "no_args_prints_help", + args: []string{}, + wantErr: false, + }, + + // error cases + { + name: "browse_without_page", + args: []string{"-b"}, + wantErr: true, + errCheck: func(t *testing.T, err error) { + assert.ErrorContains(t, err, "requires a page argument") + }, + }, + { + name: "browse_with_page", + args: []string{"-b", "tar"}, + check: func(t *testing.T, cli *CLI) { + assert.True(t, cli.Browse) + assert.Equal(t, []string{"tar"}, cli.Page) + }, + }, + { + name: "two_operations", + args: []string{"-u", "-l"}, + wantErr: true, + errCheck: func(t *testing.T, err error) { + assert.ErrorContains(t, err, "cannot be used with") + }, + }, + { + name: "three_operations", + args: []string{"-u", "-l", "-a"}, + wantErr: true, + errCheck: func(t *testing.T, err error) { + assert.ErrorContains(t, err, "cannot be used with") + }, + }, + { + name: "invalid_color", + args: []string{"--color", "invalid", "-u"}, + wantErr: true, + errCheck: func(t *testing.T, err error) { + assert.ErrorContains(t, err, "invalid value") + }, + }, + { + name: "unknown_flag", + args: []string{"--bogus"}, + wantErr: true, + errCheck: func(t *testing.T, err error) { + assert.ErrorContains(t, err, "unexpected argument") + }, + }, + { + name: "unknown_short_flag", + args: []string{"-x"}, + wantErr: true, + errCheck: func(t *testing.T, err error) { + assert.ErrorContains(t, err, "unexpected argument") + }, + }, + { + name: "unknown_flag_tip", + args: []string{"--searc"}, + wantErr: true, + errCheck: func(t *testing.T, err error) { + assert.ErrorContains(t, err, "unexpected argument") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cli, err := parse(tt.args) + if tt.wantErr { + if tt.errCheck != nil { + tt.errCheck(t, err) + } else { + assert.Error(t, err) + } + return + } + require.NoError(t, err) + require.NotNil(t, cli) + if tt.check != nil { + tt.check(t, cli) + } + }) + } +} + +func TestReorderFlags(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + args []string + want []string + }{ + { + name: "empty", + args: []string{}, + want: nil, + }, + { + name: "only_positional", + args: []string{"page1", "page2"}, + want: []string{"page1", "page2"}, + }, + { + name: "only_flags", + args: []string{"-u"}, + want: []string{"-u"}, + }, + { + name: "flags_before_positional", + args: []string{"-u", "page1"}, + want: []string{"-u", "page1"}, + }, + { + name: "flag_after_positional", + args: []string{"page1", "-u"}, + want: []string{"-u", "page1"}, + }, + { + name: "value_flag_after_positional", + args: []string{"page1", "-p", "linux"}, + want: []string{"-p", "linux", "page1"}, + }, + { + name: "long_flag_after_positional", + args: []string{"page1", "--update"}, + want: []string{"--update", "page1"}, + }, + { + name: "multiple_flags_after_positionals", + args: []string{"page1", "-u", "-p", "linux", "-o"}, + want: []string{"-u", "-p", "linux", "-o", "page1"}, + }, + { + name: "equals_syntax_stays_in_place", + args: []string{"-p=linux", "page1"}, + want: []string{"-p=linux", "page1"}, + }, + { + name: "mixed_positional_and_flags", + args: []string{"page1", "-u", "page2", "-p", "linux"}, + want: []string{"-u", "-p", "linux", "page1", "page2"}, + }, + { + name: "all_value_flags", + args: []string{"page1", "-L", "en", "-s", "foo", "-r", "file.md", "--color", "always", "--config", "/tmp/cfg"}, + want: []string{"-L", "en", "-s", "foo", "-r", "file.md", "--color", "always", "--config", "/tmp/cfg", "page1"}, + }, + { + name: "search_flag_after_positional", + args: []string{"tar", "-s", "ngi"}, + want: []string{"-s", "ngi", "tar"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := reorderFlags(tt.args) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/cmd/validate.go b/cmd/validate.go new file mode 100644 index 0000000..3fa856a --- /dev/null +++ b/cmd/validate.go @@ -0,0 +1,125 @@ +package cmd + +import ( + "flag" + "fmt" + "io" + + "github.com/TheRootDaemon/tlgc/termcolor" + "github.com/TheRootDaemon/tlgc/version" +) + +// Validate parses the flag set, validates the CLI, and returns the result. +func Validate(cli *CLI, fs *flag.FlagSet, args []string) (*CLI, error) { + fs.Usage = func() {} + fs.SetOutput(io.Discard) + + if err := fs.Parse(args); err != nil { + return nil, fmtFlagError(fs, err) + } + + cli.Page = fs.Args() + + if err := validate(cli); err != nil { + return nil, err + } + + return cli, nil +} + +// validate checks that the parsed CLI has valid flags. +func validate(cli *CLI) error { + switch cli.Color { + case "auto", "always", "never": + default: + return fmtUsage( + "invalid value for %s (expected %s, %s, %s)", + termcolor.Sprint("bold blue", "--color"), + termcolor.Sprint("blue", "auto"), + termcolor.Sprint("blue", "always"), + termcolor.Sprint("blue", "never"), + ) + } + + // show version + if cli.ShowVersion { + fmt.Printf( + "tlgc %s (implementing client specification v2.3)\n", + version.String(), + ) + return nil + } + + // show help + if cli.ShowHelp { + help() + return nil + } + + // validate that exactly one operation is active + ops := cli.operationCount() + if ops == 0 { + help() + return nil + } + if ops > 1 { + return fmtConflictError(cli) + } + + // browse requires a page argument + if cli.Browse && len(cli.Page) == 0 { + return fmtUsage( + "flag %s requires a page argument", + termcolor.Sprint("bold blue", "--browse"), + ) + } + + return nil +} + +// operationCount returns how many operation-group flags are active. +func (c *CLI) operationCount() int { + count := 0 + + if len(c.Page) > 0 && !c.Browse { + count++ + } + if c.Update { + count++ + } + if c.List { + count++ + } + if c.ListAll { + count++ + } + if c.Search != "" { + count++ + } + if c.Browse { + count++ + } + if c.ListPlatforms { + count++ + } + if c.ListLanguages { + count++ + } + if c.Info { + count++ + } + if c.Render != "" { + count++ + } + if c.CleanCache { + count++ + } + if c.GenConfig { + count++ + } + if c.ConfigPath { + count++ + } + + return count +} diff --git a/cmd/validate_test.go b/cmd/validate_test.go new file mode 100644 index 0000000..8ffa57d --- /dev/null +++ b/cmd/validate_test.go @@ -0,0 +1,207 @@ +package cmd + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestValidate(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + cli CLI + wantErr bool + }{ + { + name: "single_operation_update", + cli: CLI{Color: "auto", Update: true}, + }, + { + name: "single_operation_list", + cli: CLI{Color: "auto", List: true}, + }, + { + name: "single_operation_search", + cli: CLI{Color: "auto", Search: "ngi"}, + }, + { + name: "no_operations", + cli: CLI{Color: "auto"}, + wantErr: false, + }, + { + name: "invalid_color", + cli: CLI{Color: "invalid", Update: true}, + wantErr: true, + }, + { + name: "two_operations", + cli: CLI{Color: "auto", Update: true, List: true}, + wantErr: true, + }, + { + name: "three_operations", + cli: CLI{Color: "auto", Update: true, List: true, ListAll: true}, + wantErr: true, + }, + { + name: "browse_with_page", + cli: CLI{Color: "auto", Browse: true, Page: []string{"tar"}}, + }, + { + name: "page_and_update", + cli: CLI{Color: "auto", Page: []string{"tar"}, Update: true}, + wantErr: true, + }, + { + name: "browse_and_update", + cli: CLI{Color: "auto", Browse: true, Update: true}, + wantErr: true, + }, + { + name: "browse_without_page", + cli: CLI{Color: "auto", Browse: true}, + wantErr: true, + }, + { + name: "valid_color_auto", + cli: CLI{Color: "auto", Update: true}, + wantErr: false, + }, + { + name: "valid_color_always", + cli: CLI{Color: "always", Update: true}, + wantErr: false, + }, + { + name: "valid_color_never", + cli: CLI{Color: "never", Update: true}, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validate(&tt.cli) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestOperationCount(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + cli CLI + want int + }{ + { + name: "none", + cli: CLI{}, + want: 0, + }, + { + name: "page", + cli: CLI{Page: []string{"tar"}}, + want: 1, + }, + { + name: "update", + cli: CLI{Update: true}, + want: 1, + }, + { + name: "list", + cli: CLI{List: true}, + want: 1, + }, + { + name: "list_all", + cli: CLI{ListAll: true}, + want: 1, + }, + { + name: "search", + cli: CLI{Search: "ngi"}, + want: 1, + }, + { + name: "browse", + cli: CLI{Browse: true, Page: []string{"tar"}}, + want: 1, + }, + { + name: "list_platforms", + cli: CLI{ListPlatforms: true}, + want: 1, + }, + { + name: "list_languages", + cli: CLI{ListLanguages: true}, + want: 1, + }, + { + name: "info", + cli: CLI{Info: true}, + want: 1, + }, + { + name: "render", + cli: CLI{Render: "file.md"}, + want: 1, + }, + { + name: "clean_cache", + cli: CLI{CleanCache: true}, + want: 1, + }, + { + name: "gen_config", + cli: CLI{GenConfig: true}, + want: 1, + }, + { + name: "config_path", + cli: CLI{ConfigPath: true}, + want: 1, + }, + { + name: "two_operations", + cli: CLI{Update: true, List: true}, + want: 2, + }, + { + name: "all_operations", + cli: CLI{ + Page: []string{"tar"}, + Update: true, + List: true, + ListAll: true, + Search: "ngi", + Browse: true, + ListPlatforms: true, + ListLanguages: true, + Info: true, + Render: "file.md", + CleanCache: true, + GenConfig: true, + ConfigPath: true, + }, + want: 12, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.cli.operationCount() + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/completions/_tlgc b/completions/_tlgc new file mode 100644 index 0000000..4b72000 --- /dev/null +++ b/completions/_tlgc @@ -0,0 +1,50 @@ +#compdef tlgc + +_pages() { + local -a pages=(${(uonzf)"$(tlgc --offline --list-all 2> /dev/null)"//:/\\:}) + _describe "PAGE" pages +} + +_languages() { + local -a languages=(${(uonzf)"$(tlgc --offline --list-languages 2> /dev/null)"//:/\\:}) + _describe "LANGUAGE_CODE" languages +} + +_platforms() { + local -a platforms=(${(uonzf)"$(tlgc --offline --list-platforms 2> /dev/null)"//:/\\:}) + _describe "PLATFORM" platforms +} + +_tlgc() { + _arguments -s -S \ + {-u,--update}"[Update the cache]" \ + {-l,--list}"[List all pages in the current platform]" \ + {-a,--list-all}"[List all pages]" \ + {-s,--search}"[Search for pages containing a keyword]" \ + --list-platforms"[List available platforms]" \ + --list-languages"[List installed languages]" \ + {-i,--info}"[Show cache information]" \ + {-r,--render}"[Render the specified tldr page]:FILE:_files" \ + --clean-cache"[Interactively delete contents of the cache directory]" \ + --gen-config"[Print the default config]" \ + --config-path"[Print the default config path]" \ + {-p,--platform}"[Specify the platform to use (linux, osx, windows, etc.)]:PLATFORM:_platforms" \ + {-L,--language}"[Specify the languages to use]:LANGUAGE_CODE:_languages" \ + --short-options"[Display short options wherever possible (e.g. '-s')]" \ + --long-options"[Display long options wherever possible (e.g. '--long')]" \ + --edit"[Display a link to edit the shown page on GitHub]" \ + {-o,--offline}"[Do not update the cache, even if it is stale]" \ + {-c,--compact}"[Strip empty lines from output]" \ + --no-compact"[Do not strip empty lines from output (overrides --compact)]" \ + {-R,--raw}"[Print pages in raw markdown instead of rendering them]" \ + --no-raw"[Render pages instead of printing raw file contents (overrides --raw)]" \ + {-q,--quiet}"[Suppress status messages and warnings]" \ + --verbose"[Be more verbose (can be specified twice)]" \ + --color"[Specify when to enable color [default: auto] [possible values: auto, always, never]]:WHEN:(auto always never)" \ + --config"[Specify an alternative path to the config file]:FILE:_files" \ + {-v,--version}"[Print version]" \ + {-h,--help}"[Print help]" \ + '*:PAGE:_pages' +} + +_tlgc diff --git a/completions/tlgc.bash b/completions/tlgc.bash new file mode 100644 index 0000000..2aa7896 --- /dev/null +++ b/completions/tlgc.bash @@ -0,0 +1,32 @@ +# shellcheck shell=bash + +_tlgc() { + local cur="${COMP_WORDS[COMP_CWORD]}" + local prev="${COMP_WORDS[COMP_CWORD-1]}" + + local opts="-u -l -a -s -i -r -p -L -o -c -R -q -v -h \ + --update --list --list-all --search --list-platforms --list-languages \ + --info --render --clean-cache --gen-config --config-path --platform \ + --language --short-options --long-options --edit --offline --compact \ + --no-compact --raw --no-raw --quiet --verbose --color --config --version --help" + + if [[ $cur == -* ]]; then + mapfile -t COMPREPLY < <(compgen -W "$opts" -- "$cur") + return 0 + fi + + case $prev in + -r|--render|--config) + mapfile -t COMPREPLY < <(compgen -f -- "$cur");; + --color) + mapfile -t COMPREPLY < <(compgen -W "auto always never" -- "$cur");; + -p|--platform) + mapfile -t COMPREPLY < <(compgen -W "$(tlgc --offline --list-platforms 2> /dev/null)" -- "$cur");; + -L|--language) + mapfile -t COMPREPLY < <(compgen -W "$(tlgc --offline --list-languages 2> /dev/null)" -- "$cur");; + *) + mapfile -t COMPREPLY < <(compgen -W "$(tlgc --offline --list-all 2> /dev/null)" -- "$cur");; + esac +} + +complete -o bashdefault -F _tlgc tlgc diff --git a/completions/tlgc.fish b/completions/tlgc.fish new file mode 100644 index 0000000..1c9cf48 --- /dev/null +++ b/completions/tlgc.fish @@ -0,0 +1,34 @@ +complete -c tlgc -s u -l update -d "Update the cache" +complete -c tlgc -s l -l list -d "List all pages in the current platform" +complete -c tlgc -s a -l list-all -d "List all pages" +complete -c tlgc -s s -l search -d "Search for pages containing a keyword" +complete -c tlgc -l list-platforms -d "List available platforms" +complete -c tlgc -l list-languages -d "List installed languages" +complete -c tlgc -s i -l info -d "Show cache information" +complete -c tlgc -s r -l render -d "Render the specified tldr page" -r +complete -c tlgc -l clean-cache -d "Interactively delete contents of the cache directory" +complete -c tlgc -l gen-config -d "Print the default config" +complete -c tlgc -l config-path -d "Print the default config path" +complete -c tlgc -s p -l platform -d "Specify the platform to use (linux, osx, windows, etc.)" -x -a \ + "(tlgc --offline --list-platforms 2> /dev/null)" +complete -c tlgc -s L -l language -d "Specify the languages to use" -x -a \ + "(tlgc --offline --list-languages 2> /dev/null)" +complete -c tlgc -l short-options -d "Display short options wherever possible (e.g. '-s')" +complete -c tlgc -l long-options -d "Display long options wherever possible (e.g. '--long')" +complete -c tlgc -l edit -d "Display a link to edit the shown page on GitHub" +complete -c tlgc -s o -l offline -d "Do not update the cache, even if it is stale" +complete -c tlgc -s c -l compact -d "Strip empty lines from output" +complete -c tlgc -l no-compact -d "Do not strip empty lines from output (overrides --compact)" +complete -c tlgc -s R -l raw -d "Print pages in raw markdown instead of rendering them" +complete -c tlgc -l no-raw -d "Render pages instead of printing raw file contents (overrides --raw)" +complete -c tlgc -s q -l quiet -d "Suppress status messages and warnings" +complete -c tlgc -l verbose -d "Be more verbose (can be specified twice)" +complete -c tlgc -l color -d "Specify when to enable color [default: auto] [possible values: auto, always, never]" -x -a " + auto\t'Display color if standard output is a terminal and NO_COLOR is not set' + always\t'Always display color' + never\t'Never display color' +" +complete -c tlgc -l config -d "Specify an alternative path to the config file" -r +complete -c tlgc -s v -l version -d "Print version" +complete -c tlgc -s h -l help -d "Print help" +complete -c tlgc -f -a "(tlgc --offline --list-all 2> /dev/null)" diff --git a/internal/app/app.go b/internal/app/app.go index 0937049..7b1a160 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -72,9 +72,9 @@ func New(opts ...Option) *App { return a } -// Run dispatches the CLI command to the appropriate handler. -// It initializes the config if needed, then delegates to the matching -// sub-handler based on the CLI flags. Returns 0 on success, 1 on error. +// Run initializes the configuration when required and dispatches +// the parsed CLI command to the appropriate handler. +// It returns 0 on success and 1 on error. func (a *App) Run(cli *cmd.CLI) int { needsConfig := !cli.GenConfig && !cli.ConfigPath && !cli.ShowVersion && !cli.ShowHelp @@ -85,6 +85,13 @@ func (a *App) Run(cli *cmd.CLI) int { } } + return a.dispatch(cli) +} + +// dispatch routes the parsed CLI command to the corresponding handler +// based on the provided flags. +// It returns 0 on success and 1 on error. +func (a *App) dispatch(cli *cmd.CLI) int { switch { case cli.Update: return a.updateCache(cli) @@ -108,6 +115,8 @@ func (a *App) Run(cli *cmd.CLI) int { return a.genConfig() case cli.ConfigPath: return a.configPath() + case cli.Browse: + return a.browsePage(cli) case len(cli.Page) > 0: return a.lookupAndRenderPage(cli) default: diff --git a/internal/app/info_test.go b/internal/app/info_test.go new file mode 100644 index 0000000..795ca38 --- /dev/null +++ b/internal/app/info_test.go @@ -0,0 +1,62 @@ +package app + +import ( + "testing" + + "github.com/TheRootDaemon/tlgc/internal/cache" + "github.com/stretchr/testify/assert" +) + +func TestFormatPlatformBreakdown(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + platforms []cache.PlatformInfo + want string + }{ + { + name: "empty", + platforms: nil, + want: "", + }, + { + name: "single_platform", + platforms: []cache.PlatformInfo{ + {Name: "common", Pages: 3000}, + }, + want: " (common: 3000)", + }, + { + name: "multiple_platforms", + platforms: []cache.PlatformInfo{ + {Name: "common", Pages: 3000}, + {Name: "linux", Pages: 2500}, + }, + want: " (common: 3000, linux: 2500)", + }, + { + name: "three_platforms", + platforms: []cache.PlatformInfo{ + {Name: "common", Pages: 3000}, + {Name: "linux", Pages: 2500}, + {Name: "osx", Pages: 2100}, + }, + want: " (common: 3000, linux: 2500, osx: 2100)", + }, + { + name: "zero_pages", + platforms: []cache.PlatformInfo{ + {Name: "windows", Pages: 0}, + }, + want: " (windows: 0)", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := stripANSI(formatPlatformBreakdown(tt.platforms)) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/internal/app/page.go b/internal/app/page.go index 5fffd1e..31b7042 100644 --- a/internal/app/page.go +++ b/internal/app/page.go @@ -7,6 +7,7 @@ import ( "path/filepath" "strings" + "github.com/TheRootDaemon/tlgc/browser" "github.com/TheRootDaemon/tlgc/cmd" "github.com/TheRootDaemon/tlgc/internal/cache" "github.com/TheRootDaemon/tlgc/internal/config" @@ -14,6 +15,7 @@ import ( "github.com/TheRootDaemon/tlgc/internal/upstream" "github.com/TheRootDaemon/tlgc/logger" "github.com/TheRootDaemon/tlgc/pathutil" + "github.com/TheRootDaemon/tlgc/termcolor" ) // lookupAndRenderPage finds a page by name and renders it to the terminal. @@ -50,81 +52,90 @@ func (a *App) lookupAndRenderPage(cli *cmd.CLI) int { return 1 } - root, err := os.OpenRoot( - filepath.Dir(pagePath), - ) + page, err := loadPage(pagePath) if err != nil { logger.Error("%v", err) return 1 } - data, err := root.ReadFile( - filepath.Base(pagePath), - ) - if err != nil { - logger.Error("failed to read page: %v", err) + if err := a.renderPage(cli, renderPlatform, page); err != nil { + logger.Error("failed to render: %v", err) return 1 } + return 0 +} - if err := render.Validate(string(data)); err != nil { - logger.Error("not a valid tldr page: %s\n\n%v", pagePath, err) - return 1 - } +// browsePage looks up a page and opens it in the default web browser. +// It does not render the page content to the terminal. +// Returns 0 on success, 1 on error. +func (a *App) browsePage(cli *cmd.CLI) int { + p := a.resolvePlatform(cli.Platform) + langs := a.resolveLanguages(cli.Languages) + c := cache.New() - page := render.Parse(string(data)) - page.Path = pagePath - page.RawContent = string(data) + if !cli.Offline { + cfg := config.Cache() + if cfg.AutoUpdate && c.NeedsUpdate(cfg.MaxAge) { + client := upstream.New() + if err := c.Update(context.Background(), langs, client); err != nil { + logger.Warn("auto-update failed: %v", err) + } + } + } - renderer := render.New(a.Stdout, a.renderOptions(cli)...) - if err := renderer.Render(renderPlatform, page); err != nil { - logger.Error("failed to render page: %v", err) + query := strings.Join(cli.Page, "-") + results, err := c.Find(query, p, langs) + if err != nil { + logger.Error("failed to find page: %v", err) return 1 } - return 0 -} -// renderLocalFile reads a local tldr markdown file, validates it, -// and renders it to the terminal. -// Returns 0 on success, 1 on error. -func (a *App) renderLocalFile(cli *cmd.CLI) int { - root, err := os.OpenRoot( - filepath.Dir(cli.Render), - ) + pagePath, _, err := a.selectPage(results, query, p) if err != nil { logger.Error("%v", err) return 1 } - data, err := root.ReadFile( - filepath.Base(cli.Render), - ) - if err != nil { - logger.Error("failed to read file: %v", err) + url := render.BuildViewURL(pagePath) + if url == "" { + logger.Error("could not build URL for page") return 1 } - if err := render.Validate(string(data)); err != nil { - logger.Error("not a valid tldr page: %s\n\n%v", cli.Render, err) + logger.Info("opening page in browser") + if err := browser.Open(url); err != nil { + logger.Error("failed to open browser: %v", err) return 1 } + return 0 +} - page := render.Parse(string(data)) - if page.Title == "" { - logger.Error("not a valid tldr page: %s", cli.Render) +// renderLocalFile reads a local tldr markdown file, validates it, +// and renders it to the terminal. +// Returns 0 on success, 1 on error. +func (a *App) renderLocalFile(cli *cmd.CLI) int { + page, err := loadPage(cli.Render) + if err != nil { + logger.Error("%v", err) return 1 } - page.Path = cli.Render - page.RawContent = string(data) - - renderer := render.New(a.Stdout, a.renderOptions(cli)...) - if err := renderer.Render("", page); err != nil { + if err := a.renderPage(cli, "", page); err != nil { logger.Error("failed to render: %v", err) return 1 } return 0 } +func (a *App) renderPage( + cli *cmd.CLI, + platform string, + page *render.Page, +) error { + renderer := render.New(a.Stdout, a.renderOptions(cli)...) + return renderer.Render(platform, page) +} + // selectPage chooses the best matching page // and falls back to pages from other platforms // when no exact match exists. @@ -163,7 +174,21 @@ func (a *App) selectPage( return page, pathutil.PagePlatform(page), nil default: - return "", "", fmt.Errorf("page not found, try running tldr --update") + const ( + pageNotFound = `page not found, try running tldr --update + +If the page does not exist, you can create an issue here: +%s +or document it yourself and create a pull request here: +%s` + tldrIssues = "https://github.com/tldr-pages/tldr/issues" + tldrPulls = "https://github.com/tldr-pages/tldr/pulls" + ) + return "", "", fmt.Errorf( + pageNotFound, + termcolor.Sprint("bold", tldrIssues), + termcolor.Sprint("bold", tldrPulls), + ) } } @@ -180,15 +205,17 @@ func (a *App) renderOptions(cli *cmd.CLI) []render.RenderOption { } output := config.Output() - if cli.NoCompact { + switch { + case cli.NoCompact: output.Compact = false - } else if cli.Compact { + case cli.Compact: output.Compact = true } - if cli.NoRaw { + switch { + case cli.NoRaw: output.RawMarkdown = false - } else if cli.Raw { + case cli.Raw: output.RawMarkdown = true } @@ -208,3 +235,33 @@ func (a *App) renderOptions(cli *cmd.CLI) []render.RenderOption { opts = append(opts, render.WithOutput(output)) return opts } + +// loadPage reads and validates the TL;DR markdown page at path, +// parses it into a render.Page, +// and sets the page's Path and RawContent fields. +// It returns an error if the file cannot be read +// or is not a valid TLDR page. +func loadPage(path string) (*render.Page, error) { + root, err := os.OpenRoot(filepath.Dir(path)) + if err != nil { + return nil, err + } + defer func() { + _ = root.Close() + }() + + data, err := root.ReadFile(filepath.Base(path)) + if err != nil { + return nil, fmt.Errorf("failed to read file: %w", err) + } + + if err := render.Validate(string(data)); err != nil { + return nil, fmt.Errorf("invalid tldr page: %w", err) + } + + page := render.Parse(string(data)) + page.Path = path + page.RawContent = string(data) + + return page, nil +} diff --git a/internal/app/page_test.go b/internal/app/page_test.go new file mode 100644 index 0000000..4791670 --- /dev/null +++ b/internal/app/page_test.go @@ -0,0 +1,268 @@ +package app + +import ( + "bytes" + "os" + "path/filepath" + "testing" + + "github.com/TheRootDaemon/tlgc/cmd" + "github.com/TheRootDaemon/tlgc/internal/cache" + "github.com/TheRootDaemon/tlgc/internal/render" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSelectPage(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + results *cache.FindResult + query string + platform string + wantPath string + wantPlat string + wantErr bool + wantStderr string + }{ + { + name: "exact_match", + results: &cache.FindResult{Matches: []string{"/pages.en/common/tar.md"}}, + query: "tar", + platform: "linux", + wantPath: "/pages.en/common/tar.md", + wantPlat: "linux", + }, + { + name: "multiple_matches_uses_first", + results: &cache.FindResult{Matches: []string{"/pages.en/common/tar.md", "/pages.en/linux/tar.md"}}, + query: "tar", + platform: "linux", + wantPath: "/pages.en/common/tar.md", + wantPlat: "linux", + }, + { + name: "fallback_only", + results: &cache.FindResult{Fallbacks: []string{"/pages.en/osx/tar.md"}}, + query: "tar", + platform: "linux", + wantPath: "/pages.en/osx/tar.md", + wantPlat: "osx", + wantStderr: "1. osx (tldr --platform osx tar)\n", + }, + { + name: "matches_preferred_over_fallbacks", + results: &cache.FindResult{ + Matches: []string{"/pages.en/common/tar.md"}, + Fallbacks: []string{"/pages.en/osx/tar.md"}, + }, + query: "tar", + platform: "linux", + wantPath: "/pages.en/common/tar.md", + wantPlat: "linux", + wantStderr: "1. osx (tldr --platform osx tar)\n", + }, + { + name: "no_results", + results: &cache.FindResult{}, + query: "tar", + platform: "linux", + wantErr: true, + }, + { + name: "nil_matches_and_fallbacks", + results: &cache.FindResult{}, + query: "tar", + platform: "linux", + wantErr: true, + }, + { + name: "multiple_fallbacks", + results: &cache.FindResult{Fallbacks: []string{"/pages.en/osx/tar.md", "/pages.en/windows/tar.md"}}, + query: "tar", + platform: "linux", + wantPath: "/pages.en/osx/tar.md", + wantPlat: "osx", + wantStderr: "1. osx (tldr --platform osx tar)\n2. windows (tldr --platform windows tar)\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var stderr bytes.Buffer + a := &App{Stderr: &stderr} + + gotPath, gotPlat, err := a.selectPage(tt.results, tt.query, tt.platform) + if tt.wantErr { + assert.Error(t, err) + return + } + + require.NoError(t, err) + assert.Equal(t, tt.wantPath, gotPath) + assert.Equal(t, tt.wantPlat, gotPlat) + + if tt.wantStderr != "" { + assert.Equal(t, tt.wantStderr, stderr.String()) + } else { + assert.Empty(t, stderr.String()) + } + }) + } +} + +func TestRenderOptions(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + cli cmd.CLI + wantLen int + }{ + { + name: "default", + cli: cmd.CLI{}, + wantLen: 2, + }, + { + name: "color_always", + cli: cmd.CLI{Color: "always"}, + wantLen: 3, + }, + { + name: "color_never", + cli: cmd.CLI{Color: "never"}, + wantLen: 3, + }, + { + name: "edit", + cli: cmd.CLI{Edit: true}, + wantLen: 2, + }, + { + name: "compact", + cli: cmd.CLI{Compact: true}, + wantLen: 2, + }, + { + name: "no_compact", + cli: cmd.CLI{NoCompact: true}, + wantLen: 2, + }, + { + name: "raw", + cli: cmd.CLI{Raw: true}, + wantLen: 2, + }, + { + name: "no_raw", + cli: cmd.CLI{NoRaw: true}, + wantLen: 2, + }, + { + name: "short_options", + cli: cmd.CLI{ShortOptions: true}, + wantLen: 2, + }, + { + name: "long_options", + cli: cmd.CLI{LongOptions: true}, + wantLen: 2, + }, + { + name: "both_options", + cli: cmd.CLI{ShortOptions: true, LongOptions: true}, + wantLen: 2, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + a := &App{Stdout: &bytes.Buffer{}} + got := a.renderOptions(&tt.cli) + assert.Equal(t, tt.wantLen, len(got)) + }) + } +} + +func TestLoadPage(t *testing.T) { + t.Parallel() + + validPage := "# tar\n\n> archive utility.\n\n- create an archive:\n\n`tar cf archive.tar`\n" + + tests := []struct { + name string + content string + wantErr bool + check func(t *testing.T, page *render.Page, path string) + }{ + { + name: "valid_page", + content: validPage, + wantErr: false, + check: func(t *testing.T, page *render.Page, path string) { + assert.Equal(t, "tar", page.Title) + assert.Equal(t, path, page.Path) + assert.Equal(t, validPage, page.RawContent) + assert.NotEmpty(t, page.Examples) + }, + }, + { + name: "valid_page_with_url", + content: "# tar\n\n> archive utility.\n> More information: .\n\n- create:\n\n`tar cf archive.tar`\n", + wantErr: false, + check: func(t *testing.T, page *render.Page, _ string) { + assert.Equal(t, "tar", page.Title) + assert.Equal(t, "https://example.org/tar", page.URL) + }, + }, + { + name: "nonexistent_file", + content: "", + wantErr: true, + }, + { + name: "invalid_page_content", + content: "some random text\n", + wantErr: true, + }, + { + name: "empty_file", + content: "", + wantErr: false, + check: func(t *testing.T, page *render.Page, _ string) { + assert.Empty(t, page.Title) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "page.md") + + if tt.name != "nonexistent_file" { + require.NoError(t, writeTestFile(path, tt.content)) + } + + got, err := loadPage(path) + if tt.wantErr { + assert.Error(t, err) + return + } + + require.NoError(t, err) + require.NotNil(t, got) + if tt.check != nil { + tt.check(t, got, path) + } + }) + } +} + +// writeTestFile creates or overwrites a test file +// at path with the given content. +func writeTestFile(path, content string) error { + return os.WriteFile(path, []byte(content), 0o644) +} diff --git a/internal/app/search_test.go b/internal/app/search_test.go new file mode 100644 index 0000000..b0e3fd8 --- /dev/null +++ b/internal/app/search_test.go @@ -0,0 +1,188 @@ +package app + +import ( + "testing" + + "github.com/TheRootDaemon/tlgc/internal/cache" + "github.com/stretchr/testify/assert" +) + +func TestColumnWidths(t *testing.T) { + t.Parallel() + + languageHeader := len("Language") + platformHeader := len("Platform") + pageHeader := len("Page") + + tests := []struct { + name string + results []cache.SearchResult + wantLang int + wantPlat int + wantPage int + }{ + { + name: "empty_results_uses_header_widths", + results: []cache.SearchResult{}, + wantLang: languageHeader, + wantPlat: platformHeader, + wantPage: pageHeader, + }, + { + name: "header_wins_over_short_values", + results: []cache.SearchResult{ + {Language: "en", Platform: "common", Page: "tar"}, + }, + wantLang: languageHeader, + wantPlat: platformHeader, + wantPage: pageHeader, + }, + { + name: "long_page_widens_page_column", + results: []cache.SearchResult{ + {Language: "en", Platform: "common", Page: "very-long-page-name"}, + }, + wantLang: languageHeader, + wantPlat: platformHeader, + wantPage: len("very-long-page-name"), + }, + { + name: "long_language_widens_language_column", + results: []cache.SearchResult{ + {Language: "pt_BR", Platform: "common", Page: "tar"}, + }, + wantLang: languageHeader, + wantPlat: platformHeader, + wantPage: pageHeader, + }, + { + name: "long_platform_widens_platform_column", + results: []cache.SearchResult{ + {Language: "en", Platform: "android", Page: "tar"}, + }, + wantLang: languageHeader, + wantPlat: platformHeader, + wantPage: pageHeader, + }, + { + name: "picks_max_across_all_rows", + results: []cache.SearchResult{ + {Language: "en", Platform: "common", Page: "apt"}, + {Language: "fr_FR", Platform: "android", Page: "very-long-page-name"}, + }, + wantLang: languageHeader, + wantPlat: platformHeader, + wantPage: len("very-long-page-name"), + }, + { + name: "multiple_languages_picks_widest", + results: []cache.SearchResult{ + {Language: "en", Platform: "common", Page: "tar"}, + {Language: "de", Platform: "common", Page: "git"}, + {Language: "pt_BR", Platform: "common", Page: "ls"}, + }, + wantLang: languageHeader, + wantPlat: platformHeader, + wantPage: pageHeader, + }, + { + name: "value_exceeding_header_widens_column", + results: []cache.SearchResult{ + {Language: "en", Platform: "common", Page: "tar"}, + {Language: "en_AUSTRALIA", Platform: "common", Page: "git"}, + }, + wantLang: len("en_AUSTRALIA"), + wantPlat: platformHeader, + wantPage: pageHeader, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotLang, gotPlat, gotPage := columnWidths(tt.results) + assert.Equal(t, tt.wantLang, gotLang) + assert.Equal(t, tt.wantPlat, gotPlat) + assert.Equal(t, tt.wantPage, gotPage) + }) + } +} + +func TestHighlightQuery(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + text string + query string + }{ + { + name: "empty_query", + text: "nginx", + query: "", + }, + { + name: "no_match", + text: "nginx", + query: "xyz", + }, + { + name: "single_match_at_start", + text: "nginx", + query: "ngi", + }, + { + name: "single_match_at_end", + text: "nginx", + query: "inx", + }, + { + name: "single_match_in_middle", + text: "git-commit", + query: "com", + }, + { + name: "multiple_matches", + text: "nginx nginx", + query: "ng", + }, + { + name: "full_match", + text: "git", + query: "git", + }, + { + name: "case_insensitive_match", + text: "Nginx", + query: "ngi", + }, + { + name: "mixed_case_query", + text: "nginx", + query: "NgI", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := highlightQuery(tt.text, tt.query) + assert.Equal(t, tt.text, stripANSI(got), "visible text must be preserved") + }) + } +} + +// stripANSI removes ANSI escape sequences from s. +func stripANSI(s string) string { + var out []byte + inEscape := false + for i := 0; i < len(s); i++ { + switch { + case s[i] == '\x1b': + inEscape = true + case inEscape && s[i] == 'm': + inEscape = false + case !inEscape: + out = append(out, s[i]) + } + } + return string(out) +} diff --git a/internal/render/edit.go b/internal/render/edit.go index 9b63225..1886ca5 100644 --- a/internal/render/edit.go +++ b/internal/render/edit.go @@ -40,6 +40,22 @@ func (r *Renderer) renderPageEditLink(p *Page) error { return r.renderEditLink(r.w, url) } +// BuildViewURL returns the GitHub blob URL for a tldr page. +// The URL is constructed from the page's file path. +func BuildViewURL(path string) string { + if path != "" { + page := pathutil.PageName(path) + platform := pathutil.PagePlatform(path) + return fmt.Sprintf( + "https://github.com/tldr-pages/tldr/blob/main/pages/%s/%s.md", + platform, + page, + ) + } + + return "" +} + // buildEditURL returns the GitHub edit URL for a tldr page. // The URL is constructed from the page's file path. func buildEditURL(path string) string { diff --git a/internal/render/edit_test.go b/internal/render/edit_test.go index 018059b..b513823 100644 --- a/internal/render/edit_test.go +++ b/internal/render/edit_test.go @@ -174,3 +174,46 @@ func TestBuildEditURL(t *testing.T) { }) } } + +func TestBuildViewURL(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + path string + want string + }{ + { + name: "constructs from path", + path: "/pages/common/tar.md", + want: "https://github.com/tldr-pages/tldr/blob/main/pages/common/tar.md", + }, + { + name: "linux platform extracted correctly", + path: "/pages/linux/apt.md", + want: "https://github.com/tldr-pages/tldr/blob/main/pages/linux/apt.md", + }, + { + name: "windows platform extracted correctly", + path: "/pages/windows/dir.md", + want: "https://github.com/tldr-pages/tldr/blob/main/pages/windows/dir.md", + }, + { + name: "empty path returns empty", + path: "", + want: "", + }, + { + name: "path without .md extension adds .md", + path: "/pages/common/some-page", + want: "https://github.com/tldr-pages/tldr/blob/main/pages/common/some-page.md", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := BuildViewURL(tt.path) + assert.Equal(t, tt.want, got) + }) + } +}