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)) 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 } 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) } // "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 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" }