nxstats/internal/parser/scanner.go
2026-08-24 14:44:54 -04:00

167 lines
4.1 KiB
Go

package parser
import (
"bufio"
"compress/gzip"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
)
// ScanDirectory scans dir for nginx access and error log files.
// It matches filenames like access*.log* and error*.log*.
// If includeGzip is false, .gz files are skipped.
// Returns (accessFiles, errorFiles, error) sorted newest-first.
func ScanDirectory(dir string, includeGzip bool) ([]string, []string, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return nil, nil, fmt.Errorf("scandir %q: %w", dir, err)
}
var accessFiles, errorFiles []string
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
if !includeGzip && strings.HasSuffix(name, ".gz") {
continue
}
if matchesLogPattern(name, "access") {
accessFiles = append(accessFiles, filepath.Join(dir, name))
} else if matchesLogPattern(name, "error") {
errorFiles = append(errorFiles, filepath.Join(dir, name))
}
}
sortLogFiles(accessFiles)
sortLogFiles(errorFiles)
return accessFiles, errorFiles, nil
}
// matchesLogPattern returns true if filename starts with prefix and contains ".log".
func matchesLogPattern(name, prefix string) bool {
if !strings.HasPrefix(name, prefix) {
return false
}
return strings.Contains(name, ".log")
}
// logSortKey returns a sort key such that:
// - "access.log" → (0, 0) — most recent
// - "access.log.1" → (1, 0)
// - "access.log.1.gz" → (1, 1)
// - "access.log.2.gz" → (2, 1)
func logSortKey(path string) (int, int) {
name := filepath.Base(path)
// strip prefix up to and including ".log"
idx := strings.Index(name, ".log")
if idx < 0 {
return 999, 0
}
suffix := name[idx+4:] // e.g. "", ".1", ".1.gz", ".2.gz"
isGz := 0
if strings.HasSuffix(suffix, ".gz") {
isGz = 1
suffix = strings.TrimSuffix(suffix, ".gz")
}
suffix = strings.TrimPrefix(suffix, ".")
num := 0
if suffix != "" {
fmt.Sscanf(suffix, "%d", &num)
}
return num, isGz
}
func sortLogFiles(files []string) {
sort.SliceStable(files, func(i, j int) bool {
ni, gi := logSortKey(files[i])
nj, gj := logSortKey(files[j])
if ni != nj {
return ni < nj
}
return gi < gj
})
}
// openLogFile opens a log file for reading.
// If the path ends in ".gz", it transparently decompresses.
func openLogFile(path string) (io.ReadCloser, error) {
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("open %q: %w", path, err)
}
if strings.HasSuffix(path, ".gz") {
gz, err := gzip.NewReader(f)
if err != nil {
f.Close()
return nil, fmt.Errorf("gzip %q: %w", path, err)
}
return &gzipReadCloser{gz: gz, f: f}, nil
}
return f, nil
}
// gzipReadCloser closes both the gzip reader and underlying file.
type gzipReadCloser struct {
gz *gzip.Reader
f *os.File
}
func (g *gzipReadCloser) Read(p []byte) (int, error) { return g.gz.Read(p) }
func (g *gzipReadCloser) Close() error {
err := g.gz.Close()
g.f.Close()
return err
}
// ReadAccessEntries reads all access log entries from the given files.
// Malformed lines are silently skipped.
func ReadAccessEntries(files []string) ([]AccessEntry, error) {
var entries []AccessEntry
for _, path := range files {
rc, err := openLogFile(path)
if err != nil {
return nil, err
}
scanner := bufio.NewScanner(rc)
for scanner.Scan() {
if e, err := ParseAccessLine(scanner.Text()); err == nil {
entries = append(entries, e)
}
}
rc.Close()
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("scan %q: %w", path, err)
}
}
return entries, nil
}
// ReadErrorEntries reads all error log entries from the given files.
// Malformed lines are silently skipped.
func ReadErrorEntries(files []string) ([]ErrorEntry, error) {
var entries []ErrorEntry
for _, path := range files {
rc, err := openLogFile(path)
if err != nil {
return nil, err
}
scanner := bufio.NewScanner(rc)
for scanner.Scan() {
if e, err := ParseErrorLine(scanner.Text()); err == nil {
entries = append(entries, e)
}
}
rc.Close()
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("scan %q: %w", path, err)
}
}
return entries, nil
}