Skip to content

Commit 5184f23

Browse files
dolphclaude
andauthored
Bubble errors up from worker goroutines; exit non-zero on failure (#66)
* Bubble errors up from worker goroutines; exit non-zero on failure Replace every log.Fatal* in find_replace.go and file_handling.go with returned errors so deferred cleanup can run, then collect errors across walker goroutines and surface them at main. Architecture: - File methods (NewFile, Info, Read, Write, plus a new error-returning Mode) now return (T, error). The walker calls Info on each child so Write can rely on cached Mode without re-statting. - findReplace.{WalkDir,HandleFile,RenameFile,ReplaceContents} return errors (WalkDir records them on an embedded accumulator so its goroutines can stay fire-and-forget). - errAccumulator is a sync.Mutex-guarded []error with an errors.Join exit point. Each error is also log.Print'd at the point of failure so the existing operator-visible stderr UX is preserved; the join exists so main can scrape stderr and so errors.Is unwraps the whole chain. - main is now a thin os.Exit wrapper around a testable run(args, stderr) that returns 1 on bad arg count or any traversal error and 0 on a clean walk. - File.Write now defer-Removes its temp file immediately after creating it; on a successful rename the file no longer exists at tempName so the deferred remove is a no-op, but on a rename failure the temp file is cleaned up instead of being leaked. New tests (each was confirmed to fail for the right reason before the matching production change was finalized): - TestWalkDir_PermissionDeniedSubdirContinues: a chmod-0 subdirectory no longer aborts the walk; the sibling subtree is rewritten and the walker records an fs.ErrPermission referencing the failing path. Skips under root (where chmod 0 is bypassed) and Windows. - TestRenameFile_ReturnsErrorOnExistingDestination: an occupied destination is refused with an error, not log.Fatal. - TestWalkDir_BadRenameTargetDoesNotAbortSiblings: a sibling whose post-rename name is already occupied does not abort the rest of the tree; the walker logs and records the failure and continues. - TestWriteCleansUpTempFileOnRenameFailure: forcing the rename step to fail (target is a non-empty directory; deterministic across users) leaves no stray temp file behind. - TestRun_{ExitsZeroOnSuccess,ExitsNonZeroOnTraversalError, BadArgCountPrintsUsage}: run() returns 0 on a clean walk, non-zero when any error was recorded, and non-zero with a usage line on bad arg count. Closes #6 Closes #11 Closes #5 https://claude.ai/code/session_01Tep5t8h97Q9KKbpLMbUEJr * Bump go.mod to 1.20 to enable errors.Join The error-aggregation primitive introduced in this PR uses errors.Join, which was added in Go 1.20. CI was pinned to 1.19 via go.mod and 'go vet' failed with 'Join not declared by package errors'. This is the minimum bump needed for the chosen primitive. The broader 'be on a supported Go release' work (Go 1.19 has been EOL since August 2023) stays as #28. * Remove deprecated rand.Seed call Since Go 1.20 (the new toolchain floor in this PR), the math/rand global generator is automatically seeded by the runtime; explicit rand.Seed is a deprecated no-op and staticcheck (run by golangci-lint) flags it as SA1019. The line was already vestigial -- left in place by the original commit to keep the diff narrow. The toolchain bump in the previous commit makes that compromise no longer viable. strings.go's RandomString still uses math/rand.Intn; the change to crypto/rand for filesystem paths remains tracked under issue #3. --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b144d40 commit 5184f23

5 files changed

Lines changed: 533 additions & 105 deletions

File tree

file_handling.go

Lines changed: 48 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package main
22

33
import (
4+
"fmt"
45
"io"
56
"log"
67
"os"
@@ -15,12 +16,15 @@ type File struct {
1516
info os.FileInfo
1617
}
1718

18-
func NewFile(path string) *File {
19+
// NewFile resolves path to an absolute path and wraps it in a *File. It
20+
// returns an error if the working directory cannot be determined (the only
21+
// failure mode of filepath.Abs).
22+
func NewFile(path string) (*File, error) {
1923
absPath, err := filepath.Abs(path)
2024
if err != nil {
21-
log.Fatalf("Unable to resolve absolute path of %v: %v", path, err)
25+
return nil, fmt.Errorf("resolve absolute path of %v: %w", path, err)
2226
}
23-
return &File{Path: absPath}
27+
return &File{Path: absPath}, nil
2428
}
2529

2630
func (f *File) Base() string {
@@ -31,58 +35,81 @@ func (f *File) Dir() string {
3135
return filepath.Dir(f.Path)
3236
}
3337

34-
func (f *File) Info() os.FileInfo {
38+
// Info lazily stats the file and caches the result. It returns an error if
39+
// the underlying os.Stat fails.
40+
func (f *File) Info() (os.FileInfo, error) {
3541
if f.info == nil {
3642
stat, err := os.Stat(f.Path)
3743
if err != nil {
38-
log.Fatalf("Failed to stat %v: %v", f.Path, err)
44+
return nil, fmt.Errorf("stat %v: %w", f.Path, err)
3945
}
4046
f.info = stat
4147
}
42-
return f.info
48+
return f.info, nil
4349
}
4450

45-
func (f *File) Mode() os.FileMode {
46-
return f.Info().Mode()
51+
// Mode returns the cached mode bits. It is only safe to call after Info() has
52+
// succeeded; callers that have a *File handed to them by the walker can rely
53+
// on that precondition because the walker calls Info() before dispatching.
54+
func (f *File) Mode() (os.FileMode, error) {
55+
info, err := f.Info()
56+
if err != nil {
57+
return 0, err
58+
}
59+
return info.Mode(), nil
4760
}
4861

49-
// Read the file into a string.
50-
func (f *File) Read() string {
62+
// Read reads the file into a string, or returns the empty string for binary
63+
// files. An error indicates the file could not be opened or fully read; the
64+
// caller should log-and-skip rather than abort.
65+
func (f *File) Read() (string, error) {
5166
handle, err := os.Open(f.Path)
5267
if err != nil {
53-
log.Fatalf("Unable to open %v: %v", f.Path, err)
68+
return "", fmt.Errorf("open %v: %w", f.Path, err)
5469
}
5570
defer handle.Close()
5671

5772
// Check if the file looks like text before reading the entire file.
5873
var buf [1024]byte
5974
n, err := handle.Read(buf[0:])
6075
if err != nil || !util.IsText(buf[0:n]) {
61-
return ""
76+
return "", nil
6277
}
6378

6479
// Reset file handle so we can read the entire file.
6580
if _, err := handle.Seek(0, io.SeekStart); err != nil {
66-
log.Fatalf("Failed to seek back to beginning of %v: %v", f.Path, err)
81+
return "", fmt.Errorf("seek to start of %v: %w", f.Path, err)
6782
}
6883

6984
builder := new(strings.Builder)
7085
if _, err := io.Copy(builder, handle); err != nil {
71-
log.Fatalf("Failed to read %v to a string: %v", f.Path, err)
86+
return "", fmt.Errorf("read %v: %w", f.Path, err)
7287
}
73-
return builder.String()
88+
return builder.String(), nil
7489
}
7590

76-
// Write content to file atomically, by writing it to a temporary file first,
77-
// and then moving it to the destination, overwriting the original.
78-
func (f *File) Write(content string) {
91+
// Write atomically replaces the file with content, via a temp file + rename.
92+
// A deferred os.Remove(tempName) ensures the temp file is cleaned up if any
93+
// step after its creation fails (including the rename); on success the remove
94+
// is a no-op because the file has already been renamed away.
95+
func (f *File) Write(content string) error {
96+
mode, err := f.Mode()
97+
if err != nil {
98+
return err
99+
}
100+
79101
tempName := filepath.Join(f.Dir(), RandomString(20))
80-
if err := os.WriteFile(tempName, []byte(content), f.Mode()); err != nil {
81-
log.Fatalf("Error creating tempfile in %v: %v", f.Dir(), err)
102+
if err := os.WriteFile(tempName, []byte(content), mode); err != nil {
103+
return fmt.Errorf("create tempfile in %v: %w", f.Dir(), err)
82104
}
105+
// Make sure the temp file is removed if the rename below fails. On
106+
// success, the rename has already moved the file to f.Path so this is
107+
// a no-op (we deliberately ignore the not-exist error).
108+
defer os.Remove(tempName)
83109

84110
log.Printf("Rewriting %v", f.Path)
85111
if err := os.Rename(tempName, f.Path); err != nil {
86-
log.Fatalf("Unable to atomically move temp file %v to %v: %v", tempName, f.Path, err)
112+
return fmt.Errorf("atomically move temp file %v to %v: %w", tempName, f.Path, err)
87113
}
114+
return nil
88115
}

file_handling_test.go

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,7 @@ import (
55
"testing"
66
)
77

8-
// TestNewFile exercises NewFile's path-resolution behavior. It does NOT cover
9-
// the filepath.Abs error path: NewFile currently calls log.Fatalf on that
10-
// failure, which would kill the test binary, and that surface is not reachable
11-
// on most platforms in any case. The error path will become testable when
12-
// NewFile is refactored to return an error (see issue #6).
8+
// TestNewFile exercises NewFile's path-resolution behavior.
139
func TestNewFile(t *testing.T) {
1410
tmp := t.TempDir()
1511

@@ -65,7 +61,10 @@ func TestNewFile(t *testing.T) {
6561
want = abs
6662
}
6763

68-
got := NewFile(tc.input)
64+
got, err := NewFile(tc.input)
65+
if err != nil {
66+
t.Fatalf("NewFile(%q) returned unexpected error: %v", tc.input, err)
67+
}
6968
if got == nil {
7069
t.Fatalf("NewFile(%q) returned nil", tc.input)
7170
}

0 commit comments

Comments
 (0)