343 lines
		
	
	
		
			9.5 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
			
		
		
	
	
			343 lines
		
	
	
		
			9.5 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
package main
 | 
						|
 | 
						|
import (
 | 
						|
	"fmt"
 | 
						|
	"os"
 | 
						|
	"path/filepath"
 | 
						|
	"slices"
 | 
						|
	"strings"
 | 
						|
 | 
						|
	"gopkg.in/yaml.v3"
 | 
						|
)
 | 
						|
 | 
						|
type LinkInstruction struct {
 | 
						|
	Source string `yaml:"source"`
 | 
						|
	Target string `yaml:"target"`
 | 
						|
	Force  bool   `yaml:"force,omitempty"`
 | 
						|
	Hard   bool   `yaml:"hard,omitempty"`
 | 
						|
	Delete bool   `yaml:"delete,omitempty"`
 | 
						|
}
 | 
						|
 | 
						|
type YAMLConfig struct {
 | 
						|
	Links []LinkInstruction `yaml:"links"`
 | 
						|
}
 | 
						|
 | 
						|
func (instruction *LinkInstruction) Tidy() {
 | 
						|
	instruction.Source = strings.ReplaceAll(instruction.Source, "\"", "")
 | 
						|
	instruction.Source = strings.ReplaceAll(instruction.Source, "\\", "/")
 | 
						|
	instruction.Source = strings.TrimSpace(instruction.Source)
 | 
						|
 | 
						|
	instruction.Target = strings.ReplaceAll(instruction.Target, "\"", "")
 | 
						|
	instruction.Target = strings.ReplaceAll(instruction.Target, "\\", "/")
 | 
						|
	instruction.Target = strings.TrimSpace(instruction.Target)
 | 
						|
}
 | 
						|
 | 
						|
func (instruction *LinkInstruction) String() string {
 | 
						|
	var flags []string
 | 
						|
	if instruction.Force {
 | 
						|
		flags = append(flags, "force=true")
 | 
						|
	}
 | 
						|
	if instruction.Hard {
 | 
						|
		flags = append(flags, "hard=true")
 | 
						|
	}
 | 
						|
	if instruction.Delete {
 | 
						|
		flags = append(flags, "delete=true")
 | 
						|
	}
 | 
						|
 | 
						|
	flagsStr := ""
 | 
						|
	if len(flags) > 0 {
 | 
						|
		flagsStr = " [" + strings.Join(flags, ", ") + "]"
 | 
						|
	}
 | 
						|
 | 
						|
	return fmt.Sprintf("%s → %s%s",
 | 
						|
		FormatSourcePath(instruction.Source),
 | 
						|
		FormatTargetPath(instruction.Target),
 | 
						|
		flagsStr)
 | 
						|
}
 | 
						|
 | 
						|
func (instruction *LinkInstruction) Undo() {
 | 
						|
	if !FileExists(instruction.Target) {
 | 
						|
		LogInfo("%s does not exist, skipping", FormatTargetPath(instruction.Target))
 | 
						|
		return
 | 
						|
	}
 | 
						|
 | 
						|
	isSymlink, err := IsSymlink(instruction.Target)
 | 
						|
	if err != nil {
 | 
						|
		LogError("could not determine whether %s is a sym link or not, stopping; err: %v",
 | 
						|
			FormatTargetPath(instruction.Target), err)
 | 
						|
		return
 | 
						|
	}
 | 
						|
 | 
						|
	if isSymlink {
 | 
						|
		LogInfo("Removing symlink at %s", FormatTargetPath(instruction.Target))
 | 
						|
		err = os.Remove(instruction.Target)
 | 
						|
		if err != nil {
 | 
						|
			LogError("could not remove symlink at %s; err: %v",
 | 
						|
				FormatTargetPath(instruction.Target), err)
 | 
						|
		}
 | 
						|
	} else {
 | 
						|
		LogInfo("%s is not a symlink, skipping", FormatTargetPath(instruction.Target))
 | 
						|
	}
 | 
						|
}
 | 
						|
 | 
						|
func ParseInstruction(line, workdir string) (LinkInstruction, error) {
 | 
						|
	line = strings.TrimSpace(line)
 | 
						|
	if strings.HasPrefix(line, "#") {
 | 
						|
		return LinkInstruction{}, fmt.Errorf("comment line")
 | 
						|
	}
 | 
						|
 | 
						|
	parts := strings.Split(line, deliminer)
 | 
						|
	instruction := LinkInstruction{}
 | 
						|
 | 
						|
	if len(parts) < 2 {
 | 
						|
		return instruction, fmt.Errorf("invalid format - not enough parameters (must have at least source and target)")
 | 
						|
	}
 | 
						|
 | 
						|
	instruction.Source = strings.TrimSpace(parts[0])
 | 
						|
	instruction.Target = strings.TrimSpace(parts[1])
 | 
						|
 | 
						|
	for i := 2; i < len(parts); i++ {
 | 
						|
		flagPart := strings.TrimSpace(parts[i])
 | 
						|
 | 
						|
		// Support for legacy format (backward compatibility)
 | 
						|
		if !strings.Contains(flagPart, "=") {
 | 
						|
			// Legacy format: positional boolean flags
 | 
						|
			switch i {
 | 
						|
			case 2: // Force flag (3rd position)
 | 
						|
				instruction.Force = isTrue(flagPart)
 | 
						|
			case 3: // Hard flag (4th position)
 | 
						|
				instruction.Hard = isTrue(flagPart)
 | 
						|
			case 4: // Delete flag (5th position)
 | 
						|
				instruction.Delete = isTrue(flagPart)
 | 
						|
				if instruction.Delete {
 | 
						|
					instruction.Force = true // Delete implies Force
 | 
						|
				}
 | 
						|
			}
 | 
						|
			continue
 | 
						|
		}
 | 
						|
 | 
						|
		// New format: named flags (name=value)
 | 
						|
		nameValue := strings.SplitN(flagPart, "=", 2)
 | 
						|
		if len(nameValue) != 2 {
 | 
						|
			// Skip malformed flags
 | 
						|
			continue
 | 
						|
		}
 | 
						|
 | 
						|
		flagName := strings.ToLower(strings.TrimSpace(nameValue[0]))
 | 
						|
		flagValue := strings.TrimSpace(nameValue[1])
 | 
						|
 | 
						|
		switch flagName {
 | 
						|
		case "force", "f":
 | 
						|
			instruction.Force = isTrue(flagValue)
 | 
						|
		case "hard", "h":
 | 
						|
			instruction.Hard = isTrue(flagValue)
 | 
						|
		case "delete", "d":
 | 
						|
			instruction.Delete = isTrue(flagValue)
 | 
						|
			if instruction.Delete {
 | 
						|
				instruction.Force = true // Delete implies Force
 | 
						|
			}
 | 
						|
		}
 | 
						|
	}
 | 
						|
 | 
						|
	instruction.Tidy()
 | 
						|
	instruction.Source, _ = ConvertHome(instruction.Source)
 | 
						|
	instruction.Target, _ = ConvertHome(instruction.Target)
 | 
						|
 | 
						|
	instruction.Source = NormalizePath(instruction.Source, workdir)
 | 
						|
	instruction.Target = NormalizePath(instruction.Target, workdir)
 | 
						|
 | 
						|
	return instruction, nil
 | 
						|
}
 | 
						|
 | 
						|
func isTrue(value string) bool {
 | 
						|
	value = strings.ToLower(strings.TrimSpace(value))
 | 
						|
	return value == "true" || value == "t" || value == "yes" || value == "y" || value == "1"
 | 
						|
}
 | 
						|
 | 
						|
func (instruction *LinkInstruction) RunAsync(status chan (error)) {
 | 
						|
	defer close(status)
 | 
						|
	if undo {
 | 
						|
		instruction.Undo()
 | 
						|
		return
 | 
						|
	}
 | 
						|
 | 
						|
	if !FileExists(instruction.Source) {
 | 
						|
		status <- fmt.Errorf("instruction source %s does not exist", FormatSourcePath(instruction.Source))
 | 
						|
		return
 | 
						|
	}
 | 
						|
 | 
						|
	if !instruction.Force && AreSame(instruction.Source, instruction.Target) {
 | 
						|
		status <- fmt.Errorf("source %s and target %s are the same, nothing to do...",
 | 
						|
			FormatSourcePath(instruction.Source),
 | 
						|
			FormatTargetPath(instruction.Target))
 | 
						|
		return
 | 
						|
	}
 | 
						|
 | 
						|
	if FileExists(instruction.Target) {
 | 
						|
		if instruction.Force {
 | 
						|
			isSymlink, err := IsSymlink(instruction.Target)
 | 
						|
			if err != nil {
 | 
						|
				status <- fmt.Errorf("could not determine whether %s is a sym link or not, stopping; err: %v",
 | 
						|
					FormatTargetPath(instruction.Target), err)
 | 
						|
				return
 | 
						|
			}
 | 
						|
 | 
						|
			if instruction.Hard {
 | 
						|
				info, err := os.Stat(instruction.Target)
 | 
						|
				if err != nil {
 | 
						|
					status <- fmt.Errorf("could not stat %s, stopping; err: %v",
 | 
						|
						FormatTargetPath(instruction.Target), err)
 | 
						|
					return
 | 
						|
				}
 | 
						|
				if info.Mode().IsRegular() && info.Name() == filepath.Base(instruction.Source) {
 | 
						|
					LogTarget("Overwriting existing file %s", instruction.Target)
 | 
						|
					err := os.Remove(instruction.Target)
 | 
						|
					if err != nil {
 | 
						|
						status <- fmt.Errorf("could not remove existing file %s; err: %v",
 | 
						|
							FormatTargetPath(instruction.Target), err)
 | 
						|
						return
 | 
						|
					}
 | 
						|
				}
 | 
						|
			}
 | 
						|
 | 
						|
			if isSymlink {
 | 
						|
				LogTarget("Removing symlink at %s", instruction.Target)
 | 
						|
				err = os.Remove(instruction.Target)
 | 
						|
				if err != nil {
 | 
						|
					status <- fmt.Errorf("failed deleting %s due to %v",
 | 
						|
						FormatTargetPath(instruction.Target), err)
 | 
						|
					return
 | 
						|
				}
 | 
						|
			} else {
 | 
						|
				if !instruction.Delete {
 | 
						|
					status <- fmt.Errorf("refusing to delte actual (non symlink) file %s",
 | 
						|
						FormatTargetPath(instruction.Target))
 | 
						|
					return
 | 
						|
				}
 | 
						|
				LogImportant("Deleting (!!!) %s", instruction.Target)
 | 
						|
				err = os.RemoveAll(instruction.Target)
 | 
						|
				if err != nil {
 | 
						|
					status <- fmt.Errorf("failed deleting %s due to %v",
 | 
						|
						FormatTargetPath(instruction.Target), err)
 | 
						|
					return
 | 
						|
				}
 | 
						|
			}
 | 
						|
		} else {
 | 
						|
			status <- fmt.Errorf("target %s exists - handle manually or set the 'forced' flag (3rd field)",
 | 
						|
				FormatTargetPath(instruction.Target))
 | 
						|
			return
 | 
						|
		}
 | 
						|
	}
 | 
						|
 | 
						|
	targetDir := filepath.Dir(instruction.Target)
 | 
						|
	if _, err := os.Stat(targetDir); os.IsNotExist(err) {
 | 
						|
		err = os.MkdirAll(targetDir, 0755)
 | 
						|
		if err != nil {
 | 
						|
			status <- fmt.Errorf("failed creating directory %s due to %v",
 | 
						|
				FormatTargetPath(targetDir), err)
 | 
						|
			return
 | 
						|
		}
 | 
						|
	}
 | 
						|
 | 
						|
	var err error
 | 
						|
	if instruction.Hard {
 | 
						|
		err = os.Link(instruction.Source, instruction.Target)
 | 
						|
	} else {
 | 
						|
		err = os.Symlink(instruction.Source, instruction.Target)
 | 
						|
	}
 | 
						|
	if err != nil {
 | 
						|
		status <- fmt.Errorf("failed creating symlink between %s and %s with error %v",
 | 
						|
			FormatSourcePath(instruction.Source),
 | 
						|
			FormatTargetPath(instruction.Target),
 | 
						|
			err)
 | 
						|
		return
 | 
						|
	}
 | 
						|
	LogInfo("Created symlink between %s and %s",
 | 
						|
		FormatSourcePath(instruction.Source),
 | 
						|
		FormatTargetPath(instruction.Target))
 | 
						|
 | 
						|
	status <- nil
 | 
						|
}
 | 
						|
 | 
						|
func ParseYAMLFile(filename, workdir string) ([]LinkInstruction, error) {
 | 
						|
	data, err := os.ReadFile(filename)
 | 
						|
	if err != nil {
 | 
						|
		return nil, fmt.Errorf("error reading YAML file: %w", err)
 | 
						|
	}
 | 
						|
 | 
						|
	// First try to parse as a list of link instructions
 | 
						|
	var config YAMLConfig
 | 
						|
	err = yaml.Unmarshal(data, &config)
 | 
						|
	if err != nil || len(config.Links) == 0 {
 | 
						|
		// If that fails, try parsing as a direct list of instructions
 | 
						|
		var instructions []LinkInstruction
 | 
						|
		err = yaml.Unmarshal(data, &instructions)
 | 
						|
		if err != nil {
 | 
						|
			return nil, fmt.Errorf("error parsing YAML: %w", err)
 | 
						|
		}
 | 
						|
		config.Links = instructions
 | 
						|
	}
 | 
						|
 | 
						|
	toRemove := []int{}
 | 
						|
	for i, link := range config.Links {
 | 
						|
		if strings.Contains(link.Source, "*") {
 | 
						|
			LogSource("Expanding wildcard source %s in YAML file %s", link.Source, filename)
 | 
						|
			newlinks, err := ExpandWildcard(link.Source, workdir, link.Target)
 | 
						|
			if err != nil {
 | 
						|
				return nil, fmt.Errorf("error expanding wildcard: %w", err)
 | 
						|
			}
 | 
						|
			LogInfo("Expanded wildcard source %s in YAML file %s to %d links",
 | 
						|
				FormatSourcePath(link.Source), FormatSourcePath(filename), len(newlinks))
 | 
						|
			config.Links = append(config.Links, newlinks...)
 | 
						|
			toRemove = append(toRemove, i)
 | 
						|
		}
 | 
						|
	}
 | 
						|
 | 
						|
	for i := len(toRemove) - 1; i >= 0; i-- {
 | 
						|
		config.Links = slices.Delete(config.Links, toRemove[i], 1)
 | 
						|
	}
 | 
						|
 | 
						|
	for i := range config.Links {
 | 
						|
		link := &config.Links[i]
 | 
						|
 | 
						|
		link.Tidy()
 | 
						|
		link.Source, _ = ConvertHome(link.Source)
 | 
						|
		link.Target, _ = ConvertHome(link.Target)
 | 
						|
		link.Source = NormalizePath(link.Source, workdir)
 | 
						|
		link.Target = NormalizePath(link.Target, workdir)
 | 
						|
 | 
						|
		// If Delete is true, Force must also be true
 | 
						|
		if config.Links[i].Delete {
 | 
						|
			config.Links[i].Force = true
 | 
						|
		}
 | 
						|
	}
 | 
						|
 | 
						|
	return config.Links, nil
 | 
						|
}
 | 
						|
 | 
						|
func ExpandWildcard(source, workdir, target string) (links []LinkInstruction, err error) {
 | 
						|
	dir := filepath.Dir(source)
 | 
						|
	pattern := filepath.Base(source)
 | 
						|
 | 
						|
	files, err := filepath.Glob(filepath.Join(workdir, dir, pattern))
 | 
						|
	if err != nil {
 | 
						|
		return nil, fmt.Errorf("error expanding wildcard: %w", err)
 | 
						|
	}
 | 
						|
 | 
						|
	for _, file := range files {
 | 
						|
		link := LinkInstruction{
 | 
						|
			Source: file,
 | 
						|
			Target: filepath.Join(target, filepath.Base(file)),
 | 
						|
		}
 | 
						|
		links = append(links, link)
 | 
						|
	}
 | 
						|
 | 
						|
	LogInfo("Expanded wildcard source %s to %d links", FormatSourcePath(source), len(links))
 | 
						|
	return
 | 
						|
}
 | 
						|
 | 
						|
func IsYAMLFile(filename string) bool {
 | 
						|
	ext := strings.ToLower(filepath.Ext(filename))
 | 
						|
	return ext == ".yaml" || ext == ".yml"
 | 
						|
}
 |