Git Repository

Stackyard

Stackyard scannt lokale Entwicklungsordner und bündelt Projekte, Git-Status, TODOs, Toolchains, Docker-Compose-Setups, Datei- und Launcher-Funktionen sowie Operations-Informationen in einer lokalen WebUI.

Projektseite ↗
HTTPShttps://zanvex.de/git/stackyard.git

internal/scanner/scanner.go

Zum Verzeichnis
package scanner

import (
	"io/fs"
	"os"
	"path/filepath"
	"slices"
	"strings"
	"time"

	"stackyard/internal/config"
)

type Marker struct {
	File string
	Type string
}

var Markers = []Marker{
	{".git", "git"},
	{"go.mod", "go"},
	{"package.json", "node"},
	{"pyproject.toml", "python"},
	{"requirements.txt", "python"},
	{"Cargo.toml", "rust"},
	{"composer.json", "php"},
	{"docker-compose.yml", "docker"},
	{"compose.yml", "docker"},
	{".mise.toml", "mise"},
	{"README.md", "docs"},
}

type Candidate struct {
	RootName     string
	Path         string
	Name         string
	PrimaryType  string
	Markers      []Marker
	LastModified time.Time
}

func NameFromPath(path string) string {
	return filepath.Base(path)
}

type Scanner struct {
	maxDepth   int
	ignoreDirs map[string]struct{}
}

func New(cfg config.ScannerConfig) Scanner {
	ignoreDirs := make(map[string]struct{}, len(cfg.IgnoreDirs))
	for _, dir := range cfg.IgnoreDirs {
		ignoreDirs[dir] = struct{}{}
	}
	return Scanner{
		maxDepth:   cfg.MaxDepth,
		ignoreDirs: ignoreDirs,
	}
}

func (s Scanner) Scan(roots []config.RootConfig) ([]Candidate, error) {
	results := make([]Candidate, 0)
	for _, root := range roots {
		candidates, err := s.scanRoot(root)
		if err != nil {
			return nil, err
		}
		results = append(results, candidates...)
	}
	slices.SortFunc(results, func(a, b Candidate) int {
		return strings.Compare(a.Path, b.Path)
	})
	return results, nil
}

func (s Scanner) scanRoot(root config.RootConfig) ([]Candidate, error) {
	entries, err := os.ReadDir(root.Path)
	if err != nil {
		return nil, err
	}

	results := make([]Candidate, 0)
	for _, entry := range entries {
		if !entry.IsDir() || isSymlink(entry) || s.isIgnored(entry.Name()) {
			continue
		}

		startPath := filepath.Join(root.Path, entry.Name())
		candidates, err := s.walk(root.Name, startPath, 1)
		if err != nil {
			return nil, err
		}
		results = append(results, candidates...)
	}

	return results, nil
}

func (s Scanner) walk(rootName, dir string, depth int) ([]Candidate, error) {
	if depth > s.maxDepth {
		return nil, nil
	}

	entryInfo, err := os.Stat(dir)
	if err != nil {
		return nil, err
	}

	candidate, hasMarkers, err := inspectDirectory(rootName, dir, entryInfo.ModTime())
	if err != nil {
		return nil, err
	}

	results := make([]Candidate, 0, 1)
	if hasMarkers {
		results = append(results, candidate)
		if !shouldDescend(candidate.Markers) {
			return results, nil
		}
	}

	entries, err := os.ReadDir(dir)
	if err != nil {
		return results, nil
	}

	for _, entry := range entries {
		if !entry.IsDir() || isSymlink(entry) || s.isIgnored(entry.Name()) {
			continue
		}
		childPath := filepath.Join(dir, entry.Name())
		childResults, err := s.walk(rootName, childPath, depth+1)
		if err != nil {
			return nil, err
		}
		results = append(results, childResults...)
	}

	return results, nil
}

func inspectDirectory(rootName, dir string, modTime time.Time) (Candidate, bool, error) {
	entries, err := os.ReadDir(dir)
	if err != nil {
		return Candidate{}, false, err
	}

	var markers []Marker
	for _, marker := range Markers {
		if containsEntry(entries, marker.File) {
			markers = append(markers, marker)
		}
	}

	candidate := Candidate{
		RootName:     rootName,
		Path:         dir,
		Name:         NameFromPath(dir),
		PrimaryType:  detectPrimaryType(markers),
		Markers:      markers,
		LastModified: modTime,
	}

	return candidate, len(markers) > 0, nil
}

func detectPrimaryType(markers []Marker) string {
	for _, marker := range markers {
		switch marker.Type {
		case "go", "node", "python", "rust", "php", "docker", "mise":
			return marker.Type
		}
	}
	if len(markers) == 0 {
		return "unknown"
	}
	return markers[0].Type
}

func containsEntry(entries []fs.DirEntry, name string) bool {
	for _, entry := range entries {
		if entry.Name() == name {
			return true
		}
	}
	return false
}

func isSymlink(entry fs.DirEntry) bool {
	return entry.Type()&fs.ModeSymlink != 0
}

func (s Scanner) isIgnored(name string) bool {
	if strings.HasPrefix(name, ".") {
		return true
	}
	_, ok := s.ignoreDirs[name]
	return ok
}

func shouldDescend(markers []Marker) bool {
	if len(markers) == 0 {
		return true
	}
	for _, marker := range markers {
		switch marker.Type {
		case "git", "go", "node", "python", "rust", "php", "docker", "mise":
			return false
		}
	}
	return true
}