From a2def02b9005253b011801111290859f15b31ce5 Mon Sep 17 00:00:00 2001 From: TheRootDaemon Date: Mon, 22 Jun 2026 15:58:36 +0530 Subject: [PATCH 01/11] feat(cache): Introduced cache, page listing from cache --- internal/cache/cache.go | 127 ++++++++++++++++++++++++++++++++++++++++ internal/cache/doc.go | 16 +++++ internal/cache/list.go | 106 +++++++++++++++++++++++++++++++++ 3 files changed, 249 insertions(+) create mode 100644 internal/cache/cache.go create mode 100644 internal/cache/doc.go create mode 100644 internal/cache/list.go diff --git a/internal/cache/cache.go b/internal/cache/cache.go new file mode 100644 index 0000000..2735358 --- /dev/null +++ b/internal/cache/cache.go @@ -0,0 +1,127 @@ +package cache + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "sync/atomic" + "time" + + "github.com/TheRootDaemon/tlgc/internal/config" + "github.com/TheRootDaemon/tlgc/slice" +) + +// Cache manages the local tldr-pages cache on disk. +type Cache struct { + dir string + platforms atomic.Value +} + +// New creates a Cache using the cache directory from the config singleton. +func New() *Cache { + return &Cache{ + dir: config.Cache().Dir, + } +} + +// Dir returns the cache directory path. +func (c *Cache) Dir() string { + return c.dir +} + +// subdirExists reports whether name is a subdirectory of the cache. +func (c *Cache) subDirExists(name string) bool { + fi, err := os.Stat(filepath.Join(c.dir, name)) + return err == nil && fi.IsDir() +} + +// Age returns the cache age based on the checksum file's mtime. +// Falls back to the cache directory mtime +// if the checksum file does not exist. +func (c *Cache) Age() (time.Duration, error) { + sumfile := filepath.Join(c.dir, checksumFile) + fi, err := os.Stat(sumfile) + if err != nil { + fi, err = os.Stat(c.dir) + if err != nil { + return 0, err + } + } + + mod := fi.ModTime() + age := time.Since(mod) + + if age < 0 { + return 0, fmt.Errorf("cache mtime is in the future: clock issue") + } + + return age, nil +} + +// getPlatforms discovers available platforms +// by reading directories under pages.en/. +// Results are cached after first load. +func (c *Cache) getPlatforms() ([]string, error) { + if p, ok := c.platforms.Load().([]string); ok { + return p, nil + } + + entries, err := os.ReadDir(filepath.Join(c.dir, englishDirectory)) + if err != nil { + return nil, fmt.Errorf("reading %s: %s", englishDirectory, err) + } + + var platforms []string + for _, e := range entries { + if e.IsDir() { + platforms = append(platforms, e.Name()) + } + } + + if len(platforms) == 0 { + return nil, fmt.Errorf("'%s' contains no platform directories", englishDirectory) + } + + sort.Strings(platforms) + c.platforms.Store(platforms) + return platforms, nil +} + +// getLanguageDirectories returns all pages.* directories in the cache. +func (c *Cache) getLanguageDirectories() ([]string, error) { + entries, err := os.ReadDir(c.dir) + if err != nil { + return nil, err + } + + var dirs []string + for _, e := range entries { + if e.IsDir() && strings.HasPrefix(e.Name(), "pages.") { + dirs = append(dirs, e.Name()) + } + } + + sort.Strings(dirs) + return dirs, nil +} + +// languagesToDirs converts language codes to pages.xx dirs that exist. +// If sort is true, results are sorted; otherwise only adjacent duplicates are removed. +func (c *Cache) languagesToDirectories(languages []string, sortFlag bool) []string { + var dirs []string + for _, lang := range languages { + dir := "pages." + lang + if c.subDirExists(dir) { + dirs = append(dirs, dir) + } + } + + if sortFlag { + sort.Strings(dirs) + } + + dirs = slice.Dedup(dirs) + return dirs +} diff --git a/internal/cache/doc.go b/internal/cache/doc.go new file mode 100644 index 0000000..ca5baa5 --- /dev/null +++ b/internal/cache/doc.go @@ -0,0 +1,16 @@ +// Package cache manages local tldr-pages downloads. +// +// The cache stores tldr-pages in directories named pages. +// under a root cache directory. +// +// Each language directory contains platform subdirectories +// (common, linux, osx, windows, android) with .md page files. +package cache + +const ( + // checksumFile is the name of the checksum file in the cache directory. + checksumFile = "tldr.sha256sums" + + // englishDirectory is the name of the English pages directory. + englishDirectory = "pages.en" +) diff --git a/internal/cache/list.go b/internal/cache/list.go new file mode 100644 index 0000000..ea4cb5d --- /dev/null +++ b/internal/cache/list.go @@ -0,0 +1,106 @@ +package cache + +import ( + "os" + "path/filepath" + "sort" + "strings" + + "github.com/TheRootDaemon/tlgc/slice" +) + +// ListFor returns all page names in the give platform (plus common). +func (c *Cache) ListFor(platform string) ([]string, error) { + if _, err := c.getPlatforms(); err != nil { + return nil, err + } + + pages, err := c.listDirectory(platform, englishDirectory) + if err != nil { + return nil, err + } + + if platform != "common" { + common, err := c.listDirectory("common", englishDirectory) + if err != nil { + return nil, err + } + + pages = append(pages, common...) + } + + sort.Strings(pages) + return slice.Dedup(pages), nil +} + +// ListAll returns all page names across all platforms in English. +func (c *Cache) ListAll() ([]string, error) { + platforms, err := c.getPlatforms() + if err != nil { + return nil, err + } + + var pages []string + for _, platform := range platforms { + platformPages, err := c.listDirectory(platform, englishDirectory) + if err != nil { + return nil, err + } + + pages = append(pages, platformPages...) + } + + sort.Strings(pages) + return slice.Dedup(pages), nil +} + +// ListPlatforms returns the available platform directories. +func (c *Cache) ListPlatforms() ([]string, error) { + return c.getPlatforms() +} + +// ListLanguages returns the installed language codes (without the "pages." prefix). +func (c *Cache) ListLanguages() ([]string, error) { + directories, err := c.getLanguageDirectories() + if err != nil { + return nil, err + } + + languages := make([]string, len(directories)) + for i, directory := range directories { + languages[i] = strings.TrimPrefix(directory, "pages.") + } + + return languages, nil +} + +// listDir lists page names (without .md extension) in lang/platform. +// Returns empty slice if the directory does not exist. +func (c *Cache) listDirectory(platform, languageDirectory string) ([]string, error) { + dir := filepath.Join(c.dir, languageDirectory, platform) + entries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + + return nil, err + } + + var pages []string + for _, e := range entries { + if e.IsDir() { + continue + } + + name := e.Name() + if before, ok := strings.CutSuffix(name, ".md"); ok { + pages = append( + pages, + before, + ) + } + } + + return pages, nil +} From 72062918d18c6ea18886e64982bf5001037826a4 Mon Sep 17 00:00:00 2001 From: TheRootDaemon Date: Mon, 22 Jun 2026 19:34:02 +0530 Subject: [PATCH 02/11] feat(cache): Added page search and lookup support --- internal/cache/find.go | 139 ++++++++++++++++++++++++++++++++++ internal/cache/search.go | 157 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 296 insertions(+) create mode 100644 internal/cache/find.go create mode 100644 internal/cache/search.go diff --git a/internal/cache/find.go b/internal/cache/find.go new file mode 100644 index 0000000..b84e45e --- /dev/null +++ b/internal/cache/find.go @@ -0,0 +1,139 @@ +package cache + +import ( + "fmt" + "os" + "path/filepath" +) + +// FindResult contains the pages found for a command lookup. +// +// Matches contains pages found in the requested platform +// and/or the common platform. +// +// Fallbacks contains pages found only in other platforms. +type FindResult struct { + Matches []string + Fallbacks []string +} + +// Find locates a command page in the cache. +// +// It searches the requested platform first, +// then the common platform. +// If no match is found there, it searches the remaining platforms +// and returns those matches as fallbacks. +// +// Language directories are searched in the order provided by languages. +func (c *Cache) Find(query, platform string, languages []string) (*FindResult, error) { + languageDirectories := c.languagesToDirectories(languages, false) + if len(languageDirectories) == 0 { + return nil, fmt.Errorf("no matching language directories found in cache") + } + + platforms, err := c.getPlatforms() + if err != nil { + return nil, err + } + + if platform != "common" { + if !platformExists(platforms, platform) { + return nil, fmt.Errorf("platform %q does not exist", platform) + } + } + + file := query + ".md" + + matches := c.primaryMatches( + platform, + file, + languageDirectories, + ) + fallbacks := c.fallbackMatches( + platform, + file, + platforms, + languageDirectories, + ) + + return &FindResult{ + Matches: matches, + Fallbacks: fallbacks, + }, nil +} + +// primaryMatches searches for file in the requested platform +// and the common platform, returning matches in priority order. +func (c *Cache) primaryMatches( + platform, + file string, + languageDirectories []string, +) []string { + var results []string + + if platform != "common" { + if path := c.findPageFor( + file, + platform, + languageDirectories, + ); path != "" { + results = append(results, path) + } + } + + if path := c.findPageFor( + file, + "common", + languageDirectories, + ); path != "" { + results = append(results, path) + } + + return results +} + +// fallbackMatches searches for file in all platforms +// other than the requested platform and common, +// returning any matches found. +func (c *Cache) fallbackMatches( + file, + platform string, + platforms, + languageDirectories []string, +) []string { + var results []string + + for _, p := range platforms { + if p == platform || p == "common" { + continue + } + + if path := c.findPageFor( + file, + p, + languageDirectories, + ); path != "" { + results = append(results, path) + } + } + + return results +} + +// findPageFor searches for fname within platform across language dirs. +// Returns the first match found (respects language priority). +func (c *Cache) findPageFor(fname, platform string, languageDirectories []string) string { + for _, languageDirectory := range languageDirectories { + path := filepath.Join( + c.dir, + languageDirectory, + platform, + fname, + ) + if _, err := os.Stat(path); err == nil { + return path + } + } + + return "" +} diff --git a/internal/cache/search.go b/internal/cache/search.go new file mode 100644 index 0000000..c35313e --- /dev/null +++ b/internal/cache/search.go @@ -0,0 +1,157 @@ +package cache + +import ( + "fmt" + "slices" + "sort" + "strings" + + "github.com/TheRootDaemon/tlgc/logger" +) + +// SearchResult represents a matching page from a search. +type SearchResult struct { + Page string + Language string + Platform string +} + +// Search performs a case-insensitive substring search +// across the requested platforms and languages. +// +// If platform is empty, all platforms are searched. +// If a specific platform is requested, +// that platform and common are searched. +// Results are returned sorted by page name. +func (c *Cache) Search(query, platform string, languages []string) ([]SearchResult, error) { + platforms, err := c.resolvePlatforms(platform) + if err != nil { + return nil, err + } + + languageDirectories := c.languagesToDirectories(languages, false) + if len(languageDirectories) == 0 { + return nil, fmt.Errorf( + "no installed languages match the requested languages", + ) + } + + results := c.searchPages( + query, + platforms, + languageDirectories, + ) + + if len(results) == 0 { + return nil, fmt.Errorf("no pages matched your search term") + } + + sort.Slice( + results, + func(i, j int) bool { + return results[i].Page < results[j].Page + }, + ) + + return results, nil +} + +// resolvePlatforms validates platform +// and returns the platforms +// that should be searched. +// +// If platform is empty, all available platforms are returned. +// If platform is "common", only common is returned. +// Otherwise, the requested platform and common are returned. +func (c *Cache) resolvePlatforms(platform string) ([]string, error) { + platforms, err := c.getPlatforms() + if err != nil { + return nil, err + } + + if platform != "" && !platformExists(platforms, platform) { + return nil, fmt.Errorf( + "platform %q does not exist, possible values are: %s", + platform, + strings.Join(platforms, ", "), + ) + } + + switch { + case platform == "common": + return []string{"common"}, nil + case platform != "": + return []string{platform, "common"}, nil + default: + return platforms, nil + } +} + +// searchPages searches all platform/language combinations +// and returns the matching pages. +func (c *Cache) searchPages( + query string, + platforms, languageDirectories []string, +) []SearchResult { + query = strings.ToLower(query) + var results []SearchResult + + for _, languageDirectory := range languageDirectories { + for _, platform := range platforms { + matches := c.searchDirectory( + query, + platform, + languageDirectory, + ) + + results = append(results, matches...) + } + } + + return results +} + +// searchDirectory searches a single language/platform directory +// for pages whose names contain query. +func (c *Cache) searchDirectory(query, platform, languageDirectory string) []SearchResult { + pages, err := c.listDirectory(platform, languageDirectory) + if err != nil { + logger.Debug( + "error listing %s/%s: %s", + languageDirectory, + platform, + err, + ) + + return nil + } + + var results []SearchResult + language := strings.TrimPrefix( + languageDirectory, + "pages.", + ) + + for _, page := range pages { + if strings.Contains( + strings.ToLower(page), + query, + ) { + results = append( + results, + SearchResult{ + Page: page, + Language: language, + Platform: platform, + }, + ) + } + } + + return results +} + +// platformExists checks whether the given platform is in the list. +func platformExists(platforms []string, platform string) bool { + return slices.Contains(platforms, platform) +} From 3cc370b683ead3385b8dffdd445276afd167f356 Mon Sep 17 00:00:00 2001 From: TheRootDaemon Date: Tue, 23 Jun 2026 14:44:28 +0530 Subject: [PATCH 03/11] feat(cache): Add cache info --- internal/cache/cache.go | 24 ------- internal/cache/info.go | 138 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 138 insertions(+), 24 deletions(-) create mode 100644 internal/cache/info.go diff --git a/internal/cache/cache.go b/internal/cache/cache.go index 2735358..421da06 100644 --- a/internal/cache/cache.go +++ b/internal/cache/cache.go @@ -7,7 +7,6 @@ import ( "sort" "strings" "sync/atomic" - "time" "github.com/TheRootDaemon/tlgc/internal/config" "github.com/TheRootDaemon/tlgc/slice" @@ -37,29 +36,6 @@ func (c *Cache) subDirExists(name string) bool { return err == nil && fi.IsDir() } -// Age returns the cache age based on the checksum file's mtime. -// Falls back to the cache directory mtime -// if the checksum file does not exist. -func (c *Cache) Age() (time.Duration, error) { - sumfile := filepath.Join(c.dir, checksumFile) - fi, err := os.Stat(sumfile) - if err != nil { - fi, err = os.Stat(c.dir) - if err != nil { - return 0, err - } - } - - mod := fi.ModTime() - age := time.Since(mod) - - if age < 0 { - return 0, fmt.Errorf("cache mtime is in the future: clock issue") - } - - return age, nil -} - // getPlatforms discovers available platforms // by reading directories under pages.en/. // Results are cached after first load. diff --git a/internal/cache/info.go b/internal/cache/info.go new file mode 100644 index 0000000..8e37ad5 --- /dev/null +++ b/internal/cache/info.go @@ -0,0 +1,138 @@ +package cache + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/TheRootDaemon/tlgc/format" + "github.com/TheRootDaemon/tlgc/internal/config" +) + +type LanguageInfo struct { + Pages int + Language string +} + +type InfoResult struct { + AutoUpdate bool + TotalPages int + MaxAge uint64 + CacheDir string + Age string + LanguageStats []LanguageInfo +} + +// Age returns the cache age based on the checksum file's mtime. +// Falls back to the cache directory mtime +// if the checksum file does not exist. +func (c *Cache) Age() (time.Duration, error) { + sumfile := filepath.Join(c.dir, checksumFile) + fi, err := os.Stat(sumfile) + if err != nil { + fi, err = os.Stat(c.dir) + if err != nil { + return 0, err + } + } + + mod := fi.ModTime() + age := time.Since(mod) + + if age < 0 { + return 0, fmt.Errorf("cache mtime is in the future: clock issue") + } + + return age, nil +} + +func (c *Cache) Info() (*InfoResult, error) { + fi, err := os.Stat(c.dir) + if err != nil { + return nil, fmt.Errorf("cache directory %q: %s", c.dir, err) + } + if !fi.IsDir() { + return nil, fmt.Errorf("cache path %q is not a directory", c.dir) + } + + age, err := c.Age() + if err != nil { + return nil, err + } + + cfg := config.Cache() + + languageDirectories, err := c.getLanguageDirectories() + if err != nil { + return nil, err + } + + platforms, err := c.getPlatforms() + if err != nil { + return nil, err + } + + languageStats, total, err := c.languageStats( + platforms, + languageDirectories, + ) + if err != nil { + return nil, err + } + + return &InfoResult{ + CacheDir: c.dir, + Age: format.DurationFmt(age), + MaxAge: cfg.MaxAge, + AutoUpdate: cfg.AutoUpdate, + LanguageStats: languageStats, + TotalPages: total, + }, nil +} + +func (c *Cache) languageStats( + platforms, + languageDirectories []string, +) ([]LanguageInfo, int, error) { + var languageStats []LanguageInfo + total := 0 + + 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) + } + + languageStats = append(languageStats, LanguageInfo{ + Language: lang, + Pages: count, + }) + total += count + } + + return languageStats, total, nil +} From bda01d8cd1e3ba3dd3fb27e85e10e80d020340e6 Mon Sep 17 00:00:00 2001 From: TheRootDaemon Date: Tue, 23 Jun 2026 16:23:13 +0530 Subject: [PATCH 04/11] feat(cache): Add clean --- internal/cache/clean.go | 101 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 internal/cache/clean.go diff --git a/internal/cache/clean.go b/internal/cache/clean.go new file mode 100644 index 0000000..ced155f --- /dev/null +++ b/internal/cache/clean.go @@ -0,0 +1,101 @@ +package cache + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/TheRootDaemon/tlgc/logger" +) + +func (c *Cache) Clean() error { + entries, err := getEntries(c.dir) + if err != nil { + return err + } + + if entries == nil { + logger.Info("cache does not exist, skipping...") + return nil + } + + var log strings.Builder + log.WriteString("removing following files...\n") + for _, entry := range entries { + name := entry.Name() + fmt.Fprintf(&log, "\n%q", name) + } + + logger.InfoStart( + "%s\nproceed with cleaning: [Y/n] ", + log.String(), + ) + + cleanCache := parseInput(bufio.NewReader(os.Stdin)) + if !cleanCache { + logger.InfoEnd("aborted") + return nil + } + + logger.Info("cleaning...") + for _, entry := range entries { + if err := os.RemoveAll( + filepath.Join( + c.dir, + entry.Name(), + ), + ); err != nil { + return fmt.Errorf( + "remove %q: %w", + entry.Name(), + err, + ) + } + } + + logger.InfoEnd("done...") + + return nil +} + +func getEntries(path string) ([]os.DirEntry, error) { + entries, err := os.ReadDir(path) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + + return nil, err + } + + if len(entries) == 0 { + return nil, nil + } + + return entries, nil +} + +func parseInput(reader *bufio.Reader) bool { + input, err := reader.ReadString('\n') + if err != nil { + return false + } + + input = strings.TrimSpace(input) + if len(input) == 0 { + return true + } + + switch { + case input == "yes": + return true + case input[0] == 'y': + return true + case input[0] == 'Y': + return true + default: + return false + } +} From aa0ed0f7525b069096fa2eadb66c801a0b47fc3b Mon Sep 17 00:00:00 2001 From: TheRootDaemon Date: Wed, 24 Jun 2026 00:15:10 +0530 Subject: [PATCH 05/11] feat(cache): Implment checksum, archive, update operations --- internal/cache/archive.go | 121 ++++++++++++++++++++++++++++++++++++ internal/cache/checksums.go | 92 +++++++++++++++++++++++++++ internal/cache/update.go | 112 +++++++++++++++++++++++++++++++++ 3 files changed, 325 insertions(+) create mode 100644 internal/cache/archive.go create mode 100644 internal/cache/checksums.go create mode 100644 internal/cache/update.go diff --git a/internal/cache/archive.go b/internal/cache/archive.go new file mode 100644 index 0000000..d740854 --- /dev/null +++ b/internal/cache/archive.go @@ -0,0 +1,121 @@ +package cache + +import ( + "archive/zip" + "bytes" + "context" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/TheRootDaemon/tlgc/internal/config" + "github.com/TheRootDaemon/tlgc/internal/upstream" + "github.com/TheRootDaemon/tlgc/logger" +) + +// downloadArchive downloads the named language archive from the configured mirror. +func downloadArchive( + ctx context.Context, + client *upstream.Client, + archiveName, hash string, +) ([]byte, error) { + mirror := config.Cache().Mirror + url := mirror + "/" + archiveName + return client.DownloadBytes(ctx, url, hash) +} + +// extractArchive removes the existing language directory, +// recreates it, +// and extracts the zip archive contents into it. +func (c *Cache) extractArchive( + languageDirectory string, + data []byte, +) error { + logger.InfoStart("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 + } + + zipReader, err := zip.NewReader( + bytes.NewReader(data), + int64(len(data)), + ) + if err != nil { + return fmt.Errorf("reading zip archive: %w", err) + } + + root, err := os.OpenRoot(targetDirectory) + if err != nil { + return err + } + defer func() { + _ = root.Close() + }() + + var extracted int + for _, f := range zipReader.File { + if strings.Contains(f.Name, "..") { + continue + } + + if f.FileInfo().IsDir() { + if err := root.MkdirAll(f.Name, 0o750); err != nil { + return fmt.Errorf("creating directory %s: %w", f.Name, err) + } + + continue + } + + if err := root.MkdirAll(filepath.Dir(f.Name), 0o750); err != nil { + return fmt.Errorf("creating directory for %s: %w", f.Name, err) + } + + if err := extractFile(root, f); err != nil { + return err + } + + extracted++ + } + + logger.InfoEnd("%d pages", extracted) + return nil +} + +// extractFile writes a single zip entry to disk +// using the given root directory. +func extractFile( + root *os.Root, + f *zip.File, +) error { + rc, err := f.Open() + if err != nil { + return fmt.Errorf("opening %s in zip: %w", f.Name, err) + } + + out, err := root.OpenFile( + f.Name, + os.O_CREATE|os.O_WRONLY|os.O_TRUNC, + f.Mode(), + ) + if err != nil { + _ = rc.Close() + return fmt.Errorf("creating %s: %w", f.Name, err) + } + + _, err = out.ReadFrom(rc) + _ = rc.Close() + _ = out.Close() + + if err != nil { + return fmt.Errorf("writing %s: %w", f.Name, err) + } + + return nil +} diff --git a/internal/cache/checksums.go b/internal/cache/checksums.go new file mode 100644 index 0000000..2bae92e --- /dev/null +++ b/internal/cache/checksums.go @@ -0,0 +1,92 @@ +package cache + +import ( + "context" + "fmt" + "os" + "strings" + + "github.com/TheRootDaemon/tlgc/internal/config" + "github.com/TheRootDaemon/tlgc/internal/upstream" +) + +// loadChecksums reads the cached checksum file from disk +// and returns it as a filename-to-hash map. +func (c *Cache) loadChecksums() map[string]string { + root, err := os.OpenRoot(c.dir) + if err != nil { + return nil + } + defer func() { + _ = root.Close() + }() + + checksumBytes, err := root.ReadFile(checksumFile) + if err != nil { + return nil + } + + return parseChecksum(checksumBytes) +} + +// saveChecksums writes the checksum map to disk in sha256sum format. +func (c *Cache) saveChecksums(checksums map[string]string) error { + if err := os.MkdirAll(c.dir, 0o750); err != nil { + return fmt.Errorf("creating cache directory: %s", err) + } + + var sb strings.Builder + for name, hash := range checksums { + fmt.Fprintf(&sb, "%s %s\n", hash, name) + } + + root, err := os.OpenRoot(c.dir) + if err != nil { + return err + } + defer func() { + _ = root.Close() + }() + + if err := root.WriteFile( + checksumFile, + []byte( + sb.String(), + ), + 0o600, + ); err != nil { + return fmt.Errorf("writing checksums: %w", err) + } + + return nil +} + +// downloadChecksum fetches the tldr-pages checksum file from the configured mirror. +func downloadChecksum( + ctx context.Context, + client *upstream.Client, +) ([]byte, error) { + mirror := config.Cache().Mirror + checksumURL := mirror + "/" + checksumFile + return client.DownloadBytes(ctx, checksumURL, "") +} + +// parseChecksum parses sha256sum-formatted data into a filename-to-hash map. +func parseChecksum(checksum []byte) map[string]string { + structuredChecksum := make(map[string]string) + for line := range strings.SplitSeq(string(checksum), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + + hash, filename, err := upstream.ParseChecksum(line) + if err != nil { + continue + } + + structuredChecksum[filename] = hash + } + + return structuredChecksum +} diff --git a/internal/cache/update.go b/internal/cache/update.go new file mode 100644 index 0000000..f5579c0 --- /dev/null +++ b/internal/cache/update.go @@ -0,0 +1,112 @@ +package cache + +import ( + "context" + "fmt" + + "github.com/TheRootDaemon/tlgc/internal/upstream" + "github.com/TheRootDaemon/tlgc/logger" +) + +// Update downloads the latest tldr-pages archives +// for the given languages +// and extracts them into the cache. +func (c *Cache) Update( + ctx context.Context, + languages []string, + client *upstream.Client, +) error { + checksums, err := downloadChecksum(ctx, client) + if err != nil { + return fmt.Errorf("downloading checksum: %s", err) + } + + oldChecksums := c.loadChecksums() + newChecksums := parseChecksum(checksums) + + var downloaded int + for _, language := range languages { + updated, err := c.updateLanguage( + ctx, + client, + language, + oldChecksums, + newChecksums, + ) + if err != nil { + return err + } + + if updated { + downloaded++ + } + } + + if err := c.saveChecksums(newChecksums); err != nil { + return fmt.Errorf("saving checksums: %w", err) + } + + if downloaded == 0 { + logger.Info("pages are up to date") + return nil + } + + c.platforms.Store(nil) + return nil +} + +// updateLanguage downloads and extracts a single language archive, +// if the checksum has changed or the directory is missing. +// It returns true when an archive was actually downloaded. +func (c *Cache) updateLanguage( + ctx context.Context, + client *upstream.Client, + language string, + oldChecksums, + newChecksums map[string]string, +) (bool, error) { + languageDirectory := "pages." + language + archiveName := fmt.Sprintf("tldr-pages.%s.zip", language) + if !needsUpdate( + c.subDirExists(languageDirectory), + archiveName, + oldChecksums, + newChecksums, + ) { + return false, nil + } + + hash := newChecksums[archiveName] + data, err := downloadArchive(ctx, client, archiveName, hash) + if err != nil { + return false, fmt.Errorf("downloading %s: %w", archiveName, err) + } + + if err := c.extractArchive(languageDirectory, data); err != nil { + return false, fmt.Errorf("extracting %s: %w", languageDirectory, err) + } + + return true, nil +} + +// needsUpdate reports whether an archive should be downloaded, +// based on whether it exists in the new checksums, +// whether we already have the same hash, +// and whether the language directory exists on disk. +func needsUpdate( + exists bool, + archive string, + oldChecksums map[string]string, + newChecksums map[string]string, +) bool { + newHash, ok := newChecksums[archive] + if !ok { + return false + } + + oldHash, hadOld := oldChecksums[archive] + + return !hadOld || + !exists || + oldHash != newHash +} From 4eb0556dc6c2aa3e039814d1d35c6d53726b497f Mon Sep 17 00:00:00 2001 From: TheRootDaemon Date: Wed, 24 Jun 2026 21:58:39 +0530 Subject: [PATCH 06/11] test(cache): Tests for cache, info, checksums and add a Global function to reset the singleton for testing --- internal/cache/cache.go | 2 +- internal/cache/cache_test.go | 539 +++++++++++++++++++++++++++++++ internal/cache/checksums_test.go | 365 +++++++++++++++++++++ internal/cache/info_test.go | 213 ++++++++++++ internal/cache/update.go | 2 +- internal/config/singleton.go | 6 + 6 files changed, 1125 insertions(+), 2 deletions(-) create mode 100644 internal/cache/cache_test.go create mode 100644 internal/cache/checksums_test.go create mode 100644 internal/cache/info_test.go diff --git a/internal/cache/cache.go b/internal/cache/cache.go index 421da06..8d7d796 100644 --- a/internal/cache/cache.go +++ b/internal/cache/cache.go @@ -40,7 +40,7 @@ func (c *Cache) subDirExists(name string) bool { // by reading directories under pages.en/. // Results are cached after first load. func (c *Cache) getPlatforms() ([]string, error) { - if p, ok := c.platforms.Load().([]string); ok { + if p, ok := c.platforms.Load().([]string); ok && p != nil { return p, nil } diff --git a/internal/cache/cache_test.go b/internal/cache/cache_test.go new file mode 100644 index 0000000..4948eb4 --- /dev/null +++ b/internal/cache/cache_test.go @@ -0,0 +1,539 @@ +package cache + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/TheRootDaemon/tlgc/internal/config" +) + +func TestNew(t *testing.T) { + t.Run("from_initialized_config", func(t *testing.T) { + config.ResetForTesting() + defer config.ResetForTesting() + + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.toml") + err := os.WriteFile(cfgPath, []byte("[cache]\ndir = \"/custom/cache\"\n"), 0o644) + require.NoError(t, err) + + t.Setenv("TLGC_CONFIG", cfgPath) + err = config.Initialize() + require.NoError(t, err) + + c := New() + assert.Equal(t, "/custom/cache", c.Dir()) + }) + + t.Run("default_dir_when_not_in_config", func(t *testing.T) { + config.ResetForTesting() + defer config.ResetForTesting() + + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.toml") + err := os.WriteFile(cfgPath, []byte("[output]\nshow_title = false\n"), 0o644) + require.NoError(t, err) + + t.Setenv("TLGC_CONFIG", cfgPath) + err = config.Initialize() + require.NoError(t, err) + + c := New() + assert.Equal(t, config.Cache().Dir, c.Dir()) + }) + + t.Run("empty_dir_in_config", func(t *testing.T) { + config.ResetForTesting() + defer config.ResetForTesting() + + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.toml") + err := os.WriteFile(cfgPath, []byte("[cache]\ndir = \"\"\n"), 0o644) + require.NoError(t, err) + + t.Setenv("TLGC_CONFIG", cfgPath) + err = config.Initialize() + require.NoError(t, err) + + c := New() + assert.Equal(t, "", c.Dir()) + }) +} + +// TestDir tests Cache.Dir. +func TestDir(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + dir string + want string + }{ + {name: "simple_path", dir: "/tmp/cache", want: "/tmp/cache"}, + {name: "empty_string", dir: "", want: ""}, + {name: "relative_path", dir: "./test/cache", want: "./test/cache"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Cache{dir: tt.dir} + assert.Equal(t, tt.want, c.Dir()) + }) + } +} + +// TestSubDirExists tests Cache.subDirExists. +func TestSubDirExists(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + setupDir func(t *testing.T) string + subName string + want bool + }{ + { + name: "existing_subdirectory", + setupDir: func(t *testing.T) string { + d := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(d, "sub"), 0o755)) + return d + }, + subName: "sub", + want: true, + }, + { + name: "non_existent_name", + setupDir: func(t *testing.T) string { + return t.TempDir() + }, + subName: "nonexistent", + want: false, + }, + { + name: "file_instead_of_dir", + setupDir: func(t *testing.T) string { + d := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(d, "file.txt"), nil, 0o644)) + return d + }, + subName: "file.txt", + want: false, + }, + { + name: "empty_name_returns_true", + setupDir: func(t *testing.T) string { + return t.TempDir() + }, + subName: "", + want: true, + }, + { + name: "nested_path", + setupDir: func(t *testing.T) string { + d := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(d, "a", "b"), 0o755)) + return d + }, + subName: "a/b", + want: true, + }, + { + name: "path_traversal", + setupDir: func(t *testing.T) string { + return t.TempDir() + }, + subName: "../etc", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := tt.setupDir(t) + c := &Cache{dir: dir} + assert.Equal(t, tt.want, c.subDirExists(tt.subName)) + }) + } +} + +// TestGetPlatforms tests Cache.getPlatforms. +func TestGetPlatforms(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + setupDir func(t *testing.T) string + want []string + wantErr bool + skipCacheSeed bool + extraChecks func(t *testing.T, c *Cache, dir string) + }{ + { + name: "no_pages_en_dir", + setupDir: func(t *testing.T) string { + return t.TempDir() + }, + wantErr: true, + }, + { + name: "pages_en_empty_no_subdirs", + setupDir: func(t *testing.T) string { + d := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en"), 0o755)) + return d + }, + wantErr: true, + }, + { + name: "pages_en_only_files", + setupDir: func(t *testing.T) string { + d := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(d, "pages.en", "file1.md"), nil, 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(d, "pages.en", "file2.txt"), nil, 0o644)) + return d + }, + wantErr: true, + }, + { + name: "single_platform", + setupDir: func(t *testing.T) string { + d := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en", "common"), 0o755)) + return d + }, + want: []string{"common"}, + }, + { + name: "multiple_platforms_sorted", + setupDir: func(t *testing.T) string { + d := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en", "linux"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en", "common"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en", "osx"), 0o755)) + return d + }, + want: []string{"common", "linux", "osx"}, + }, + { + name: "caches_result", + setupDir: func(t *testing.T) string { + d := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en", "common"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en", "linux"), 0o755)) + return d + }, + skipCacheSeed: true, + want: []string{"common", "linux"}, + extraChecks: func(t *testing.T, c *Cache, dir string) { + got1, err := c.getPlatforms() + require.NoError(t, err) + assert.Equal(t, []string{"common", "linux"}, got1) + + require.NoError(t, os.RemoveAll(filepath.Join(dir, "pages.en", "linux"))) + + got2, err := c.getPlatforms() + require.NoError(t, err) + assert.Equal(t, got1, got2) + }, + }, + { + name: "re_read_after_cache_nil", + setupDir: func(t *testing.T) string { + d := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en", "common"), 0o755)) + return d + }, + skipCacheSeed: true, + want: []string{"common", "linux"}, + extraChecks: func(t *testing.T, c *Cache, dir string) { + got1, err := c.getPlatforms() + require.NoError(t, err) + assert.Equal(t, []string{"common"}, got1) + + c.platforms.Store([]string(nil)) + + require.NoError(t, os.MkdirAll(filepath.Join(dir, "pages.en", "linux"), 0o755)) + + got2, err := c.getPlatforms() + require.NoError(t, err) + assert.Equal(t, []string{"common", "linux"}, got2) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := tt.setupDir(t) + c := &Cache{dir: dir} + + if tt.extraChecks != nil { + tt.extraChecks(t, c, dir) + return + } + + got, err := c.getPlatforms() + if tt.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +// TestGetLanguageDirectories tests Cache.getLanguageDirectories. +func TestGetLanguageDirectories(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + setupDir func(t *testing.T) string + want []string + wantErr bool + }{ + { + name: "empty_cache_dir", + setupDir: func(t *testing.T) string { + return t.TempDir() + }, + want: nil, + }, + { + name: "only_non_pages_dirs", + setupDir: func(t *testing.T) string { + d := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(d, "other"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(d, "data"), 0o755)) + return d + }, + want: nil, + }, + { + name: "single_pages_en", + setupDir: func(t *testing.T) string { + d := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en"), 0o755)) + return d + }, + want: []string{"pages.en"}, + }, + { + name: "multiple_pages_dirs", + setupDir: func(t *testing.T) string { + d := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.de"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.zh"), 0o755)) + return d + }, + want: []string{"pages.de", "pages.en", "pages.zh"}, + }, + { + name: "mixed_pages_and_other", + setupDir: func(t *testing.T) string { + d := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(d, "other"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.de"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(d, "data"), 0o755)) + return d + }, + want: []string{"pages.de", "pages.en"}, + }, + { + name: "handles_readdir_error", + setupDir: func(t *testing.T) string { + d := t.TempDir() + filePath := filepath.Join(d, "not_a_dir") + require.NoError(t, os.WriteFile(filePath, nil, 0o644)) + return filePath + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := tt.setupDir(t) + c := &Cache{dir: dir} + + got, err := c.getLanguageDirectories() + if tt.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +// TestLanguagesToDirectories tests Cache.languagesToDirectories. +func TestLanguagesToDirectories(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + setupDir func(t *testing.T) string + languages []string + sortFlag bool + want []string + }{ + { + name: "nil_languages", + setupDir: func(t *testing.T) string { + d := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en"), 0o755)) + return d + }, + languages: nil, + sortFlag: false, + want: nil, + }, + { + name: "empty_languages", + setupDir: func(t *testing.T) string { + d := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en"), 0o755)) + return d + }, + languages: []string{}, + sortFlag: false, + want: nil, + }, + { + name: "single_language_exists", + setupDir: func(t *testing.T) string { + d := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en"), 0o755)) + return d + }, + languages: []string{"en"}, + sortFlag: false, + want: []string{"pages.en"}, + }, + { + name: "single_language_does_not_exist", + setupDir: func(t *testing.T) string { + d := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en"), 0o755)) + return d + }, + languages: []string{"de"}, + sortFlag: false, + want: nil, + }, + { + name: "multiple_languages_all_exist", + setupDir: func(t *testing.T) string { + d := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.de"), 0o755)) + return d + }, + languages: []string{"en", "de"}, + sortFlag: false, + want: []string{"pages.en", "pages.de"}, + }, + { + name: "multiple_languages_some_missing", + setupDir: func(t *testing.T) string { + d := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.fr"), 0o755)) + return d + }, + languages: []string{"en", "de", "fr"}, + sortFlag: false, + want: []string{"pages.en", "pages.fr"}, + }, + { + name: "duplicates_in_input", + setupDir: func(t *testing.T) string { + d := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.de"), 0o755)) + return d + }, + languages: []string{"en", "de", "en"}, + sortFlag: false, + want: []string{"pages.en", "pages.de"}, + }, + { + name: "sort_flag_true", + setupDir: func(t *testing.T) string { + d := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.de"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.fr"), 0o755)) + return d + }, + languages: []string{"fr", "en", "de"}, + sortFlag: true, + want: []string{"pages.de", "pages.en", "pages.fr"}, + }, + { + name: "sort_flag_true_with_duplicates", + setupDir: func(t *testing.T) string { + d := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.de"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.fr"), 0o755)) + return d + }, + languages: []string{"fr", "en", "de", "en"}, + sortFlag: true, + want: []string{"pages.de", "pages.en", "pages.fr"}, + }, + { + name: "sort_flag_false_preserves_order", + setupDir: func(t *testing.T) string { + d := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.de"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.fr"), 0o755)) + return d + }, + languages: []string{"fr", "en", "de"}, + sortFlag: false, + want: []string{"pages.fr", "pages.en", "pages.de"}, + }, + { + name: "all_languages_missing", + setupDir: func(t *testing.T) string { + return t.TempDir() + }, + languages: []string{"en", "de"}, + sortFlag: false, + want: nil, + }, + { + name: "sort_with_missing", + setupDir: func(t *testing.T) string { + d := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.zh"), 0o755)) + return d + }, + languages: []string{"zh", "de", "en"}, + sortFlag: true, + want: []string{"pages.en", "pages.zh"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := tt.setupDir(t) + c := &Cache{dir: dir} + got := c.languagesToDirectories(tt.languages, tt.sortFlag) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/internal/cache/checksums_test.go b/internal/cache/checksums_test.go new file mode 100644 index 0000000..9a81642 --- /dev/null +++ b/internal/cache/checksums_test.go @@ -0,0 +1,365 @@ +package cache + +import ( + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLoadChecksums(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + setupDir func(t *testing.T) string + want map[string]string + wantNil bool + }{ + { + name: "file_exists_valid", + setupDir: func(t *testing.T) string { + dir := t.TempDir() + err := os.WriteFile( + filepath.Join(dir, checksumFile), + []byte("abc111 en.zip\ndef222 de.zip\n"), + 0o600, + ) + require.NoError(t, err) + return dir + }, + want: map[string]string{ + "en.zip": "abc111", + "de.zip": "def222", + }, + }, + { + name: "file_does_not_exist", + setupDir: func(t *testing.T) string { + return t.TempDir() + }, + wantNil: true, + }, + { + name: "dir_does_not_exist", + setupDir: func(t *testing.T) string { + return filepath.Join(t.TempDir(), "nonexistent") + }, + wantNil: true, + }, + { + name: "file_empty", + setupDir: func(t *testing.T) string { + dir := t.TempDir() + err := os.WriteFile( + filepath.Join(dir, checksumFile), + []byte(""), + 0o600, + ) + require.NoError(t, err) + return dir + }, + want: map[string]string{}, + }, + { + name: "file_whitespace_only", + setupDir: func(t *testing.T) string { + dir := t.TempDir() + err := os.WriteFile( + filepath.Join(dir, checksumFile), + []byte("\n\n \n"), + 0o600, + ) + require.NoError(t, err) + return dir + }, + want: map[string]string{}, + }, + { + name: "file_mixed_valid_invalid", + setupDir: func(t *testing.T) string { + dir := t.TempDir() + err := os.WriteFile( + filepath.Join(dir, checksumFile), + []byte("abc111 good.zip\nbadline\nabc222 ok.zip\n"), + 0o600, + ) + require.NoError(t, err) + return dir + }, + want: map[string]string{ + "good.zip": "abc111", + "ok.zip": "abc222", + }, + }, + { + name: "full_sha256_hash", + setupDir: func(t *testing.T) string { + dir := t.TempDir() + err := os.WriteFile( + filepath.Join(dir, checksumFile), + []byte("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 tldr-pages.en.zip\n"), + 0o600, + ) + require.NoError(t, err) + return dir + }, + want: map[string]string{ + "tldr-pages.en.zip": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := tt.setupDir(t) + c := &Cache{dir: dir} + got := c.loadChecksums() + + if tt.wantNil { + assert.Nil(t, got) + return + } + + assert.Equal(t, tt.want, got) + }) + } +} + +func TestSaveChecksums(t *testing.T) { + t.Parallel() + + t.Run("saves_single_entry", func(t *testing.T) { + dir := t.TempDir() + c := &Cache{dir: dir} + + err := c.saveChecksums(map[string]string{ + "en.zip": "abc", + }) + require.NoError(t, err) + + data, err := os.ReadFile(filepath.Join(dir, checksumFile)) + require.NoError(t, err) + assert.Equal(t, "abc en.zip\n", string(data)) + }) + + t.Run("saves_multiple_entries_and_round_trip", func(t *testing.T) { + dir := t.TempDir() + c := &Cache{dir: dir} + + original := map[string]string{ + "en.zip": "abc", + "de.zip": "def", + "zh.zip": "ghi", + } + + err := c.saveChecksums(original) + require.NoError(t, err) + + got := c.loadChecksums() + assert.Equal(t, original, got) + }) + + t.Run("overwrites_existing_file", func(t *testing.T) { + dir := t.TempDir() + c := &Cache{dir: dir} + + err := os.WriteFile( + filepath.Join(dir, checksumFile), + []byte("oldhash old.zip\n"), + 0o600, + ) + require.NoError(t, err) + + err = c.saveChecksums(map[string]string{ + "new.zip": "newhash", + }) + require.NoError(t, err) + + data, err := os.ReadFile(filepath.Join(dir, checksumFile)) + require.NoError(t, err) + assert.Equal(t, "newhash new.zip\n", string(data)) + }) + + t.Run("creates_directory", func(t *testing.T) { + base := t.TempDir() + nested := filepath.Join(base, "sub", "dir") + c := &Cache{dir: nested} + + err := c.saveChecksums(map[string]string{ + "a.zip": "h", + }) + require.NoError(t, err) + + data, err := os.ReadFile(filepath.Join(nested, checksumFile)) + require.NoError(t, err) + assert.Equal(t, "h a.zip\n", string(data)) + }) + + t.Run("empty_map", func(t *testing.T) { + dir := t.TempDir() + c := &Cache{dir: dir} + + err := c.saveChecksums(map[string]string{}) + require.NoError(t, err) + + data, err := os.ReadFile(filepath.Join(dir, checksumFile)) + require.NoError(t, err) + assert.Empty(t, data) + }) + + t.Run("special_chars_in_filename", func(t *testing.T) { + dir := t.TempDir() + c := &Cache{dir: dir} + + err := c.saveChecksums(map[string]string{ + "f!@#.zip": "abc123", + }) + require.NoError(t, err) + + data, err := os.ReadFile(filepath.Join(dir, checksumFile)) + require.NoError(t, err) + assert.Equal(t, "abc123 f!@#.zip\n", string(data)) + }) + + t.Run("round_trip_empty_map", func(t *testing.T) { + dir := t.TempDir() + c := &Cache{dir: dir} + + err := c.saveChecksums(map[string]string{}) + require.NoError(t, err) + + got := c.loadChecksums() + assert.Equal(t, map[string]string{}, got) + }) + + t.Run("round_trip_large_map", func(t *testing.T) { + dir := t.TempDir() + c := &Cache{dir: dir} + + original := make(map[string]string) + for i := range 20 { + name := fmt.Sprintf("tldr-pages.%d.zip", i) + hash := fmt.Sprintf("%064d", i) + original[name] = hash + } + + err := c.saveChecksums(original) + require.NoError(t, err) + + got := c.loadChecksums() + assert.Equal(t, original, got) + }) +} + +func TestParseChecksum(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input []byte + want map[string]string + }{ + { + name: "single_valid_line", + input: []byte("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 tldr-pages.en.zip\n"), + want: map[string]string{ + "tldr-pages.en.zip": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + }, + }, + { + name: "multiple_languages", + input: []byte( + "abc111 tldr-pages.en.zip\n" + + "def222 tldr-pages.de.zip\n" + + "ghi333 tldr-pages.zh.zip\n", + ), + want: map[string]string{ + "tldr-pages.en.zip": "abc111", + "tldr-pages.de.zip": "def222", + "tldr-pages.zh.zip": "ghi333", + }, + }, + { + name: "skips_empty_lines", + input: []byte( + "abc111 a.zip\n\n\n\nabc222 b.zip\n", + ), + want: map[string]string{ + "a.zip": "abc111", + "b.zip": "abc222", + }, + }, + { + name: "skips_invalid_lines", + input: []byte( + "abc111 good.zip\n" + + "badline\n" + + "short\n", + ), + want: map[string]string{ + "good.zip": "abc111", + }, + }, + { + name: "all_lines_invalid", + input: []byte("garbage\nshort \n"), + want: map[string]string{}, + }, + { + name: "empty_input", + input: []byte(""), + want: map[string]string{}, + }, + { + name: "only_whitespace", + input: []byte(" \n\t\n \n"), + want: map[string]string{}, + }, + { + name: "binary_mode_strips_star", + input: []byte("abc123 *tldr-pages.en.zip\n"), + want: map[string]string{ + "tldr-pages.en.zip": "abc123", + }, + }, + { + name: "filename_with_path", + input: []byte("abc123 sub/dir/file.txt\n"), + want: map[string]string{ + "sub/dir/file.txt": "abc123", + }, + }, + { + name: "trailing_newline", + input: []byte( + "abc111 a.zip\n" + + "abc222 b.zip\n", + ), + want: map[string]string{ + "a.zip": "abc111", + "b.zip": "abc222", + }, + }, + { + name: "duplicate_filename_last_wins", + input: []byte( + "abc111 f.zip\n" + + "abc222 f.zip\n", + ), + want: map[string]string{ + "f.zip": "abc222", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseChecksum(tt.input) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/internal/cache/info_test.go b/internal/cache/info_test.go new file mode 100644 index 0000000..860a125 --- /dev/null +++ b/internal/cache/info_test.go @@ -0,0 +1,213 @@ +package cache + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAge(t *testing.T) { + t.Parallel() + + t.Run("uses_checksum_file_mtime", func(t *testing.T) { + dir := t.TempDir() + sumPath := filepath.Join(dir, checksumFile) + err := os.WriteFile(sumPath, []byte("sums"), 0o644) + require.NoError(t, err) + err = os.Chtimes( + sumPath, + time.Now().Add(-1*time.Hour), + time.Now().Add(-1*time.Hour), + ) + require.NoError(t, err) + + c := &Cache{dir: dir} + age, err := c.Age() + require.NoError(t, err) + assert.Greater(t, age, 55*time.Minute) + assert.Less(t, age, 65*time.Minute) + }) + + t.Run("falls_back_to_cache_dir_mtime", func(t *testing.T) { + dir := t.TempDir() + err := os.Chtimes( + dir, + time.Now().Add(-2*time.Hour), + time.Now().Add(-2*time.Hour), + ) + require.NoError(t, err) + + c := &Cache{dir: dir} + age, err := c.Age() + require.NoError(t, err) + assert.Greater(t, age, 115*time.Minute) + assert.Less(t, age, 125*time.Minute) + }) + + t.Run("error_on_non_existent_dir", func(t *testing.T) { + c := &Cache{dir: "/nonexistent/path"} + _, err := c.Age() + assert.Error(t, err) + }) + + t.Run("error_on_future_mtime", func(t *testing.T) { + dir := t.TempDir() + future := time.Now().Add(1 * time.Hour) + err := os.Chtimes(dir, future, future) + require.NoError(t, err) + + c := &Cache{dir: dir} + _, err = c.Age() + assert.Error(t, err) + assert.Contains(t, err.Error(), "future") + }) +} + +func TestInfo(t *testing.T) { + t.Run("returns_info_for_valid_cache", 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", "linux", "apt.md"), nil, 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "pages.en", "linux", "pacman.md"), nil, 0o644)) + + c := &Cache{dir: dir} + info, err := c.Info() + require.NoError(t, err) + assert.Equal(t, dir, info.CacheDir) + assert.Equal(t, 3, info.TotalPages) + assert.Len(t, info.LanguageStats, 1) + assert.Equal(t, "en", info.LanguageStats[0].Language) + assert.Equal(t, 3, info.LanguageStats[0].Pages) + assert.NotEmpty(t, info.Age) + assert.True(t, info.AutoUpdate) + assert.Equal(t, uint64(336), info.MaxAge) + }) + + t.Run("error_on_non_existent_dir", func(t *testing.T) { + c := &Cache{dir: "/nonexistent/path"} + _, err := c.Info() + assert.Error(t, err) + assert.Contains(t, err.Error(), "cache directory") + }) + + t.Run("error_on_file_instead_of_dir", func(t *testing.T) { + dir := t.TempDir() + filePath := filepath.Join(dir, "not_a_dir") + require.NoError(t, os.WriteFile(filePath, nil, 0o644)) + + c := &Cache{dir: filePath} + _, err := c.Info() + assert.Error(t, err) + assert.Contains(t, err.Error(), "not a directory") + }) + + t.Run("empty_cache_returns_zero_pages", func(t *testing.T) { + dir := t.TempDir() + c := &Cache{dir: dir} + _, err := c.Info() + assert.Error(t, err) + }) + + t.Run("cache_with_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} + info, err := c.Info() + require.NoError(t, err) + assert.Equal(t, 2, info.TotalPages) + assert.Len(t, info.LanguageStats, 2) + }) +} + +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)) + + 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) + }) + + 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( + []string{"common", "linux"}, + []string{"pages.en"}, + ) + require.NoError(t, err) + assert.Equal(t, 1, total) + assert.Len(t, stats, 1) + }) + + 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) + }) + + 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)) + + c := &Cache{dir: dir} + _, total, err := c.languageStats( + []string{"common"}, + []string{"pages.en"}, + ) + require.NoError(t, err) + assert.Equal(t, 1, total) + }) +} diff --git a/internal/cache/update.go b/internal/cache/update.go index f5579c0..55bcb6c 100644 --- a/internal/cache/update.go +++ b/internal/cache/update.go @@ -51,7 +51,7 @@ func (c *Cache) Update( return nil } - c.platforms.Store(nil) + c.platforms.Store([]string(nil)) return nil } diff --git a/internal/config/singleton.go b/internal/config/singleton.go index dc635fd..9ae627e 100644 --- a/internal/config/singleton.go +++ b/internal/config/singleton.go @@ -52,3 +52,9 @@ func Indent() IndentConfig { func Output() OutputConfig { return C().Output } + +// ResetForTesting clears the global config singleton. +// This function is only intended for use by tests. +func ResetForTesting() { + currentConfig.Store(nil) +} From 16d4a8ab844c28fa76f96ee668fbbea9b31a6109 Mon Sep 17 00:00:00 2001 From: TheRootDaemon Date: Wed, 24 Jun 2026 22:33:26 +0530 Subject: [PATCH 07/11] test(cache): Tests for search, find, list --- internal/cache/find.go | 2 +- internal/cache/find_test.go | 394 ++++++++++++++++++++++++++++++++++ internal/cache/list_test.go | 340 +++++++++++++++++++++++++++++ internal/cache/search_test.go | 341 +++++++++++++++++++++++++++++ 4 files changed, 1076 insertions(+), 1 deletion(-) create mode 100644 internal/cache/find_test.go create mode 100644 internal/cache/list_test.go create mode 100644 internal/cache/search_test.go diff --git a/internal/cache/find.go b/internal/cache/find.go index b84e45e..b2b8d16 100644 --- a/internal/cache/find.go +++ b/internal/cache/find.go @@ -50,8 +50,8 @@ func (c *Cache) Find(query, platform string, languages []string) (*FindResult, e languageDirectories, ) fallbacks := c.fallbackMatches( - platform, file, + platform, platforms, languageDirectories, ) diff --git a/internal/cache/find_test.go b/internal/cache/find_test.go new file mode 100644 index 0000000..802b1d3 --- /dev/null +++ b/internal/cache/find_test.go @@ -0,0 +1,394 @@ +package cache + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFind(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + setupDir func(t *testing.T) string + query string + platform string + languages []string + want *FindResult + wantErr bool + }{ + { + name: "match_in_requested_platform", + setupDir: func(t *testing.T) string { + d := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en", "linux"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en", "common"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(d, "pages.en", "linux", "apt.md"), nil, 0o644)) + return d + }, + query: "apt", + platform: "linux", + languages: []string{"en"}, + want: &FindResult{ + Matches: []string{filepath.Join("testroot", "pages.en", "linux", "apt.md")}, + Fallbacks: nil, + }, + }, + { + name: "fallback_to_common", + setupDir: func(t *testing.T) string { + d := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en", "linux"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en", "common"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(d, "pages.en", "common", "ls.md"), nil, 0o644)) + return d + }, + query: "ls", + platform: "linux", + languages: []string{"en"}, + want: &FindResult{ + Matches: []string{filepath.Join("testroot", "pages.en", "common", "ls.md")}, + Fallbacks: nil, + }, + }, + { + name: "both_primary_and_common", + setupDir: func(t *testing.T) string { + d := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en", "linux"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en", "common"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(d, "pages.en", "linux", "apt.md"), nil, 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(d, "pages.en", "common", "ls.md"), nil, 0o644)) + return d + }, + query: "apt", + platform: "linux", + languages: []string{"en"}, + want: &FindResult{ + Matches: []string{filepath.Join("testroot", "pages.en", "linux", "apt.md")}, + Fallbacks: nil, + }, + }, + { + name: "fallback_to_other_platforms", + setupDir: func(t *testing.T) string { + d := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en", "linux"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en", "osx"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en", "common"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(d, "pages.en", "osx", "brew.md"), nil, 0o644)) + return d + }, + query: "brew", + platform: "linux", + languages: []string{"en"}, + want: &FindResult{ + Matches: nil, + Fallbacks: []string{filepath.Join("testroot", "pages.en", "osx", "brew.md")}, + }, + }, + { + name: "no_match_returns_empty_lists", + setupDir: func(t *testing.T) string { + d := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en", "linux"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en", "common"), 0o755)) + return d + }, + query: "nonexistent", + platform: "linux", + languages: []string{"en"}, + want: &FindResult{ + Matches: nil, + Fallbacks: nil, + }, + }, + { + name: "error_on_unknown_platform", + setupDir: func(t *testing.T) string { + d := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en", "common"), 0o755)) + return d + }, + query: "apt", + platform: "nonexistent", + languages: []string{"en"}, + wantErr: true, + }, + { + name: "common_platform_only", + setupDir: func(t *testing.T) string { + d := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en", "common"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(d, "pages.en", "common", "ls.md"), nil, 0o644)) + return d + }, + query: "ls", + platform: "common", + languages: []string{"en"}, + want: &FindResult{ + Matches: []string{filepath.Join("testroot", "pages.en", "common", "ls.md")}, + Fallbacks: nil, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := tt.setupDir(t) + c := &Cache{dir: dir} + got, err := c.Find(tt.query, tt.platform, tt.languages) + if tt.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + + // Fix up expected paths to use the actual dir. + if tt.want != nil { + for i := range tt.want.Matches { + tt.want.Matches[i] = strings.Replace(tt.want.Matches[i], "testroot", dir, 1) + } + for i := range tt.want.Fallbacks { + tt.want.Fallbacks[i] = strings.Replace(tt.want.Fallbacks[i], "testroot", dir, 1) + } + } + assert.Equal(t, tt.want, got) + }) + } +} + +func TestFind_PrimaryMatches(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + setupDir func(t *testing.T) string + platform string + file string + langDirs []string + want []string + }{ + { + name: "found_in_requested_platform", + setupDir: func(t *testing.T) string { + d := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en", "linux"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(d, "pages.en", "linux", "apt.md"), nil, 0o644)) + return d + }, + platform: "linux", + file: "apt.md", + langDirs: []string{"pages.en"}, + want: []string{filepath.Join("testroot", "pages.en", "linux", "apt.md")}, + }, + { + name: "fallback_to_common", + setupDir: func(t *testing.T) string { + d := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en", "linux"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en", "common"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(d, "pages.en", "common", "ls.md"), nil, 0o644)) + return d + }, + platform: "linux", + file: "ls.md", + langDirs: []string{"pages.en"}, + want: []string{filepath.Join("testroot", "pages.en", "common", "ls.md")}, + }, + { + name: "both_requested_and_common", + setupDir: func(t *testing.T) string { + d := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en", "linux"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en", "common"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(d, "pages.en", "linux", "apt.md"), nil, 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(d, "pages.en", "common", "apt.md"), nil, 0o644)) + return d + }, + platform: "linux", + file: "apt.md", + langDirs: []string{"pages.en"}, + want: []string{ + filepath.Join("testroot", "pages.en", "linux", "apt.md"), + filepath.Join("testroot", "pages.en", "common", "apt.md"), + }, + }, + { + name: "common_platform_skips_self", + setupDir: func(t *testing.T) string { + d := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en", "common"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(d, "pages.en", "common", "ls.md"), nil, 0o644)) + return d + }, + platform: "common", + file: "ls.md", + langDirs: []string{"pages.en"}, + want: []string{filepath.Join("testroot", "pages.en", "common", "ls.md")}, + }, + { + name: "not_found", + setupDir: func(t *testing.T) string { + d := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en", "linux"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en", "common"), 0o755)) + return d + }, + platform: "linux", + file: "nonexistent.md", + langDirs: []string{"pages.en"}, + want: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := tt.setupDir(t) + c := &Cache{dir: dir} + for i := range tt.want { + tt.want[i] = strings.Replace(tt.want[i], "testroot", dir, 1) + } + got := c.primaryMatches(tt.platform, tt.file, tt.langDirs) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestFind_FallbackMatches(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + setupDir func(t *testing.T) string + file string + platform string + platforms []string + langDirs []string + want []string + }{ + { + name: "found_in_other_platform", + setupDir: func(t *testing.T) string { + d := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en", "osx"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(d, "pages.en", "osx", "brew.md"), nil, 0o644)) + return d + }, + file: "brew.md", + platform: "linux", + platforms: []string{"common", "linux", "osx"}, + langDirs: []string{"pages.en"}, + want: []string{filepath.Join("testroot", "pages.en", "osx", "brew.md")}, + }, + { + name: "skips_requested_platform", + setupDir: func(t *testing.T) string { + d := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en", "linux"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(d, "pages.en", "linux", "apt.md"), nil, 0o644)) + return d + }, + file: "apt.md", + platform: "linux", + platforms: []string{"common", "linux"}, + langDirs: []string{"pages.en"}, + want: nil, + }, + { + name: "skips_common", + setupDir: func(t *testing.T) string { + d := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en", "common"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(d, "pages.en", "common", "ls.md"), nil, 0o644)) + return d + }, + file: "ls.md", + platform: "linux", + platforms: []string{"common", "linux"}, + langDirs: []string{"pages.en"}, + want: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := tt.setupDir(t) + c := &Cache{dir: dir} + for i := range tt.want { + tt.want[i] = strings.Replace(tt.want[i], "testroot", dir, 1) + } + got := c.fallbackMatches(tt.file, tt.platform, tt.platforms, tt.langDirs) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestFind_PageFor(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + setupDir func(t *testing.T) string + file string + platform string + langDirs []string + want string + }{ + { + name: "found_in_first_lang_dir", + setupDir: func(t *testing.T) string { + d := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en", "common"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(d, "pages.en", "common", "git.md"), nil, 0o644)) + return d + }, + file: "git.md", + platform: "common", + langDirs: []string{"pages.en"}, + want: filepath.Join("testroot", "pages.en", "common", "git.md"), + }, + { + name: "searches_lang_dirs_in_order", + setupDir: func(t *testing.T) string { + d := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en", "common"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.de", "common"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(d, "pages.en", "common", "git.md"), nil, 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(d, "pages.de", "common", "git.md"), nil, 0o644)) + return d + }, + file: "git.md", + platform: "common", + langDirs: []string{"pages.en", "pages.de"}, + want: filepath.Join("testroot", "pages.en", "common", "git.md"), + }, + { + name: "not_found_returns_empty", + setupDir: func(t *testing.T) string { + d := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en", "common"), 0o755)) + return d + }, + file: "nonexistent.md", + platform: "common", + langDirs: []string{"pages.en"}, + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := tt.setupDir(t) + c := &Cache{dir: dir} + tt.want = strings.Replace(tt.want, "testroot", dir, 1) + got := c.findPageFor(tt.file, tt.platform, tt.langDirs) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/internal/cache/list_test.go b/internal/cache/list_test.go new file mode 100644 index 0000000..a866eab --- /dev/null +++ b/internal/cache/list_test.go @@ -0,0 +1,340 @@ +package cache + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestListFor(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + setupDir func(t *testing.T) string + platform string + want []string + wantErr bool + }{ + { + name: "pages_in_platform_and_common", + setupDir: func(t *testing.T) string { + d := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en", "linux"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en", "common"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(d, "pages.en", "linux", "apt.md"), nil, 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(d, "pages.en", "common", "ls.md"), nil, 0o644)) + return d + }, + platform: "linux", + want: []string{"apt", "ls"}, + }, + { + name: "common_platform_returns_only_common", + setupDir: func(t *testing.T) string { + d := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en", "common"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(d, "pages.en", "common", "ls.md"), nil, 0o644)) + return d + }, + platform: "common", + want: []string{"ls"}, + }, + { + name: "dedupes_duplicate_pages", + setupDir: func(t *testing.T) string { + d := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en", "linux"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en", "common"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(d, "pages.en", "linux", "git.md"), nil, 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(d, "pages.en", "common", "git.md"), nil, 0o644)) + return d + }, + platform: "linux", + want: []string{"git"}, + }, + { + name: "empty_platform_dir", + setupDir: func(t *testing.T) string { + d := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en", "linux"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en", "common"), 0o755)) + return d + }, + platform: "linux", + want: nil, + }, + { + name: "error_when_no_pages_en", + setupDir: func(t *testing.T) string { + return t.TempDir() + }, + platform: "linux", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := tt.setupDir(t) + c := &Cache{dir: dir} + got, err := c.ListFor(tt.platform) + if tt.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestListAll(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + setupDir func(t *testing.T) string + want []string + wantErr bool + }{ + { + name: "all_pages_across_platforms", + setupDir: func(t *testing.T) string { + d := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en", "linux"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en", "common"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(d, "pages.en", "linux", "apt.md"), nil, 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(d, "pages.en", "common", "ls.md"), nil, 0o644)) + return d + }, + want: []string{"apt", "ls"}, + }, + { + name: "dedupes_duplicates", + setupDir: func(t *testing.T) string { + d := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en", "common"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(d, "pages.en", "common", "git.md"), nil, 0o644)) + return d + }, + want: []string{"git"}, + }, + { + name: "error_when_no_pages_en", + setupDir: func(t *testing.T) string { + return t.TempDir() + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := tt.setupDir(t) + c := &Cache{dir: dir} + got, err := c.ListAll() + if tt.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestListPlatforms(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + setupDir func(t *testing.T) string + want []string + wantErr bool + }{ + { + name: "returns_platforms", + setupDir: func(t *testing.T) string { + d := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en", "linux"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en", "common"), 0o755)) + return d + }, + want: []string{"common", "linux"}, + }, + { + name: "single_platform", + setupDir: func(t *testing.T) string { + d := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en", "common"), 0o755)) + return d + }, + want: []string{"common"}, + }, + { + name: "error_when_no_pages_en", + setupDir: func(t *testing.T) string { + return t.TempDir() + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := tt.setupDir(t) + c := &Cache{dir: dir} + got, err := c.ListPlatforms() + if tt.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestListLanguages(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + setupDir func(t *testing.T) string + want []string + wantErr bool + }{ + { + name: "single_language", + setupDir: func(t *testing.T) string { + d := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en"), 0o755)) + return d + }, + want: []string{"en"}, + }, + { + name: "multiple_languages_sorted", + setupDir: func(t *testing.T) string { + d := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.de"), 0o755)) + return d + }, + want: []string{"de", "en"}, + }, + { + name: "no_pages_dirs", + setupDir: func(t *testing.T) string { + return t.TempDir() + }, + want: []string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := tt.setupDir(t) + c := &Cache{dir: dir} + got, err := c.ListLanguages() + if tt.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestListDirectory(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + setupDir func(t *testing.T) string + platform string + languageDir string + want []string + wantErr bool + }{ + { + name: "returns_md_files", + setupDir: func(t *testing.T) string { + d := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en", "common"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(d, "pages.en", "common", "git.md"), nil, 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(d, "pages.en", "common", "ls.md"), nil, 0o644)) + return d + }, + platform: "common", + languageDir: "pages.en", + want: []string{"git", "ls"}, + }, + { + name: "skips_non_md_files", + setupDir: func(t *testing.T) string { + d := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en", "common"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(d, "pages.en", "common", "git.md"), nil, 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(d, "pages.en", "common", "notes.txt"), nil, 0o644)) + return d + }, + platform: "common", + languageDir: "pages.en", + want: []string{"git"}, + }, + { + name: "skips_directories", + setupDir: func(t *testing.T) string { + d := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en", "common", "subdir"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(d, "pages.en", "common", "git.md"), nil, 0o644)) + return d + }, + platform: "common", + languageDir: "pages.en", + want: []string{"git"}, + }, + { + name: "non_existent_dir_returns_nil", + setupDir: func(t *testing.T) string { + d := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en", "common"), 0o755)) + return d + }, + platform: "nonexistent", + languageDir: "pages.en", + want: nil, + }, + { + name: "empty_dir_returns_nil", + setupDir: func(t *testing.T) string { + d := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en", "common"), 0o755)) + return d + }, + platform: "common", + languageDir: "pages.en", + want: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := tt.setupDir(t) + c := &Cache{dir: dir} + got, err := c.listDirectory(tt.platform, tt.languageDir) + if tt.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/internal/cache/search_test.go b/internal/cache/search_test.go new file mode 100644 index 0000000..7493cbc --- /dev/null +++ b/internal/cache/search_test.go @@ -0,0 +1,341 @@ +package cache + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestSearch tests Cache.Search. +func TestSearch(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + setupDir func(t *testing.T) string + query string + platform string + languages []string + want []SearchResult + wantErr bool + }{ + { + name: "case_insensitive_substring_match", + setupDir: func(t *testing.T) string { + d := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en", "common"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(d, "pages.en", "common", "git-add.md"), nil, 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(d, "pages.en", "common", "git-commit.md"), nil, 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(d, "pages.en", "common", "ls.md"), nil, 0o644)) + return d + }, + query: "GIT", + platform: "common", + languages: []string{"en"}, + want: []SearchResult{ + {Page: "git-add", Language: "en", Platform: "common"}, + {Page: "git-commit", Language: "en", Platform: "common"}, + }, + }, + { + name: "results_sorted_by_page_name", + setupDir: func(t *testing.T) string { + d := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en", "common"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(d, "pages.en", "common", "java.md"), nil, 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(d, "pages.en", "common", "apt.md"), nil, 0o644)) + return d + }, + query: "a", + platform: "common", + languages: []string{"en"}, + want: []SearchResult{ + {Page: "apt", Language: "en", Platform: "common"}, + {Page: "java", Language: "en", Platform: "common"}, + }, + }, + { + name: "empty_platform_searches_all", + setupDir: func(t *testing.T) string { + d := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en", "linux"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en", "osx"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en", "common"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(d, "pages.en", "linux", "apt.md"), nil, 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(d, "pages.en", "osx", "bat.md"), nil, 0o644)) + return d + }, + query: "a", + platform: "", + languages: []string{"en"}, + want: []SearchResult{ + {Page: "apt", Language: "en", Platform: "linux"}, + {Page: "bat", Language: "en", Platform: "osx"}, + }, + }, + { + name: "no_match_returns_error", + setupDir: func(t *testing.T) string { + d := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en", "common"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(d, "pages.en", "common", "ls.md"), nil, 0o644)) + return d + }, + query: "zzzznotfound", + platform: "common", + languages: []string{"en"}, + wantErr: true, + }, + { + name: "error_on_unknown_platform", + setupDir: func(t *testing.T) string { + d := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en", "common"), 0o755)) + return d + }, + query: "ls", + platform: "nonexistent", + languages: []string{"en"}, + wantErr: true, + }, + { + name: "error_on_no_matching_languages", + setupDir: func(t *testing.T) string { + d := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en", "common"), 0o755)) + return d + }, + query: "ls", + platform: "common", + languages: []string{"de"}, + wantErr: true, + }, + { + name: "specific_platform_only", + setupDir: func(t *testing.T) string { + d := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en", "linux"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en", "osx"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(d, "pages.en", "linux", "apt.md"), nil, 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(d, "pages.en", "osx", "brew.md"), nil, 0o644)) + return d + }, + query: "a", + platform: "linux", + languages: []string{"en"}, + want: []SearchResult{ + {Page: "apt", Language: "en", Platform: "linux"}, + }, + }, + { + name: "searches_across_languages", + setupDir: func(t *testing.T) string { + d := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en", "common"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.de", "common"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(d, "pages.en", "common", "git.md"), nil, 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(d, "pages.de", "common", "git.md"), nil, 0o644)) + return d + }, + query: "git", + platform: "common", + languages: []string{"en", "de"}, + want: []SearchResult{ + {Page: "git", Language: "en", Platform: "common"}, + {Page: "git", Language: "de", Platform: "common"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := tt.setupDir(t) + c := &Cache{dir: dir} + got, err := c.Search(tt.query, tt.platform, tt.languages) + if tt.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +// TestResolvePlatforms tests Cache.resolvePlatforms. +func TestResolvePlatforms(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + setupDir func(t *testing.T) string + platform string + want []string + wantErr bool + }{ + { + name: "empty_returns_all", + setupDir: func(t *testing.T) string { + d := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en", "linux"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en", "common"), 0o755)) + return d + }, + platform: "", + want: []string{"common", "linux"}, + }, + { + name: "common_returns_only_common", + setupDir: func(t *testing.T) string { + d := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en", "common"), 0o755)) + return d + }, + platform: "common", + want: []string{"common"}, + }, + { + name: "specific_platform_and_common", + setupDir: func(t *testing.T) string { + d := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en", "linux"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en", "common"), 0o755)) + return d + }, + platform: "linux", + want: []string{"linux", "common"}, + }, + { + name: "error_on_unknown_platform", + setupDir: func(t *testing.T) string { + d := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en", "common"), 0o755)) + return d + }, + platform: "nonexistent", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := tt.setupDir(t) + c := &Cache{dir: dir} + got, err := c.resolvePlatforms(tt.platform) + if tt.wantErr { + assert.Error(t, err) + return + } + + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +// TestPlatformExists tests platformExists. +func TestPlatformExists(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + platforms []string + platform string + want bool + }{ + { + name: "platform_exists", + platforms: []string{"linux", "osx", "common"}, + platform: "linux", + want: true, + }, + { + name: "platform_does_not_exist", + platforms: []string{"linux", "osx"}, + platform: "windows", + want: false, + }, + { + name: "empty_platforms_list", + platforms: []string{}, + platform: "linux", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := platformExists(tt.platforms, tt.platform) + assert.Equal(t, tt.want, got) + }) + } +} + +// TestSearchDirectory tests Cache.searchDirectory. +func TestSearchDirectory(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + setupDir func(t *testing.T) string + query string + platform string + languageDirectory string + want []SearchResult + }{ + { + name: "matches_substring_case_insensitive", + setupDir: func(t *testing.T) string { + d := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en", "common"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(d, "pages.en", "common", "git-add.md"), nil, 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(d, "pages.en", "common", "git-commit.md"), nil, 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(d, "pages.en", "common", "ls.md"), nil, 0o644)) + return d + }, + query: "commit", + platform: "common", + languageDirectory: "pages.en", + want: []SearchResult{ + {Page: "git-commit", Language: "en", Platform: "common"}, + }, + }, + { + name: "no_match_returns_empty", + setupDir: func(t *testing.T) string { + d := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en", "common"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(d, "pages.en", "common", "ls.md"), nil, 0o644)) + return d + }, + query: "zzzz", + platform: "common", + languageDirectory: "pages.en", + want: nil, + }, + { + name: "nonexistent_directory_returns_empty", + setupDir: func(t *testing.T) string { + d := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en", "common"), 0o755)) + return d + }, + query: "ls", + platform: "nonexistent", + languageDirectory: "pages.en", + want: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := tt.setupDir(t) + c := &Cache{dir: dir} + got := c.searchDirectory(tt.query, tt.platform, tt.languageDirectory) + assert.Equal(t, tt.want, got) + }) + } +} From fdc87244518221f546f0a4cfc1d257ec80fa5b11 Mon Sep 17 00:00:00 2001 From: TheRootDaemon Date: Wed, 24 Jun 2026 23:10:45 +0530 Subject: [PATCH 08/11] test(cache): Tests for clean, reafactor clean to accept io.Reader to extend testability --- internal/cache/clean.go | 18 ++- internal/cache/clean_test.go | 279 +++++++++++++++++++++++++++++++++++ 2 files changed, 295 insertions(+), 2 deletions(-) create mode 100644 internal/cache/clean_test.go diff --git a/internal/cache/clean.go b/internal/cache/clean.go index ced155f..3556317 100644 --- a/internal/cache/clean.go +++ b/internal/cache/clean.go @@ -3,6 +3,7 @@ package cache import ( "bufio" "fmt" + "io" "os" "path/filepath" "strings" @@ -10,7 +11,10 @@ import ( "github.com/TheRootDaemon/tlgc/logger" ) -func (c *Cache) Clean() error { +// Clean removes all cached entries after prompting for confirmation +// from r. If the cache directory does not exist or is empty, +// Clean returns without modifying anything. +func (c *Cache) Clean(r io.Reader) error { entries, err := getEntries(c.dir) if err != nil { return err @@ -33,7 +37,7 @@ func (c *Cache) Clean() error { log.String(), ) - cleanCache := parseInput(bufio.NewReader(os.Stdin)) + cleanCache := parseInput(bufio.NewReader(r)) if !cleanCache { logger.InfoEnd("aborted") return nil @@ -60,6 +64,9 @@ func (c *Cache) Clean() error { return nil } +// getEntries returns the entries in path. +// If path does not exist or contains no entries, +// it returns nil, nil. func getEntries(path string) ([]os.DirEntry, error) { entries, err := os.ReadDir(path) if err != nil { @@ -77,6 +84,13 @@ func getEntries(path string) ([]os.DirEntry, error) { return entries, nil } +// parseInput reads a confirmation response from reader. +// +// An empty response, "yes", +// and any response beginning with 'y' or 'Y' +// are treated as confirmation. +// Any other response, or a read error, +// is treated as a rejection. func parseInput(reader *bufio.Reader) bool { input, err := reader.ReadString('\n') if err != nil { diff --git a/internal/cache/clean_test.go b/internal/cache/clean_test.go new file mode 100644 index 0000000..7e13b74 --- /dev/null +++ b/internal/cache/clean_test.go @@ -0,0 +1,279 @@ +package cache + +import ( + "bufio" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestClean(t *testing.T) { + tests := []struct { + name string + setupDir func(t *testing.T) string + input string + wantErr bool + check func(t *testing.T, dir string) + }{ + { + name: "cache_does_not_exist", + setupDir: func(t *testing.T) string { + return filepath.Join(t.TempDir(), "nonexistent") + }, + input: "", + check: func(t *testing.T, dir string) {}, + }, + { + name: "cache_is_empty", + setupDir: func(t *testing.T) string { + return t.TempDir() + }, + input: "", + check: func(t *testing.T, dir string) {}, + }, + { + name: "user_confirms_with_y", + setupDir: func(t *testing.T) string { + d := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en", "common"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(d, "pages.en", "common", "ls.md"), nil, 0o644)) + return d + }, + input: "y\n", + check: func(t *testing.T, dir string) { + entries, err := os.ReadDir(dir) + require.NoError(t, err) + assert.Empty(t, entries) + }, + }, + { + name: "user_confirms_with_Y", + setupDir: func(t *testing.T) string { + d := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en"), 0o755)) + return d + }, + input: "Y\n", + check: func(t *testing.T, dir string) { + entries, err := os.ReadDir(dir) + require.NoError(t, err) + assert.Empty(t, entries) + }, + }, + { + name: "user_declines_with_n", + setupDir: func(t *testing.T) string { + d := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en"), 0o755)) + return d + }, + input: "n\n", + check: func(t *testing.T, dir string) { + entries, err := os.ReadDir(dir) + require.NoError(t, err) + assert.Len(t, entries, 1) + }, + }, + { + name: "user_declines_with_N", + setupDir: func(t *testing.T) string { + d := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en"), 0o755)) + return d + }, + input: "N\n", + check: func(t *testing.T, dir string) { + entries, err := os.ReadDir(dir) + require.NoError(t, err) + assert.Len(t, entries, 1) + }, + }, + { + name: "multiple_entries_removed", + setupDir: func(t *testing.T) string { + d := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.de"), 0o755)) + return d + }, + input: "y\n", + check: func(t *testing.T, dir string) { + 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 := tt.setupDir(t) + c := &Cache{dir: dir} + + err := c.Clean(strings.NewReader(tt.input)) + if tt.wantErr { + assert.Error(t, err) + return + } + + require.NoError(t, err) + tt.check(t, dir) + }) + } +} + +func TestGetEntries(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + setup func(t *testing.T) string + wantLen int + wantNil bool + wantErr bool + }{ + { + name: "directory_with_entries", + setup: func(t *testing.T) string { + d := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(d, "a.txt"), nil, 0o644)) + require.NoError(t, os.MkdirAll(filepath.Join(d, "sub"), 0o755)) + return d + }, + wantLen: 2, + }, + { + name: "directory_does_not_exist", + setup: func(t *testing.T) string { + return filepath.Join(t.TempDir(), "nonexistent") + }, + wantNil: true, + }, + { + name: "empty_directory", + setup: func(t *testing.T) string { + return t.TempDir() + }, + wantNil: true, + }, + { + name: "path_is_a_file", + setup: func(t *testing.T) string { + d := t.TempDir() + f := filepath.Join(d, "file.txt") + require.NoError(t, os.WriteFile(f, nil, 0o644)) + return f + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + path := tt.setup(t) + entries, err := getEntries(path) + if tt.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + if tt.wantNil { + assert.Nil(t, entries) + return + } + assert.Len(t, entries, tt.wantLen) + }) + } +} + +func TestParseInput(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + want bool + }{ + { + name: "empty_input_means_yes", + input: "\n", + want: true, + }, + { + name: "lowercase_y", + input: "y\n", + want: true, + }, + { + name: "uppercase_Y", + input: "Y\n", + want: true, + }, + { + name: "full_yes", + input: "yes\n", + want: true, + }, + { + name: "lowercase_n", + input: "n\n", + want: false, + }, + { + name: "uppercase_N", + input: "N\n", + want: false, + }, + { + name: "full_no", + input: "no\n", + want: false, + }, + { + name: "arbitrary_text", + input: "garbage\n", + want: false, + }, + { + name: "whitespace_only", + input: " \n", + want: true, + }, + { + name: "trailing_spaces", + input: " yes \n", + want: true, + }, + { + name: "starts_with_y", + input: "yellow\n", + want: true, + }, + { + name: "starts_with_Y", + input: "Yellow\n", + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseInput(bufio.NewReader(strings.NewReader(tt.input))) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestParseInput_ReaderError(t *testing.T) { + t.Parallel() + r, w, err := os.Pipe() + require.NoError(t, err) + _ = w.Close() + + got := parseInput(bufio.NewReader(r)) + assert.False(t, got) +} From c951f6d820c73367847f2c957c5e0e135473e795 Mon Sep 17 00:00:00 2001 From: TheRootDaemon Date: Thu, 25 Jun 2026 11:40:42 +0530 Subject: [PATCH 09/11] test(cache): Validate directory paths in getEntries, expand tests --- internal/cache/clean.go | 10 +++++++++- internal/cache/clean_test.go | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/internal/cache/clean.go b/internal/cache/clean.go index 3556317..62bd6f0 100644 --- a/internal/cache/clean.go +++ b/internal/cache/clean.go @@ -68,12 +68,20 @@ func (c *Cache) Clean(r io.Reader) error { // If path does not exist or contains no entries, // it returns nil, nil. func getEntries(path string) ([]os.DirEntry, error) { - entries, err := os.ReadDir(path) + fi, err := os.Stat(path) if err != nil { if os.IsNotExist(err) { return nil, nil } + return nil, err + } + if !fi.IsDir() { + return nil, fmt.Errorf("%q is not a directory", path) + } + + entries, err := os.ReadDir(path) + if err != nil { return nil, err } diff --git a/internal/cache/clean_test.go b/internal/cache/clean_test.go index 7e13b74..79d9149 100644 --- a/internal/cache/clean_test.go +++ b/internal/cache/clean_test.go @@ -4,6 +4,7 @@ import ( "bufio" "os" "path/filepath" + "runtime" "strings" "testing" @@ -170,6 +171,38 @@ func TestGetEntries(t *testing.T) { }, wantErr: true, }, + { + name: "stat_returns_non_not_exist_error", + setup: func(t *testing.T) string { + if runtime.GOOS == "windows" { + t.Skip("permission-based test not applicable on Windows") + } + + d := t.TempDir() + sub := filepath.Join(d, "sub") + require.NoError(t, os.MkdirAll(sub, 0o755)) + require.NoError(t, os.Chmod(sub, 0o000)) + t.Cleanup(func() { _ = os.Chmod(sub, 0o755) }) + return filepath.Join(sub, "file.txt") + }, + wantErr: true, + }, + { + name: "readdir_returns_error", + setup: func(t *testing.T) string { + if runtime.GOOS == "windows" { + t.Skip("permission-based test not applicable on Windows") + } + + d := t.TempDir() + sub := filepath.Join(d, "sub") + require.NoError(t, os.MkdirAll(sub, 0o755)) + require.NoError(t, os.Chmod(sub, 0o000)) + t.Cleanup(func() { _ = os.Chmod(sub, 0o755) }) + return sub + }, + wantErr: true, + }, } for _, tt := range tests { From 77f1f2cb1ec7991c9f05597779bbe76933294d8c Mon Sep 17 00:00:00 2001 From: TheRootDaemon Date: Thu, 25 Jun 2026 12:33:23 +0530 Subject: [PATCH 10/11] test(cache): Tests for archive, update and refactor downloadArchive to accept mirror as an argument to extend testability --- internal/cache/archive.go | 6 +- internal/cache/archive_test.go | 298 +++++++++++++++++++++++++++ internal/cache/main_test.go | 62 ++++++ internal/cache/update.go | 9 +- internal/cache/update_test.go | 359 +++++++++++++++++++++++++++++++++ 5 files changed, 730 insertions(+), 4 deletions(-) create mode 100644 internal/cache/archive_test.go create mode 100644 internal/cache/main_test.go create mode 100644 internal/cache/update_test.go diff --git a/internal/cache/archive.go b/internal/cache/archive.go index d740854..5b6a019 100644 --- a/internal/cache/archive.go +++ b/internal/cache/archive.go @@ -9,18 +9,18 @@ import ( "path/filepath" "strings" - "github.com/TheRootDaemon/tlgc/internal/config" "github.com/TheRootDaemon/tlgc/internal/upstream" "github.com/TheRootDaemon/tlgc/logger" ) -// downloadArchive downloads the named language archive from the configured mirror. +// downloadArchive downloads the named archive from the given mirror +// and verifies it against the provided hash. func downloadArchive( ctx context.Context, client *upstream.Client, + mirror string, archiveName, hash string, ) ([]byte, error) { - mirror := config.Cache().Mirror url := mirror + "/" + archiveName return client.DownloadBytes(ctx, url, hash) } diff --git a/internal/cache/archive_test.go b/internal/cache/archive_test.go new file mode 100644 index 0000000..5eca9da --- /dev/null +++ b/internal/cache/archive_test.go @@ -0,0 +1,298 @@ +package cache + +import ( + "archive/zip" + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/TheRootDaemon/tlgc/internal/upstream" +) + +func TestDownloadArchive(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + archiveName string + hash string + handler http.HandlerFunc + wantErr bool + wantData string + }{ + { + name: "successful_download", + archiveName: "tldr-pages.en.zip", + handler: func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/tldr-pages.en.zip", r.URL.Path) + _, _ = w.Write([]byte("zip-content")) + }, + wantData: "zip-content", + }, + { + name: "empty_hash_matches_any_content", + archiveName: "archive.zip", + hash: "", + handler: func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("anything")) + }, + wantData: "anything", + }, + { + name: "hash_matches", + archiveName: "tldr-pages.de.zip", + hash: func() string { + h := sha256.Sum256([]byte("de-content")) + return hex.EncodeToString(h[:]) + }(), + handler: func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("de-content")) + }, + wantData: "de-content", + }, + { + name: "hash_mismatch", + archiveName: "data.zip", + hash: "0000000000000000000000000000000000000000000000000000000000000000", + handler: func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("actual-data")) + }, + wantErr: true, + }, + { + name: "server_error", + archiveName: "missing.zip", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ts := httptest.NewServer(tt.handler) + defer ts.Close() + + client := upstream.New( + upstream.WithHTTPClient(ts.Client()), + ) + + got, err := downloadArchive( + context.Background(), + client, + ts.URL, + tt.archiveName, + tt.hash, + ) + + if tt.wantErr { + assert.Error(t, err) + return + } + + require.NoError(t, err) + assert.Equal(t, tt.wantData, string(got)) + }) + } +} + +func TestExtractArchive(t *testing.T) { + t.Parallel() + + tests := []struct { + wantErr bool + name string + languageDirectory string + buildZip func(t *testing.T) []byte + preExist func(t *testing.T, c *Cache) + check func(t *testing.T, c *Cache) + }{ + { + name: "flat_structure", + languageDirectory: "pages.en", + buildZip: func(t *testing.T) []byte { + return createTestZip(t, map[string]string{ + "common/git.md": "", + "common/ls.md": "", + }) + }, + check: func(t *testing.T, c *Cache) { + assert.FileExists(t, filepath.Join(c.dir, "pages.en", "common", "git.md")) + assert.FileExists(t, filepath.Join(c.dir, "pages.en", "common", "ls.md")) + }, + }, + { + name: "nested_directories", + languageDirectory: "pages.en", + buildZip: func(t *testing.T) []byte { + return createTestZip(t, map[string]string{ + "common/git.md": "", + "linux/apt.md": "", + "osx/brew.md": "", + }) + }, + check: func(t *testing.T, c *Cache) { + assert.FileExists(t, filepath.Join(c.dir, "pages.en", "common", "git.md")) + assert.FileExists(t, filepath.Join(c.dir, "pages.en", "linux", "apt.md")) + assert.FileExists(t, filepath.Join(c.dir, "pages.en", "osx", "brew.md")) + }, + }, + { + name: "directory_entries_in_zip", + languageDirectory: "pages.en", + buildZip: func(t *testing.T) []byte { + return createTestZip(t, map[string]string{ + "common/": "", + "common/git.md": "", + }) + }, + check: func(t *testing.T, c *Cache) { + assert.FileExists(t, filepath.Join(c.dir, "pages.en", "common", "git.md")) + }, + }, + { + name: "empty_zip", + languageDirectory: "pages.en", + buildZip: createEmptyZip, + check: func(t *testing.T, c *Cache) { + dir := filepath.Join(c.dir, "pages.en") + assert.DirExists(t, dir) + + entries, err := os.ReadDir(dir) + require.NoError(t, err) + assert.Empty(t, entries) + }, + }, + { + name: "invalid_zip_data", + languageDirectory: "pages.en", + buildZip: func(t *testing.T) []byte { + return []byte("not a zip file") + }, + wantErr: true, + }, + { + name: "skips_path_traversal", + languageDirectory: "pages.en", + buildZip: func(t *testing.T) []byte { + return createTestZip(t, map[string]string{ + "../escape.md": "EVIL", + "common/git.md": "", + }) + }, + check: func(t *testing.T, c *Cache) { + assert.FileExists(t, filepath.Join(c.dir, "pages.en", "common", "git.md")) + assert.NoFileExists(t, filepath.Join(c.dir, "escape.md")) + }, + }, + { + name: "removes_existing_directory", + languageDirectory: "pages.en", + buildZip: func(t *testing.T) []byte { + return createTestZip(t, map[string]string{ + "common/git.md": "", + }) + }, + preExist: func(t *testing.T, c *Cache) { + oldDir := filepath.Join(c.dir, "pages.en", "common") + require.NoError(t, os.MkdirAll(oldDir, 0o750)) + require.NoError(t, os.WriteFile( + filepath.Join(oldDir, "old.md"), + []byte("old"), + 0o640, + )) + }, + check: func(t *testing.T, c *Cache) { + assert.FileExists(t, filepath.Join(c.dir, "pages.en", "common", "git.md")) + assert.NoFileExists(t, filepath.Join(c.dir, "pages.en", "common", "old.md")) + }, + }, + { + name: "single_file_content", + languageDirectory: "pages.en", + buildZip: func(t *testing.T) []byte { + return createTestZip(t, map[string]string{ + "common/git.md": "# git\n", + }) + }, + check: func(t *testing.T, c *Cache) { + got, err := os.ReadFile(filepath.Join(c.dir, "pages.en", "common", "git.md")) + require.NoError(t, err) + assert.Equal(t, "# git\n", string(got)) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Cache{dir: t.TempDir()} + + if tt.preExist != nil { + tt.preExist(t, c) + } + + zipData := tt.buildZip(t) + err := c.extractArchive(tt.languageDirectory, zipData) + + if tt.wantErr { + assert.Error(t, err) + return + } + + require.NoError(t, err) + + if tt.check != nil { + tt.check(t, c) + } + }) + } +} + +// TestExtractFile tests the extractFile helper. +func TestExtractFile(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + content string + }{ + {name: "writes_file_content", content: "# git\n"}, + {name: "empty_file", content: ""}, + {name: "binary_content", content: "\x00\x01\x02"}, + } + + 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, map[string]string{"test.md": tt.content}) + zipReader, err := zip.NewReader(bytes.NewReader(zipData), int64(len(zipData))) + require.NoError(t, err) + require.Len(t, zipReader.File, 1) + + f := zipReader.File[0] + err = extractFile(root, f) + require.NoError(t, err) + + got, err := os.ReadFile(filepath.Join(dir, "test.md")) + require.NoError(t, err) + assert.Equal(t, tt.content, string(got)) + }) + } +} diff --git a/internal/cache/main_test.go b/internal/cache/main_test.go new file mode 100644 index 0000000..b0c4005 --- /dev/null +++ b/internal/cache/main_test.go @@ -0,0 +1,62 @@ +package cache + +import ( + "archive/zip" + "bytes" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/TheRootDaemon/tlgc/internal/config" + "github.com/stretchr/testify/require" +) + +// createTestZip builds an in-memory ZIP from a path→content map. +// Entries whose path ends with "/" are treated as directory entries. +func createTestZip(t *testing.T, files map[string]string) []byte { + t.Helper() + + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + + for path, content := range files { + w, err := zw.Create(path) + require.NoError(t, err) + + if _, err := w.Write([]byte(content)); err != nil { + require.NoError(t, err) + } + } + + require.NoError(t, zw.Close()) + return buf.Bytes() +} + +// createEmptyZip builds a valid empty ZIP archive. +func createEmptyZip(t *testing.T) []byte { + t.Helper() + + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + require.NoError(t, zw.Close()) + return buf.Bytes() +} + +// setupConfig writes a temporary config file, +// sets TLGC_CONFIG, +// reinitializes the config singleton, +// and returns a cleanup function. +func setupConfig(t *testing.T, dir, mirror string) func() { + t.Helper() + + cfgDir := t.TempDir() + cfgPath := filepath.Join(cfgDir, "config.toml") + content := fmt.Sprintf("[cache]\ndir = %q\nmirror = %q\n", dir, mirror) + require.NoError(t, os.WriteFile(cfgPath, []byte(content), 0o644)) + + config.ResetForTesting() + t.Setenv("TLGC_CONFIG", cfgPath) + require.NoError(t, config.Initialize()) + return config.ResetForTesting +} diff --git a/internal/cache/update.go b/internal/cache/update.go index 55bcb6c..745f929 100644 --- a/internal/cache/update.go +++ b/internal/cache/update.go @@ -4,6 +4,7 @@ import ( "context" "fmt" + "github.com/TheRootDaemon/tlgc/internal/config" "github.com/TheRootDaemon/tlgc/internal/upstream" "github.com/TheRootDaemon/tlgc/logger" ) @@ -77,7 +78,13 @@ func (c *Cache) updateLanguage( } hash := newChecksums[archiveName] - data, err := downloadArchive(ctx, client, archiveName, hash) + data, err := downloadArchive( + ctx, + client, + config.Cache().Mirror, + archiveName, + hash, + ) if err != nil { return false, fmt.Errorf("downloading %s: %w", archiveName, err) } diff --git a/internal/cache/update_test.go b/internal/cache/update_test.go new file mode 100644 index 0000000..8f12be4 --- /dev/null +++ b/internal/cache/update_test.go @@ -0,0 +1,359 @@ +package cache + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/TheRootDaemon/tlgc/internal/upstream" +) + +func TestUpdate(t *testing.T) { + ctx := context.Background() + + // pre-compute a valid ZIP for english and its hash. + zipData := createTestZip(t, map[string]string{"common/git.md": ""}) + h := sha256.Sum256(zipData) + correctHash := hex.EncodeToString(h[:]) + + // pre-compute a valid ZIP for german and its hash. + zipDataDe := createTestZip(t, map[string]string{"common/apt.md": ""}) + hDe := sha256.Sum256(zipDataDe) + correctHashDe := hex.EncodeToString(hDe[:]) + + tests := []struct { + name string + languages []string + preExist func(t *testing.T, c *Cache) + handler http.HandlerFunc + wantErr bool + check func(t *testing.T, c *Cache) + }{ + { + name: "fresh_update", + languages: []string{"en"}, + handler: func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/" + checksumFile: + _, _ = fmt.Fprintf(w, "%s %s\n", correctHash, "tldr-pages.en.zip") + case "/tldr-pages.en.zip": + _, _ = w.Write(zipData) + default: + w.WriteHeader(http.StatusNotFound) + } + }, + check: func(t *testing.T, c *Cache) { + assert.DirExists(t, filepath.Join(c.dir, "pages.en", "common")) + assert.FileExists(t, filepath.Join(c.dir, "pages.en", "common", "git.md")) + + data, err := os.ReadFile(filepath.Join(c.dir, checksumFile)) + require.NoError(t, err) + assert.Contains(t, string(data), correctHash) + }, + }, + { + name: "already_up_to_date", + languages: []string{"en"}, + preExist: func(t *testing.T, c *Cache) { + require.NoError(t, os.MkdirAll(filepath.Join(c.dir, "pages.en", "common"), 0o750)) + require.NoError(t, c.saveChecksums(map[string]string{"tldr-pages.en.zip": correctHash})) + }, + handler: func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/" + checksumFile: + _, _ = fmt.Fprintf(w, "%s %s\n", correctHash, "tldr-pages.en.zip") + default: + w.WriteHeader(http.StatusNotFound) + } + }, + check: func(t *testing.T, c *Cache) { + assert.DirExists(t, filepath.Join(c.dir, "pages.en", "common")) + }, + }, + { + name: "partial_update", + languages: []string{"en", "de"}, + preExist: func(t *testing.T, c *Cache) { + require.NoError(t, os.MkdirAll(filepath.Join(c.dir, "pages.en", "common"), 0o750)) + require.NoError(t, os.WriteFile( + filepath.Join(c.dir, "pages.en", "common", "git.md"), + []byte("# git\n"), 0o640, + )) + require.NoError(t, c.saveChecksums(map[string]string{"tldr-pages.en.zip": correctHash})) + }, + handler: func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/" + checksumFile: + _, _ = fmt.Fprintf( + w, "%s %s\n%s %s\n", + correctHash, "tldr-pages.en.zip", + correctHashDe, "tldr-pages.de.zip", + ) + case "/tldr-pages.de.zip": + _, _ = w.Write(zipDataDe) + default: + w.WriteHeader(http.StatusNotFound) + } + }, + check: func(t *testing.T, c *Cache) { + assert.DirExists(t, filepath.Join(c.dir, "pages.en", "common")) + assert.FileExists(t, filepath.Join(c.dir, "pages.en", "common", "git.md")) + + assert.DirExists(t, filepath.Join(c.dir, "pages.de", "common")) + assert.FileExists(t, filepath.Join(c.dir, "pages.de", "common", "apt.md")) + + data, err := os.ReadFile(filepath.Join(c.dir, checksumFile)) + require.NoError(t, err) + assert.Contains(t, string(data), correctHash) + assert.Contains(t, string(data), correctHashDe) + }, + }, + { + name: "checksum_download_fails", + languages: []string{"en"}, + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ts := httptest.NewServer(tt.handler) + defer ts.Close() + + cacheDir := t.TempDir() + defer setupConfig(t, cacheDir, ts.URL)() + + if tt.preExist != nil { + tt.preExist(t, &Cache{dir: cacheDir}) + } + + c := &Cache{dir: cacheDir} + client := upstream.New( + upstream.WithHTTPClient(ts.Client()), + ) + + err := c.Update(ctx, tt.languages, client) + + if tt.wantErr { + assert.Error(t, err) + return + } + + require.NoError(t, err) + + if tt.check != nil { + tt.check(t, c) + } + }) + } +} + +func TestUpdateLanguage(t *testing.T) { + ctx := context.Background() + + // pre-compute a valid ZIP and its SHA256 hash. + zipData := createTestZip(t, map[string]string{"common/git.md": ""}) + h := sha256.Sum256(zipData) + correctHash := hex.EncodeToString(h[:]) + + // pre-compute garbage data and its hash for the invalid-zip case. + invalidZipData := []byte("not a valid zip file") + hi := sha256.Sum256(invalidZipData) + invalidHash := hex.EncodeToString(hi[:]) + + tests := []struct { + name string + language string + preExist func(t *testing.T, c *Cache) + oldChecksums map[string]string + newChecksums map[string]string + handler http.HandlerFunc + wantUpdated bool + wantErr bool + }{ + { + name: "needs_update", + language: "en", + oldChecksums: nil, + newChecksums: map[string]string{"tldr-pages.en.zip": correctHash}, + handler: func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(zipData) + }, + wantUpdated: true, + }, + { + name: "up_to_date", + language: "en", + preExist: func(t *testing.T, c *Cache) { + require.NoError(t, os.MkdirAll(filepath.Join(c.dir, "pages.en", "common"), 0o750)) + }, + oldChecksums: map[string]string{"tldr-pages.en.zip": correctHash}, + newChecksums: map[string]string{"tldr-pages.en.zip": correctHash}, + wantUpdated: false, + }, + { + name: "not_in_new_checksums", + language: "en", + oldChecksums: nil, + newChecksums: map[string]string{}, + wantUpdated: false, + }, + { + name: "hash_changed", + language: "en", + preExist: func(t *testing.T, c *Cache) { + require.NoError(t, os.MkdirAll(filepath.Join(c.dir, "pages.en", "common"), 0o750)) + }, + oldChecksums: map[string]string{"tldr-pages.en.zip": "oldhash"}, + newChecksums: map[string]string{"tldr-pages.en.zip": correctHash}, + handler: func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(zipData) + }, + wantUpdated: true, + }, + { + name: "checksum_mismatch", + language: "en", + oldChecksums: nil, + newChecksums: map[string]string{"tldr-pages.en.zip": "0000000000000000000000000000000000000000000000000000000000000000"}, + handler: func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(zipData) + }, + wantUpdated: false, + wantErr: true, + }, + { + name: "download_fails", + language: "en", + oldChecksums: nil, + newChecksums: map[string]string{"tldr-pages.en.zip": "irrelevant"}, + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + }, + wantUpdated: false, + wantErr: true, + }, + { + name: "invalid_zip", + language: "en", + oldChecksums: nil, + newChecksums: map[string]string{"tldr-pages.en.zip": invalidHash}, + handler: func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(invalidZipData) + }, + wantUpdated: false, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ts := httptest.NewServer(tt.handler) + defer ts.Close() + + cacheDir := t.TempDir() + defer setupConfig(t, cacheDir, ts.URL)() + + if tt.preExist != nil { + tt.preExist(t, &Cache{dir: cacheDir}) + } + + c := &Cache{dir: cacheDir} + client := upstream.New(upstream.WithHTTPClient(ts.Client())) + + gotUpdated, err := c.updateLanguage( + ctx, client, tt.language, + tt.oldChecksums, tt.newChecksums, + ) + + if tt.wantErr { + assert.Error(t, err) + assert.False(t, gotUpdated) + return + } + + require.NoError(t, err) + assert.Equal(t, tt.wantUpdated, gotUpdated) + }) + } +} + +func TestNeedsUpdate(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + exists bool + archive string + oldChecksums map[string]string + newChecksums map[string]string + want bool + }{ + { + name: "archive_not_in_new_checksums", + archive: "tldr-pages.en.zip", + oldChecksums: nil, + newChecksums: map[string]string{}, + want: false, + }, + { + name: "no_old_hash", + exists: true, + archive: "tldr-pages.en.zip", + oldChecksums: map[string]string{}, + newChecksums: map[string]string{"tldr-pages.en.zip": "abc"}, + want: true, + }, + { + name: "directory_missing", + exists: false, + archive: "tldr-pages.en.zip", + oldChecksums: map[string]string{"tldr-pages.en.zip": "abc"}, + newChecksums: map[string]string{"tldr-pages.en.zip": "abc"}, + want: true, + }, + { + name: "hash_changed", + exists: true, + archive: "tldr-pages.en.zip", + oldChecksums: map[string]string{"tldr-pages.en.zip": "abc"}, + newChecksums: map[string]string{"tldr-pages.en.zip": "def"}, + want: true, + }, + { + name: "up_to_date", + exists: true, + archive: "tldr-pages.en.zip", + oldChecksums: map[string]string{"tldr-pages.en.zip": "abc"}, + newChecksums: map[string]string{"tldr-pages.en.zip": "abc"}, + want: false, + }, + { + name: "empty_new_checksums", + exists: true, + archive: "tldr-pages.en.zip", + oldChecksums: map[string]string{"tldr-pages.en.zip": "abc"}, + newChecksums: map[string]string{}, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := needsUpdate(tt.exists, tt.archive, tt.oldChecksums, tt.newChecksums) + assert.Equal(t, tt.want, got) + }) + } +} From 7e469e529326bc694cc3c4551e765bf82a071fed Mon Sep 17 00:00:00 2001 From: TheRootDaemon Date: Thu, 25 Jun 2026 12:54:59 +0530 Subject: [PATCH 11/11] test(cache): Tests for missed control branches for archive.go, refactor downloadChecksum to accept mirror as a param to extend testability --- internal/cache/archive_test.go | 36 ++++++++++++++++ internal/cache/checksums.go | 3 +- internal/cache/checksums_test.go | 71 ++++++++++++++++++++++++++++++++ internal/cache/update.go | 2 +- 4 files changed, 109 insertions(+), 3 deletions(-) diff --git a/internal/cache/archive_test.go b/internal/cache/archive_test.go index 5eca9da..d2c1ee6 100644 --- a/internal/cache/archive_test.go +++ b/internal/cache/archive_test.go @@ -10,6 +10,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "runtime" "testing" "github.com/stretchr/testify/assert" @@ -296,3 +297,38 @@ func TestExtractFile(t *testing.T) { }) } } + +func TestExtractArchive_MkdirAllError(t *testing.T) { + dir := t.TempDir() + filePath := filepath.Join(dir, "notadir") + require.NoError(t, os.WriteFile(filePath, nil, 0o644)) + + c := &Cache{dir: filePath} + err := c.extractArchive("pages.en", createEmptyZip(t)) + assert.Error(t, err) +} + +func TestExtractFile_OpenFileError(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("chmod 0o500 does not prevent file creation on Windows") + } + + dir := t.TempDir() + require.NoError(t, os.Chmod(dir, 0o500)) + t.Cleanup(func() { _ = os.Chmod(dir, 0o755) }) + + root, err := os.OpenRoot(dir) + require.NoError(t, err) + defer func() { + _ = root.Close() + }() + + zipData := createTestZip(t, map[string]string{"test.md": "content"}) + zipReader, err := zip.NewReader(bytes.NewReader(zipData), int64(len(zipData))) + require.NoError(t, err) + require.Len(t, zipReader.File, 1) + + f := zipReader.File[0] + err = extractFile(root, f) + assert.Error(t, err) +} diff --git a/internal/cache/checksums.go b/internal/cache/checksums.go index 2bae92e..f22da23 100644 --- a/internal/cache/checksums.go +++ b/internal/cache/checksums.go @@ -6,7 +6,6 @@ import ( "os" "strings" - "github.com/TheRootDaemon/tlgc/internal/config" "github.com/TheRootDaemon/tlgc/internal/upstream" ) @@ -65,8 +64,8 @@ func (c *Cache) saveChecksums(checksums map[string]string) error { func downloadChecksum( ctx context.Context, client *upstream.Client, + mirror string, ) ([]byte, error) { - mirror := config.Cache().Mirror checksumURL := mirror + "/" + checksumFile return client.DownloadBytes(ctx, checksumURL, "") } diff --git a/internal/cache/checksums_test.go b/internal/cache/checksums_test.go index 9a81642..345d0bc 100644 --- a/internal/cache/checksums_test.go +++ b/internal/cache/checksums_test.go @@ -1,13 +1,18 @@ package cache import ( + "context" "fmt" + "net/http" + "net/http/httptest" "os" "path/filepath" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/TheRootDaemon/tlgc/internal/upstream" ) func TestLoadChecksums(t *testing.T) { @@ -255,6 +260,72 @@ func TestSaveChecksums(t *testing.T) { }) } +func TestDownloadChecksum(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + handler http.HandlerFunc + wantErr bool + want string + }{ + { + name: "successful_download", + handler: func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/"+checksumFile, r.URL.Path) + _, _ = w.Write([]byte("hash en.zip\n")) + }, + want: "hash en.zip\n", + }, + { + name: "empty_response", + handler: func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(nil) + }, + want: "", + }, + { + name: "server_error", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }, + wantErr: true, + }, + { + name: "not_found", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ts := httptest.NewServer(tt.handler) + defer ts.Close() + + client := upstream.New( + upstream.WithHTTPClient(ts.Client()), + ) + + got, err := downloadChecksum( + context.Background(), + client, + ts.URL, + ) + + if tt.wantErr { + assert.Error(t, err) + return + } + + require.NoError(t, err) + assert.Equal(t, tt.want, string(got)) + }) + } +} + func TestParseChecksum(t *testing.T) { t.Parallel() diff --git a/internal/cache/update.go b/internal/cache/update.go index 745f929..5ebceb1 100644 --- a/internal/cache/update.go +++ b/internal/cache/update.go @@ -17,7 +17,7 @@ func (c *Cache) Update( languages []string, client *upstream.Client, ) error { - checksums, err := downloadChecksum(ctx, client) + checksums, err := downloadChecksum(ctx, client, config.Cache().Mirror) if err != nil { return fmt.Errorf("downloading checksum: %s", err) }