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/config/config.go

Zum Verzeichnis
package config

import (
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"runtime"
	"strings"

	"gopkg.in/yaml.v3"
)

var defaultIgnoreDirs = []string{
	"node_modules",
	".venv",
	"venv",
	"vendor",
	"target",
	"dist",
	"build",
	".cache",
}

type Config struct {
	Server     ServerConfig     `yaml:"server"`
	Database   DatabaseConfig   `yaml:"database"`
	Roots      []RootConfig     `yaml:"roots"`
	Scanner    ScannerConfig    `yaml:"scanner"`
	Launchers  LaunchersConfig  `yaml:"launchers"`
	Monitoring MonitoringConfig `yaml:"monitoring"`
}

type MonitoringConfig struct {
	RefreshSeconds int `yaml:"refresh_seconds"`
}

type ServerConfig struct {
	Listen string `yaml:"listen"`
}

type DatabaseConfig struct {
	Path string `yaml:"path"`
}

type RootConfig struct {
	Name string `yaml:"name"`
	Path string `yaml:"path"`
}

type ScannerConfig struct {
	MaxDepth   int      `yaml:"max_depth"`
	IgnoreDirs []string `yaml:"ignore_dirs"`
}

type LaunchersConfig struct {
	VSCode   LauncherConfig `yaml:"vscode"`
	Sublime  LauncherConfig `yaml:"sublime"`
	Terminal LauncherConfig `yaml:"terminal"`
	Explorer LauncherConfig `yaml:"explorer"`
}

type LauncherConfig struct {
	Enabled bool   `yaml:"enabled"`
	Command string `yaml:"command"`
}

func Load(path string) (Config, error) {
	if path == "" {
		path = DefaultPath()
	}

	data, err := os.ReadFile(path)
	if err != nil {
		return Config{}, fmt.Errorf("read config %q: %w", path, err)
	}

	var cfg Config
	if err := yaml.Unmarshal(data, &cfg); err != nil {
		return Config{}, fmt.Errorf("parse config %q: %w", path, err)
	}

	baseDir := filepath.Dir(path)
	if err := cfg.applyDefaults(baseDir); err != nil {
		return Config{}, err
	}

	return cfg, nil
}

func DefaultPath() string {
	executable, err := os.Executable()
	return defaultPath(executable, err)
}

func defaultPath(executable string, err error) string {
	if err != nil || strings.TrimSpace(executable) == "" {
		return "config.yaml"
	}
	return filepath.Join(filepath.Dir(executable), "config.yaml")
}

func (c *Config) applyDefaults(baseDir string) error {
	if c.Server.Listen == "" {
		c.Server.Listen = "127.0.0.1:8799"
	}
	if c.Scanner.MaxDepth <= 0 {
		c.Scanner.MaxDepth = 2
	}
	if c.Monitoring.RefreshSeconds <= 0 {
		c.Monitoring.RefreshSeconds = 10
	}
	if len(c.Scanner.IgnoreDirs) == 0 {
		c.Scanner.IgnoreDirs = append([]string(nil), defaultIgnoreDirs...)
	}
	if len(c.Roots) == 0 {
		return errors.New("config requires at least one root")
	}

	for i := range c.Roots {
		root := &c.Roots[i]
		if root.Name == "" {
			root.Name = fmt.Sprintf("root-%d", i+1)
		}
		if root.Path == "" {
			return fmt.Errorf("root %q has empty path", root.Name)
		}
		resolved, err := expandPath(root.Path, baseDir)
		if err != nil {
			return fmt.Errorf("resolve root %q path: %w", root.Name, err)
		}
		root.Path = resolved
	}

	if c.Database.Path != "" {
		resolved, err := expandPath(c.Database.Path, baseDir)
		if err != nil {
			return fmt.Errorf("resolve database path: %w", err)
		}
		c.Database.Path = resolved
	}

	applyLauncherDefault(&c.Launchers.VSCode, "code")
	applyLauncherDefault(&c.Launchers.Sublime, "subl")
	if !c.Launchers.Terminal.Enabled {
		c.Launchers.Terminal.Enabled = true
	}
	if !c.Launchers.Explorer.Enabled {
		c.Launchers.Explorer.Enabled = true
	}

	return nil
}

func applyLauncherDefault(cfg *LauncherConfig, command string) {
	if !cfg.Enabled && strings.TrimSpace(cfg.Command) == "" {
		cfg.Enabled = true
	}
	if strings.TrimSpace(cfg.Command) == "" {
		cfg.Command = command
	}
}

func expandPath(value, baseDir string) (string, error) {
	if value == "" {
		return "", nil
	}

	if translated, ok := translateWindowsPath(value); ok {
		value = translated
	}

	if strings.HasPrefix(value, "~/") || value == "~" {
		home, err := os.UserHomeDir()
		if err != nil {
			return "", fmt.Errorf("determine home directory: %w", err)
		}
		if value == "~" {
			value = home
		} else {
			value = filepath.Join(home, value[2:])
		}
	}

	if filepath.IsAbs(value) {
		return filepath.Clean(value), nil
	}

	return filepath.Clean(filepath.Join(baseDir, value)), nil
}

func translateWindowsPath(value string) (string, bool) {
	if len(value) < 3 {
		return "", false
	}

	normalized := strings.ReplaceAll(value, "\\", "/")
	drive := normalized[0]
	if normalized[1] != ':' || normalized[2] != '/' {
		return "", false
	}
	if !((drive >= 'A' && drive <= 'Z') || (drive >= 'a' && drive <= 'z')) {
		return "", false
	}

	if runtime.GOOS == "windows" {
		return filepath.Clean(normalized), true
	}

	mountDrive := strings.ToLower(string(drive))
	candidate := filepath.Join("/mnt", mountDrive, strings.TrimPrefix(normalized[3:], "/"))
	if _, err := os.Stat(filepath.Join("/mnt", mountDrive)); err == nil {
		return filepath.Clean(candidate), true
	}
	return filepath.Clean(candidate), true
}