diff --git a/cmd/cli.go b/cmd/cli.go new file mode 100644 index 0000000..9abbbd9 --- /dev/null +++ b/cmd/cli.go @@ -0,0 +1,86 @@ +package cmd + +// CLI holds all parsed command-line flags and positional arguments. +type CLI struct { + // operations + + // Page holds the positional arguments (page names to look up). + Page []string + + // Update requests a cache update. + Update bool + + // List requests listing pages for the current platform. + List bool + + // ListAll requests listing all pages across all platforms. + ListAll bool + + // Search requests a keyword search across pages. + Search string + + // ListPlatforms requests listing available platforms. + ListPlatforms bool + + // ListLanguages requests listing installed languages. + ListLanguages bool + + // Info requests showing cache information. + Info bool + + // Render requests rendering a local markdown file. + Render string + + // CleanCache requests interactively cleaning the cache. + CleanCache bool + + // GenConfig requests printing the default configuration. + GenConfig bool + + // ConfigPath requests printing the config file path. + ConfigPath bool + + // ShowVersion requests printing the version string. + ShowVersion bool + + // ShowHelp requests printing the help text. + ShowHelp bool + + // Options + + // Platform overrides the platform used for page lookup. + Platform string + + // Languages overrides the language list. + Languages []string + + // ShortOptions requests displaying short option forms. + ShortOptions bool + + // 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 + + // Raw prints pages in raw markdown. + Raw bool + + // Quiet suppresses informational and warning messages. + Quiet bool + + // Verbose controls the verbosity level (0–2). + Verbose uint8 + + // Color controls when to enable color output: auto, always, never. + Color string + + // Config specifies an alternative config file path. + Config string +} diff --git a/cmd/count_value.go b/cmd/count_value.go new file mode 100644 index 0000000..6a60da0 --- /dev/null +++ b/cmd/count_value.go @@ -0,0 +1,31 @@ +package cmd + +import "fmt" + +// countValue implements flag.Value for a counter. +// +// Each occurrence of the associated flag increments the counter by one, +// allowing flags such as: +// +// --verbose +// --verbose --verbose +// +// to be represented as increasing verbosity levels. +type countValue struct { + count *uint8 +} + +// String returns the current counter value as a string. +func (v *countValue) String() string { + if v.count == nil { + return "0" + } + + return fmt.Sprintf("%d", *v.count) +} + +// Set increments the counter each time the flag is encountered. +func (v *countValue) Set(string) error { + *v.count++ + return nil +} diff --git a/cmd/list_values.go b/cmd/list_values.go new file mode 100644 index 0000000..c1fd3d0 --- /dev/null +++ b/cmd/list_values.go @@ -0,0 +1,30 @@ +package cmd + +import "strings" + +// stringListValue implements flag. +// Value for a string slice. +// It supports both repeated flags (-L de -L pl) +// and comma-separated values (-L de,pl). +type stringListValue struct { + values *[]string +} + +// String returns the comma-separated representation of the value. +func (v *stringListValue) String() string { + if v.values == nil { + return "" + } + return strings.Join(*v.values, ",") +} + +// Set appends one or more comma-separated values to the slice +func (v *stringListValue) Set(s string) error { + for part := range strings.SplitSeq(s, ",") { + part = strings.TrimSpace(part) + if part != "" { + *v.values = append(*v.values, part) + } + } + return nil +} diff --git a/cmd/parse.go b/cmd/parse.go new file mode 100644 index 0000000..87cff43 --- /dev/null +++ b/cmd/parse.go @@ -0,0 +1,291 @@ +package cmd + +import ( + "flag" + "fmt" + "os" + + "github.com/TheRootDaemon/tlgc/version" +) + +// Parse parses the process command-line arguments into a CLI value. +func Parse() (*CLI, error) { + return parse(os.Args[1:]) +} + +// parse parses the provided command-line arguments into a CLI value. +// +// It validates the parsed flags +// and ensures that exactly one operation has been requested. +// If the arguments are empty it prints help message. +func parse(args []string) (*CLI, error) { + cli := &CLI{} + + fs := flag.NewFlagSet("tlgc", flag.ContinueOnError) + + // operations + fs.BoolVar(&cli.Update, "u", false, "update the cache") + fs.BoolVar(&cli.Update, "update", false, "update the cache") + + fs.BoolVar( + &cli.List, + "l", + false, + "list all pages for the current platform", + ) + fs.BoolVar( + &cli.List, + "list", + false, + "list all pages for the current platform", + ) + + fs.BoolVar(&cli.ListAll, "a", false, "list all pages") + fs.BoolVar(&cli.ListAll, "list-all", false, "list all pages") + + fs.StringVar( + &cli.Search, + "s", + "", + "search for pages containing a keyword", + ) + fs.StringVar( + &cli.Search, + "search", + "", + "search for pages containing a keyword", + ) + + fs.BoolVar( + &cli.ListPlatforms, + "list-platforms", + false, + "list available platforms", + ) + + fs.BoolVar( + &cli.ListLanguages, + "list-languages", + false, + "list installed languages", + ) + + fs.BoolVar(&cli.Info, "i", false, "show cache information") + fs.BoolVar(&cli.Info, "info", false, "show cache information") + + fs.StringVar(&cli.Render, "r", "", "render the specified tldr page") + fs.StringVar(&cli.Render, "render", "", "render the specified tldr page") + + fs.BoolVar( + &cli.CleanCache, + "clean-cache", + false, + "interactively delete the cache contents", + ) + + fs.BoolVar( + &cli.GenConfig, + "gen-config", + false, + "print the default configuration", + ) + + fs.BoolVar( + &cli.ConfigPath, + "config-path", + false, + "print the configuration path", + ) + + fs.BoolVar(&cli.ShowVersion, "v", false, "display version") + fs.BoolVar(&cli.ShowVersion, "version", false, "display version") + + fs.BoolVar(&cli.ShowHelp, "h", false, "display help") + fs.BoolVar(&cli.ShowHelp, "help", false, "display help") + + // options + fs.StringVar( + &cli.Platform, + "p", + "", + "specify the platform to use (linux, osx, windows, etc.)", + ) + fs.StringVar( + &cli.Platform, + "platform", + "", + "specify the platform to use (linux, osx, windows, etc.)", + ) + + fs.Var( + &stringListValue{ + values: &cli.Languages, + }, + "L", + "specify the languages to use", + ) + fs.Var( + &stringListValue{ + values: &cli.Languages, + }, + "language", + "specify the languages to use", + ) + + fs.BoolVar( + &cli.ShortOptions, + "short-options", + false, + "display short options wherever possible (e.g. '-s')", + ) + fs.BoolVar( + &cli.LongOptions, + "long-options", + false, + "display long options wherever possible (e.g. '--long')", + ) + + fs.BoolVar( + &cli.Edit, + "edit", + false, + "display a link to edit the page on GitHub", + ) + + fs.BoolVar( + &cli.Offline, + "o", + false, + "do not update the cache, even if it is stale", + ) + fs.BoolVar( + &cli.Offline, + "offline", + false, + "do not update the cache, even if it is stale", + ) + + 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.Raw, + "R", + false, + "print pages in raw markdown instead of rendering them", + ) + fs.BoolVar( + &cli.Raw, + "raw", + false, + "print pages in raw markdown instead of rendering them", + ) + + fs.BoolVar(&cli.Quiet, "q", false, "suppress status messages and warnings") + fs.BoolVar(&cli.Quiet, "quiet", false, "suppress status messages and warnings") + + fs.Var( + &countValue{ + count: &cli.Verbose, + }, + "verbose", + "increase verbosity (repeat upto twice)", + ) + + fs.StringVar( + &cli.Color, + "color", + "auto", + "specify when to enable color (auto, always, never)", + ) + + fs.StringVar( + &cli.Config, + "config", + "", + "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.Version, + ) + return cli, nil + } + + // show help + if cli.ShowHelp { + fmt.Println("TODO") + return cli, nil + } + + // positional arguments + cli.Page = fs.Args() + + // validate that exactly one operation is active + ops := cli.operationCount() + if ops == 0 { + fmt.Println("TODO") + 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 +} diff --git a/internal/app/app.go b/internal/app/app.go new file mode 100644 index 0000000..ea4a80c --- /dev/null +++ b/internal/app/app.go @@ -0,0 +1,126 @@ +package app + +import ( + "io" + "os" + + "github.com/TheRootDaemon/tlgc/cmd" + "github.com/TheRootDaemon/tlgc/internal/config" + "github.com/TheRootDaemon/tlgc/locale" + "github.com/TheRootDaemon/tlgc/logger" + "github.com/TheRootDaemon/tlgc/platform" +) + +// App is the main application struct that holds I/O streams and configuration. +type App struct { + // Stdout is the writer for standard output. + Stdout io.Writer + + // Stderr is the writer for diagnostic output. + Stderr io.Writer + + // ConfigPath is the path to the configuration file, if set. + ConfigPath string +} + +// Option configures the App. +type Option func(*App) + +// WithStdout sets the standard output writer for the App. +func WithStdout(w io.Writer) Option { + return func(a *App) { + a.Stdout = w + } +} + +// WithStderr sets the standard error writer for the App. +func WithStderr(w io.Writer) Option { + return func(a *App) { + a.Stderr = w + } +} + +// WithConfigPath sets the configuration file path for the App. +func WithConfigPath(path string) Option { + return func(a *App) { + a.ConfigPath = path + } +} + +// New creates a new App with the given options. +// It defaults Stdout to os.Stdout and Stderr to os.Stderr. +func New(opts ...Option) *App { + a := &App{ + Stdout: os.Stdout, + Stderr: os.Stderr, + } + + for _, opt := range opts { + opt(a) + } + 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. +func (a *App) Run(cli *cmd.CLI) int { + needsConfig := !cli.GenConfig && !cli.ConfigPath && !cli.ShowVersion && !cli.ShowHelp + + if needsConfig { + if err := config.Initialize(); err != nil { + logger.Error("failed to load config: %v", err) + return 1 + } + } + + switch { + case cli.Update: + return a.updateCache(cli) + case cli.List: + return a.listPages(cli) + case cli.ListAll: + return a.listAllPages() + case cli.Search != "": + return a.searchPages(cli) + case cli.ListPlatforms: + return a.listPlatforms() + case cli.ListLanguages: + return a.listLanguages() + case cli.Info: + return a.cacheInfo() + case cli.Render != "": + return a.renderLocalFile(cli) + case cli.GenConfig: + return a.genConfig() + case cli.ConfigPath: + return a.configPath() + case len(cli.Page) > 0: + return a.lookupAndRenderPage(cli) + default: + return 0 + } +} + +// resolveLanguages returns the resolved list of languages from the CLI flag, +// config file, or system locale, in that order of precedence. +func (a *App) resolveLanguages(flagLangs []string) []string { + if len(flagLangs) > 0 { + return flagLangs + } + if cfgLangs := config.Cache().Languages; len(cfgLangs) > 0 { + return cfgLangs + } + var langs []string + locale.GetLanguages(&langs) + return langs +} + +// resolvePlatform returns the resolved platform string from the CLI flag +// or the default platform. +func (a *App) resolvePlatform(flagPlatform string) string { + if flagPlatform != "" { + return platform.Resolve(flagPlatform) + } + return platform.Default() +} diff --git a/internal/app/config.go b/internal/app/config.go new file mode 100644 index 0000000..57b9019 --- /dev/null +++ b/internal/app/config.go @@ -0,0 +1,34 @@ +package app + +import ( + "fmt" + + "github.com/TheRootDaemon/tlgc/internal/config" + "github.com/TheRootDaemon/tlgc/logger" +) + +// genConfig prints a default configuration file to stdout. +// Returns 0 on success, 1 on error. +func (a *App) genConfig() int { + cfg, err := config.DefaultConfig() + if err != nil { + logger.Error("failed to generate config: %w", err) + return 1 + } + + if _, err := fmt.Fprint(a.Stdout, cfg); err != nil { + logger.Error("%w", err) + return 1 + } + return 0 +} + +// configPath prints the configuration file path to stdout. +// Returns 0 on success. +func (a *App) configPath() int { + if _, err := fmt.Fprintln(a.Stdout, config.ConfigPath()); err != nil { + logger.Error("%w", err) + return 1 + } + return 0 +} diff --git a/internal/app/doc.go b/internal/app/doc.go new file mode 100644 index 0000000..b2dc58a --- /dev/null +++ b/internal/app/doc.go @@ -0,0 +1,3 @@ +// Package app coordinates the cache, config, render, and upstream +// packages to provide the application's main entry point. +package app diff --git a/internal/app/info.go b/internal/app/info.go new file mode 100644 index 0000000..d4b4703 --- /dev/null +++ b/internal/app/info.go @@ -0,0 +1,50 @@ +package app + +import ( + "fmt" + + "github.com/TheRootDaemon/tlgc/internal/cache" + "github.com/TheRootDaemon/tlgc/logger" +) + +// cacheInfo prints cache metadata (directory, age, page count, etc.). +// Returns 0 on success, 1 on error. +func (a *App) cacheInfo() int { + c := cache.New() + info, err := c.Info() + if err != nil { + logger.Error("failed to get cache info: %v", err) + return 1 + } + + 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, + ); err != nil { + logger.Error("%w", err) + return 1 + } + + for _, ls := range info.LanguageStats { + if _, err := fmt.Fprintf( + a.Stdout, + "%s: %d pages\n", + ls.Language, + ls.Pages, + ); err != nil { + logger.Error("%w", err) + return 1 + } + } + return 0 +} diff --git a/internal/app/list.go b/internal/app/list.go new file mode 100644 index 0000000..3b386d0 --- /dev/null +++ b/internal/app/list.go @@ -0,0 +1,86 @@ +package app + +import ( + "fmt" + + "github.com/TheRootDaemon/tlgc/cmd" + "github.com/TheRootDaemon/tlgc/internal/cache" + "github.com/TheRootDaemon/tlgc/logger" +) + +// listPages lists pages for a specific platform from the cache. +// Returns 0 on success, 1 on error. +func (a *App) listPages(cli *cmd.CLI) int { + c := cache.New() + p := a.resolvePlatform(cli.Platform) + pages, err := c.ListFor(p) + if err != nil { + logger.Error("failed to list pages: %v", err) + return 1 + } + + for _, page := range pages { + if _, err := fmt.Fprintln(a.Stdout, page); err != nil { + logger.Error("%w", err) + return 1 + } + } + return 0 +} + +// listAllPages lists all cached pages across all platforms. +// Returns 0 on success, 1 on error. +func (a *App) listAllPages() int { + c := cache.New() + pages, err := c.ListAll() + if err != nil { + logger.Error("failed to list pages: %v", err) + return 1 + } + + for _, page := range pages { + if _, err := fmt.Fprintln(a.Stdout, page); err != nil { + logger.Error("%w", err) + return 1 + } + } + return 0 +} + +// listPlatforms lists all available platforms from the cache. +// Returns 0 on success, 1 on error. +func (a *App) listPlatforms() int { + c := cache.New() + platforms, err := c.ListPlatforms() + if err != nil { + logger.Error("failed to list platforms: %v", err) + return 1 + } + + for _, p := range platforms { + if _, err := fmt.Fprintln(a.Stdout, p); err != nil { + logger.Error("%w", err) + return 1 + } + } + return 0 +} + +// listLanguages lists all available languages from the cache. +// Returns 0 on success, 1 on error. +func (a *App) listLanguages() int { + c := cache.New() + languages, err := c.ListLanguages() + if err != nil { + logger.Error("failed to list languages: %v", err) + return 1 + } + + for _, l := range languages { + if _, err := fmt.Fprintln(a.Stdout, l); err != nil { + logger.Error("%w", err) + return 1 + } + } + return 0 +} diff --git a/internal/app/page.go b/internal/app/page.go new file mode 100644 index 0000000..73c262b --- /dev/null +++ b/internal/app/page.go @@ -0,0 +1,186 @@ +package app + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/TheRootDaemon/tlgc/cmd" + "github.com/TheRootDaemon/tlgc/internal/cache" + "github.com/TheRootDaemon/tlgc/internal/config" + "github.com/TheRootDaemon/tlgc/internal/render" + "github.com/TheRootDaemon/tlgc/logger" + "github.com/TheRootDaemon/tlgc/pathutil" +) + +// lookupAndRenderPage finds a page by name and renders it to the terminal. +// Returns 0 on success, 1 on error. +func (a *App) lookupAndRenderPage(cli *cmd.CLI) int { + p := a.resolvePlatform(cli.Platform) + langs := a.resolveLanguages(cli.Languages) + c := cache.New() + + 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 + } + + pagePath, renderPlatform, err := a.selectPage( + results, + query, + p, + ) + if err != nil { + logger.Error("%v", err) + return 1 + } + + root, err := os.OpenRoot( + filepath.Dir(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) + return 1 + } + + if err := render.Validate(string(data)); err != nil { + logger.Error("not a valid tldr page: %s\n\n%v", pagePath, err) + return 1 + } + + page := render.Parse(string(data)) + page.Path = pagePath + page.RawContent = string(data) + + renderer := render.New(a.Stdout, a.renderOptions(cli)...) + if err := renderer.Render(renderPlatform, page); err != nil { + logger.Error("failed to render 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), + ) + 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) + return 1 + } + + if err := render.Validate(string(data)); err != nil { + logger.Error("not a valid tldr page: %s\n\n%v", cli.Render, err) + return 1 + } + + page := render.Parse(string(data)) + if page.Title == "" { + logger.Error("not a valid tldr page: %s", cli.Render) + 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 { + logger.Error("failed to render: %v", err) + return 1 + } + return 0 +} + +// selectPage chooses the best matching page +// and falls back to pages from other platforms +// when no exact match exists. +func (a *App) selectPage( + results *cache.FindResult, + query, + requestedPlatform string, +) (string, string, error) { + if len(results.Fallbacks) > 0 { + logger.Warn( + "%d page(s) found for other platforms:", + len(results.Fallbacks), + ) + + for i, f := range results.Fallbacks { + platform := pathutil.PagePlatform(f) + _, err := fmt.Fprintf( + a.Stderr, + "%d. %s (tldr --platform %s %s)\n", + i+1, + platform, + platform, + query, + ) + if err != nil { + return "", "", err + } + } + } + + switch { + case len(results.Matches) > 0: + return results.Matches[0], requestedPlatform, nil + case len(results.Fallbacks) > 0: + page := results.Fallbacks[0] + return page, pathutil.PagePlatform(page), nil + + default: + return "", "", fmt.Errorf("page not found, try running tldr --update") + } +} + +// renderOptions builds the render options from the CLI flags and config. +func (a *App) renderOptions(cli *cmd.CLI) []render.RenderOption { + opts := []render.RenderOption{ + render.WithWriter(a.Stdout), + } + switch cli.Color { + case "always": + opts = append(opts, render.WithColor(true)) + case "never": + opts = append(opts, render.WithColor(false)) + } + + output := config.Output() + switch { + case cli.Compact: + output.Compact = true + case cli.Raw: + output.RawMarkdown = true + case cli.Edit: + output.EditLink = true + case cli.ShortOptions: + output.OptionStyle = config.OptionStyleShort + case cli.LongOptions: + output.OptionStyle = config.OptionStyleLong + } + + opts = append(opts, render.WithOutput(output)) + return opts +} diff --git a/internal/app/search.go b/internal/app/search.go new file mode 100644 index 0000000..62d0fe4 --- /dev/null +++ b/internal/app/search.go @@ -0,0 +1,36 @@ +package app + +import ( + "fmt" + + "github.com/TheRootDaemon/tlgc/cmd" + "github.com/TheRootDaemon/tlgc/internal/cache" + "github.com/TheRootDaemon/tlgc/logger" +) + +// searchPages searches cached pages for the given query. +// Returns 0 on success, 1 on error. +func (a *App) searchPages(cli *cmd.CLI) int { + c := cache.New() + p := a.resolvePlatform(cli.Platform) + languages := a.resolveLanguages(cli.Languages) + + results, err := c.Search(cli.Search, p, languages) + if err != nil { + logger.Error("search failed: %v", err) + return 1 + } + + for _, r := range results { + if _, err := fmt.Fprintf( + a.Stdout, + "%s/%s\n", + r.Platform, + r.Page, + ); err != nil { + logger.Error("%w", err) + return 1 + } + } + return 0 +} diff --git a/internal/app/update.go b/internal/app/update.go new file mode 100644 index 0000000..56dffc9 --- /dev/null +++ b/internal/app/update.go @@ -0,0 +1,25 @@ +package app + +import ( + "context" + + "github.com/TheRootDaemon/tlgc/cmd" + "github.com/TheRootDaemon/tlgc/internal/cache" + "github.com/TheRootDaemon/tlgc/internal/upstream" + "github.com/TheRootDaemon/tlgc/logger" +) + +// updateCache downloads the latest tldr-pages +// for the configured languages. +// Returns 0 on success, 1 on error. +func (a *App) updateCache(cli *cmd.CLI) int { + c := cache.New() + languages := a.resolveLanguages(cli.Languages) + client := upstream.New() + + if err := c.Update(context.Background(), languages, client); err != nil { + logger.Error("failed to update cache: %v", err) + return 1 + } + return 0 +} diff --git a/internal/cache/archive.go b/internal/cache/archive.go index 5b6a019..9458138 100644 --- a/internal/cache/archive.go +++ b/internal/cache/archive.go @@ -62,11 +62,21 @@ func (c *Cache) extractArchive( var extracted int for _, f := range zipReader.File { if strings.Contains(f.Name, "..") { + logger.Warn( + "skipping zip entry with '..': %s", + f.Name, + ) continue } if f.FileInfo().IsDir() { - if err := root.MkdirAll(f.Name, 0o750); err != nil { + if err := root.MkdirAll( + strings.TrimSuffix( + f.Name, + "/", + ), + 0o750, + ); err != nil { return fmt.Errorf("creating directory %s: %w", f.Name, err) } diff --git a/internal/cache/cache.go b/internal/cache/cache.go index 8d7d796..5bd07d7 100644 --- a/internal/cache/cache.go +++ b/internal/cache/cache.go @@ -9,6 +9,7 @@ import ( "sync/atomic" "github.com/TheRootDaemon/tlgc/internal/config" + "github.com/TheRootDaemon/tlgc/logger" "github.com/TheRootDaemon/tlgc/slice" ) @@ -20,8 +21,10 @@ type Cache struct { // New creates a Cache using the cache directory from the config singleton. func New() *Cache { + dir := config.Cache().Dir + logger.Debug("cache dir: %s", dir) return &Cache{ - dir: config.Cache().Dir, + dir: dir, } } @@ -62,6 +65,11 @@ func (c *Cache) getPlatforms() ([]string, error) { sort.Strings(platforms) c.platforms.Store(platforms) + logger.Debug( + "discovered %d platforms from %s", + len(platforms), + englishDirectory, + ) return platforms, nil } diff --git a/internal/cache/checksums.go b/internal/cache/checksums.go index f22da23..7e4502b 100644 --- a/internal/cache/checksums.go +++ b/internal/cache/checksums.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/TheRootDaemon/tlgc/internal/upstream" + "github.com/TheRootDaemon/tlgc/logger" ) // loadChecksums reads the cached checksum file from disk @@ -14,6 +15,7 @@ import ( func (c *Cache) loadChecksums() map[string]string { root, err := os.OpenRoot(c.dir) if err != nil { + logger.Trace("no existing checksums file") return nil } defer func() { @@ -22,10 +24,13 @@ func (c *Cache) loadChecksums() map[string]string { checksumBytes, err := root.ReadFile(checksumFile) if err != nil { + logger.Trace("no existing checksums file") return nil } - return parseChecksum(checksumBytes) + checksums := parseChecksum(checksumBytes) + logger.Debug("loaded %d checksums", len(checksums)) + return checksums } // saveChecksums writes the checksum map to disk in sha256sum format. @@ -67,6 +72,7 @@ func downloadChecksum( mirror string, ) ([]byte, error) { checksumURL := mirror + "/" + checksumFile + logger.Debug("fetching checksums from %s", checksumURL) return client.DownloadBytes(ctx, checksumURL, "") } diff --git a/internal/cache/find.go b/internal/cache/find.go index 69c02eb..26f3dde 100644 --- a/internal/cache/find.go +++ b/internal/cache/find.go @@ -4,6 +4,8 @@ import ( "fmt" "os" "path/filepath" + + "github.com/TheRootDaemon/tlgc/logger" ) // FindResult contains the pages found for a command lookup. @@ -30,6 +32,13 @@ type FindResult struct { // // Language directories are searched in the order provided by languages. func (c *Cache) Find(query, platform string, languages []string) (*FindResult, error) { + logger.Debug( + "find: query=%q, platform=%q, languages=%v", + query, + platform, + languages, + ) + languageDirectories := c.languagesToDirectories(languages, false) if len(languageDirectories) == 0 { return nil, fmt.Errorf("no matching language directories found in cache") @@ -60,6 +69,12 @@ func (c *Cache) Find(query, platform string, languages []string) (*FindResult, e languageDirectories, ) + logger.Debug( + "primary matches: %d, fallbacks: %d", + len(matches), + len(fallbacks), + ) + return &FindResult{ Matches: matches, Fallbacks: fallbacks, diff --git a/internal/cache/info.go b/internal/cache/info.go index 638f0e0..a1af424 100644 --- a/internal/cache/info.go +++ b/internal/cache/info.go @@ -9,6 +9,7 @@ import ( "github.com/TheRootDaemon/tlgc/format" "github.com/TheRootDaemon/tlgc/internal/config" + "github.com/TheRootDaemon/tlgc/logger" ) // LanguageInfo contains cache statistics for a single language. @@ -48,6 +49,11 @@ func (c *Cache) Age() (time.Duration, error) { sumfile := filepath.Join(c.dir, checksumFile) fi, err := os.Stat(sumfile) if err != nil { + logger.Debug( + "cache age: stat failed for %s, falling back to %s", + sumfile, + c.dir, + ) fi, err = os.Stat(c.dir) if err != nil { return 0, err @@ -58,6 +64,7 @@ func (c *Cache) Age() (time.Duration, error) { age := time.Since(mod) if age < 0 { + logger.Warn("cache mtime is in the future: clock may be wrong") return 0, fmt.Errorf("cache mtime is in the future: clock issue") } @@ -68,6 +75,8 @@ func (c *Cache) Age() (time.Duration, error) { // including its location, age, configuration, // per-language page counts, and total page count. func (c *Cache) Info() (*InfoResult, error) { + logger.Debug("cache dir=%q", c.dir) + fi, err := os.Stat(c.dir) if err != nil { return nil, fmt.Errorf("cache directory %q: %s", c.dir, err) diff --git a/internal/cache/list.go b/internal/cache/list.go index ea4cb5d..11b9652 100644 --- a/internal/cache/list.go +++ b/internal/cache/list.go @@ -6,11 +6,13 @@ import ( "sort" "strings" + "github.com/TheRootDaemon/tlgc/logger" "github.com/TheRootDaemon/tlgc/slice" ) // ListFor returns all page names in the give platform (plus common). func (c *Cache) ListFor(platform string) ([]string, error) { + logger.Debug("platform=%q", platform) if _, err := c.getPlatforms(); err != nil { return nil, err } @@ -36,6 +38,7 @@ func (c *Cache) ListFor(platform string) ([]string, error) { // ListAll returns all page names across all platforms in English. func (c *Cache) ListAll() ([]string, error) { platforms, err := c.getPlatforms() + logger.Debug("%d platforms", len(platforms)) if err != nil { return nil, err } @@ -62,6 +65,7 @@ func (c *Cache) ListPlatforms() ([]string, error) { // ListLanguages returns the installed language codes (without the "pages." prefix). func (c *Cache) ListLanguages() ([]string, error) { directories, err := c.getLanguageDirectories() + logger.Debug("found %d languages", len(directories)) if err != nil { return nil, err } diff --git a/internal/cache/search.go b/internal/cache/search.go index d8002b2..e9c84b5 100644 --- a/internal/cache/search.go +++ b/internal/cache/search.go @@ -29,6 +29,8 @@ type SearchResult struct { // that platform and common are searched. // Results are returned sorted by page name. func (c *Cache) Search(query, platform string, languages []string) ([]SearchResult, error) { + logger.Debug("query=%q, platform=%q, languages=%v", query, platform, languages) + platforms, err := c.resolvePlatforms(platform) if err != nil { return nil, err @@ -84,10 +86,13 @@ func (c *Cache) resolvePlatforms(platform string) ([]string, error) { switch { case platform == "common": + logger.Debug("resolved platforms: [common]") return []string{"common"}, nil case platform != "": + logger.Debug("resolved platforms: [%s, common]", platform) return []string{platform, "common"}, nil default: + logger.Debug("resolved platforms: %v", platforms) return platforms, nil } } diff --git a/internal/cache/update.go b/internal/cache/update.go index 5ebceb1..260fe5b 100644 --- a/internal/cache/update.go +++ b/internal/cache/update.go @@ -17,6 +17,8 @@ func (c *Cache) Update( languages []string, client *upstream.Client, ) error { + logger.Info("updating cache...") + checksums, err := downloadChecksum(ctx, client, config.Cache().Mirror) if err != nil { return fmt.Errorf("downloading checksum: %s", err) @@ -25,6 +27,8 @@ func (c *Cache) Update( oldChecksums := c.loadChecksums() newChecksums := parseChecksum(checksums) + logger.Debug("checking %d languages for updates", len(languages)) + var downloaded int for _, language := range languages { updated, err := c.updateLanguage( @@ -53,6 +57,7 @@ func (c *Cache) Update( } c.platforms.Store([]string(nil)) + logger.Info("cache updated successfully") return nil } @@ -74,9 +79,11 @@ func (c *Cache) updateLanguage( oldChecksums, newChecksums, ) { + logger.Debug("language %q: up to date, skipped", language) return false, nil } + logger.Debug("language %q: downloading", language) hash := newChecksums[archiveName] data, err := downloadArchive( ctx, diff --git a/internal/config/config.go b/internal/config/config.go index a39e50a..2c2a455 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/BurntSushi/toml" + "github.com/TheRootDaemon/tlgc/logger" ) // Config is the top-level configuration structure. @@ -57,6 +58,7 @@ func DefaultConfig() (string, error) { // - Windows: %AppData%/tlgc/config.toml func ConfigPath() string { if p := os.Getenv("TLGC_CONFIG"); p != "" { + logger.Trace("config path from TLGC_CONFIG=%s", p) return p } @@ -74,6 +76,7 @@ func ConfigPath() string { // Fields not present in the file // retain their default values. func LoadConfig(path string) (*Config, error) { + logger.Trace("loading config from %s", path) cfg := Default() _, err := toml.DecodeFile(path, &cfg) if err != nil { diff --git a/internal/config/singleton.go b/internal/config/singleton.go index 9ae627e..b65896a 100644 --- a/internal/config/singleton.go +++ b/internal/config/singleton.go @@ -1,23 +1,41 @@ package config import ( + "os" "sync/atomic" + + "github.com/TheRootDaemon/tlgc/logger" ) // currentConfig represents the singleton // of the current client configuration. var currentConfig atomic.Pointer[Config] -// Initialize loads a TOML config file and sets it as the global singleton. -// It is safe to call from multiple goroutines. -// Subsequent calls replace the current singleton. +// Initialize loads the TOML config file and sets the global singleton. +// If the config file does not exist, the singleton is set to defaults +// and no error is returned. It is safe to call from multiple goroutines. func Initialize() error { - cfg, err := LoadConfig(ConfigPath()) + path := ConfigPath() + logger.Debug("config path: %s", path) + + if _, err := os.Stat(path); err != nil { + if os.IsNotExist(err) { + logger.Info("config file not found, using defaults") + d := Default() + currentConfig.Store(&d) + return nil + } + logger.Warn("config stat failed: %v", err) + return err + } + + cfg, err := LoadConfig(path) if err != nil { return err } currentConfig.Store(cfg) + logger.Info("config loaded from %s", path) return nil } diff --git a/internal/config/singleton_test.go b/internal/config/singleton_test.go index da929f6..10f1c50 100644 --- a/internal/config/singleton_test.go +++ b/internal/config/singleton_test.go @@ -41,12 +41,30 @@ func TestInitialize_and_C(t *testing.T) { assert.Equal(t, DefaultCacheConfig(), cfg.Cache) } -func TestInitialize_Error(t *testing.T) { +func TestInitialize_MissingFileDefaults(t *testing.T) { resetCurrentConfig() defer resetCurrentConfig() t.Setenv("TLGC_CONFIG", "/nonexistent/path/config.toml") err := Initialize() + require.NoError(t, err) + + cfg := C() + require.NotNil(t, cfg) + assert.Equal(t, Default(), *cfg) +} + +func TestInitialize_MalformedFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.toml") + err := os.WriteFile(path, []byte("invalid toml content {{{"), 0o644) + require.NoError(t, err) + + resetCurrentConfig() + defer resetCurrentConfig() + + t.Setenv("TLGC_CONFIG", path) + err = Initialize() require.Error(t, err) } diff --git a/internal/render/command.go b/internal/render/command.go index 1c1c223..d4c7546 100644 --- a/internal/render/command.go +++ b/internal/render/command.go @@ -5,6 +5,7 @@ import ( "strings" "github.com/TheRootDaemon/tlgc/internal/config" + "github.com/TheRootDaemon/tlgc/logger" "github.com/TheRootDaemon/tlgc/text" ) @@ -26,6 +27,7 @@ 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 } @@ -37,6 +39,11 @@ 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 { @@ -72,6 +79,7 @@ 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 @@ -153,9 +161,17 @@ 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) - return strings.Split(wrapped, "\n") + lines := strings.Split(wrapped, "\n") + logger.Trace( + "width=%d input=%d output=%d lines", + width, + len(displayText), + len(lines), + ) + return lines } diff --git a/internal/render/page.go b/internal/render/page.go index f09e13f..04ffa23 100644 --- a/internal/render/page.go +++ b/internal/render/page.go @@ -1,10 +1,12 @@ package render import ( + "fmt" "regexp" "strings" "github.com/TheRootDaemon/tlgc/internal/config" + "github.com/TheRootDaemon/tlgc/logger" ) var ( @@ -59,10 +61,43 @@ type Page struct { Title string URL string Path string + RawContent string Description []string Examples []Example } +// Validate checks that every non-empty line in content +// starts with a valid tldr prefix (#, >, -, or `). +// Returns nil if the content is valid, +// or an error describing the first invalid line. +func Validate(content string) error { + lines := strings.SplitSeq(content, "\n") + lineNum := 0 + + for line := range lines { + lineNum++ + trimmed := strings.TrimSpace(line) + if trimmed == "" { + continue + } + + if strings.HasPrefix(line, "# ") || + strings.HasPrefix(line, "> ") || + strings.HasPrefix(line, "- ") || + (strings.HasPrefix(line, "`") && strings.HasSuffix(line, "`")) { + continue + } + + return fmt.Errorf( + "line %d: %q does not start with a valid prefix "+ + "(must begin with '# ', '> ', '- ', or '`')", + lineNum, trimmed, + ) + } + + return nil +} + // Parse parses a raw markdown tldr page string into a Page. // // It recognises the following markdown structure: @@ -117,6 +152,10 @@ func Parse(content string) *Page { } } + logger.Debug( + "title=%q descs=%d examples=%d url=%s", + p.Title, len(p.Description), len(p.Examples), p.URL, + ) return p } @@ -133,6 +172,7 @@ 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, @@ -179,6 +219,7 @@ func ParseCommand(raw string) []Segment { ) } + logger.Trace("raw=%q -> %d segments", raw, len(segments)) return segments } @@ -242,6 +283,7 @@ func parseInnerPlaceholders(inner string) Segment { short, long = right, left } + logger.Trace("option short=%q long=%q", short, long) return Segment{ Kind: Option, Long: long, @@ -249,6 +291,7 @@ func parseInnerPlaceholders(inner string) Segment { } } + logger.Trace("placeholder text=%q", inner) return Segment{ Kind: Placeholder, Text: inner, diff --git a/internal/render/page_test.go b/internal/render/page_test.go index 7583674..a36db94 100644 --- a/internal/render/page_test.go +++ b/internal/render/page_test.go @@ -8,6 +8,77 @@ import ( "github.com/stretchr/testify/require" ) +func TestValidate(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + content string + wantErr string + }{ + { + name: "valid page", + content: "# tar\n\n> archive utility.\n\n- create:\n\n`tar cf archive.tar`\n", + }, + { + name: "title only", + content: "# tar\n", + }, + { + name: "description only with URL", + content: "# tar\n\n> archive utility.\n> More information: .\n", + }, + { + name: "command without description", + content: "# tar\n\n`tar --help`\n", + }, + { + name: "empty content", + content: "", + }, + { + name: "blank lines only", + content: "\n\n\n", + }, + { + name: "invalid line at start", + content: "some random text\n", + wantErr: `line 1: "some random text" does not start with a valid tldr prefix`, + }, + { + name: "invalid line after title", + content: "# tar\n\nThis is not valid.\n", + wantErr: `line 3: "This is not valid." does not start with a valid tldr prefix`, + }, + { + name: "multiple lines, first invalid", + content: "bad\n# tar\n", + wantErr: `line 1: "bad" does not start with a valid tldr prefix`, + }, + { + name: "hash without space", + content: "#wrong\n", + wantErr: `line 1: "#wrong" does not start with a valid tldr prefix`, + }, + { + name: "dash without space", + content: "-wrong\n", + wantErr: `line 1: "-wrong" does not start with a valid tldr prefix`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := Validate(tt.content) + if tt.wantErr != "" { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + func TestParse(t *testing.T) { t.Parallel() diff --git a/internal/render/render.go b/internal/render/render.go index 6b5a0b6..b1505e2 100644 --- a/internal/render/render.go +++ b/internal/render/render.go @@ -80,6 +80,8 @@ func New(w io.Writer, options ...RenderOption) *Renderer { option(r) } + logger.Debug("useColor=%t", r.useColor) + return r } @@ -88,9 +90,21 @@ 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 } + logger.Debug( + "title=%q platform=%q raw=%t compact=%t edit=%d descs=%d examples=%d", + p.Title, + platform, + r.output.RawMarkdown, + r.output.Compact, + r.output.EditLink, + len(p.Description), + len(p.Examples), + ) + if r.output.EditLink { if url := buildEditURL(p.Path, p.URL); url != "" { if err := r.renderEditLink(r.w, url); err != nil { @@ -115,7 +129,7 @@ func (r *Renderer) Render(platform string, p *Page) error { _, err := io.WriteString(r.w, "\n\n") if err != nil { - return nil + return err } } @@ -143,6 +157,7 @@ func (r *Renderer) Render(platform string, p *Page) error { // 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 @@ -159,6 +174,15 @@ func (r *Renderer) renderEditLink(w io.Writer, url string) error { // 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 @@ -173,6 +197,7 @@ func (r *Renderer) renderRaw(p *Page) error { // 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, @@ -188,6 +213,7 @@ func (r *Renderer) renderTitle(w io.Writer, title string) error { // 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) _, err := io.WriteString( w, r.applyStyle( @@ -212,6 +238,8 @@ 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 { @@ -234,6 +262,7 @@ func (r *Renderer) renderDescriptions(w io.Writer, descs []string, url string) e // 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) _, err := io.WriteString( w, r.applyStyle( @@ -250,6 +279,7 @@ func (r *Renderer) renderBulletLine(w io.Writer, text, indent string) error { // 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 @@ -268,6 +298,11 @@ func (r *Renderer) renderExample(w io.Writer, ex Example) error { } } + _, err := io.WriteString(w, "\n") + if err != nil { + return err + } + if ex.Command != "" { segments := ParseCommand(ex.Command) if err := r.renderCommand(w, segments); err != nil { @@ -283,19 +318,23 @@ func (r *Renderer) renderExample(w io.Writer, ex Example) 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) - return fmt.Sprintf( + result := 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 "" } @@ -305,9 +344,20 @@ func buildEditURL(path, url string) string { // 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/render/render_test.go b/internal/render/render_test.go index 8f9caf4..210e9aa 100644 --- a/internal/render/render_test.go +++ b/internal/render/render_test.go @@ -208,10 +208,10 @@ func TestRender(t *testing.T) { " archive utility.\n" + " More information: https://example.org.\n" + "\n" + - " create archive\n" + + " create archive\n\n" + " tar cf archive.tar\n" + "\n" + - " extract\n" + + " extract\n\n" + " tar xf archive.tar\n" tests := []struct { @@ -278,9 +278,9 @@ func TestRender(t *testing.T) { }, }, { - name: "raw markdown mode writes file content", + name: "raw markdown mode writes content from RawContent", renderer: &Renderer{output: config.OutputConfig{RawMarkdown: true}}, - page: &Page{}, + page: &Page{RawContent: "# test page\n\n> description.\n"}, want: "# test page\n\n> description.\n", }, { @@ -329,12 +329,6 @@ func TestRender(t *testing.T) { tt.renderer.w = &buf } - if tt.renderer.output.RawMarkdown && tt.want != "" { - path := filepath.Join(t.TempDir(), "page.md") - require.NoError(t, os.WriteFile(path, []byte(tt.want), 0o644)) - tt.page.Path = path - } - err := tt.renderer.Render(tt.platform, tt.page) if tt.wantErr != "" { @@ -420,6 +414,8 @@ func TestRenderRaw(t *testing.T) { tests := []struct { name string + rawContent string + path string content string writer io.Writer want string @@ -427,25 +423,39 @@ func TestRenderRaw(t *testing.T) { wantAnyErr bool }{ { - name: "writes file content", - content: "# hello", - want: "# hello", + name: "uses RawContent when set", + rawContent: "# hello from content", + want: "# hello from content", + }, + { + name: "RawContent takes precedence over Path", + rawContent: "from content", + path: "/some/nonexistent/path.md", + want: "from content", + }, + { + name: "falls back to file when RawContent is empty", + rawContent: "", + content: "# file content", + want: "# file content", }, { - name: "file not found", + name: "file not found when RawContent empty and path invalid", + rawContent: "", + path: "/nonexistent/file.md", wantAnyErr: true, }, { - name: "write error", - content: "data", - writer: &errorWriter{err: errors.New("write error")}, - wantErr: "write error", + name: "write error", + rawContent: "data", + writer: &errorWriter{err: errors.New("write error")}, + wantErr: "write error", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - path := "/nonexistent/file.md" + path := tt.path if tt.content != "" { path = filepath.Join(t.TempDir(), "page.md") require.NoError(t, os.WriteFile(path, []byte(tt.content), 0o644)) @@ -458,7 +468,7 @@ func TestRenderRaw(t *testing.T) { } r := &Renderer{w: w} - err := r.renderRaw(&Page{Path: path}) + err := r.renderRaw(&Page{RawContent: tt.rawContent, Path: path}) if tt.wantErr != "" { assert.ErrorContains(t, err, tt.wantErr) @@ -738,27 +748,27 @@ func TestRenderExample(t *testing.T) { name: "description and command non-compact", ex: Example{Description: "create archive", Command: "tar cf archive.tar"}, indent: config.IndentConfig{Bullet: 2, Example: 4}, - want: " create archive\n tar cf archive.tar\n", + want: " create archive\n\n tar cf archive.tar\n", }, { name: "description only no command", ex: Example{Description: "just a description"}, indent: config.IndentConfig{Bullet: 2, Example: 4}, - want: " just a description\n", + want: " just a description\n\n", }, { name: "hyphens enabled", ex: Example{Description: "create archive", Command: "tar cf archive.tar"}, indent: config.IndentConfig{Bullet: 2, Example: 4}, output: config.OutputConfig{ShowHyphens: true, ExamplePrefix: "- "}, - want: " - create archive\n tar cf archive.tar\n", + want: " - create archive\n\n tar cf archive.tar\n", }, { name: "compact mode no blank line", ex: Example{Description: "create archive", Command: "tar cf archive.tar"}, indent: config.IndentConfig{Bullet: 2, Example: 4}, output: config.OutputConfig{Compact: true}, - want: " create archive tar cf archive.tar\n", + want: " create archive\n tar cf archive.tar\n", }, { name: "write error", diff --git a/internal/render/style.go b/internal/render/style.go index 6865906..50b37e3 100644 --- a/internal/render/style.go +++ b/internal/render/style.go @@ -4,6 +4,7 @@ import ( "strings" "github.com/TheRootDaemon/tlgc/internal/config" + "github.com/TheRootDaemon/tlgc/logger" "github.com/TheRootDaemon/tlgc/termcolor" ) @@ -14,7 +15,9 @@ func (r *Renderer) applyStyle(s config.OutputStyle, t string) string { return t } - return termcolor.Sprint(styleString(s), t) + styled := termcolor.Sprint(styleString(s), t) + logger.Trace("len=%d styled=%t", len(t), styled != t) + return styled } // styleForSegment returns the OutputStyle @@ -22,6 +25,7 @@ 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 @@ -62,5 +66,7 @@ func styleString(s config.OutputStyle) string { parts = append(parts, "on_"+string(s.Background.Named)) } - return strings.Join(parts, " ") + result := strings.Join(parts, " ") + logger.Trace("result=%q", result) + return result } diff --git a/internal/upstream/checksum.go b/internal/upstream/checksum.go index 1a20748..97213bc 100644 --- a/internal/upstream/checksum.go +++ b/internal/upstream/checksum.go @@ -5,6 +5,8 @@ import ( "fmt" "hash" "strings" + + "github.com/TheRootDaemon/tlgc/logger" ) // verifySHA256hex verifies got against an expected SHA256 checksum. @@ -12,6 +14,7 @@ import ( // Expected may be either a raw 64-character hexadecimal SHA256 hash // or a checksum-file entry in the form " ". func verifySHA256hex(got, expected string) error { + logger.Trace("verifying sha256: expected %.16s...", expected) expected = strings.TrimSpace(expected) if expected == "" { return nil @@ -64,6 +67,7 @@ func verifySHA256Hash(h hash.Hash, expected string) error { // Returns the hash and filename. // Returns an error for empty lines. func ParseChecksum(line string) (hash, filename string, err error) { + logger.Trace("parsing checksum: %s", line) line = strings.TrimSpace(line) if line == "" { return "", "", fmt.Errorf("empty checksum line") diff --git a/internal/upstream/http.go b/internal/upstream/http.go index 98cdeb4..a5cabd4 100644 --- a/internal/upstream/http.go +++ b/internal/upstream/http.go @@ -5,6 +5,8 @@ import ( "fmt" "io" "net/http" + + "github.com/TheRootDaemon/tlgc/logger" ) // newRequest creates an HTTP GET request with the configured User-Agent. @@ -38,6 +40,7 @@ func (c *Client) send(req *http.Request) (*http.Response, error) { // if resp does not contain a successful 2xx status code. // The response body is closed on failure. func (c *Client) validateResponse(resp *http.Response) error { + logger.Trace("response: %d", resp.StatusCode) if resp.StatusCode < 200 || resp.StatusCode >= 300 { _ = resp.Body.Close() return fmt.Errorf("unexpected status: %d", resp.StatusCode) @@ -74,6 +77,7 @@ func (c *Client) wrapBody( // execute performs an HTTP GET request, validates the response, and wraps // the response body with any configured limits and progress reporting. func (c *Client) execute(ctx context.Context, url string) (*http.Response, error) { + logger.Debug("GET %s", url) req, err := c.newRequest(ctx, url) if err != nil { return nil, err diff --git a/main.go b/main.go index 330eb1a..8237037 100644 --- a/main.go +++ b/main.go @@ -1,11 +1,28 @@ package main import ( - "fmt" + "os" - "github.com/TheRootDaemon/tlgc/internal/config" + "github.com/TheRootDaemon/tlgc/cmd" + "github.com/TheRootDaemon/tlgc/internal/app" + "github.com/TheRootDaemon/tlgc/logger" ) func main() { - fmt.Println(config.DefaultConfig()) + cli, err := cmd.Parse() + if err != nil { + logger.Error("%w", err) + os.Exit(1) + } + + logger.SetDefault( + logger.New( + cli.Quiet, + cli.Verbose, + ), + ) + + os.Exit( + app.New().Run(cli), + ) }