diff --git a/internal/adapters/secondary/repository/repository_test.go b/internal/adapters/secondary/repository/repository_test.go index 813a305b..6098cec2 100644 --- a/internal/adapters/secondary/repository/repository_test.go +++ b/internal/adapters/secondary/repository/repository_test.go @@ -6,6 +6,7 @@ import ( "errors" "io" "os" + "path/filepath" "testing" "time" @@ -27,6 +28,7 @@ func NewMockFileSystem() *MockFileSystem { } func (m *MockFileSystem) ReadFile(name string) ([]byte, error) { + name = filepath.ToSlash(name) if data, ok := m.files[name]; ok { return data, nil } @@ -34,6 +36,7 @@ func (m *MockFileSystem) ReadFile(name string) ([]byte, error) { } func (m *MockFileSystem) WriteFile(name string, data []byte, _ os.FileMode) error { + name = filepath.ToSlash(name) m.files[name] = data return nil } @@ -43,6 +46,7 @@ func (m *MockFileSystem) MkdirAll(_ string, _ os.FileMode) error { } func (m *MockFileSystem) Stat(name string) (os.FileInfo, error) { + name = filepath.ToSlash(name) if _, ok := m.files[name]; ok { //nolint:nilnil // Mock for testing return nil, nil @@ -51,6 +55,7 @@ func (m *MockFileSystem) Stat(name string) (os.FileInfo, error) { } func (m *MockFileSystem) Remove(name string) error { + name = filepath.ToSlash(name) delete(m.files, name) return nil } @@ -60,6 +65,8 @@ func (m *MockFileSystem) RemoveAll(_ string) error { } func (m *MockFileSystem) Rename(oldpath, newpath string) error { + oldpath = filepath.ToSlash(oldpath) + newpath = filepath.ToSlash(newpath) if data, ok := m.files[oldpath]; ok { m.files[newpath] = data delete(m.files, oldpath) @@ -82,6 +89,7 @@ func (m *MockFileSystem) Create(_ string) (io.WriteCloser, error) { } func (m *MockFileSystem) Exists(name string) bool { + name = filepath.ToSlash(name) _, ok := m.files[name] return ok } diff --git a/internal/core/services/installer/core.go b/internal/core/services/installer/core.go index b22c080c..39f604cb 100644 --- a/internal/core/services/installer/core.go +++ b/internal/core/services/installer/core.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "runtime" "strings" "github.com/hashload/boss/pkg/pkgmanager" @@ -25,6 +26,7 @@ import ( "github.com/hashload/boss/pkg/msg" "github.com/hashload/boss/utils" "github.com/hashload/boss/utils/librarypath" + "github.com/hashload/boss/utils/normalizer" ) type installContext struct { @@ -537,6 +539,15 @@ func (ic *installContext) checkoutAndUpdate( } ic.addWarning(fmt.Sprintf("%s: %s", dep.Name(), warnMsg)) } + + // Normalize line endings to CRLF on Windows (Issue #197) + if runtime.GOOS == "windows" { + depDir := filepath.Join(ic.modulesDir, dep.Name()) + if err := normalizer.NormalizeDirectoryLineEndings(depDir); err != nil { + msg.Debug("Failed to normalize line endings for %s: %v", dep.Name(), err) + } + } + return nil } diff --git a/utils/normalizer/line_normalizer.go b/utils/normalizer/line_normalizer.go new file mode 100644 index 00000000..3acea1bf --- /dev/null +++ b/utils/normalizer/line_normalizer.go @@ -0,0 +1,71 @@ +package normalizer + +import ( + "bytes" + "io/fs" + "os" + "path/filepath" + "strings" + + "github.com/hashload/boss/pkg/msg" +) + +var delphiExtensions = map[string]bool{ + ".pas": true, + ".inc": true, + ".dfm": true, + ".dpk": true, + ".dproj": true, +} + +// NormalizeDirectoryLineEndings walks the given directory and converts LF to CRLF in all Delphi-related files. +func NormalizeDirectoryLineEndings(dir string) error { + msg.Debug(" 🧹 Normalizing line endings to CRLF in %s", dir) + return filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + + ext := strings.ToLower(filepath.Ext(path)) + if !delphiExtensions[ext] { + return nil + } + + return NormalizeFileLineEndings(path) + }) +} + +// NormalizeFileLineEndings converts all LF to CRLF in a specific file. +func NormalizeFileLineEndings(filePath string) error { + content, err := os.ReadFile(filePath) // #nosec G304 -- Reading Delphi files from controlled package directories + if err != nil { + return err + } + + // Check if there are any raw LFs (LFs not preceded by CR) + hasLF := false + for i, b := range content { + if b == '\n' { + if i == 0 || content[i-1] != '\r' { + hasLF = true + break + } + } + } + + if !hasLF { + return nil // Already CRLF or has no LFs + } + + msg.Debug(" Normalizing line endings for %s", filepath.Base(filePath)) + + // Normalize: replace all CRLF with LF, then replace all LF with CRLF + normalized := bytes.ReplaceAll(content, []byte("\r\n"), []byte("\n")) + crlfContent := bytes.ReplaceAll(normalized, []byte("\n"), []byte("\r\n")) + + // Write back + return os.WriteFile(filePath, crlfContent, 0600) +} diff --git a/utils/normalizer/line_normalizer_test.go b/utils/normalizer/line_normalizer_test.go new file mode 100644 index 00000000..4d81edf7 --- /dev/null +++ b/utils/normalizer/line_normalizer_test.go @@ -0,0 +1,58 @@ +package normalizer_test + +import ( + "bytes" + "os" + "path/filepath" + "testing" + + "github.com/hashload/boss/utils/normalizer" +) + +func TestNormalizeDirectoryLineEndings(t *testing.T) { + tempDir, err := os.MkdirTemp("", "boss-normalizer-test") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tempDir) + + // 1. Create a Delphi .pas file with LF line endings + pasPath := filepath.Join(tempDir, "unit.pas") + pasContent := []byte("unit Test;\ninterface\n\nimplementation\nend.\n") + if err := os.WriteFile(pasPath, pasContent, 0600); err != nil { + t.Fatalf("failed to write pas file: %v", err) + } + + // 2. Create a non-Delphi .txt file with LF line endings + txtPath := filepath.Join(tempDir, "readme.txt") + txtContent := []byte("hello\nworld\n") + if err := os.WriteFile(txtPath, txtContent, 0600); err != nil { + t.Fatalf("failed to write txt file: %v", err) + } + + // Run the normalizer + if err := normalizer.NormalizeDirectoryLineEndings(tempDir); err != nil { + t.Fatalf("NormalizeDirectoryLineEndings failed: %v", err) + } + + // 3. Verify .pas file was converted to CRLF + newPasContent, err := os.ReadFile(pasPath) + if err != nil { + t.Fatalf("failed to read pas file: %v", err) + } + if !bytes.Contains(newPasContent, []byte("\r\n")) { + t.Error("expected pas file to contain CRLF line endings") + } + if bytes.Contains(newPasContent, []byte("\n")) && !bytes.Contains(newPasContent, []byte("\r\n")) { + t.Error("expected pas file to have all LFs preceded by CR") + } + + // 4. Verify .txt file was NOT converted (remains LF) + newTxtContent, err := os.ReadFile(txtPath) + if err != nil { + t.Fatalf("failed to read txt file: %v", err) + } + if bytes.Contains(newTxtContent, []byte("\r\n")) { + t.Error("expected txt file to NOT contain CRLF line endings") + } +}