package main import ( "fmt" "os" "path/filepath" "strings" "github.com/bmatcuk/doublestar/v4" "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"` From []string `yaml:"from,omitempty"` } 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) } LogSuccess("Removed symlink at %s", FormatTargetPath(instruction.Target)) } 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)) LogInfo("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 } LogSuccess("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 } expanded := []LinkInstruction{} for _, link := range config.Links { LogSource("Expanding pattern source %s in YAML file %s", link.Source, filename) newlinks, err := ExpandPattern(link.Source, workdir, link.Target) if err != nil { return nil, fmt.Errorf("error expanding pattern: %w", err) } // "Clone" the original link instruction for each expanded link for i := range newlinks { newlinks[i].Delete = link.Delete newlinks[i].Hard = link.Hard newlinks[i].Force = link.Force } LogInfo("Expanded pattern %s in YAML file %s to %d links", FormatSourcePath(link.Source), FormatSourcePath(filename), len(newlinks)) expanded = append(expanded, newlinks...) } for i := range expanded { link := &expanded[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 link.Delete { link.Force = true } } return expanded, nil } // ParseYAMLFileRecursive parses a YAML file and recursively processes any "From" references func ParseYAMLFileRecursive(filename, workdir string) ([]LinkInstruction, error) { visited := make(map[string]bool) return parseYAMLFileRecursive(filename, workdir, visited) } // parseYAMLFileRecursive is the internal recursive function that tracks visited files to prevent cycles func parseYAMLFileRecursive(filename, workdir string, visited map[string]bool) ([]LinkInstruction, error) { // Normalize the filename to prevent cycles with different path representations normalizedFilename, err := filepath.Abs(filename) if err != nil { return nil, fmt.Errorf("error normalizing filename: %w", err) } // Check for cycles if visited[normalizedFilename] { return nil, fmt.Errorf("circular reference detected: %s", filename) } visited[normalizedFilename] = true defer delete(visited, normalizedFilename) // Parse the current file instructions, err := ParseYAMLFile(filename, workdir) if err != nil { return nil, err } // Read the file to check for "From" references data, err := os.ReadFile(filename) if err != nil { return nil, fmt.Errorf("error reading YAML file: %w", err) } var config YAMLConfig err = yaml.Unmarshal(data, &config) if err != nil { // If parsing as YAMLConfig fails, there are no "From" references to process return instructions, nil } // Process "From" references for _, fromFile := range config.From { // Convert relative paths to absolute paths based on the current file's directory fromPath := fromFile if !filepath.IsAbs(fromPath) { currentDir := filepath.Dir(filename) fromPath = filepath.Join(currentDir, fromPath) } // Normalize the path fromPath = filepath.Clean(fromPath) // Recursively parse the referenced file // Use the directory of the referenced file as the workdir for pattern expansion fromWorkdir := filepath.Dir(fromPath) fromInstructions, err := parseYAMLFileRecursive(fromPath, fromWorkdir, visited) if err != nil { return nil, fmt.Errorf("error parsing referenced file %s: %w", fromFile, err) } // Append the instructions from the referenced file instructions = append(instructions, fromInstructions...) } return instructions, nil } func ExpandPattern(source, workdir, target string) (links []LinkInstruction, err error) { static, pattern := doublestar.SplitPattern(source) if static == "" || static == "." { static = workdir } LogInfo("Static part: %s", static) LogInfo("Pattern part: %s", pattern) files, err := doublestar.Glob(os.DirFS(static), pattern) if err != nil { return nil, fmt.Errorf("error expanding pattern: %w", err) } targetIsFile := false if info, err := os.Stat(target); err == nil && !info.IsDir() { targetIsFile = true } for _, file := range files { if len(files) == 1 { // Special case: if there is only one file // This should only ever happen if our source is a path (and not a glob!) // And our target is a path // ...but it will also happen if the source IS a glob and it happens to match ONE file // I think that should happen rarely enough to not be an issue... links = append(links, LinkInstruction{ Source: filepath.Join(static, file), Target: target, }) continue } if info, err := os.Stat(file); err == nil && info.IsDir() { // We don't care about matched directories // We want files within them LogInfo("Skipping directory %s", file) continue } var targetPath string if targetIsFile && len(files) == 1 { // Special case: target is a file, and glob matches exactly one file. // Use target directly (don't append filename). targetPath = target } else { // Default: append filename to target dir. targetPath = filepath.Join(target, file) } links = append(links, LinkInstruction{ Source: filepath.Join(static, file), Target: targetPath, }) } LogInfo("Expanded pattern %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" }