From 1eda2fcc82f3226c8f175b4268856dc341da8745 Mon Sep 17 00:00:00 2001 From: PhatPhuckDave Date: Thu, 20 Nov 2025 15:29:27 +0100 Subject: [PATCH] Add a dry run flag --- filesystem.go | 180 ++++++++++++++++++++++++++++++++++++++++++++ instruction.go | 43 ++++++++--- instruction_test.go | 29 +++++++ main.go | 22 ++++++ 4 files changed, 262 insertions(+), 12 deletions(-) create mode 100644 filesystem.go diff --git a/filesystem.go b/filesystem.go new file mode 100644 index 0000000..56ab744 --- /dev/null +++ b/filesystem.go @@ -0,0 +1,180 @@ +package main + +import ( + "fmt" + "os" + "sync" +) + +var filesystem FileSystem = NewRealFileSystem() + +// FileSystem abstracts filesystem operations so we can swap implementations +type FileSystem interface { + Remove(path string) error + RemoveAll(path string) error + MkdirAll(path string, perm os.FileMode) error + Symlink(source, target string) error + Link(source, target string) error + SummaryLines() []string + IsDryRun() bool +} + +const ( + opRemove = "remove" + opRemoveAll = "remove_all" + opMkdirAll = "mkdir_all" + opSymlink = "symlink" + opHardlink = "hardlink" +) + +type operationRecord struct { + kind string + source string + target string + err error + dryRun bool +} + +func (r operationRecord) summaryLine() (string, bool) { + if r.kind != opSymlink && r.kind != opHardlink { + return "", false + } + + kindLabel := "Symlink" + if r.kind == opHardlink { + kindLabel = "Hardlink" + } + + status := "OK" + if r.err != nil { + status = fmt.Sprintf("FAIL: %v", r.err) + } else if r.dryRun { + status = "DRY-RUN" + } + + return fmt.Sprintf("%s %s -> %s (%s)", kindLabel, r.source, r.target, status), true +} + +type baseFileSystem struct { + mu sync.Mutex + operations []operationRecord +} + +func (b *baseFileSystem) addOperation(kind, source, target string, err error, dryRun bool) { + b.mu.Lock() + defer b.mu.Unlock() + b.operations = append(b.operations, operationRecord{ + kind: kind, + source: source, + target: target, + err: err, + dryRun: dryRun, + }) +} + +func (b *baseFileSystem) snapshot() []operationRecord { + b.mu.Lock() + defer b.mu.Unlock() + ops := make([]operationRecord, len(b.operations)) + copy(ops, b.operations) + return ops +} + +func summarizeOperations(records []operationRecord) []string { + var lines []string + for _, record := range records { + if line, ok := record.summaryLine(); ok { + lines = append(lines, line) + } + } + return lines +} + +type realFileSystem struct { + baseFileSystem +} + +// NewRealFileSystem returns a filesystem implementation that writes to disk +func NewRealFileSystem() FileSystem { + return &realFileSystem{} +} + +func (fs *realFileSystem) Remove(path string) error { + err := os.Remove(path) + fs.addOperation(opRemove, "", path, err, false) + return err +} + +func (fs *realFileSystem) RemoveAll(path string) error { + err := os.RemoveAll(path) + fs.addOperation(opRemoveAll, "", path, err, false) + return err +} + +func (fs *realFileSystem) MkdirAll(path string, perm os.FileMode) error { + err := os.MkdirAll(path, perm) + fs.addOperation(opMkdirAll, "", path, err, false) + return err +} + +func (fs *realFileSystem) Symlink(source, target string) error { + err := os.Symlink(source, target) + fs.addOperation(opSymlink, source, target, err, false) + return err +} + +func (fs *realFileSystem) Link(source, target string) error { + err := os.Link(source, target) + fs.addOperation(opHardlink, source, target, err, false) + return err +} + +func (fs *realFileSystem) SummaryLines() []string { + return summarizeOperations(fs.snapshot()) +} + +func (fs *realFileSystem) IsDryRun() bool { + return false +} + +type dryRunFileSystem struct { + baseFileSystem +} + +// NewDryRunFileSystem returns a filesystem implementation that only records operations +func NewDryRunFileSystem() FileSystem { + return &dryRunFileSystem{} +} + +func (fs *dryRunFileSystem) Remove(path string) error { + fs.addOperation(opRemove, "", path, nil, true) + return nil +} + +func (fs *dryRunFileSystem) RemoveAll(path string) error { + fs.addOperation(opRemoveAll, "", path, nil, true) + return nil +} + +func (fs *dryRunFileSystem) MkdirAll(path string, perm os.FileMode) error { + fs.addOperation(opMkdirAll, "", path, nil, true) + return nil +} + +func (fs *dryRunFileSystem) Symlink(source, target string) error { + fs.addOperation(opSymlink, source, target, nil, true) + return nil +} + +func (fs *dryRunFileSystem) Link(source, target string) error { + fs.addOperation(opHardlink, source, target, nil, true) + return nil +} + +func (fs *dryRunFileSystem) SummaryLines() []string { + return summarizeOperations(fs.snapshot()) +} + +func (fs *dryRunFileSystem) IsDryRun() bool { + return true +} diff --git a/instruction.go b/instruction.go index 9b9316c..11eafb1 100644 --- a/instruction.go +++ b/instruction.go @@ -76,12 +76,17 @@ func (instruction *LinkInstruction) Undo() { if isSymlink { LogInfo("Removing symlink at %s", FormatTargetPath(instruction.Target)) - err = os.Remove(instruction.Target) + err = filesystem.Remove(instruction.Target) if err != nil { LogError("could not remove symlink at %s; err: %v", FormatTargetPath(instruction.Target), err) + } else { + if filesystem.IsDryRun() { + LogInfo("[DRY-RUN] Would remove symlink at %s", FormatTargetPath(instruction.Target)) + } else { + LogSuccess("Removed symlink at %s", FormatTargetPath(instruction.Target)) + } } - LogSuccess("Removed symlink at %s", FormatTargetPath(instruction.Target)) } else { LogInfo("%s is not a symlink, skipping", FormatTargetPath(instruction.Target)) } @@ -223,7 +228,7 @@ func (instruction *LinkInstruction) RunAsync(status chan (error)) { } if info.Mode().IsRegular() && info.Name() == filepath.Base(instruction.Source) { LogTarget("Overwriting existing file %s", instruction.Target) - err := os.Remove(instruction.Target) + err := filesystem.Remove(instruction.Target) if err != nil { status <- fmt.Errorf("could not remove existing file %s; err: %v", FormatTargetPath(instruction.Target), err) @@ -234,7 +239,7 @@ func (instruction *LinkInstruction) RunAsync(status chan (error)) { if isSymlink { LogTarget("Removing symlink at %s", instruction.Target) - err = os.Remove(instruction.Target) + err = filesystem.Remove(instruction.Target) if err != nil { status <- fmt.Errorf("failed deleting %s due to %v", FormatTargetPath(instruction.Target), err) @@ -247,7 +252,7 @@ func (instruction *LinkInstruction) RunAsync(status chan (error)) { return } LogImportant("Deleting (!!!) %s", instruction.Target) - err = os.RemoveAll(instruction.Target) + err = filesystem.RemoveAll(instruction.Target) if err != nil { status <- fmt.Errorf("failed deleting %s due to %v", FormatTargetPath(instruction.Target), err) @@ -263,7 +268,7 @@ func (instruction *LinkInstruction) RunAsync(status chan (error)) { targetDir := filepath.Dir(instruction.Target) if _, err := os.Stat(targetDir); os.IsNotExist(err) { - err = os.MkdirAll(targetDir, 0755) + err = filesystem.MkdirAll(targetDir, 0755) if err != nil { status <- fmt.Errorf("failed creating directory %s due to %v", FormatTargetPath(targetDir), err) @@ -271,22 +276,36 @@ func (instruction *LinkInstruction) RunAsync(status chan (error)) { } } + linkType := "symlink" + if instruction.Hard { + linkType = "hardlink" + } + var err error if instruction.Hard { - err = os.Link(instruction.Source, instruction.Target) + err = filesystem.Link(instruction.Source, instruction.Target) } else { - err = os.Symlink(instruction.Source, instruction.Target) + err = filesystem.Symlink(instruction.Source, instruction.Target) } if err != nil { - status <- fmt.Errorf("failed creating symlink between %s and %s with error %v", + status <- fmt.Errorf("failed creating %s between %s and %s with error %v", + linkType, FormatSourcePath(instruction.Source), FormatTargetPath(instruction.Target), err) return } - LogSuccess("Created symlink between %s and %s", - FormatSourcePath(instruction.Source), - FormatTargetPath(instruction.Target)) + if filesystem.IsDryRun() { + LogInfo("[DRY-RUN] Would create %s between %s and %s", + linkType, + FormatSourcePath(instruction.Source), + FormatTargetPath(instruction.Target)) + } else { + LogSuccess("Created %s between %s and %s", + linkType, + FormatSourcePath(instruction.Source), + FormatTargetPath(instruction.Target)) + } status <- nil } diff --git a/instruction_test.go b/instruction_test.go index 2f2d456..4f21f30 100644 --- a/instruction_test.go +++ b/instruction_test.go @@ -394,6 +394,35 @@ func TestLinkInstruction_RunAsync(t *testing.T) { assert.Contains(t, err.Error(), "files=true") assert.True(t, FileExists(targetDir)) }) + + t.Run("Dry run does not modify filesystem", func(t *testing.T) { + originalFS := filesystem + filesystem = NewDryRunFileSystem() + defer func() { + filesystem = originalFS + }() + + target := filepath.Join(testDir, "dryrun-link.txt") + instruction := LinkInstruction{ + Source: srcFile, + Target: target, + Force: true, + Delete: true, + } + + status := make(chan error) + go instruction.RunAsync(status) + + err := <-status + assert.NoError(t, err) + assert.False(t, FileExists(target)) + + summary := filesystem.SummaryLines() + assert.Equal(t, 1, len(summary)) + assert.Contains(t, summary[0], "DRY-RUN") + assert.Contains(t, summary[0], srcFile) + assert.Contains(t, summary[0], target) + }) } func TestLinkInstruction_Undo(t *testing.T) { diff --git a/main.go b/main.go index b6b3891..87a62b2 100644 --- a/main.go +++ b/main.go @@ -8,6 +8,7 @@ import ( "os" "path/filepath" "sync/atomic" + utils "git.site.quack-lab.dev/dave/cyutils" ) @@ -27,11 +28,19 @@ func main() { file := flag.String("f", "", "file to read instructions from") debug := flag.Bool("d", false, "debug") undoF := flag.Bool("u", false, "undo") + dryRun := flag.Bool("n", false, "dry run (no filesystem changes)") flag.Parse() undo = *undoF setupLogging(*debug) + if *dryRun { + filesystem = NewDryRunFileSystem() + LogInfo("Dry run mode enabled - no filesystem changes will be made") + } else { + filesystem = NewRealFileSystem() + } + instructions := make(chan *LinkInstruction, 1000) status := make(chan error) @@ -43,9 +52,11 @@ func main() { if instructionsDone == 0 { LogInfo("No instructions were processed") + printSummary(filesystem) os.Exit(1) } LogInfo("All done") + printSummary(filesystem) } // setupLogging configures logging based on debug flag @@ -162,6 +173,17 @@ func processInstructions(instructions chan *LinkInstruction) int32 { return instructionsDone } +func printSummary(fs FileSystem) { + lines := fs.SummaryLines() + if len(lines) == 0 { + return + } + LogInfo("Summary:") + for _, line := range lines { + LogInfo("%s", line) + } +} + func IsPipeInput() bool { info, err := os.Stdin.Stat() if err != nil {