🏡 index : ironmagma/quintodrome.git

package scanner

import (
	"bufio"
	"context"
	"io/fs"
	"path"
	"strings"

	"github.com/navidrome/navidrome/consts"
	"github.com/navidrome/navidrome/log"
	ignore "github.com/sabhiram/go-gitignore"
)

// IgnoreChecker manages .ndignore patterns using a stack-based approach.
// Use Push() to add patterns when entering a folder, Pop() when leaving,
// and ShouldIgnore() to check if a path should be ignored.
type IgnoreChecker struct {
	fsys            fs.FS
	patternStack    [][]string        // Stack of patterns for each folder level
	currentPatterns []string          // Flattened current patterns
	matcher         *ignore.GitIgnore // Compiled matcher for current patterns
}

// newIgnoreChecker creates a new IgnoreChecker for the given filesystem.
func newIgnoreChecker(fsys fs.FS) *IgnoreChecker {
	return &IgnoreChecker{
		fsys:         fsys,
		patternStack: make([][]string, 0),
	}
}

// Push loads .ndignore patterns from the specified folder and adds them to the pattern stack.
// Use this when entering a folder during directory tree traversal.
func (ic *IgnoreChecker) Push(ctx context.Context, folder string) error {
	patterns := ic.loadPatternsFromFolder(ctx, folder)
	ic.patternStack = append(ic.patternStack, patterns)
	ic.rebuildCurrentPatterns()
	return nil
}

// Pop removes the most recent patterns from the stack.
// Use this when leaving a folder during directory tree traversal.
func (ic *IgnoreChecker) Pop() {
	if len(ic.patternStack) > 0 {
		ic.patternStack = ic.patternStack[:len(ic.patternStack)-1]
		ic.rebuildCurrentPatterns()
	}
}

// PushAllParents pushes patterns from root down to the target path.
// This is a convenience method for when you need to check a specific path
// without recursively walking the tree. It handles the common pattern of
// pushing all parent directories from root to the target.
// This method is optimized to compile patterns only once at the end.
func (ic *IgnoreChecker) PushAllParents(ctx context.Context, targetPath string) error {
	if targetPath == "." || targetPath == "" {
		// Simple case: just push root
		return ic.Push(ctx, ".")
	}

	// Load patterns for root
	patterns := ic.loadPatternsFromFolder(ctx, ".")
	ic.patternStack = append(ic.patternStack, patterns)

	// Load patterns for each parent directory
	currentPath := "."
	parts := strings.SplitSeq(path.Clean(targetPath), "/")
	for part := range parts {
		if part == "." || part == "" {
			continue
		}
		currentPath = path.Join(currentPath, part)
		patterns = ic.loadPatternsFromFolder(ctx, currentPath)
		ic.patternStack = append(ic.patternStack, patterns)
	}

	// Rebuild and compile patterns only once at the end
	ic.rebuildCurrentPatterns()
	return nil
}

// ShouldIgnore checks if the given path should be ignored based on the current patterns.
// Returns true if the path matches any ignore pattern, false otherwise.
func (ic *IgnoreChecker) ShouldIgnore(ctx context.Context, relPath string) bool {
	// Handle root/empty path - never ignore
	if relPath == "" || relPath == "." {
		return false
	}

	// If no patterns loaded, nothing to ignore
	if ic.matcher == nil {
		return false
	}

	matches := ic.matcher.MatchesPath(relPath)
	if matches {
		log.Trace(ctx, "Scanner: Ignoring entry matching .ndignore", "path", relPath)
	}
	return matches
}

// loadPatternsFromFolder reads the .ndignore file in the specified folder and returns the patterns.
// If the file doesn't exist, returns an empty slice.
// If the file exists but is empty, returns a pattern to ignore everything ("**/*").
func (ic *IgnoreChecker) loadPatternsFromFolder(ctx context.Context, folder string) []string {
	ignoreFilePath := path.Join(folder, consts.ScanIgnoreFile)
	var patterns []string

	// Check if .ndignore file exists
	if _, err := fs.Stat(ic.fsys, ignoreFilePath); err != nil {
		// No .ndignore file in this folder
		return patterns
	}

	// Read and parse the .ndignore file
	ignoreFile, err := ic.fsys.Open(ignoreFilePath)
	if err != nil {
		log.Warn(ctx, "Scanner: Error opening .ndignore file", "path", ignoreFilePath, err)
		return patterns
	}
	defer ignoreFile.Close()

	lineScanner := bufio.NewScanner(ignoreFile)
	for lineScanner.Scan() {
		line := strings.TrimSpace(lineScanner.Text())
		if line == "" || strings.HasPrefix(line, "#") {
			continue // Skip empty lines, whitespace-only lines, and comments
		}
		patterns = append(patterns, line)
	}

	if err := lineScanner.Err(); err != nil {
		log.Warn(ctx, "Scanner: Error reading .ndignore file", "path", ignoreFilePath, err)
		return patterns
	}

	// If the .ndignore file is empty, ignore everything
	if len(patterns) == 0 {
		log.Trace(ctx, "Scanner: .ndignore file is empty, ignoring everything", "path", folder)
		patterns = []string{"**/*"}
	}

	return patterns
}

// rebuildCurrentPatterns flattens the pattern stack into currentPatterns and recompiles the matcher.
func (ic *IgnoreChecker) rebuildCurrentPatterns() {
	ic.currentPatterns = make([]string, 0)
	for _, patterns := range ic.patternStack {
		ic.currentPatterns = append(ic.currentPatterns, patterns...)
	}
	ic.compilePatterns()
}

// compilePatterns compiles the current patterns into a GitIgnore matcher.
func (ic *IgnoreChecker) compilePatterns() {
	if len(ic.currentPatterns) == 0 {
		ic.matcher = nil
		return
	}
	ic.matcher = ignore.CompileIgnoreLines(ic.currentPatterns...)
}