< Summary - Neospec Coverage

Line coverage
88%
Covered lines: 96
Uncovered lines: 12
Coverable lines: 108
Total lines: 313
Line coverage: 88.8%
Branch coverage
N/A
Covered branches: 0
Total branches: 0
Branch coverage: N/A
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
File 1: NewCache0%00100%
File 1: extractTarGz0%0093.48%
File 1: extractZip0%0081.82%
File 1: writeFile0%0072.73%
File 1: stripFirstComponent0%00100%
File 2: NewDownloader0%00100%
File 3: NewProvider0%00100%

File(s)

/home/runner/work/neospec/neospec/internal/adapters/neovim/cache.go

#LineLine coverage
 1package neovim
 2
 3import (
 4  "archive/tar"
 5  "archive/zip"
 6  "compress/gzip"
 7  "fmt"
 8  "io"
 9  "os"
 10  "path/filepath"
 11
 12  "github.com/jedi-knights/neospec/internal/domain"
 13)
 14
 15// Cache manages the on-disk store of extracted Neovim binaries.
 16// Layout: <cacheDir>/<version>/<platform>/bin/nvim[.exe]
 17type Cache struct {
 18  root string
 19}
 20
 21// NewCache creates a Cache rooted at the given directory.
 2222func NewCache(root string) *Cache {
 2223  return &Cache{root: root}
 2224}
 25
 26// VersionDir returns the cache subdirectory for a version+platform pair.
 27func (c *Cache) VersionDir(v domain.Version, p domain.Platform) string {
 28  return filepath.Join(c.root, v.Tag, string(p.OS), string(p.Arch))
 29}
 30
 31// Lookup checks whether a cached binary exists and returns its path.
 32func (c *Cache) Lookup(v domain.Version, p domain.Platform) (string, bool) {
 33  binPath := filepath.Join(c.VersionDir(v, p), "bin", domain.BinaryName(p))
 34  if _, err := os.Stat(binPath); err == nil {
 35    return binPath, true
 36  }
 37  return "", false
 38}
 39
 40// Extract unpacks the archive at archivePath into the cache directory for the
 41// given version and platform, and returns the path to the nvim binary.
 42func (c *Cache) Extract(v domain.Version, p domain.Platform, archivePath string) (string, error) {
 43  destDir := c.VersionDir(v, p)
 44  if err := os.MkdirAll(destDir, 0o755); err != nil {
 45    return "", fmt.Errorf("creating cache dir: %w", err)
 46  }
 47
 48  switch p.OS {
 49  case domain.OSWindows:
 50    if err := extractZip(archivePath, destDir); err != nil {
 51      return "", err
 52    }
 53  default:
 54    if err := extractTarGz(archivePath, destDir); err != nil {
 55      return "", err
 56    }
 57  }
 58
 59  binPath := filepath.Join(destDir, "bin", domain.BinaryName(p))
 60  if _, err := os.Stat(binPath); err != nil {
 61    return "", fmt.Errorf("binary not found after extraction at %s: %w", binPath, err)
 62  }
 63  return binPath, nil
 64}
 65
 66// extractTarGz extracts a .tar.gz archive, stripping the first path component
 67// so that nvim-linux-x86_64/bin/nvim becomes <destDir>/bin/nvim.
 1368func extractTarGz(archivePath, destDir string) error {
 1369  f, err := os.Open(archivePath)
 170  if err != nil {
 171    return err
 172  }
 1273  defer f.Close()
 1274
 1275  gz, err := gzip.NewReader(f)
 276  if err != nil {
 277    return fmt.Errorf("opening gzip stream: %w", err)
 278  }
 1079  defer gz.Close()
 1080
 1081  tr := tar.NewReader(gz)
 1082  for {
 2283    hdr, err := tr.Next()
 684    if err == io.EOF {
 685      break
 86    }
 187    if err != nil {
 188      return fmt.Errorf("reading tar entry: %w", err)
 189    }
 90
 1591    relPath := stripFirstComponent(hdr.Name)
 292    if relPath == "" {
 293      continue
 94    }
 95
 1396    target := filepath.Join(destDir, relPath)
 1397
 1398    switch hdr.Typeflag {
 299    case tar.TypeDir:
 1100      if err := os.MkdirAll(target, 0o755); err != nil {
 1101        return err
 1102      }
 10103    case tar.TypeReg:
 1104      if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
 1105        return err
 1106      }
 9107      mode := os.FileMode(hdr.Mode)
 1108      if err := writeFile(target, tr, mode); err != nil {
 1109        return err
 1110      }
 1111    case tar.TypeSymlink:
 1112      // Neovim archives include symlinks; recreate them.
 1113      _ = os.Remove(target) // ignore error — may not exist
 0114      if err := os.Symlink(hdr.Linkname, target); err != nil {
 0115        return err
 0116      }
 117    }
 118  }
 6119  return nil
 120}
 121
 122// extractZip extracts a .zip archive (Windows), stripping the first component.
 6123func extractZip(archivePath, destDir string) error {
 6124  r, err := zip.OpenReader(archivePath)
 1125  if err != nil {
 1126    return err
 1127  }
 5128  defer r.Close()
 5129
 5130  for _, f := range r.File {
 9131    relPath := stripFirstComponent(f.Name)
 1132    if relPath == "" {
 1133      continue
 134    }
 8135    target := filepath.Join(destDir, relPath)
 8136
 2137    if f.FileInfo().IsDir() {
 1138      if err := os.MkdirAll(target, 0o755); err != nil {
 1139        return err
 1140      }
 1141      continue
 142    }
 143
 1144    if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
 1145      return err
 1146    }
 5147    rc, err := f.Open()
 0148    if err != nil {
 0149      return err
 0150    }
 5151    writeErr := writeFile(target, rc, f.Mode())
 0152    if cerr := rc.Close(); cerr != nil && writeErr == nil {
 0153      writeErr = cerr
 0154    }
 1155    if writeErr != nil {
 1156      return writeErr
 1157    }
 158  }
 2159  return nil
 160}
 161
 15162func writeFile(path string, r io.Reader, mode os.FileMode) (retErr error) {
 15163  dst, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode)
 3164  if err != nil {
 3165    return err
 3166  }
 12167  defer func() {
 0168    if cerr := dst.Close(); cerr != nil && retErr == nil {
 0169      retErr = cerr
 0170    }
 171  }()
 12172  _, retErr = io.Copy(dst, r)
 12173  return
 174}
 175
 176// stripFirstComponent removes the first path segment from a slash-separated
 177// path, e.g. "nvim-linux-x86_64/bin/nvim" → "bin/nvim".
 24178func stripFirstComponent(p string) string {
 24179  for i, c := range p {
 23180    if (c == '/' || c == '\\') && i > 0 {
 23181      return filepath.FromSlash(p[i+1:])
 23182    }
 183  }
 1184  return ""
 185}

/home/runner/work/neospec/neospec/internal/adapters/neovim/downloader.go

#LineLine coverage
 1package neovim
 2
 3import (
 4  "context"
 5  "fmt"
 6  "io"
 7  "net/http"
 8  "os"
 9  "path/filepath"
 10
 11  "github.com/jedi-knights/neospec/internal/domain"
 12)
 13
 14// githubReleaseBase is the base URL for Neovim's GitHub releases.
 15const githubReleaseBase = "https://github.com/neovim/neovim/releases/download"
 16
 17// Downloader fetches Neovim release archives from GitHub.
 18type Downloader struct {
 19  client *http.Client
 20}
 21
 22// NewDownloader creates a Downloader with the default HTTP client.
 623func NewDownloader() *Downloader {
 624  return &Downloader{client: &http.Client{}}
 625}
 26
 27// Download fetches the release archive for version+platform and writes it to
 28// destPath, creating parent directories as needed.
 29func (d *Downloader) Download(ctx context.Context, v domain.Version, p domain.Platform, destPath string) (retErr error) 
 30  assetName := v.AssetName(p)
 31  if assetName == "" {
 32    return fmt.Errorf("no asset defined for platform %s", p)
 33  }
 34
 35  url := fmt.Sprintf("%s/%s/%s", githubReleaseBase, v.Tag, assetName)
 36
 37  if err := os.MkdirAll(filepath.Dir(destPath), 0o755); err != nil {
 38    return fmt.Errorf("creating download dir: %w", err)
 39  }
 40
 41  // This error branch is structurally unreachable: http.NewRequestWithContext
 42  // only fails when the method or URL is malformed. Both are constructed from
 43  // the compile-time constant http.MethodGet and a well-formed URL built from
 44  // the fixed githubReleaseBase prefix. No user-supplied input reaches here.
 45  req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
 46  if err != nil {
 47    return fmt.Errorf("creating request: %w", err)
 48  }
 49
 50  resp, err := d.client.Do(req)
 51  if err != nil {
 52    return fmt.Errorf("fetching %s: %w", url, err)
 53  }
 54  defer resp.Body.Close()
 55
 56  if resp.StatusCode != http.StatusOK {
 57    return fmt.Errorf("unexpected HTTP %d fetching %s", resp.StatusCode, url)
 58  }
 59
 60  f, err := os.Create(destPath)
 61  if err != nil {
 62    return fmt.Errorf("creating archive file: %w", err)
 63  }
 64  defer func() {
 65    if cerr := f.Close(); cerr != nil && retErr == nil {
 66      retErr = cerr
 67    }
 68  }()
 69
 70  if _, err := io.Copy(f, resp.Body); err != nil {
 71    return fmt.Errorf("writing archive: %w", err)
 72  }
 73  return nil
 74}

/home/runner/work/neospec/neospec/internal/adapters/neovim/provider.go

#LineLine coverage
 1// Package neovim implements ports.NeovimProvider. It downloads Neovim release
 2// archives from GitHub, caches the extracted binary on disk, and returns the
 3// path to the binary on each call to Ensure.
 4package neovim
 5
 6import (
 7  "context"
 8  "fmt"
 9  "path/filepath"
 10
 11  "github.com/jedi-knights/neospec/internal/domain"
 12)
 13
 14// Provider satisfies ports.NeovimProvider.
 15type Provider struct {
 16  cache      *Cache
 17  downloader *Downloader
 18}
 19
 20// NewProvider creates a Provider that stores binaries under cacheDir.
 321func NewProvider(cacheDir string) *Provider {
 322  return &Provider{
 323    cache:      NewCache(cacheDir),
 324    downloader: NewDownloader(),
 325  }
 326}
 27
 28// Ensure returns the path to an nvim binary of the requested version.
 29// It checks the local cache first; on a cache miss it downloads and extracts
 30// the archive, then caches the result.
 31func (p *Provider) Ensure(ctx context.Context, version domain.Version, platform domain.Platform) (string, error) {
 32  binaryPath, ok := p.cache.Lookup(version, platform)
 33  if ok {
 34    return binaryPath, nil
 35  }
 36
 37  assetName := version.AssetName(platform)
 38  if assetName == "" {
 39    return "", fmt.Errorf("unsupported platform: %s", platform)
 40  }
 41
 42  archivePath := filepath.Join(p.cache.VersionDir(version, platform), assetName)
 43
 44  if err := p.downloader.Download(ctx, version, platform, archivePath); err != nil {
 45    return "", fmt.Errorf("downloading neovim %s for %s: %w", version, platform, err)
 46  }
 47
 48  binaryPath, err := p.cache.Extract(version, platform, archivePath)
 49  if err != nil {
 50    return "", fmt.Errorf("extracting neovim archive: %w", err)
 51  }
 52
 53  return binaryPath, nil
 54}