diff --git a/Makefile b/Makefile index 40187aa..cea1710 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ PKGS = $(shell go list ./...) PKGS_WITH_TESTS := $(shell go list -f '{{if .TestGoFiles}}{{.ImportPath}}{{end}}' ./...) -VERSION := $(shell git describe --tags --dirty --always 2>/dev/null || echo "dev") +VERSION := $(shell git describe --tags --always 2>/dev/null || echo "dev") # # Build targets @@ -17,6 +17,14 @@ build: -o bin/tldr \ ./main.go +.PHONY: install +install: + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ + go install \ + -trimpath \ + -ldflags="-s -w -X github.com/TheRootDaemon/tlgc/version.Version=$(VERSION)" \ + . + # # Development targets # diff --git a/README.md b/README.md new file mode 100644 index 0000000..31faeb0 --- /dev/null +++ b/README.md @@ -0,0 +1,13 @@ +# tlgc + +A tldr client written in Go. + +## Install + +```sh +go install github.com/TheRootDaemon/tlgc@latest +``` + +## Usage + +See `tlgc --help` for all options. diff --git a/cmd/cli.go b/cmd/cli.go index 9abbbd9..abd3337 100644 --- a/cmd/cli.go +++ b/cmd/cli.go @@ -69,9 +69,13 @@ type CLI struct { // Compact strips empty lines from output. Compact bool + NoCompact bool + // Raw prints pages in raw markdown. Raw bool + NoRaw bool + // Quiet suppresses informational and warning messages. Quiet bool diff --git a/cmd/count_value.go b/cmd/count_value.go index 6a60da0..5aad484 100644 --- a/cmd/count_value.go +++ b/cmd/count_value.go @@ -24,6 +24,12 @@ func (v *countValue) String() string { return fmt.Sprintf("%d", *v.count) } +// IsBoolFlag returns true so flag.Parse treats --verbose as a boolean flag +// that does not consume the next argument. +func (v *countValue) IsBoolFlag() bool { + return true +} + // Set increments the counter each time the flag is encountered. func (v *countValue) Set(string) error { *v.count++ diff --git a/cmd/help.go b/cmd/help.go new file mode 100644 index 0000000..4fb93ca --- /dev/null +++ b/cmd/help.go @@ -0,0 +1,251 @@ +package cmd + +import ( + "fmt" + "strings" + + "github.com/TheRootDaemon/tlgc/termcolor" + "github.com/TheRootDaemon/tlgc/version" +) + +func help() { + printUsage() + printFlags() + printFooter() +} + +func printUsage() { + fmt.Printf( + "tlgc %s (implementing client specification v2.3)\n\n", + version.Version, + ) + fmt.Printf( + "%s tldr [OPTIONS] [PAGE]...\n\n", + termcolor.Sprint("bold underline", "Usage:"), + ) + fmt.Printf( + "%s\n", + termcolor.Sprint("bold underline", "Arguments:"), + ) + fmt.Printf(" [PAGE]... The tldr page to show\n\n") +} + +func printFlags() { + type flagEntry struct { + short string + long string + arg string + description string + } + + flags := []flagEntry{ + { + short: "-u", + long: "--update", + description: "Update the cache", + }, + { + short: "-l", + long: "--list", + description: "List all pages in the current platform", + }, + { + short: "-a", + long: "--list-all", + description: "List all pages", + }, + { + short: "-s", + long: "--search", + arg: "", description: "Search for pages containing a keyword", + }, + { + long: "--list-platforms", + description: "List available platforms", + }, + { + long: "--list-languages", + description: "List installed languages", + }, + { + short: "-i", + long: "--info", + description: "Show cache information", + }, + { + short: "-r", + long: "--render", + arg: "", description: "Render the specified tldr page", + }, + { + long: "--clean-cache", + description: "Interactively delete contents of the cache directory", + }, + + { + long: "--gen-config", + description: "Print the default config", + }, + + { + long: "--config-path", + description: "Print the default config path", + }, + { + short: "-p", + long: "--platform", + arg: "", description: "Specify the platform to use (linux, osx, windows, etc.)", + }, + { + short: "-L", + long: "--language", + arg: "", description: "Specify the languages to use", + }, + { + long: "--short-options", + description: "Display short options wherever possible (e.g. '-s')", + }, + + { + long: "--long-options", + description: "Display long options wherever possible (e.g. '--long')", + }, + + { + long: "--edit", + description: "Display a link to edit the shown page on GitHub", + }, + + { + short: "-o", + long: "--offline", + description: "Do not update the cache, even if it is stale", + }, + { + short: "-c", + long: "--compact", + description: "Strip empty lines from output", + }, + { + long: "--no-compact", + description: "Do not strip empty lines from output (overrides --compact)", + }, + { + short: "-R", + long: "--raw", + description: "Print pages in raw markdown instead of rendering them", + }, + {long: "--no-raw", description: "Render pages instead of printing raw file contents (overrides --raw)"}, + { + short: "-q", + long: "--quiet", + description: "Suppress status messages and warnings", + }, + {long: "--verbose...", description: "Be more verbose (can be specified twice)"}, + { + long: "--color", + arg: "", + description: "Specify when to enable color [default: auto] [possible values: auto, always, never]", + }, + { + long: "--config", + arg: "", + description: "Specify an alternative path to the config file", + }, + { + short: "-v", + long: "--version", + description: "Print version", + }, + { + short: "-h", + long: "--help", + description: "Print help", + }, + } + + maxShort, maxLong := 0, 0 + for _, f := range flags { + updateColumnWidths( + &maxShort, + &maxLong, + f.short, + f.long, + f.arg, + ) + } + + fmt.Printf( + "%s\n", + termcolor.Sprint("bold underline", "Options:"), + ) + + for _, f := range flags { + printFlag( + maxShort, + maxLong, + f.short, + f.long, + f.arg, + f.description, + ) + } +} + +func printFooter() { + fmt.Printf("\nSee https://github.com/TheRootDaemon/tlgc for more information.\n") +} + +func updateColumnWidths( + maxShort, + maxLong *int, + short, long, arg string, +) { + shortWidth := len(short) + if short != "" && long != "" { + shortWidth++ + } + + *maxShort = max(*maxShort, shortWidth) + + if long == "" { + return + } + + longWidth := len(long) + 1 + if arg != "" { + longWidth += len(arg) + } + + *maxLong = max(*maxLong, longWidth) +} + +func printFlag( + maxShort, + maxLong int, + short, long, arg, description string, +) { + shortText := short + if shortText != "" && long != "" { + shortText += "," + } + + longText := long + if arg != "" { + longText += " " + arg + } + + longDisplay := termcolor.Sprint("bold", long) + if arg != "" { + longDisplay += " " + arg + } + + fmt.Printf( + " %s%s %s%s %s\n", + termcolor.Sprint("bold", shortText), + strings.Repeat(" ", maxShort-len(shortText)), + longDisplay, + strings.Repeat(" ", maxLong-len(longText)), + " "+description, + ) +} diff --git a/cmd/parse.go b/cmd/parse.go index 87cff43..3a84053 100644 --- a/cmd/parse.go +++ b/cmd/parse.go @@ -4,6 +4,7 @@ import ( "flag" "fmt" "os" + "strings" "github.com/TheRootDaemon/tlgc/version" ) @@ -19,6 +20,8 @@ func Parse() (*CLI, error) { // and ensures that exactly one operation has been requested. // If the arguments are empty it prints help message. func parse(args []string) (*CLI, error) { + args = reorderFlags(args) + cli := &CLI{} fs := flag.NewFlagSet("tlgc", flag.ContinueOnError) @@ -167,6 +170,12 @@ func parse(args []string) (*CLI, error) { fs.BoolVar(&cli.Compact, "c", false, "strip empty lines from output") fs.BoolVar(&cli.Compact, "compact", false, "strip empty lines from output") + fs.BoolVar( + &cli.NoCompact, + "no-compact", + false, + "do not strip empty lines from output (overrides --compact)", + ) fs.BoolVar( &cli.Raw, @@ -180,6 +189,12 @@ func parse(args []string) (*CLI, error) { false, "print pages in raw markdown instead of rendering them", ) + fs.BoolVar( + &cli.NoRaw, + "no-raw", + false, + "render pages instead of printing raw file contents (overrides --raw)", + ) fs.BoolVar(&cli.Quiet, "q", false, "suppress status messages and warnings") fs.BoolVar(&cli.Quiet, "quiet", false, "suppress status messages and warnings") @@ -227,7 +242,7 @@ func parse(args []string) (*CLI, error) { // show help if cli.ShowHelp { - fmt.Println("TODO") + help() return cli, nil } @@ -237,7 +252,7 @@ func parse(args []string) (*CLI, error) { // validate that exactly one operation is active ops := cli.operationCount() if ops == 0 { - fmt.Println("TODO") + help() return cli, nil } else if ops > 1 { return nil, fmt.Errorf("only one operation can be specified at a time") @@ -289,3 +304,38 @@ func (c *CLI) operationCount() int { return count } + +// reorderFlags moves all flags before positional arguments +// so that Go's flag package can parse flags +// that appear after the page name. +func reorderFlags(args []string) []string { + var flags, positional []string + + for i := 0; i < len(args); i++ { + arg := args[i] + if !strings.HasPrefix(arg, "-") { + positional = append(positional, arg) + continue + } + + flags = append(flags, arg) + name := strings.TrimLeft(arg, "-") + if strings.Contains(name, "=") { + continue + } + switch name { + case "p", "platform", + "L", "language", + "color", + "config", + "s", "search", + "r", "render": + if i+1 < len(args) { + i++ + flags = append(flags, args[i]) + } + } + } + + return append(flags, positional...) +} diff --git a/format/duration.go b/format/duration.go index 8805c4e..44c84df 100644 --- a/format/duration.go +++ b/format/duration.go @@ -2,6 +2,7 @@ package format import ( "fmt" + "math" "time" ) @@ -53,3 +54,13 @@ func DurationFmt(d time.Duration) string { return fmt.Sprintf("%ds", seconds) } } + +func ValidateDurationOverflow(hours uint64) (time.Duration, error) { + const maxHours = math.MaxInt64 / int64(time.Hour) + + if hours > uint64(maxHours) { + return 0, fmt.Errorf("max age %d hours overflows time.Duration", hours) + } + + return time.Duration(hours) * time.Hour, nil +} diff --git a/format/duration_test.go b/format/duration_test.go index ca3c5d2..78703bf 100644 --- a/format/duration_test.go +++ b/format/duration_test.go @@ -1,6 +1,7 @@ package format import ( + "math" "testing" "time" @@ -32,3 +33,34 @@ func TestDurationFmt(t *testing.T) { }) } } + +func TestValidateDurationOverflow(t *testing.T) { + t.Parallel() + + const maxValid = math.MaxInt64 / int64(time.Hour) + + tests := []struct { + name string + hours uint64 + want time.Duration + wantErr string + }{ + {name: "zero_hours", hours: 0, want: 0}, + {name: "one_hour", hours: 1, want: time.Hour}, + {name: "max_valid", hours: uint64(maxValid), want: time.Duration(maxValid) * time.Hour}, + {name: "overflow", hours: uint64(maxValid) + 1, wantErr: "overflows"}, + {name: "max_uint64", hours: math.MaxUint64, wantErr: "overflows"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ValidateDurationOverflow(tt.hours) + if tt.wantErr != "" { + assert.ErrorContains(t, err, tt.wantErr) + return + } + assert.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/internal/app/app.go b/internal/app/app.go index ea4a80c..0937049 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -13,6 +13,9 @@ import ( // App is the main application struct that holds I/O streams and configuration. type App struct { + // Stdin is the reader for standard input. + Stdin io.Reader + // Stdout is the writer for standard output. Stdout io.Writer @@ -26,6 +29,13 @@ type App struct { // Option configures the App. type Option func(*App) +// WithStdin sets the standard input reader for the App. +func WithStdin(r io.Reader) Option { + return func(a *App) { + a.Stdin = r + } +} + // WithStdout sets the standard output writer for the App. func WithStdout(w io.Writer) Option { return func(a *App) { @@ -48,9 +58,10 @@ func WithConfigPath(path string) Option { } // New creates a new App with the given options. -// It defaults Stdout to os.Stdout and Stderr to os.Stderr. +// It defaults Stdin to os.Stdin, Stdout to os.Stdout, and Stderr to os.Stderr. func New(opts ...Option) *App { a := &App{ + Stdin: os.Stdin, Stdout: os.Stdout, Stderr: os.Stderr, } @@ -89,6 +100,8 @@ func (a *App) Run(cli *cmd.CLI) int { return a.listLanguages() case cli.Info: return a.cacheInfo() + case cli.CleanCache: + return a.cleanCache() case cli.Render != "": return a.renderLocalFile(cli) case cli.GenConfig: diff --git a/internal/app/clean.go b/internal/app/clean.go new file mode 100644 index 0000000..4042638 --- /dev/null +++ b/internal/app/clean.go @@ -0,0 +1,17 @@ +package app + +import ( + "github.com/TheRootDaemon/tlgc/internal/cache" + "github.com/TheRootDaemon/tlgc/logger" +) + +// cleanCache interactively removes all cached entries. +// Returns 0 on success, 1 on error. +func (a *App) cleanCache() int { + c := cache.New() + if err := c.Clean(a.Stdin); err != nil { + logger.Error("failed to clean cache: %v", err) + return 1 + } + return 0 +} diff --git a/internal/app/info.go b/internal/app/info.go index d4b4703..7cb8149 100644 --- a/internal/app/info.go +++ b/internal/app/info.go @@ -2,12 +2,17 @@ package app import ( "fmt" + "strings" + "time" + "github.com/TheRootDaemon/tlgc/format" "github.com/TheRootDaemon/tlgc/internal/cache" "github.com/TheRootDaemon/tlgc/logger" + "github.com/TheRootDaemon/tlgc/termcolor" ) -// cacheInfo prints cache metadata (directory, age, page count, etc.). +// cacheInfo prints detailed cache metadata including directory, +// age, auto-update status, mirror, platforms, and per-language breakdown. // Returns 0 on success, 1 on error. func (a *App) cacheInfo() int { c := cache.New() @@ -17,34 +22,144 @@ func (a *App) cacheInfo() int { return 1 } + if err := a.printCacheHeader(info); err != nil { + logger.Error("%v", err) + return 1 + } + + if err := a.printAutoUpdate(info); err != nil { + logger.Error("%v", err) + return 1 + } + + if err := a.printMirror(info); err != nil { + logger.Error("%v", err) + return 1 + } + + if err := a.printLanguages(info); err != nil { + logger.Error("%v", err) + return 1 + } + + return 0 +} + +// printCacheHeader prints the cache directory and last update time. +func (a *App) printCacheHeader(info *cache.InfoResult) error { + cacheDir := termcolor.Sprint("red", info.CacheDir) + lastUpdated := termcolor.Sprint("bold blue", info.Age) + + _, err := fmt.Fprintf( + a.Stdout, + "Cache: %s (last update: %s ago)\n", + cacheDir, + lastUpdated, + ) + + return err +} + +// printAutoUpdate prints the automatic cache update configuration. +func (a *App) printAutoUpdate(info *cache.InfoResult) error { + if !info.AutoUpdate { + _, err := fmt.Fprintln(a.Stdout, "Auto update: disabled") + return err + } + + maxAge, err := format.ValidateDurationOverflow(info.MaxAge) + if err != nil { + return err + } + + frequency := termcolor.Sprint( + "bold blue", + format.DurationFmt(maxAge), + ) + remaining := termcolor.Sprint( + "bold blue", + format.DurationFmt( + max( + 0, + maxAge*time.Hour-info.AgeDuration, + ), + ), + ) + + _, err = fmt.Fprintf( + a.Stdout, + "Auto update: every %s (next in %s)\n", + frequency, + remaining, + ) + return err +} + +// printMirror prints the configured tldr-pages mirror URL. +func (a *App) printMirror(info *cache.InfoResult) error { + mirror := termcolor.Sprint("green", info.Mirror) + _, err := fmt.Fprintf( + a.Stdout, + "Mirror: %s\n", + mirror, + ) + return err +} + +// printLanguages prints per-language page counts and the total number +// of cached pages. +func (a *App) printLanguages(info *cache.InfoResult) error { + if _, err := fmt.Fprintln(a.Stdout, "Installed languages:"); err != nil { + return err + } + + maxWidth := len("total") + for _, ls := range info.LanguageStats { + if len(ls.Language) > maxWidth { + maxWidth = len(ls.Language) + } + } + + totalPages := termcolor.Fprintf("bold blue", "%d", info.TotalPages) if _, err := fmt.Fprintf( a.Stdout, - `Cache: %s -Cache age: %s -Total pages: %d -Auto update: %v -Max age (hours): %d -`, - info.CacheDir, - info.Age, - info.TotalPages, - info.AutoUpdate, - info.MaxAge, + " %-*s : %s pages\n", + maxWidth, + "total", + totalPages, ); err != nil { - logger.Error("%w", err) - return 1 + return err } for _, ls := range info.LanguageStats { + pages := termcolor.Fprintf("bold blue", "%d", ls.Pages) if _, err := fmt.Fprintf( a.Stdout, - "%s: %d pages\n", + " %-*s : %s pages%s\n", + maxWidth, ls.Language, - ls.Pages, + pages, + formatPlatformBreakdown(ls.Platforms), ); err != nil { - logger.Error("%w", err) - return 1 + return err } } - return 0 + + return nil +} + +// formatPlatformBreakdown formats per-platform page counts +// as a parenthesized suffix, e.g. " (common: 3000, linux: 2500)". +func formatPlatformBreakdown(platforms []cache.PlatformInfo) string { + if len(platforms) == 0 { + return "" + } + + parts := make([]string, len(platforms)) + for i, p := range platforms { + pages := termcolor.Fprintf("bold blue", "%d", p.Pages) + parts[i] = fmt.Sprintf("%s: %s", p.Name, pages) + } + + return " (" + strings.Join(parts, ", ") + ")" } diff --git a/internal/app/page.go b/internal/app/page.go index 73c262b..5fffd1e 100644 --- a/internal/app/page.go +++ b/internal/app/page.go @@ -1,6 +1,7 @@ package app import ( + "context" "fmt" "os" "path/filepath" @@ -10,6 +11,7 @@ import ( "github.com/TheRootDaemon/tlgc/internal/cache" "github.com/TheRootDaemon/tlgc/internal/config" "github.com/TheRootDaemon/tlgc/internal/render" + "github.com/TheRootDaemon/tlgc/internal/upstream" "github.com/TheRootDaemon/tlgc/logger" "github.com/TheRootDaemon/tlgc/pathutil" ) @@ -21,6 +23,16 @@ func (a *App) lookupAndRenderPage(cli *cmd.CLI) int { langs := a.resolveLanguages(cli.Languages) c := cache.New() + 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) + } + } + } + query := strings.Join(cli.Page, "-") results, err := c.Find(query, p, langs) if err != nil { @@ -168,19 +180,31 @@ func (a *App) renderOptions(cli *cmd.CLI) []render.RenderOption { } output := config.Output() - switch { - case cli.Compact: + if cli.NoCompact { + output.Compact = false + } else if cli.Compact { output.Compact = true - case cli.Raw: + } + + if cli.NoRaw { + output.RawMarkdown = false + } else if cli.Raw { output.RawMarkdown = true - case cli.Edit: - output.EditLink = true + } + + switch { + case cli.ShortOptions && cli.LongOptions: + output.OptionStyle = config.OptionStyleCombined case cli.ShortOptions: output.OptionStyle = config.OptionStyleShort case cli.LongOptions: output.OptionStyle = config.OptionStyleLong } + if cli.Edit { + output.EditLink = true + } + opts = append(opts, render.WithOutput(output)) return opts } diff --git a/internal/app/search.go b/internal/app/search.go index 62d0fe4..e5602ee 100644 --- a/internal/app/search.go +++ b/internal/app/search.go @@ -2,10 +2,12 @@ package app import ( "fmt" + "strings" "github.com/TheRootDaemon/tlgc/cmd" "github.com/TheRootDaemon/tlgc/internal/cache" "github.com/TheRootDaemon/tlgc/logger" + "github.com/TheRootDaemon/tlgc/termcolor" ) // searchPages searches cached pages for the given query. @@ -21,16 +23,132 @@ func (a *App) searchPages(cli *cmd.CLI) int { return 1 } + if err := a.printSearchResults(results, cli.Search); err != nil { + logger.Error("%w", err) + return 1 + } + + return 0 +} + +// printSearchResults renders the search results as a table, +// including the header and all matching rows. +func (a *App) printSearchResults( + results []cache.SearchResult, + query string, +) error { + langW, platW, pageW := columnWidths(results) + + if err := a.printSearchHeader(langW, platW, pageW); err != nil { + return err + } + + return a.printSearchRows( + results, + query, + langW, + platW, + pageW, + ) +} + +// printSearchHeader writes the table header +// using the provided column widths. +func (a *App) printSearchHeader(langW, platW, pageW int) error { + header := termcolor.Fprintf( + "bold", + "%-*s %-*s %-*s", + langW, + "Language", + platW, + "Platform", + pageW, + "Page", + ) + + _, err := fmt.Fprintln(a.Stdout, header) + return err +} + +// printSearchRows writes each search result as a table row, +// highlighting occurrences of query in the page name. +func (a *App) printSearchRows( + results []cache.SearchResult, + query string, + langW, platW, pageW int, +) error { for _, r := range results { - if _, err := fmt.Fprintf( + page := highlightQuery(r.Page, query) + padding := max(pageW-len(r.Page), 0) + + _, err := fmt.Fprintf( a.Stdout, - "%s/%s\n", + "%-*s %-*s %s%s\n", + langW, + r.Language, + platW, r.Platform, - r.Page, - ); err != nil { - logger.Error("%w", err) - return 1 + page, + strings.Repeat(" ", padding), + ) + if err != nil { + return err } } - return 0 + + return nil +} + +// columnWidths returns the widths required to display the +// Language, Platform, and Page columns without truncation. +func columnWidths(results []cache.SearchResult) (int, int, int) { + langW := len("Language") + platW := len("Platform") + pageW := len("Page") + + for _, r := range results { + langW = max(langW, len(r.Language)) + platW = max(platW, len(r.Platform)) + pageW = max(pageW, len(r.Page)) + } + + return langW, platW, pageW +} + +// highlightQuery returns text +// with every case-insensitive occurrence of query +// wrapped in ANSI color codes. +func highlightQuery(text, query string) string { + if query == "" { + return text + } + + lower := strings.ToLower(text) + queryLower := strings.ToLower(query) + + var b strings.Builder + b.Grow(len(text) + len(query)*10) + + remaining := text + rest := lower + for { + idx := strings.Index(rest, queryLower) + if idx < 0 { + b.WriteString(remaining) + break + } + + b.WriteString(remaining[:idx]) + b.WriteString( + termcolor.Sprint( + "blue", + remaining[idx:idx+len(query)], + ), + ) + + remaining = remaining[idx+len(query):] + rest = rest[idx+len(query):] + } + + return b.String() } diff --git a/internal/cache/archive.go b/internal/cache/archive.go index a2ae9d7..9bbfd69 100644 --- a/internal/cache/archive.go +++ b/internal/cache/archive.go @@ -5,9 +5,9 @@ import ( "bytes" "context" "fmt" + "io/fs" "os" "path/filepath" - "strings" "github.com/TheRootDaemon/tlgc/internal/upstream" "github.com/TheRootDaemon/tlgc/logger" @@ -31,66 +31,129 @@ func downloadArchive( func (c *Cache) extractArchive( languageDirectory string, data []byte, -) error { - logger.InfoStart("extracting '%s'... ", languageDirectory) +) (int, int, error) { + logger.Debug("extracting '%s'... ", languageDirectory) targetDirectory := filepath.Join(c.dir, languageDirectory) - if err := os.RemoveAll(targetDirectory); err != nil { - return err - } - if err := os.MkdirAll(targetDirectory, 0o750); err != nil { - return err + preExisting, err := existingFiles(targetDirectory) + if err != nil { + return 0, 0, err } - zipReader, err := zip.NewReader( - bytes.NewReader(data), - int64(len(data)), - ) - if err != nil { - return fmt.Errorf("reading zip archive: %w", err) + if err := recreateDirectory(targetDirectory); err != nil { + return 0, 0, err } root, err := os.OpenRoot(targetDirectory) if err != nil { - return err + return 0, 0, err } defer func() { _ = root.Close() }() - var extracted int - for _, f := range zipReader.File { - if strings.Contains(f.Name, "..") { - logger.Warn( - "skipping zip entry with '..': %s", - f.Name, - ) - continue + return extractZip(root, data, preExisting) +} + +// existingFiles walks dir and returns a set of relative file paths. +// If dir does not exist, it returns an empty set and no error. +func existingFiles(dir string) (map[string]struct{}, error) { + files := make(map[string]struct{}) + err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, walkErr error) error { + if walkErr != nil || d.IsDir() { + return walkErr + } + rel, relErr := filepath.Rel(dir, path) + if relErr != nil { + return nil } + files[rel] = struct{}{} + return nil + }) + + if err != nil && !os.IsNotExist(err) { + return nil, err + } + + return files, nil +} + +// recreateDirectory removes dir if it exists +// and recreates it with 0o750 permissions. +func recreateDirectory(dir string) error { + if err := os.RemoveAll(dir); err != nil { + return err + } + return os.MkdirAll(dir, 0o750) +} - name := filepath.Clean(f.Name) +// extractZip extracts the zip data into root +// and returns the total number of files extracted +// and how many are new (not present in preExisting). +func extractZip( + root *os.Root, + data []byte, + preExisting map[string]struct{}, +) (int, int, error) { + zr, err := zip.NewReader( + bytes.NewReader(data), + int64(len(data)), + ) + if err != nil { + return 0, 0, fmt.Errorf("reading zip archive: %w", err) + } - if f.FileInfo().IsDir() { - if err := root.MkdirAll(name, 0o750); err != nil { - return fmt.Errorf("creating directory %s: %w", f.Name, err) - } - continue + var extracted, newCount int + for _, f := range zr.File { + extractedFile, newFile, err := extractZipEntry(root, f, preExisting) + if err != nil { + return 0, 0, err } - if err := root.MkdirAll(filepath.Dir(name), 0o750); err != nil { - return fmt.Errorf("creating directory for %s: %w", f.Name, err) + if extractedFile { + extracted++ } - if err := extractFile(root, f); err != nil { - return err + if newFile { + newCount++ } + } + return extracted, newCount, nil +} - extracted++ +// extractZipEntry extracts a single zip entry into root. +// It returns whether the entry was a file (not a directory) +// and whether it is new (not in preExisting). +func extractZipEntry( + root *os.Root, + f *zip.File, + preExisting map[string]struct{}, +) (bool, bool, error) { + if !filepath.IsLocal(f.Name) { + logger.Debug( + "skipping unsafe zip entry: %s", + f.Name, + ) + return false, false, nil } - logger.InfoEnd("%d pages", extracted) - return nil + name := filepath.Clean(f.Name) + + if f.FileInfo().IsDir() { + return false, false, root.MkdirAll(name, 0o750) + } + + if err := root.MkdirAll(filepath.Dir(name), 0o750); err != nil { + return false, false, fmt.Errorf("creating directory for %s: %w", f.Name, err) + } + + if err := extractFile(root, f); err != nil { + return false, false, err + } + + _, ok := preExisting[name] + return true, !ok, nil } // extractFile writes a single zip entry to disk diff --git a/internal/cache/archive_test.go b/internal/cache/archive_test.go index d2c1ee6..5f5c7e9 100644 --- a/internal/cache/archive_test.go +++ b/internal/cache/archive_test.go @@ -243,7 +243,7 @@ func TestExtractArchive(t *testing.T) { } zipData := tt.buildZip(t) - err := c.extractArchive(tt.languageDirectory, zipData) + _, _, err := c.extractArchive(tt.languageDirectory, zipData) if tt.wantErr { assert.Error(t, err) @@ -304,7 +304,7 @@ func TestExtractArchive_MkdirAllError(t *testing.T) { require.NoError(t, os.WriteFile(filePath, nil, 0o644)) c := &Cache{dir: filePath} - err := c.extractArchive("pages.en", createEmptyZip(t)) + _, _, err := c.extractArchive("pages.en", createEmptyZip(t)) assert.Error(t, err) } @@ -332,3 +332,274 @@ func TestExtractFile_OpenFileError(t *testing.T) { err = extractFile(root, f) assert.Error(t, err) } + +func TestExistingFiles(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + setup func(t *testing.T, dir string) + want map[string]struct{} + }{ + { + name: "nonexistent_directory", + setup: nil, + want: map[string]struct{}{}, + }, + { + name: "empty_directory", + setup: func(t *testing.T, dir string) { + require.NoError(t, os.MkdirAll(dir, 0o750)) + }, + want: map[string]struct{}{}, + }, + { + name: "flat_files", + setup: func(t *testing.T, dir string) { + require.NoError(t, os.MkdirAll(dir, 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "a.md"), nil, 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "b.md"), nil, 0o644)) + }, + want: map[string]struct{}{ + "a.md": {}, + "b.md": {}, + }, + }, + { + name: "nested_files", + setup: func(t *testing.T, dir string) { + require.NoError(t, os.MkdirAll(filepath.Join(dir, "common"), 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "common", "git.md"), nil, 0o644)) + }, + want: map[string]struct{}{ + filepath.Join("common", "git.md"): {}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := filepath.Join(t.TempDir(), "subdir") + if tt.setup != nil { + tt.setup(t, dir) + } + + got, err := existingFiles(dir) + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestExtractZip(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + files map[string]string + rawData []byte // when set, used instead of building a zip from files + preExisting []string + wantTotal int + wantNew int + wantErr bool + }{ + { + name: "all_new", + files: map[string]string{ + "common/git.md": "", + "common/ls.md": "", + }, + wantTotal: 2, + wantNew: 2, + }, + { + name: "all_existing", + files: map[string]string{ + "common/git.md": "", + }, + preExisting: []string{filepath.Clean("common/git.md")}, + wantTotal: 1, + wantNew: 0, + }, + { + name: "mixed_new_and_existing", + files: map[string]string{ + "common/git.md": "", + "common/ls.md": "", + }, + preExisting: []string{filepath.Clean("common/git.md")}, + wantTotal: 2, + wantNew: 1, + }, + { + name: "directory_entries_not_counted", + files: map[string]string{ + "common/": "", + "common/git.md": "", + }, + wantTotal: 1, + wantNew: 1, + }, + { + name: "skips_unsafe_entries", + files: map[string]string{ + "../escape.md": "EVIL", + "common/git.md": "", + }, + wantTotal: 1, + wantNew: 1, + }, + { + name: "invalid_zip", + rawData: []byte("not a zip file"), + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + root, err := os.OpenRoot(dir) + require.NoError(t, err) + defer func() { _ = root.Close() }() + + preExisting := make(map[string]struct{}, len(tt.preExisting)) + for _, p := range tt.preExisting { + preExisting[p] = struct{}{} + } + + var zipData []byte + if tt.rawData != nil { + zipData = tt.rawData + } else if tt.files != nil { + zipData = createTestZip(t, tt.files) + } else { + zipData = createEmptyZip(t) + } + + total, newCount, err := extractZip(root, zipData, preExisting) + + if tt.wantErr { + assert.Error(t, err) + return + } + + require.NoError(t, err) + assert.Equal(t, tt.wantTotal, total) + assert.Equal(t, tt.wantNew, newCount) + }) + } +} + +func TestRecreateDirectory(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + setup func(t *testing.T, dir string) + check func(t *testing.T, dir string) + }{ + { + name: "creates_new_directory", + check: func(t *testing.T, dir string) { + assert.DirExists(t, dir) + }, + }, + { + name: "replaces_existing_directory", + setup: func(t *testing.T, dir string) { + require.NoError(t, os.MkdirAll(filepath.Join(dir, "sub"), 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "old.md"), nil, 0o644)) + }, + check: func(t *testing.T, dir string) { + assert.DirExists(t, dir) + entries, err := os.ReadDir(dir) + require.NoError(t, err) + assert.Empty(t, entries) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := filepath.Join(t.TempDir(), "dir") + if tt.setup != nil { + tt.setup(t, dir) + } + require.NoError(t, recreateDirectory(dir)) + tt.check(t, dir) + }) + } +} + +func TestExtractZipEntry(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + files map[string]string + preExisting map[string]struct{} + wantFile bool + wantNew bool + check func(t *testing.T, dir string) + }{ + { + name: "extracts_file", + files: map[string]string{"common/git.md": "# git\n"}, + wantFile: true, + wantNew: true, + check: func(t *testing.T, dir string) { + got, err := os.ReadFile(filepath.Join(dir, "common", "git.md")) + require.NoError(t, err) + assert.Equal(t, "# git\n", string(got)) + }, + }, + { + name: "existing_file", + files: map[string]string{"common/git.md": "# git\n"}, + preExisting: map[string]struct{}{ + "common/git.md": {}, + }, + wantFile: true, + wantNew: false, + }, + { + name: "directory_entry", + files: map[string]string{"common/": ""}, + wantFile: false, + wantNew: false, + check: func(t *testing.T, dir string) { + assert.DirExists(t, filepath.Join(dir, "common")) + }, + }, + { + name: "unsafe_entry", + files: map[string]string{"../escape.md": "EVIL"}, + wantFile: false, + wantNew: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + root, err := os.OpenRoot(dir) + require.NoError(t, err) + defer func() { _ = root.Close() }() + + zipData := createTestZip(t, tt.files) + zr, err := zip.NewReader(bytes.NewReader(zipData), int64(len(zipData))) + require.NoError(t, err) + require.Len(t, zr.File, 1) + + isFile, isNew, err := extractZipEntry(root, zr.File[0], tt.preExisting) + require.NoError(t, err) + assert.Equal(t, tt.wantFile, isFile) + assert.Equal(t, tt.wantNew, isNew) + + if tt.check != nil { + tt.check(t, dir) + } + }) + } +} diff --git a/internal/cache/cache.go b/internal/cache/cache.go index 5bd07d7..eaa9bff 100644 --- a/internal/cache/cache.go +++ b/internal/cache/cache.go @@ -8,6 +8,7 @@ import ( "strings" "sync/atomic" + "github.com/TheRootDaemon/tlgc/format" "github.com/TheRootDaemon/tlgc/internal/config" "github.com/TheRootDaemon/tlgc/logger" "github.com/TheRootDaemon/tlgc/slice" @@ -109,3 +110,25 @@ func (c *Cache) languagesToDirectories(languages []string, sortFlag bool) []stri dirs = slice.Dedup(dirs) return dirs } + +// NeedsUpdate reports whether the cache needs to be refreshed. +// It returns true when no language directories exist (empty cache) +// or when the cache age exceeds maxAge hours. +func (c *Cache) NeedsUpdate(maxAge uint64) bool { + dirs, err := c.getLanguageDirectories() + if err != nil || len(dirs) == 0 { + return true + } + + age, err := c.Age() + if err != nil { + return true + } + + maxAgeDuration, err := format.ValidateDurationOverflow(maxAge) + if err != nil { + return true + } + + return age > maxAgeDuration +} diff --git a/internal/cache/cache_test.go b/internal/cache/cache_test.go index 4948eb4..66ad056 100644 --- a/internal/cache/cache_test.go +++ b/internal/cache/cache_test.go @@ -1,9 +1,11 @@ package cache import ( + "math" "os" "path/filepath" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -537,3 +539,123 @@ func TestLanguagesToDirectories(t *testing.T) { }) } } + +// TestCacheNeedsUpdate tests Cache.NeedsUpdate. +func TestCacheNeedsUpdate(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + setupDir func(t *testing.T) string + maxAge uint64 + want bool + }{ + { + name: "empty_cache", + setupDir: func(t *testing.T) string { + return t.TempDir() + }, + maxAge: 336, + want: true, + }, + { + name: "cache_dir_does_not_exist", + setupDir: func(t *testing.T) string { + return filepath.Join(t.TempDir(), "nonexistent") + }, + maxAge: 336, + want: true, + }, + { + name: "fresh_cache", + setupDir: func(t *testing.T) string { + d := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en"), 0o750)) + return d + }, + maxAge: 336, + want: false, + }, + { + name: "stale_cache", + setupDir: func(t *testing.T) string { + d := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en"), 0o750)) + return d + }, + maxAge: 0, + want: true, + }, + { + name: "cache_below_max_age", + setupDir: func(t *testing.T) string { + d := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en"), 0o750)) + sumfile := filepath.Join(d, checksumFile) + require.NoError(t, os.WriteFile(sumfile, nil, 0o644)) + require.NoError(t, os.Chtimes( + sumfile, + time.Now().Add(-50*time.Minute), + time.Now().Add(-50*time.Minute), + )) + return d + }, + maxAge: 1, + want: false, + }, + { + name: "cache_older_than_max_age", + setupDir: func(t *testing.T) string { + d := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en"), 0o750)) + sumfile := filepath.Join(d, checksumFile) + require.NoError(t, os.WriteFile(sumfile, nil, 0o644)) + require.NoError(t, os.Chtimes( + sumfile, + time.Now().Add(-2*time.Hour), + time.Now().Add(-2*time.Hour), + )) + return d + }, + maxAge: 1, + want: true, + }, + { + name: "overflow_max_age", + setupDir: func(t *testing.T) string { + d := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en"), 0o750)) + return d + }, + maxAge: math.MaxUint64, + want: true, + }, + { + name: "future_mtime_on_checksum_file", + setupDir: func(t *testing.T) string { + d := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en"), 0o750)) + sumfile := filepath.Join(d, checksumFile) + require.NoError(t, os.WriteFile(sumfile, nil, 0o644)) + require.NoError(t, os.Chtimes( + sumfile, + time.Now().Add(1*time.Hour), + time.Now().Add(1*time.Hour), + )) + return d + }, + maxAge: 336, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := tt.setupDir(t) + c := &Cache{dir: dir} + + got := c.NeedsUpdate(tt.maxAge) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/internal/cache/clean.go b/internal/cache/clean.go index 62bd6f0..1edc68c 100644 --- a/internal/cache/clean.go +++ b/internal/cache/clean.go @@ -9,6 +9,7 @@ import ( "strings" "github.com/TheRootDaemon/tlgc/logger" + "github.com/TheRootDaemon/tlgc/termcolor" ) // Clean removes all cached entries after prompting for confirmation @@ -26,10 +27,17 @@ func (c *Cache) Clean(r io.Reader) error { } var log strings.Builder - log.WriteString("removing following files...\n") + log.WriteString("removing following files...") for _, entry := range entries { - name := entry.Name() - fmt.Fprintf(&log, "\n%q", name) + name := termcolor.Sprint( + "bold red", + entry.Name(), + ) + fmt.Fprintf( + &log, + "\n%s", + name, + ) } logger.InfoStart( @@ -39,11 +47,11 @@ func (c *Cache) Clean(r io.Reader) error { cleanCache := parseInput(bufio.NewReader(r)) if !cleanCache { - logger.InfoEnd("aborted") + logger.InfoEnd("aborted...") return nil } - logger.Info("cleaning...") + logger.InfoStart("cleaning... ") for _, entry := range entries { if err := os.RemoveAll( filepath.Join( @@ -59,7 +67,7 @@ func (c *Cache) Clean(r io.Reader) error { } } - logger.InfoEnd("done...") + logger.InfoEnd("done") return nil } diff --git a/internal/cache/info.go b/internal/cache/info.go index a1af424..1adeda0 100644 --- a/internal/cache/info.go +++ b/internal/cache/info.go @@ -19,27 +19,48 @@ type LanguageInfo struct { // Language is the language name (e.g. "en", "pt", "es"). Language string + + // Platforms contains per-platform page counts for this language. + Platforms []PlatformInfo } -// InfoResult contains information about the current cache state. -type InfoResult struct { - // AutoUpdate indicates whether automatic cache updates are enabled. - AutoUpdate bool +// PlatformInfo contains page statistics for a single platform. +type PlatformInfo struct { + // Name is the platform name (e.g. "common", "linux", "osx"). + Name string - // TotalPages is the total number of cached pages across all languages. - TotalPages int - - // MaxAge is the maximum cache age in seconds before a refresh is due. - MaxAge uint64 + // Pages is the number of cached pages for this platform. + Pages int +} +// InfoResult contains information about the current cache state. +type InfoResult struct { // CacheDir is the absolute path to the cache directory. CacheDir string // Age is a human-readable string representing the cache age. Age string + // AgeDuration is the raw cache age duration. + AgeDuration time.Duration + + // MaxAge is the maximum cache age in hours before a refresh is due. + MaxAge uint64 + + // AutoUpdate indicates whether automatic cache updates are enabled. + AutoUpdate bool + + // Mirror is the URL used to download tldr-pages archives. + Mirror string + + // Platforms lists the available platforms discovered in the cache. + Platforms []string + // LanguageStats contains per-language page statistics. LanguageStats []LanguageInfo + + // TotalPages is the total number of cached pages across all languages. + TotalPages int } // Age returns the cache age based on the checksum file's mtime. @@ -113,8 +134,11 @@ func (c *Cache) Info() (*InfoResult, error) { return &InfoResult{ CacheDir: c.dir, Age: format.DurationFmt(age), + AgeDuration: age, MaxAge: cfg.MaxAge, AutoUpdate: cfg.AutoUpdate, + Mirror: cfg.Mirror, + Platforms: platforms, LanguageStats: languageStats, TotalPages: total, }, nil @@ -127,43 +151,78 @@ func (c *Cache) languageStats( platforms, languageDirectories []string, ) ([]LanguageInfo, int, error) { - var languageStats []LanguageInfo total := 0 + languageStats := make( + []LanguageInfo, + 0, + len(languageDirectories), + ) for _, languageDirectory := range languageDirectories { lang := strings.TrimPrefix( languageDirectory, "pages.", ) - count := 0 - - for _, platform := range platforms { - if !c.subDirExists( - filepath.Join( - languageDirectory, - platform, - ), - ) { - continue - } - - pages, err := c.listDirectory( - platform, - languageDirectory, - ) - if err != nil { - return nil, 0, err - } - count += len(pages) + platformInfos, count, err := c.platformStats( + languageDirectory, + platforms, + ) + if err != nil { + return nil, 0, err } - languageStats = append(languageStats, LanguageInfo{ - Language: lang, - Pages: count, - }) + languageStats = append( + languageStats, + LanguageInfo{ + Language: lang, + Pages: count, + Platforms: platformInfos, + }, + ) + total += count } return languageStats, total, nil } + +func (c *Cache) platformStats( + languageDirectory string, + platforms []string, +) ([]PlatformInfo, int, error) { + total := 0 + var infos []PlatformInfo + + for _, platform := range platforms { + if !c.subDirExists( + filepath.Join( + languageDirectory, + platform, + ), + ) { + continue + } + + pages, err := c.listDirectory( + platform, + languageDirectory, + ) + if err != nil { + return nil, 0, err + } + + count := len(pages) + infos = append( + infos, + PlatformInfo{ + Name: platform, + Pages: count, + }, + ) + + total += count + } + + return infos, total, nil +} diff --git a/internal/cache/info_test.go b/internal/cache/info_test.go index 860a125..fb80361 100644 --- a/internal/cache/info_test.go +++ b/internal/cache/info_test.go @@ -87,6 +87,16 @@ func TestInfo(t *testing.T) { assert.NotEmpty(t, info.Age) assert.True(t, info.AutoUpdate) assert.Equal(t, uint64(336), info.MaxAge) + assert.NotEmpty(t, info.Mirror) + assert.NotEmpty(t, info.Platforms) + assert.Contains(t, info.Platforms, "common") + assert.Contains(t, info.Platforms, "linux") + assert.Greater(t, info.AgeDuration, time.Duration(0)) + assert.Len(t, info.LanguageStats[0].Platforms, 2) + assert.Equal(t, "common", info.LanguageStats[0].Platforms[0].Name) + assert.Equal(t, 1, info.LanguageStats[0].Platforms[0].Pages) + assert.Equal(t, "linux", info.LanguageStats[0].Platforms[1].Name) + assert.Equal(t, 2, info.LanguageStats[0].Platforms[1].Pages) }) t.Run("error_on_non_existent_dir", func(t *testing.T) { @@ -126,88 +136,206 @@ func TestInfo(t *testing.T) { require.NoError(t, err) assert.Equal(t, 2, info.TotalPages) assert.Len(t, info.LanguageStats, 2) + assert.Len(t, info.LanguageStats[0].Platforms, 1) + assert.Equal(t, "common", info.LanguageStats[0].Platforms[0].Name) + assert.Equal(t, 1, info.LanguageStats[0].Platforms[0].Pages) }) } func TestLanguageStats(t *testing.T) { t.Parallel() - t.Run("counts_pages_across_platforms", func(t *testing.T) { - dir := t.TempDir() - require.NoError(t, os.MkdirAll(filepath.Join(dir, "pages.en", "common"), 0o755)) - require.NoError(t, os.MkdirAll(filepath.Join(dir, "pages.en", "linux"), 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(dir, "pages.en", "common", "git.md"), nil, 0o644)) - require.NoError(t, os.WriteFile(filepath.Join(dir, "pages.en", "common", "ls.md"), nil, 0o644)) - require.NoError(t, os.WriteFile(filepath.Join(dir, "pages.en", "linux", "apt.md"), nil, 0o644)) + tests := []struct { + name string + platforms []string + langDirs []string + setup func(t *testing.T, dir string) + wantStats []LanguageInfo + wantTotal int + }{ + { + name: "single_language", + platforms: []string{"common", "linux"}, + langDirs: []string{"pages.en"}, + setup: func(t *testing.T, dir string) { + require.NoError(t, os.MkdirAll(filepath.Join(dir, "pages.en", "common"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(dir, "pages.en", "linux"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "pages.en", "common", "git.md"), nil, 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "pages.en", "common", "ls.md"), nil, 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "pages.en", "linux", "apt.md"), nil, 0o644)) + }, + wantStats: []LanguageInfo{ + { + Language: "en", + Pages: 3, + Platforms: []PlatformInfo{ + {Name: "common", Pages: 2}, + {Name: "linux", Pages: 1}, + }, + }, + }, + wantTotal: 3, + }, + { + name: "multiple_languages", + platforms: []string{"common"}, + langDirs: []string{"pages.en", "pages.zh"}, + setup: func(t *testing.T, dir string) { + require.NoError(t, os.MkdirAll(filepath.Join(dir, "pages.en", "common"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(dir, "pages.zh", "common"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "pages.en", "common", "git.md"), nil, 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "pages.zh", "common", "git.md"), nil, 0o644)) + }, + wantStats: []LanguageInfo{ + { + Language: "en", + Pages: 1, + Platforms: []PlatformInfo{{Name: "common", Pages: 1}}, + }, + { + Language: "zh", + Pages: 1, + Platforms: []PlatformInfo{{Name: "common", Pages: 1}}, + }, + }, + wantTotal: 2, + }, + { + name: "strips_pages_prefix", + platforms: []string{"common"}, + langDirs: []string{"pages.en", "pages.de"}, + setup: func(t *testing.T, dir string) { + require.NoError(t, os.MkdirAll(filepath.Join(dir, "pages.en", "common"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(dir, "pages.de", "common"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "pages.en", "common", "git.md"), nil, 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "pages.de", "common", "git.md"), nil, 0o644)) + }, + wantStats: []LanguageInfo{ + { + Language: "en", + Pages: 1, + Platforms: []PlatformInfo{{Name: "common", Pages: 1}}, + }, + { + Language: "de", + Pages: 1, + Platforms: []PlatformInfo{{Name: "common", Pages: 1}}, + }, + }, + wantTotal: 2, + }, + { + name: "empty_directories_list", + platforms: []string{"common"}, + langDirs: nil, + setup: func(t *testing.T, dir string) {}, + wantStats: []LanguageInfo{}, + wantTotal: 0, + }, + } - c := &Cache{dir: dir} - stats, total, err := c.languageStats( - []string{"common", "linux"}, - []string{"pages.en"}, - ) - require.NoError(t, err) - assert.Equal(t, 3, total) - assert.Len(t, stats, 1) - assert.Equal(t, "en", stats[0].Language) - assert.Equal(t, 3, stats[0].Pages) - }) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + tt.setup(t, dir) - t.Run("multiple_languages", func(t *testing.T) { - dir := t.TempDir() - require.NoError(t, os.MkdirAll(filepath.Join(dir, "pages.en", "common"), 0o755)) - require.NoError(t, os.MkdirAll(filepath.Join(dir, "pages.zh", "common"), 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(dir, "pages.en", "common", "git.md"), nil, 0o644)) - require.NoError(t, os.WriteFile(filepath.Join(dir, "pages.zh", "common", "git.md"), nil, 0o644)) - - c := &Cache{dir: dir} - stats, total, err := c.languageStats( - []string{"common"}, - []string{"pages.en", "pages.zh"}, - ) - require.NoError(t, err) - assert.Equal(t, 2, total) - assert.Len(t, stats, 2) - }) - - t.Run("skips_non_existent_platform_dirs", func(t *testing.T) { - dir := t.TempDir() - require.NoError(t, os.MkdirAll(filepath.Join(dir, "pages.en", "common"), 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(dir, "pages.en", "common", "git.md"), nil, 0o644)) + c := &Cache{dir: dir} + stats, total, err := c.languageStats(tt.platforms, tt.langDirs) + require.NoError(t, err) + assert.Equal(t, tt.wantTotal, total) + assert.Equal(t, tt.wantStats, stats) + }) + } +} - c := &Cache{dir: dir} - stats, total, err := c.languageStats( - []string{"common", "linux"}, - []string{"pages.en"}, - ) - require.NoError(t, err) - assert.Equal(t, 1, total) - assert.Len(t, stats, 1) - }) +func TestPlatformStats(t *testing.T) { + t.Parallel() - t.Run("empty_directories_list", func(t *testing.T) { - dir := t.TempDir() - c := &Cache{dir: dir} - stats, total, err := c.languageStats( - []string{"common"}, - nil, - ) - require.NoError(t, err) - assert.Equal(t, 0, total) - assert.Empty(t, stats) - }) + tests := []struct { + name string + platforms []string + setup func(t *testing.T, dir string) + want []PlatformInfo + wantTotal int + }{ + { + name: "single_platform", + platforms: []string{"common"}, + setup: func(t *testing.T, dir string) { + require.NoError(t, os.MkdirAll(filepath.Join(dir, "pages.en", "common"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "pages.en", "common", "git.md"), nil, 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "pages.en", "common", "ls.md"), nil, 0o644)) + }, + want: []PlatformInfo{{Name: "common", Pages: 2}}, + wantTotal: 2, + }, + { + name: "multiple_platforms", + platforms: []string{"common", "linux"}, + setup: func(t *testing.T, dir string) { + require.NoError(t, os.MkdirAll(filepath.Join(dir, "pages.en", "common"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(dir, "pages.en", "linux"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "pages.en", "common", "git.md"), nil, 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "pages.en", "linux", "apt.md"), nil, 0o644)) + }, + want: []PlatformInfo{ + {Name: "common", Pages: 1}, + {Name: "linux", Pages: 1}, + }, + wantTotal: 2, + }, + { + name: "skips_missing_platform", + platforms: []string{"common", "linux"}, + setup: func(t *testing.T, dir string) { + require.NoError(t, os.MkdirAll(filepath.Join(dir, "pages.en", "common"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "pages.en", "common", "git.md"), nil, 0o644)) + }, + want: []PlatformInfo{{Name: "common", Pages: 1}}, + wantTotal: 1, + }, + { + name: "empty_platforms_list", + platforms: []string{}, + setup: func(t *testing.T, dir string) { + require.NoError(t, os.MkdirAll(filepath.Join(dir, "pages.en", "common"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "pages.en", "common", "git.md"), nil, 0o644)) + }, + want: nil, + wantTotal: 0, + }, + { + name: "ignores_non_md_files", + platforms: []string{"common"}, + setup: func(t *testing.T, dir string) { + require.NoError(t, os.MkdirAll(filepath.Join(dir, "pages.en", "common"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "pages.en", "common", "git.md"), nil, 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "pages.en", "common", "notes.txt"), nil, 0o644)) + }, + want: []PlatformInfo{{Name: "common", Pages: 1}}, + wantTotal: 1, + }, + { + name: "empty_directory", + platforms: []string{"common"}, + setup: func(t *testing.T, dir string) { + require.NoError(t, os.MkdirAll(filepath.Join(dir, "pages.en", "common"), 0o755)) + }, + want: []PlatformInfo{{Name: "common", Pages: 0}}, + wantTotal: 0, + }, + } - t.Run("ignores_non_md_files", func(t *testing.T) { - dir := t.TempDir() - require.NoError(t, os.MkdirAll(filepath.Join(dir, "pages.en", "common"), 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(dir, "pages.en", "common", "git.md"), nil, 0o644)) - require.NoError(t, os.WriteFile(filepath.Join(dir, "pages.en", "common", "notes.txt"), nil, 0o644)) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + tt.setup(t, dir) - c := &Cache{dir: dir} - _, total, err := c.languageStats( - []string{"common"}, - []string{"pages.en"}, - ) - require.NoError(t, err) - assert.Equal(t, 1, total) - }) + c := &Cache{dir: dir} + got, total, err := c.platformStats("pages.en", tt.platforms) + require.NoError(t, err) + assert.Equal(t, tt.wantTotal, total) + assert.Equal(t, tt.want, got) + }) + } } diff --git a/internal/cache/main_test.go b/internal/cache/main_test.go index b0c4005..84b3cb1 100644 --- a/internal/cache/main_test.go +++ b/internal/cache/main_test.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "testing" "github.com/TheRootDaemon/tlgc/internal/config" @@ -13,7 +14,7 @@ import ( ) // createTestZip builds an in-memory ZIP from a path→content map. -// Entries whose path ends with "/" are treated as directory entries. +// Entries whose path ends with "/" are created as proper directory entries. func createTestZip(t *testing.T, files map[string]string) []byte { t.Helper() @@ -21,6 +22,15 @@ func createTestZip(t *testing.T, files map[string]string) []byte { zw := zip.NewWriter(&buf) for path, content := range files { + if strings.HasSuffix(path, "/") { + _, err := zw.CreateHeader(&zip.FileHeader{ + Name: path, + Method: zip.Store, + }) + require.NoError(t, err) + continue + } + w, err := zw.Create(path) require.NoError(t, err) diff --git a/internal/cache/update.go b/internal/cache/update.go index 260fe5b..0058b1f 100644 --- a/internal/cache/update.go +++ b/internal/cache/update.go @@ -3,10 +3,12 @@ package cache import ( "context" "fmt" + "strconv" "github.com/TheRootDaemon/tlgc/internal/config" "github.com/TheRootDaemon/tlgc/internal/upstream" "github.com/TheRootDaemon/tlgc/logger" + "github.com/TheRootDaemon/tlgc/termcolor" ) // Update downloads the latest tldr-pages archives @@ -29,9 +31,9 @@ func (c *Cache) Update( logger.Debug("checking %d languages for updates", len(languages)) - var downloaded int + var totalPages, newPages int for _, language := range languages { - updated, err := c.updateLanguage( + updated, pages, np, err := c.updateLanguage( ctx, client, language, @@ -43,7 +45,8 @@ func (c *Cache) Update( } if updated { - downloaded++ + totalPages += pages + newPages += np } } @@ -51,13 +54,15 @@ func (c *Cache) Update( return fmt.Errorf("saving checksums: %w", err) } - if downloaded == 0 { + if totalPages == 0 { logger.Info("pages are up to date") return nil } c.platforms.Store([]string(nil)) - logger.Info("cache updated successfully") + logger.Info("cache update successful (total: %s pages, %s new)", + termcolor.Sprint("bold blue", strconv.Itoa(totalPages)), + termcolor.Sprint("bold blue", strconv.Itoa(newPages))) return nil } @@ -70,7 +75,7 @@ func (c *Cache) updateLanguage( language string, oldChecksums, newChecksums map[string]string, -) (bool, error) { +) (updated bool, pages, newPages int, err error) { languageDirectory := "pages." + language archiveName := fmt.Sprintf("tldr-pages.%s.zip", language) if !needsUpdate( @@ -80,7 +85,7 @@ func (c *Cache) updateLanguage( newChecksums, ) { logger.Debug("language %q: up to date, skipped", language) - return false, nil + return false, 0, 0, nil } logger.Debug("language %q: downloading", language) @@ -93,14 +98,15 @@ func (c *Cache) updateLanguage( hash, ) if err != nil { - return false, fmt.Errorf("downloading %s: %w", archiveName, err) + return false, 0, 0, fmt.Errorf("downloading %s: %w", archiveName, err) } - if err := c.extractArchive(languageDirectory, data); err != nil { - return false, fmt.Errorf("extracting %s: %w", languageDirectory, err) + pages, np, err := c.extractArchive(languageDirectory, data) + if err != nil { + return false, 0, 0, fmt.Errorf("extracting %s: %w", languageDirectory, err) } - return true, nil + return true, pages, np, nil } // needsUpdate reports whether an archive should be downloaded, diff --git a/internal/cache/update_test.go b/internal/cache/update_test.go index 8f12be4..fdf1df3 100644 --- a/internal/cache/update_test.go +++ b/internal/cache/update_test.go @@ -273,7 +273,7 @@ func TestUpdateLanguage(t *testing.T) { c := &Cache{dir: cacheDir} client := upstream.New(upstream.WithHTTPClient(ts.Client())) - gotUpdated, err := c.updateLanguage( + gotUpdated, _, _, err := c.updateLanguage( ctx, client, tt.language, tt.oldChecksums, tt.newChecksums, ) diff --git a/internal/render/command.go b/internal/render/command.go index d4c7546..4b36dd7 100644 --- a/internal/render/command.go +++ b/internal/render/command.go @@ -5,7 +5,6 @@ import ( "strings" "github.com/TheRootDaemon/tlgc/internal/config" - "github.com/TheRootDaemon/tlgc/logger" "github.com/TheRootDaemon/tlgc/text" ) @@ -13,9 +12,16 @@ import ( // with the index of its originating Segment, // so that the segment's style can be applied // during line-by-line rendering. +// +// spaceAfter reports whether a space originally +// followed this word in the source, +// so commandText can reconstruct +// the display string faithfully +// without guessing punctuation spacing. type mappedWord struct { - text string - segmentIndex int + followedBySpace bool + segmentIndex int + text string } // renderCommand writes a styled, wrapped command to w. @@ -27,7 +33,6 @@ type mappedWord struct { func (r *Renderer) renderCommand(w io.Writer, segments []Segment) error { mappedWords := mapWords(segments, r.output.OptionStyle) if len(mappedWords) == 0 { - logger.Trace("no mapped words, skipped") return nil } @@ -39,11 +44,6 @@ func (r *Renderer) renderCommand(w io.Writer, segments []Segment) error { displayText, ) - logger.Trace( - "segments=%d mappedWords=%d lines=%d", - len(segments), len(mappedWords), len(lines), - ) - wordOffset := 0 for _, line := range lines { @@ -71,6 +71,10 @@ func (r *Renderer) renderCommand(w io.Writer, segments []Segment) error { // applying the style of each word's originating Segment. // wordOffset tracks the current position in mappedWords // across multi-line rendering. +// +// Consecutive mappedWords with spaceAfter=false form a single +// whitespace-delimited field in the wrapped display text; +// each sub-word is rendered with its own segment's style. func (r *Renderer) renderCommandLine( w io.Writer, words []string, @@ -79,28 +83,37 @@ func (r *Renderer) renderCommandLine( indent string, wordOffset *int, ) error { - logger.Trace("words=%d offset=%d", len(words), *wordOffset) _, err := io.WriteString(w, indent) if err != nil { return err } - for j, word := range words { + for j := range words { if *wordOffset >= len(mappedWords) { break } - mapped := mappedWords[*wordOffset] - segment := segments[mapped.segmentIndex] + start := *wordOffset + for *wordOffset < len(mappedWords) { + mw := mappedWords[*wordOffset] + *wordOffset++ + if mw.followedBySpace { + break + } + } - if _, err := io.WriteString( - w, - r.applyStyle( - r.styleForSegment(&segment), - word, - ), - ); err != nil { - return err + for k := start; k < *wordOffset; k++ { + mw := mappedWords[k] + seg := segments[mw.segmentIndex] + if _, err := io.WriteString( + w, + r.applyStyle( + r.styleForSegment(&seg), + mw.text, + ), + ); err != nil { + return err + } } if j < len(words)-1 { @@ -109,27 +122,40 @@ func (r *Renderer) renderCommandLine( return err } } - - *wordOffset++ } _, err = io.WriteString(w, "\n") return err } -// mapWords flattens each Segment's DisplayText into individual words. +// mapWords flattens each Segment's DisplayText into individual words +// and records whether each word was followed by whitespace. func mapWords(segments []Segment, optionStyle config.OptionStyle) []mappedWord { var mappedWords []mappedWord for i, segment := range segments { - words := strings.FieldsSeq(segment.DisplayText(optionStyle)) - for word := range words { - mappedWords = append( - mappedWords, - mappedWord{ - text: word, - segmentIndex: i, - }, - ) + text := segment.DisplayText(optionStyle) + segmentWords := strings.Fields(text) + for j, w := range segmentWords { + mw := mappedWord{ + text: w, + segmentIndex: i, + } + + isLast := j == len(segmentWords)-1 + + if isLast { + trailingSpace := hasTrailingSpace(text) + leadingSpace := false + if i+1 < len(segments) { + next := segments[i+1].DisplayText(optionStyle) + leadingSpace = hasLeadingSpace(next) + } + mw.followedBySpace = trailingSpace || leadingSpace + } else { + mw.followedBySpace = true + } + + mappedWords = append(mappedWords, mw) } } @@ -138,14 +164,16 @@ func mapWords(segments []Segment, optionStyle config.OptionStyle) []mappedWord { // commandText joins the text fields of mapped words back // into a single space-separated string, suitable for text wrapping. +// A space is inserted only where the original source had whitespace +// (recorded in spaceAfter), so punctuation spacing is preserved +// faithfully without a hard-coded punctuation list. func commandText(words []mappedWord) string { var b strings.Builder - - for i, word := range words { - if i > 0 { + for i, w := range words { + if i > 0 && words[i-1].followedBySpace { b.WriteByte(' ') } - b.WriteString(word.text) + b.WriteString(w.text) } return b.String() @@ -161,17 +189,22 @@ func wrapLines( ) []string { var wrapped string if width <= 0 { - logger.Trace("no wrap (width=%d)", width) return []string{displayText} } wrapped = text.Wrap(displayText, width, indent) lines := strings.Split(wrapped, "\n") - logger.Trace( - "width=%d input=%d output=%d lines", - width, - len(displayText), - len(lines), - ) return lines } + +// hasTrailingSpace reports whether s ends with a space, +// indicating that whitespace originally followed the segment. +func hasTrailingSpace(s string) bool { + return len(s) > 0 && s[len(s)-1] == ' ' +} + +// hasLeadingSpace reports whether s begins with a space, +// indicating that whitespace originally preceded the segment. +func hasLeadingSpace(s string) bool { + return len(s) > 0 && s[0] == ' ' +} diff --git a/internal/render/command_test.go b/internal/render/command_test.go index 1f65780..efab2e9 100644 --- a/internal/render/command_test.go +++ b/internal/render/command_test.go @@ -48,6 +48,36 @@ func TestRenderCommand(t *testing.T) { raw: "tar cf {{archive.tar}} {{dest}}", want: " tar cf archive.tar dest\n", }, + { + name: "comma between placeholders renders without space after comma", + renderer: noColorRenderer, + raw: "tokei {{path}} {{[-t|--types]}} {{Rust}},{{Markdown}}", + want: " tokei path --types Rust,Markdown\n", + }, + { + name: "slash between placeholders no spaces", + renderer: noColorRenderer, + raw: "{{image}}/{{repository}}", + want: " image/repository\n", + }, + { + name: "colon between placeholders no spaces", + renderer: noColorRenderer, + raw: "{{host}}:{{port}}", + want: " host:port\n", + }, + { + name: "equals between placeholders no spaces", + renderer: noColorRenderer, + raw: "{{a}}={{b}}", + want: " a=b\n", + }, + { + name: "equals with spaces around it", + renderer: noColorRenderer, + raw: "{{a}} = {{b}}", + want: " a = b\n", + }, } for _, tt := range tests { @@ -184,8 +214,8 @@ func TestRenderCommandLine(t *testing.T) { name: "multiple words", words: []string{"tar", "cf", "archive.tar"}, mappedWords: []mappedWord{ - {text: "tar", segmentIndex: 0}, - {text: "cf", segmentIndex: 0}, + {text: "tar", segmentIndex: 0, followedBySpace: true}, + {text: "cf", segmentIndex: 0, followedBySpace: true}, {text: "archive.tar", segmentIndex: 0}, }, segments: []Segment{ @@ -280,8 +310,8 @@ func TestMapWords(t *testing.T) { segments: []Segment{{Kind: Text, Text: "tar cf archive.tar"}}, optionStyle: config.OptionStyleLong, want: []mappedWord{ - {text: "tar", segmentIndex: 0}, - {text: "cf", segmentIndex: 0}, + {text: "tar", segmentIndex: 0, followedBySpace: true}, + {text: "cf", segmentIndex: 0, followedBySpace: true}, {text: "archive.tar", segmentIndex: 0}, }, }, @@ -294,8 +324,8 @@ func TestMapWords(t *testing.T) { }, optionStyle: config.OptionStyleLong, want: []mappedWord{ - {text: "mv", segmentIndex: 0}, - {text: "src", segmentIndex: 1}, + {text: "mv", segmentIndex: 0, followedBySpace: true}, + {text: "src", segmentIndex: 1, followedBySpace: true}, {text: "dst", segmentIndex: 2}, }, }, @@ -307,7 +337,7 @@ func TestMapWords(t *testing.T) { }, optionStyle: config.OptionStyleShort, want: []mappedWord{ - {text: "cmd", segmentIndex: 0}, + {text: "cmd", segmentIndex: 0, followedBySpace: true}, {text: "-s", segmentIndex: 1}, }, }, @@ -319,7 +349,7 @@ func TestMapWords(t *testing.T) { }, optionStyle: config.OptionStyleLong, want: []mappedWord{ - {text: "cmd", segmentIndex: 0}, + {text: "cmd", segmentIndex: 0, followedBySpace: true}, {text: "--long", segmentIndex: 1}, }, }, @@ -331,7 +361,7 @@ func TestMapWords(t *testing.T) { }, optionStyle: config.OptionStyleCombined, want: []mappedWord{ - {text: "cmd", segmentIndex: 0}, + {text: "cmd", segmentIndex: 0, followedBySpace: true}, {text: "[-s|--long]", segmentIndex: 1}, }, }, @@ -343,7 +373,7 @@ func TestMapWords(t *testing.T) { }, optionStyle: config.OptionStyleLong, want: []mappedWord{ - {text: "echo", segmentIndex: 0}, + {text: "echo", segmentIndex: 0, followedBySpace: true}, {text: "hello", segmentIndex: 1}, }, }, @@ -357,11 +387,25 @@ func TestMapWords(t *testing.T) { }, optionStyle: config.OptionStyleShort, want: []mappedWord{ - {text: "cmd", segmentIndex: 0}, - {text: "-o", segmentIndex: 1}, + {text: "cmd", segmentIndex: 0, followedBySpace: true}, + {text: "-o", segmentIndex: 1, followedBySpace: true}, {text: "file", segmentIndex: 3}, }, }, + { + name: "slash with spaces on both sides", + segments: []Segment{ + {Kind: Placeholder, Text: "a"}, + {Kind: Text, Text: " / "}, + {Kind: Placeholder, Text: "b"}, + }, + optionStyle: config.OptionStyleLong, + want: []mappedWord{ + {text: "a", segmentIndex: 0, followedBySpace: true}, + {text: "/", segmentIndex: 1, followedBySpace: true}, + {text: "b", segmentIndex: 2}, + }, + }, } for _, tt := range tests { @@ -395,12 +439,65 @@ func TestCommandText(t *testing.T) { { name: "multiple words", words: []mappedWord{ - {text: "tar"}, - {text: "cf"}, + {text: "tar", followedBySpace: true}, + {text: "cf", followedBySpace: true}, {text: "archive.tar"}, }, want: "tar cf archive.tar", }, + { + name: "adjacent placeholders no space", + words: []mappedWord{ + {text: "Rust"}, + {text: ","}, + {text: "Markdown"}, + }, + want: "Rust,Markdown", + }, + { + name: "colon without trailing space", + words: []mappedWord{ + {text: "host"}, + {text: ":"}, + {text: "port"}, + }, + want: "host:port", + }, + { + name: "colon with trailing space", + words: []mappedWord{ + {text: "host"}, + {text: ":", followedBySpace: true}, + {text: "port"}, + }, + want: "host: port", + }, + { + name: "slash between placeholders no spaces", + words: []mappedWord{ + {text: "a"}, + {text: "/"}, + {text: "b"}, + }, + want: "a/b", + }, + { + name: "slash with spaces on both sides", + words: []mappedWord{ + {text: "a", followedBySpace: true}, + {text: "/", followedBySpace: true}, + {text: "b"}, + }, + want: "a / b", + }, + { + name: "space between regular words", + words: []mappedWord{ + {text: "hello", followedBySpace: true}, + {text: "world"}, + }, + want: "hello world", + }, } for _, tt := range tests { @@ -468,3 +565,103 @@ func TestWrapLines(t *testing.T) { }) } } + +func TestHasTrailingSpace(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + s string + want bool + }{ + { + name: "empty string", + s: "", + want: false, + }, + { + name: "no trailing space", + s: "hello", + want: false, + }, + { + name: "single trailing space", + s: "hello ", + want: true, + }, + { + name: "space only", + s: " ", + want: true, + }, + { + name: "multiple trailing spaces", + s: "hello ", + want: true, + }, + { + name: "leading but no trailing space", + s: " hello", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := hasTrailingSpace(tt.s) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestHasLeadingSpace(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + s string + want bool + }{ + { + name: "empty string", + s: "", + want: false, + }, + { + name: "no leading space", + s: "hello", + want: false, + }, + { + name: "single leading space", + s: " hello", + want: true, + }, + { + name: "space only", + s: " ", + want: true, + }, + { + name: "multiple leading spaces", + s: " hello", + want: true, + }, + { + name: "trailing but no leading space", + s: "hello ", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := hasLeadingSpace(tt.s) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/internal/render/description.go b/internal/render/description.go index f84e9f3..f8a3585 100644 --- a/internal/render/description.go +++ b/internal/render/description.go @@ -3,15 +3,12 @@ package render import ( "io" "strings" - - "github.com/TheRootDaemon/tlgc/logger" ) // renderDescriptionLine writes one description line, // styled with r.style.Description, indented, // and followed by a newline. func (r *Renderer) renderDescriptionLine(w io.Writer, text, indent string) error { - logger.Trace("text=%q", text) if err := r.renderStyledInline( w, text, @@ -35,8 +32,6 @@ func (r *Renderer) renderDescriptions(w io.Writer, descs []string, url string) e return nil } - logger.Trace("count=%d hasURL=%t", len(descs), url != "") - indent := strings.Repeat(" ", r.indent.Description) for _, d := range descs { @@ -98,7 +93,6 @@ func (r *Renderer) renderDescriptionURL(w io.Writer, url, indent string) error { // styled with r.style.Bullet and indented. // No trailing newline is added. func (r *Renderer) renderBulletLine(w io.Writer, text, indent string) error { - logger.Trace("text=%q", text) return r.renderStyledInline( w, text, diff --git a/internal/render/edit.go b/internal/render/edit.go index 8fb4a67..e913168 100644 --- a/internal/render/edit.go +++ b/internal/render/edit.go @@ -12,7 +12,6 @@ import ( // unless output is in compact mode. func (r *Renderer) renderEditLink(w io.Writer, url string) error { logger.Info("edit this page on GitHub") - logger.Trace("url=%q compact=%t", url, r.output.Compact) _, err := io.WriteString(w, url) if err != nil { return err @@ -46,22 +45,18 @@ func (r *Renderer) renderPageEditLink(p *Page) error { // Otherwise the URL is constructed from the page's file path. func buildEditURL(path, url string) string { if url != "" { - logger.Trace("using custom url=%s", url) return url } if path != "" { page := pathutil.PageName(path) platform := pathutil.PagePlatform(path) - result := fmt.Sprintf( + return fmt.Sprintf( "https://github.com/tldr-pages/tldr/edit/main/pages/%s/%s.md", platform, page, ) - logger.Trace("path=%s -> %s", path, result) - return result } - logger.Trace("empty path and url") return "" } diff --git a/internal/render/example.go b/internal/render/example.go index 4375c27..4116905 100644 --- a/internal/render/example.go +++ b/internal/render/example.go @@ -3,8 +3,6 @@ package render import ( "io" "strings" - - "github.com/TheRootDaemon/tlgc/logger" ) // renderExample writes one example, @@ -12,12 +10,6 @@ import ( // followed by the styled command text on the next line. // In compact mode the blank line between bullet and command is omitted. func (r *Renderer) renderExample(w io.Writer, ex Example) error { - logger.Trace( - "desc=%q hasCmd=%t compact=%t", - ex.Description, - ex.Command != "", - r.output.Compact, - ) indent := strings.Repeat(" ", r.indent.Bullet) desc := ex.Description diff --git a/internal/render/inline.go b/internal/render/inline.go index 009044a..d90a967 100644 --- a/internal/render/inline.go +++ b/internal/render/inline.go @@ -5,7 +5,6 @@ import ( "strings" "github.com/TheRootDaemon/tlgc/internal/config" - "github.com/TheRootDaemon/tlgc/logger" ) // inlineSegment is one piece of inline content @@ -30,8 +29,6 @@ func (r *Renderer) renderStyledInline( baseStyle, codeStyle config.OutputStyle, ) error { - logger.Trace("text=%q len=%d", text, len(text)) - segments := parseInline(text) if len(segments) == 1 { _, err := io.WriteString( diff --git a/internal/render/page.go b/internal/render/page.go index 04ffa23..3fec45a 100644 --- a/internal/render/page.go +++ b/internal/render/page.go @@ -172,7 +172,6 @@ func ParseCommand(raw string) []Segment { matches := placeholderPattern.FindAllStringSubmatchIndex(raw, -1) if len(matches) == 0 { - logger.Trace("no placeholders, raw=%q", raw) return []Segment{ { Kind: Text, @@ -219,7 +218,6 @@ func ParseCommand(raw string) []Segment { ) } - logger.Trace("raw=%q -> %d segments", raw, len(segments)) return segments } @@ -283,7 +281,6 @@ func parseInnerPlaceholders(inner string) Segment { short, long = right, left } - logger.Trace("option short=%q long=%q", short, long) return Segment{ Kind: Option, Long: long, @@ -291,7 +288,6 @@ func parseInnerPlaceholders(inner string) Segment { } } - logger.Trace("placeholder text=%q", inner) return Segment{ Kind: Placeholder, Text: inner, diff --git a/internal/render/raw.go b/internal/render/raw.go index fe24721..ed4e0f2 100644 --- a/internal/render/raw.go +++ b/internal/render/raw.go @@ -1,23 +1,17 @@ package render -import ( - "os" - - "github.com/TheRootDaemon/tlgc/logger" -) +import "os" // renderRaw reads the raw markdown file at p.Path and // writes it to the Renderer's writer. func (r *Renderer) renderRaw(p *Page) error { if p.RawContent != "" { - logger.Trace("using RawContent (%d bytes)", len(p.RawContent)) _, err := r.w.Write( []byte(p.RawContent), ) return err } - logger.Trace("reading from path=%s", p.Path) data, err := os.ReadFile(p.Path) if err != nil { return err diff --git a/internal/render/render.go b/internal/render/render.go index 5f1100a..c6a3633 100644 --- a/internal/render/render.go +++ b/internal/render/render.go @@ -85,7 +85,6 @@ func New(w io.Writer, options ...RenderOption) *Renderer { // Nil pages are silently ignored. func (r *Renderer) Render(platform string, p *Page) error { if p == nil { - logger.Trace("nil page, skipped") return nil } @@ -100,18 +99,18 @@ func (r *Renderer) Render(platform string, p *Page) error { len(p.Examples), ) - if !r.output.Compact { - if err := r.writeNewline(); err != nil { - return err - } + if r.output.RawMarkdown { + return r.renderRaw(p) } if err := r.renderPageEditLink(p); err != nil { return err } - if r.output.RawMarkdown { - return r.renderRaw(p) + if !r.output.Compact { + if err := r.writeNewline(); err != nil { + return err + } } if err := r.renderPageTitle(platform, p); err != nil { diff --git a/internal/render/render_test.go b/internal/render/render_test.go index f773d12..583a8d2 100644 --- a/internal/render/render_test.go +++ b/internal/render/render_test.go @@ -271,7 +271,7 @@ func TestRender(t *testing.T) { name: "raw markdown mode writes content from RawContent", renderer: &Renderer{output: config.OutputConfig{RawMarkdown: true}}, page: &Page{RawContent: "# test page\n\n> description.\n"}, - want: "\n# test page\n\n> description.\n", + want: "# test page\n\n> description.\n", }, { name: "leading and trailing newlines in non-compact mode", diff --git a/internal/render/style.go b/internal/render/style.go index 50b37e3..5dcef6b 100644 --- a/internal/render/style.go +++ b/internal/render/style.go @@ -4,7 +4,6 @@ import ( "strings" "github.com/TheRootDaemon/tlgc/internal/config" - "github.com/TheRootDaemon/tlgc/logger" "github.com/TheRootDaemon/tlgc/termcolor" ) @@ -15,9 +14,12 @@ func (r *Renderer) applyStyle(s config.OutputStyle, t string) string { return t } - styled := termcolor.Sprint(styleString(s), t) - logger.Trace("len=%d styled=%t", len(t), styled != t) - return styled + c := termcolor.Parse(styleString(s)) + if c.String() == "" { + return t + } + + return c.String() + t + termcolor.Reset() } // styleForSegment returns the OutputStyle @@ -25,7 +27,6 @@ func (r *Renderer) applyStyle(s config.OutputStyle, t string) string { // Text segments use the Example style; // Placeholder and Option segments use the Placeholder style. func (r *Renderer) styleForSegment(s *Segment) config.OutputStyle { - logger.Trace("kind=%d", s.Kind) switch s.Kind { case Text: return r.style.Example @@ -66,7 +67,5 @@ func styleString(s config.OutputStyle) string { parts = append(parts, "on_"+string(s.Background.Named)) } - result := strings.Join(parts, " ") - logger.Trace("result=%q", result) - return result + return strings.Join(parts, " ") } diff --git a/internal/render/title.go b/internal/render/title.go index 160c864..bfba820 100644 --- a/internal/render/title.go +++ b/internal/render/title.go @@ -3,8 +3,6 @@ package render import ( "io" "strings" - - "github.com/TheRootDaemon/tlgc/logger" ) // renderTitle writes the page title, @@ -12,8 +10,6 @@ import ( // indented by r.indent.Title spaces. func (r *Renderer) renderTitle(w io.Writer, title string) error { indent := strings.Repeat(" ", r.indent.Title) - logger.Trace("title=%q indent=%d", title, r.indent.Title) - _, err := io.WriteString( w, r.applyStyle( diff --git a/internal/render/wrap.go b/internal/render/wrap.go index b1dbaa6..c504860 100644 --- a/internal/render/wrap.go +++ b/internal/render/wrap.go @@ -1,9 +1,6 @@ package render -import ( - "github.com/TheRootDaemon/tlgc/logger" - "github.com/TheRootDaemon/tlgc/text" -) +import "github.com/TheRootDaemon/tlgc/text" // wrapText applies text wrapping to s and prepends indent to every line. // Wrapping is controlled by r.output.LineLength; @@ -11,20 +8,9 @@ import ( // only the indent is prepended without wrapping. func (r *Renderer) wrapText(s, indent string) string { if r.output.LineLength <= 0 || s == "" { - logger.Trace( - "no wrap (len=%d ll=%d)", - len(s), - r.output.LineLength, - ) return indent + s } wrapped := text.Wrap(s, r.output.LineLength, indent) - logger.Trace( - "input=%d ll=%d -> %d chars", - len(s), - r.output.LineLength, - len(wrapped), - ) return indent + wrapped } diff --git a/internal/upstream/download_bytes.go b/internal/upstream/download_bytes.go index 7194614..90d835b 100644 --- a/internal/upstream/download_bytes.go +++ b/internal/upstream/download_bytes.go @@ -4,10 +4,13 @@ import ( "context" "fmt" "io" + "path" + "strings" "time" "github.com/TheRootDaemon/tlgc/format" "github.com/TheRootDaemon/tlgc/logger" + "github.com/TheRootDaemon/tlgc/termcolor" ) // DownloadBytes downloads the content at url into memory @@ -18,7 +21,11 @@ import ( // If sha256hex is non-empty, the downloaded content must match // the expected SHA256 checksum or an error is returned. func (c *Client) DownloadBytes(ctx context.Context, url, sha256hex string) ([]byte, error) { - logger.Info("downloading from %s...", url) + filename := path.Base(strings.Split(url, "?")[0]) + logger.InfoStart( + "downloading %s... ", + termcolor.Sprint("green", filename), + ) start := time.Now() resp, err := c.execute(ctx, url) @@ -48,10 +55,8 @@ func (c *Client) DownloadBytes(ctx context.Context, url, sha256hex string) ([]by logger.InfoEnd( "done (%s in %s)", - format.BytesFmt( - int64(len(data)), - ), - format.DurationFmt(time.Since(start)), + termcolor.Sprint("bold blue", format.BytesFmt(int64(len(data)))), + termcolor.Sprint("bold blue", format.DurationFmt(time.Since(start))), ) return data, nil diff --git a/internal/upstream/download_file.go b/internal/upstream/download_file.go deleted file mode 100644 index 1cccd97..0000000 --- a/internal/upstream/download_file.go +++ /dev/null @@ -1,148 +0,0 @@ -package upstream - -import ( - "context" - "crypto/sha256" - "errors" - "fmt" - "hash" - "io" - "os" - "time" - - "github.com/TheRootDaemon/tlgc/format" - "github.com/TheRootDaemon/tlgc/logger" -) - -// DownloadFile downloads the content at url and writes it to destination. -// -// The download is subject to the client's configured limits and timeouts. -// -// If sha256hex is non-empty, -// the downloaded content must match the expected SHA256 checksum -// or an error is returned. -// -// If the download fails, -// the checksum does not match, -// or the size limit is exceeded, -// any partially written file is removed. -func (c *Client) DownloadFile( - ctx context.Context, - url, - sha256hex string, - root *os.Root, - destination string, -) error { - if root == nil { - return errors.New("root must not be nil") - } - - if destination == "" { - return errors.New("destination must not be empty") - } - - logger.Info("downloading from %s...", url) - start := time.Now() - - resp, err := c.execute(ctx, url) - if err != nil { - logger.InfoEnd("failed: %s", err) - return err - } - - defer func() { - _ = resp.Body.Close() - }() - - f, err := root.OpenFile( - destination, - os.O_CREATE|os.O_WRONLY|os.O_EXCL, - 0o600, - ) - if err != nil { - logger.InfoEnd("failed: %s", err) - return err - } - - cleanup := true - defer func() { - _ = f.Close() - if cleanup { - _ = root.Remove(destination) - } - }() - - n, err := c.transfer( - f, - resp.Body, - sha256hex, - ) - if err != nil { - return err - } - - cleanup = false - - logger.InfoEnd( - "done (%s in %s)", - format.BytesFmt( - int64(n), - ), - format.DurationFmt(time.Since(start)), - ) - - return nil -} - -// transfer copies data from source to destination. -// -// If expectedSHA256 is non-empty, a SHA256 checksum is computed -// while copying and verified against the expected value after the copy completes. -// -// When a maximum body size is configured, -// transfer returns an error -// if the copied data exceeds the limit. -func (c *Client) transfer( - destination io.Writer, - source io.Reader, - expectedSHA256 string, -) (int64, error) { - writer := destination - var hasher hash.Hash - - if expectedSHA256 != "" { - hasher = sha256.New() - writer = io.MultiWriter(destination, hasher) - } - - var ( - n int64 - err error - ) - if c.maxBodySize > 0 { - n, err = io.Copy( - writer, - io.LimitReader(source, c.maxBodySize+1), - ) - } else { - n, err = io.Copy(writer, source) - } - - if err != nil { - return 0, fmt.Errorf("copy: %w", err) - } - - if c.maxBodySize > 0 && n > c.maxBodySize { - return 0, fmt.Errorf("body %d bytes exceeds limit %d", n, c.maxBodySize) - } - - if expectedSHA256 != "" && hasher != nil { - if err := verifySHA256Hash( - hasher, expectedSHA256, - ); err != nil { - return 0, err - } - } - - return n, nil -} diff --git a/internal/upstream/download_file_test.go b/internal/upstream/download_file_test.go deleted file mode 100644 index c689441..0000000 --- a/internal/upstream/download_file_test.go +++ /dev/null @@ -1,209 +0,0 @@ -package upstream - -import ( - "bytes" - "context" - "errors" - "io" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -var openSHA256 = "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9" - -type errReader struct{ err error } - -func (r *errReader) Read(p []byte) (int, error) { return 0, r.err } - -func TestTransfer(t *testing.T) { - t.Parallel() - - errExpected := errors.New("io error") - tests := []struct { - name string - maxBodySize int64 - source string - expectedSHA256 string - wantN int64 - wantErr bool - errContains string - }{ - { - name: "simple_copy", - source: "hello world", - wantN: 11, - }, - { - name: "matching_sha256", - source: "hello world", - expectedSHA256: openSHA256, - wantN: 11, - }, - { - name: "mismatching_sha256", - source: "hello world", - expectedSHA256: "0000000000000000000000000000000000000000000000000000000000000000", - wantErr: true, - }, - { - name: "body_exceeds_maxBodySize", - maxBodySize: 5, - source: "hello world", - wantErr: true, errContains: "exceeds limit", - }, - { - name: "body_within_maxBodySize", - maxBodySize: 20, - source: "hello world", - wantN: 11, - }, - { - name: "source_error", - wantErr: true, - errContains: "copy:", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - c := &Client{ - maxBodySize: tt.maxBodySize, - } - - var dst bytes.Buffer - var src io.Reader - - if tt.name == "source_error" { - src = &errReader{ - err: errExpected, - } - } else { - src = strings.NewReader(tt.source) - } - - n, err := c.transfer(&dst, src, tt.expectedSHA256) - if tt.wantErr { - require.Error(t, err) - assert.Equal(t, tt.wantN, n) - - if tt.errContains != "" { - assert.Contains(t, err.Error(), tt.errContains) - } - - if tt.name == "mismatching_sha256" { - assert.Equal(t, tt.source, dst.String()) - } - - return - } - - require.NoError(t, err) - assert.Equal(t, tt.wantN, n) - assert.Equal(t, tt.source, dst.String()) - }) - } -} - -func TestDownloadFile(t *testing.T) { - t.Parallel() - tests := []struct { - name string - handler http.HandlerFunc - sha256hex string - useNilRoot bool - useEmptyDest bool - wantErr bool - wantContent string - }{ - { - name: "creates_file_with_correct_content", - handler: func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(200) - _, _ = w.Write([]byte("hello world")) - }, - sha256hex: openSHA256, - wantContent: "hello world", - }, - { - name: "sha256_mismatch_cleans_up_file", - handler: func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(200) - _, _ = w.Write([]byte("hello world")) - }, - sha256hex: "0000000000000000000000000000000000000000000000000000000000000000", - wantErr: true, - }, - { - name: "http_error_does_not_create_file", - handler: func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(404) - }, - wantErr: true, - }, - { - name: "nil_root", - handler: func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) }, - useNilRoot: true, - wantErr: true, - }, - { - name: "empty_destination", - handler: func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) }, - useEmptyDest: true, - wantErr: true, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - ts := httptest.NewServer(tt.handler) - defer ts.Close() - - dir := t.TempDir() - root, err := os.OpenRoot(dir) - require.NoError(t, err) - defer func() { - _ = root.Close() - }() - - r := root - if tt.useNilRoot { - r = nil - } - - dest := "test.out" - if tt.useEmptyDest { - dest = "" - } - - c := New(WithHTTPClient(ts.Client())) - - ctx := context.Background() - err = c.DownloadFile(ctx, ts.URL, tt.sha256hex, r, dest) - - if tt.wantErr { - require.Error(t, err) - if dest != "" { - _, statErr := os.Stat(filepath.Join(dir, dest)) - assert.True(t, os.IsNotExist(statErr)) - } - return - } - - require.NoError(t, err) - data, err := os.ReadFile(filepath.Join(dir, dest)) - require.NoError(t, err) - assert.Equal(t, tt.wantContent, string(data)) - }) - } -} diff --git a/termcolor/print.go b/termcolor/print.go index c09d9ad..289c49e 100644 --- a/termcolor/print.go +++ b/termcolor/print.go @@ -12,6 +12,9 @@ import "fmt" // // Sprint("bold red", "error") => "\x1b[1;31merror\x1b[0m" func Sprint(styleString, text string) string { + if !SupportsColor() { + return text + } c := Parse(styleString) if c.String() == "" { return text diff --git a/termcolor/print_test.go b/termcolor/print_test.go index 8242ce6..5747f97 100644 --- a/termcolor/print_test.go +++ b/termcolor/print_test.go @@ -13,6 +13,7 @@ func TestSprint(t *testing.T) { style string text string expectSame bool + noColor bool }{ { name: "empty string returns empty string", @@ -44,10 +45,44 @@ func TestSprint(t *testing.T) { text: "hi", expectSame: false, }, + { + name: "NO_COLOR suppresses ANSI", + style: "red", + text: "hello", + expectSame: true, + noColor: true, + }, + { + name: "TERM=dumb suppresses ANSI", + style: "bold underline green", + text: "hi", + expectSame: true, + noColor: true, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + oldGetenv := getenv + oldIsTerminal := isTerminal + t.Cleanup(func() { + getenv = oldGetenv + isTerminal = oldIsTerminal + }) + + if tt.noColor { + getenv = func(key string) string { + if key == "NO_COLOR" { + return "1" + } + return "" + } + isTerminal = func(uintptr) bool { return true } + } else { + getenv = func(string) string { return "" } + isTerminal = func(uintptr) bool { return true } + } + got := Sprint(tt.style, tt.text) if tt.expectSame { @@ -64,10 +99,11 @@ func TestSprint(t *testing.T) { func TestFprintf(t *testing.T) { tests := []struct { - name string - style string - format string - args []any + name string + style string + format string + args []any + noColor bool }{ { name: "basic formatting", @@ -81,17 +117,44 @@ func TestFprintf(t *testing.T) { format: "%d + %d = %d", args: []any{1, 2, 3}, }, + { + name: "NO_COLOR suppresses ANSI", + style: "red", + format: "hello %s", + args: []any{"world"}, + noColor: true, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + oldGetenv := getenv + oldIsTerminal := isTerminal + t.Cleanup(func() { + getenv = oldGetenv + isTerminal = oldIsTerminal + }) + + if tt.noColor { + getenv = func(key string) string { + if key == "NO_COLOR" { + return "1" + } + return "" + } + isTerminal = func(uintptr) bool { return true } + } else { + getenv = func(string) string { return "" } + isTerminal = func(uintptr) bool { return true } + } + got := Fprintf(tt.style, tt.format, tt.args...) expectedContent := fmt.Sprintf(tt.format, tt.args...) require.Contains(t, got, expectedContent) - if tt.style == "" { + if tt.style == "" || tt.noColor { require.Equal(t, expectedContent, got) } else { require.NotEqual(t, expectedContent, got)