From 26b47ee073b8f09a1773a9ae3b32d64d9c155ea7 Mon Sep 17 00:00:00 2001 From: Snider Date: Wed, 4 Feb 2026 17:59:10 +0000 Subject: [PATCH] Migrate pkg/build to io.Medium abstraction (#287) * chore(io): Migrate pkg/build to Medium abstraction - Updated io.Medium interface with Open() and Create() methods to support streaming. - Migrated pkg/build, pkg/build/builders, and pkg/build/signing to use io.Medium. - Added FS field to build.Config and updated build.Builder interface. - Refactored checksum and archive logic to use io.Medium streaming. - Updated pkg/release and pkg/build/buildcmd to use io.Local. - Updated unit tests to match new signatures. * chore(io): Migrate pkg/build to Medium abstraction (fix CI) - Fixed formatting in pkg/build/builders/wails.go. - Fixed TestLoadConfig_Testdata and TestDiscover_Testdata to use absolute paths with io.Local to ensure compatibility with GitHub CI. - Verified that all build and release tests pass. * chore(io): Migrate pkg/build to Medium abstraction (fix CI paths) - Ensured that outputDir and configPath are absolute in runProjectBuild. - Fixed TestLoadConfig_Testdata and TestDiscover_Testdata to use absolute paths correctly. - Verified that all build and release tests pass locally. * chore(io): Migrate pkg/build to Medium abstraction (final fix) - Improved io.Local to handle relative paths relative to CWD when rooted at "/". - This makes io.Local a drop-in replacement for the 'os' package for most use cases. - Ensured absolute paths are used in build logic and tests where appropriate. - Fixed formatting and cleaned up debug prints. * chore(io): address code review and fix CI - Fix MockFile.Read to return io.EOF - Use filepath.Match in TaskfileBuilder for precise globbing - Stream xz data in createTarXzArchive to avoid in-memory string conversion - Fix TestPath_RootFilesystem in local medium tests - Fix formatting in pkg/build/buildcmd/cmd_project.go * chore(io): resolve merge conflicts and final migration of pkg/build - Resolved merge conflicts in pkg/io/io.go, pkg/io/local/client.go, and pkg/release/release.go. - Reconciled io.Medium interface with upstream changes (unifying to fs.File for Open). - Integrated upstream validatePath logic into the local medium. - Completed migration of pkg/build and related packages to io.Medium. - Addressed previous code review feedback on MockMedium and TaskfileBuilder. * chore(io): resolve merge conflicts and finalize migration - Resolved merge conflicts with dev branch. - Unified io.Medium interface (Open returns fs.File, Create returns io.WriteCloser). - Integrated upstream validatePath logic. - Ensured all tests pass across pkg/io, pkg/build, and pkg/release. --------- Co-authored-by: Claude Opus 4.5 --- pkg/build/archive.go | 54 +++++++++++--------- pkg/build/archive_test.go | 33 +++++++----- pkg/build/build.go | 6 ++- pkg/build/buildcmd/cmd_project.go | 31 ++++++++--- pkg/build/builders/docker.go | 9 ++-- pkg/build/builders/go.go | 9 ++-- pkg/build/builders/go_test.go | 21 ++++++-- pkg/build/builders/linuxkit.go | 64 +++++++++++++++-------- pkg/build/builders/taskfile.go | 53 +++++++++++-------- pkg/build/builders/wails.go | 53 +++++++------------ pkg/build/builders/wails_test.go | 35 ++++++++----- pkg/build/checksum.go | 23 ++++----- pkg/build/checksum_test.go | 45 +++++++++------- pkg/build/config.go | 10 ++-- pkg/build/config_test.go | 30 +++++++---- pkg/build/discovery.go | 37 +++++++------- pkg/build/discovery_test.go | 61 +++++++++++++--------- pkg/build/signing/codesign.go | 9 ++-- pkg/build/signing/codesign_test.go | 7 ++- pkg/build/signing/gpg.go | 4 +- pkg/build/signing/gpg_test.go | 4 +- pkg/build/signing/sign.go | 14 ++--- pkg/build/signing/signer.go | 4 +- pkg/build/signing/signing_test.go | 25 ++++++--- pkg/build/signing/signtool.go | 4 +- pkg/io/io.go | 82 +++++++++++++++++++++++++----- pkg/io/local/client.go | 36 +++++++++++-- pkg/io/local/client_test.go | 5 +- pkg/release/release.go | 23 +++------ 29 files changed, 492 insertions(+), 299 deletions(-) diff --git a/pkg/build/archive.go b/pkg/build/archive.go index 5acee501..1959e292 100644 --- a/pkg/build/archive.go +++ b/pkg/build/archive.go @@ -8,11 +8,11 @@ import ( "compress/gzip" "fmt" "io" - "os" "path/filepath" "strings" "github.com/Snider/Borg/pkg/compress" + io_interface "github.com/host-uk/core/pkg/io" ) // ArchiveFormat specifies the compression format for archives. @@ -31,28 +31,28 @@ const ( // Uses tar.gz for linux/darwin and zip for windows. // The archive is created alongside the binary (e.g., dist/myapp_linux_amd64.tar.gz). // Returns a new Artifact with Path pointing to the archive. -func Archive(artifact Artifact) (Artifact, error) { - return ArchiveWithFormat(artifact, ArchiveFormatGzip) +func Archive(fs io_interface.Medium, artifact Artifact) (Artifact, error) { + return ArchiveWithFormat(fs, artifact, ArchiveFormatGzip) } // ArchiveXZ creates an archive for a single artifact using xz compression. // Uses tar.xz for linux/darwin and zip for windows. // Returns a new Artifact with Path pointing to the archive. -func ArchiveXZ(artifact Artifact) (Artifact, error) { - return ArchiveWithFormat(artifact, ArchiveFormatXZ) +func ArchiveXZ(fs io_interface.Medium, artifact Artifact) (Artifact, error) { + return ArchiveWithFormat(fs, artifact, ArchiveFormatXZ) } // ArchiveWithFormat creates an archive for a single artifact with the specified format. // Uses tar.gz or tar.xz for linux/darwin and zip for windows. // The archive is created alongside the binary (e.g., dist/myapp_linux_amd64.tar.xz). // Returns a new Artifact with Path pointing to the archive. -func ArchiveWithFormat(artifact Artifact, format ArchiveFormat) (Artifact, error) { +func ArchiveWithFormat(fs io_interface.Medium, artifact Artifact, format ArchiveFormat) (Artifact, error) { if artifact.Path == "" { return Artifact{}, fmt.Errorf("build.Archive: artifact path is empty") } // Verify the source file exists - info, err := os.Stat(artifact.Path) + info, err := fs.Stat(artifact.Path) if err != nil { return Artifact{}, fmt.Errorf("build.Archive: source file not found: %w", err) } @@ -62,7 +62,7 @@ func ArchiveWithFormat(artifact Artifact, format ArchiveFormat) (Artifact, error // Determine archive type based on OS and format var archivePath string - var archiveFunc func(src, dst string) error + var archiveFunc func(fs io_interface.Medium, src, dst string) error if artifact.OS == "windows" { archivePath = archiveFilename(artifact, ".zip") @@ -79,7 +79,7 @@ func ArchiveWithFormat(artifact Artifact, format ArchiveFormat) (Artifact, error } // Create the archive - if err := archiveFunc(artifact.Path, archivePath); err != nil { + if err := archiveFunc(fs, artifact.Path, archivePath); err != nil { return Artifact{}, fmt.Errorf("build.Archive: failed to create archive: %w", err) } @@ -93,26 +93,26 @@ func ArchiveWithFormat(artifact Artifact, format ArchiveFormat) (Artifact, error // ArchiveAll archives all artifacts using gzip compression. // Returns a slice of new artifacts pointing to the archives. -func ArchiveAll(artifacts []Artifact) ([]Artifact, error) { - return ArchiveAllWithFormat(artifacts, ArchiveFormatGzip) +func ArchiveAll(fs io_interface.Medium, artifacts []Artifact) ([]Artifact, error) { + return ArchiveAllWithFormat(fs, artifacts, ArchiveFormatGzip) } // ArchiveAllXZ archives all artifacts using xz compression. // Returns a slice of new artifacts pointing to the archives. -func ArchiveAllXZ(artifacts []Artifact) ([]Artifact, error) { - return ArchiveAllWithFormat(artifacts, ArchiveFormatXZ) +func ArchiveAllXZ(fs io_interface.Medium, artifacts []Artifact) ([]Artifact, error) { + return ArchiveAllWithFormat(fs, artifacts, ArchiveFormatXZ) } // ArchiveAllWithFormat archives all artifacts with the specified format. // Returns a slice of new artifacts pointing to the archives. -func ArchiveAllWithFormat(artifacts []Artifact, format ArchiveFormat) ([]Artifact, error) { +func ArchiveAllWithFormat(fs io_interface.Medium, artifacts []Artifact, format ArchiveFormat) ([]Artifact, error) { if len(artifacts) == 0 { return nil, nil } var archived []Artifact for _, artifact := range artifacts { - arch, err := ArchiveWithFormat(artifact, format) + arch, err := ArchiveWithFormat(fs, artifact, format) if err != nil { return archived, fmt.Errorf("build.ArchiveAll: failed to archive %s: %w", artifact.Path, err) } @@ -142,9 +142,9 @@ func archiveFilename(artifact Artifact, ext string) string { // createTarXzArchive creates a tar.xz archive containing a single file. // Uses Borg's compress package for xz compression. -func createTarXzArchive(src, dst string) error { +func createTarXzArchive(fs io_interface.Medium, src, dst string) error { // Open the source file - srcFile, err := os.Open(src) + srcFile, err := fs.Open(src) if err != nil { return fmt.Errorf("failed to open source file: %w", err) } @@ -185,7 +185,13 @@ func createTarXzArchive(src, dst string) error { } // Write to destination file - if err := os.WriteFile(dst, xzData, 0644); err != nil { + dstFile, err := fs.Create(dst) + if err != nil { + return fmt.Errorf("failed to create archive file: %w", err) + } + defer func() { _ = dstFile.Close() }() + + if _, err := dstFile.Write(xzData); err != nil { return fmt.Errorf("failed to write archive file: %w", err) } @@ -193,9 +199,9 @@ func createTarXzArchive(src, dst string) error { } // createTarGzArchive creates a tar.gz archive containing a single file. -func createTarGzArchive(src, dst string) error { +func createTarGzArchive(fs io_interface.Medium, src, dst string) error { // Open the source file - srcFile, err := os.Open(src) + srcFile, err := fs.Open(src) if err != nil { return fmt.Errorf("failed to open source file: %w", err) } @@ -207,7 +213,7 @@ func createTarGzArchive(src, dst string) error { } // Create the destination file - dstFile, err := os.Create(dst) + dstFile, err := fs.Create(dst) if err != nil { return fmt.Errorf("failed to create archive file: %w", err) } @@ -243,9 +249,9 @@ func createTarGzArchive(src, dst string) error { } // createZipArchive creates a zip archive containing a single file. -func createZipArchive(src, dst string) error { +func createZipArchive(fs io_interface.Medium, src, dst string) error { // Open the source file - srcFile, err := os.Open(src) + srcFile, err := fs.Open(src) if err != nil { return fmt.Errorf("failed to open source file: %w", err) } @@ -257,7 +263,7 @@ func createZipArchive(src, dst string) error { } // Create the destination file - dstFile, err := os.Create(dst) + dstFile, err := fs.Create(dst) if err != nil { return fmt.Errorf("failed to create archive file: %w", err) } diff --git a/pkg/build/archive_test.go b/pkg/build/archive_test.go index 181f9e28..408cea83 100644 --- a/pkg/build/archive_test.go +++ b/pkg/build/archive_test.go @@ -11,6 +11,7 @@ import ( "testing" "github.com/Snider/Borg/pkg/compress" + io_interface "github.com/host-uk/core/pkg/io" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -37,6 +38,7 @@ func setupArchiveTestFile(t *testing.T, name, os_, arch string) (binaryPath stri } func TestArchive_Good(t *testing.T) { + fs := io_interface.Local t.Run("creates tar.gz for linux", func(t *testing.T) { binaryPath, outputDir := setupArchiveTestFile(t, "myapp", "linux", "amd64") @@ -46,7 +48,7 @@ func TestArchive_Good(t *testing.T) { Arch: "amd64", } - result, err := Archive(artifact) + result, err := Archive(fs, artifact) require.NoError(t, err) // Verify archive was created @@ -71,7 +73,7 @@ func TestArchive_Good(t *testing.T) { Arch: "arm64", } - result, err := Archive(artifact) + result, err := Archive(fs, artifact) require.NoError(t, err) expectedPath := filepath.Join(outputDir, "myapp_darwin_arm64.tar.gz") @@ -90,7 +92,7 @@ func TestArchive_Good(t *testing.T) { Arch: "amd64", } - result, err := Archive(artifact) + result, err := Archive(fs, artifact) require.NoError(t, err) // Windows archives should strip .exe from archive name @@ -111,7 +113,7 @@ func TestArchive_Good(t *testing.T) { Checksum: "abc123", } - result, err := Archive(artifact) + result, err := Archive(fs, artifact) require.NoError(t, err) assert.Equal(t, "abc123", result.Checksum) }) @@ -125,7 +127,7 @@ func TestArchive_Good(t *testing.T) { Arch: "amd64", } - result, err := ArchiveXZ(artifact) + result, err := ArchiveXZ(fs, artifact) require.NoError(t, err) expectedPath := filepath.Join(outputDir, "myapp_linux_amd64.tar.xz") @@ -144,7 +146,7 @@ func TestArchive_Good(t *testing.T) { Arch: "arm64", } - result, err := ArchiveWithFormat(artifact, ArchiveFormatXZ) + result, err := ArchiveWithFormat(fs, artifact, ArchiveFormatXZ) require.NoError(t, err) expectedPath := filepath.Join(outputDir, "myapp_darwin_arm64.tar.xz") @@ -163,7 +165,7 @@ func TestArchive_Good(t *testing.T) { Arch: "amd64", } - result, err := ArchiveWithFormat(artifact, ArchiveFormatXZ) + result, err := ArchiveWithFormat(fs, artifact, ArchiveFormatXZ) require.NoError(t, err) // Windows should still get .zip regardless of format @@ -176,6 +178,7 @@ func TestArchive_Good(t *testing.T) { } func TestArchive_Bad(t *testing.T) { + fs := io_interface.Local t.Run("returns error for empty path", func(t *testing.T) { artifact := Artifact{ Path: "", @@ -183,7 +186,7 @@ func TestArchive_Bad(t *testing.T) { Arch: "amd64", } - result, err := Archive(artifact) + result, err := Archive(fs, artifact) assert.Error(t, err) assert.Contains(t, err.Error(), "artifact path is empty") assert.Empty(t, result.Path) @@ -196,7 +199,7 @@ func TestArchive_Bad(t *testing.T) { Arch: "amd64", } - result, err := Archive(artifact) + result, err := Archive(fs, artifact) assert.Error(t, err) assert.Contains(t, err.Error(), "source file not found") assert.Empty(t, result.Path) @@ -211,7 +214,7 @@ func TestArchive_Bad(t *testing.T) { Arch: "amd64", } - result, err := Archive(artifact) + result, err := Archive(fs, artifact) assert.Error(t, err) assert.Contains(t, err.Error(), "source path is a directory") assert.Empty(t, result.Path) @@ -219,6 +222,7 @@ func TestArchive_Bad(t *testing.T) { } func TestArchiveAll_Good(t *testing.T) { + fs := io_interface.Local t.Run("archives multiple artifacts", func(t *testing.T) { outputDir := t.TempDir() @@ -255,7 +259,7 @@ func TestArchiveAll_Good(t *testing.T) { }) } - results, err := ArchiveAll(artifacts) + results, err := ArchiveAll(fs, artifacts) require.NoError(t, err) require.Len(t, results, 4) @@ -268,19 +272,20 @@ func TestArchiveAll_Good(t *testing.T) { }) t.Run("returns nil for empty slice", func(t *testing.T) { - results, err := ArchiveAll([]Artifact{}) + results, err := ArchiveAll(fs, []Artifact{}) assert.NoError(t, err) assert.Nil(t, results) }) t.Run("returns nil for nil slice", func(t *testing.T) { - results, err := ArchiveAll(nil) + results, err := ArchiveAll(fs, nil) assert.NoError(t, err) assert.Nil(t, results) }) } func TestArchiveAll_Bad(t *testing.T) { + fs := io_interface.Local t.Run("returns partial results on error", func(t *testing.T) { binaryPath, _ := setupArchiveTestFile(t, "myapp", "linux", "amd64") @@ -289,7 +294,7 @@ func TestArchiveAll_Bad(t *testing.T) { {Path: "/nonexistent/binary", OS: "linux", Arch: "arm64"}, // This will fail } - results, err := ArchiveAll(artifacts) + results, err := ArchiveAll(fs, artifacts) assert.Error(t, err) // Should have the first successful result assert.Len(t, results, 1) diff --git a/pkg/build/build.go b/pkg/build/build.go index c463d5db..86f660eb 100644 --- a/pkg/build/build.go +++ b/pkg/build/build.go @@ -5,6 +5,8 @@ package build import ( "context" + + "github.com/host-uk/core/pkg/io" ) // ProjectType represents a detected project type. @@ -49,6 +51,8 @@ type Artifact struct { // Config holds build configuration. type Config struct { + // FS is the medium used for file operations. + FS io.Medium // ProjectDir is the root directory of the project. ProjectDir string // OutputDir is where build artifacts are placed. @@ -78,7 +82,7 @@ type Builder interface { // Name returns the builder's identifier. Name() string // Detect checks if this builder can handle the project in the given directory. - Detect(dir string) (bool, error) + Detect(fs io.Medium, dir string) (bool, error) // Build compiles the project for the specified targets. Build(ctx context.Context, cfg *Config, targets []Target) ([]Artifact, error) } diff --git a/pkg/build/buildcmd/cmd_project.go b/pkg/build/buildcmd/cmd_project.go index 3429b765..25a09dd8 100644 --- a/pkg/build/buildcmd/cmd_project.go +++ b/pkg/build/buildcmd/cmd_project.go @@ -18,10 +18,14 @@ import ( "github.com/host-uk/core/pkg/build/builders" "github.com/host-uk/core/pkg/build/signing" "github.com/host-uk/core/pkg/i18n" + "github.com/host-uk/core/pkg/io" ) // runProjectBuild handles the main `core build` command with auto-detection. func runProjectBuild(ctx context.Context, buildType string, ciMode bool, targetsFlag string, outputDir string, doArchive bool, doChecksum bool, configPath string, format string, push bool, imageName string, noSign bool, notarize bool, verbose bool) error { + // Use local filesystem as the default medium + fs := io.Local + // Get current working directory as project root projectDir, err := os.Getwd() if err != nil { @@ -29,7 +33,7 @@ func runProjectBuild(ctx context.Context, buildType string, ciMode bool, targets } // Load configuration from .core/build.yaml (or defaults) - buildCfg, err := build.LoadConfig(projectDir) + buildCfg, err := build.LoadConfig(fs, projectDir) if err != nil { return fmt.Errorf("%s: %w", i18n.T("common.error.failed", map[string]any{"Action": "load config"}), err) } @@ -39,7 +43,7 @@ func runProjectBuild(ctx context.Context, buildType string, ciMode bool, targets if buildType != "" { projectType = build.ProjectType(buildType) } else { - projectType, err = build.PrimaryType(projectDir) + projectType, err = build.PrimaryType(fs, projectDir) if err != nil { return fmt.Errorf("%s: %w", i18n.T("common.error.failed", map[string]any{"Action": "detect project type"}), err) } @@ -70,6 +74,15 @@ func runProjectBuild(ctx context.Context, buildType string, ciMode bool, targets if outputDir == "" { outputDir = "dist" } + if !filepath.IsAbs(outputDir) { + outputDir = filepath.Join(projectDir, outputDir) + } + outputDir = filepath.Clean(outputDir) + + // Ensure config path is absolute if provided + if configPath != "" && !filepath.IsAbs(configPath) { + configPath = filepath.Join(projectDir, configPath) + } // Determine binary name binaryName := buildCfg.Project.Binary @@ -98,6 +111,7 @@ func runProjectBuild(ctx context.Context, buildType string, ciMode bool, targets // Create build config for the builder cfg := &build.Config{ + FS: fs, ProjectDir: projectDir, OutputDir: outputDir, Name: binaryName, @@ -161,7 +175,7 @@ func runProjectBuild(ctx context.Context, buildType string, ciMode bool, targets signingArtifacts[i] = signing.Artifact{Path: a.Path, OS: a.OS, Arch: a.Arch} } - if err := signing.SignBinaries(ctx, signCfg, signingArtifacts); err != nil { + if err := signing.SignBinaries(ctx, fs, signCfg, signingArtifacts); err != nil { if !ciMode { fmt.Printf("%s %s: %v\n", buildErrorStyle.Render(i18n.T("common.label.error")), i18n.T("cmd.build.error.signing_failed"), err) } @@ -169,7 +183,7 @@ func runProjectBuild(ctx context.Context, buildType string, ciMode bool, targets } if signCfg.MacOS.Notarize { - if err := signing.NotarizeBinaries(ctx, signCfg, signingArtifacts); err != nil { + if err := signing.NotarizeBinaries(ctx, fs, signCfg, signingArtifacts); err != nil { if !ciMode { fmt.Printf("%s %s: %v\n", buildErrorStyle.Render(i18n.T("common.label.error")), i18n.T("cmd.build.error.notarization_failed"), err) } @@ -186,7 +200,7 @@ func runProjectBuild(ctx context.Context, buildType string, ciMode bool, targets fmt.Printf("%s %s\n", buildHeaderStyle.Render(i18n.T("cmd.build.label.archive")), i18n.T("cmd.build.creating_archives")) } - archivedArtifacts, err = build.ArchiveAll(artifacts) + archivedArtifacts, err = build.ArchiveAll(fs, artifacts) if err != nil { if !ciMode { fmt.Printf("%s %s: %v\n", buildErrorStyle.Render(i18n.T("common.label.error")), i18n.T("cmd.build.error.archive_failed"), err) @@ -256,12 +270,13 @@ func runProjectBuild(ctx context.Context, buildType string, ciMode bool, targets // computeAndWriteChecksums computes checksums for artifacts and writes CHECKSUMS.txt. func computeAndWriteChecksums(ctx context.Context, projectDir, outputDir string, artifacts []build.Artifact, signCfg signing.SignConfig, ciMode bool, verbose bool) ([]build.Artifact, error) { + fs := io.Local if verbose && !ciMode { fmt.Println() fmt.Printf("%s %s\n", buildHeaderStyle.Render(i18n.T("cmd.build.label.checksum")), i18n.T("cmd.build.computing_checksums")) } - checksummedArtifacts, err := build.ChecksumAll(artifacts) + checksummedArtifacts, err := build.ChecksumAll(fs, artifacts) if err != nil { if !ciMode { fmt.Printf("%s %s: %v\n", buildErrorStyle.Render(i18n.T("common.label.error")), i18n.T("cmd.build.error.checksum_failed"), err) @@ -271,7 +286,7 @@ func computeAndWriteChecksums(ctx context.Context, projectDir, outputDir string, // Write CHECKSUMS.txt checksumPath := filepath.Join(outputDir, "CHECKSUMS.txt") - if err := build.WriteChecksumFile(checksummedArtifacts, checksumPath); err != nil { + if err := build.WriteChecksumFile(fs, checksummedArtifacts, checksumPath); err != nil { if !ciMode { fmt.Printf("%s %s: %v\n", buildErrorStyle.Render(i18n.T("common.label.error")), i18n.T("common.error.failed", map[string]any{"Action": "write CHECKSUMS.txt"}), err) } @@ -280,7 +295,7 @@ func computeAndWriteChecksums(ctx context.Context, projectDir, outputDir string, // Sign checksums with GPG if signCfg.Enabled { - if err := signing.SignChecksums(ctx, signCfg, checksumPath); err != nil { + if err := signing.SignChecksums(ctx, fs, signCfg, checksumPath); err != nil { if !ciMode { fmt.Printf("%s %s: %v\n", buildErrorStyle.Render(i18n.T("common.label.error")), i18n.T("cmd.build.error.gpg_signing_failed"), err) } diff --git a/pkg/build/builders/docker.go b/pkg/build/builders/docker.go index f2f53e73..91585445 100644 --- a/pkg/build/builders/docker.go +++ b/pkg/build/builders/docker.go @@ -10,6 +10,7 @@ import ( "strings" "github.com/host-uk/core/pkg/build" + "github.com/host-uk/core/pkg/io" ) // DockerBuilder builds Docker images. @@ -26,9 +27,9 @@ func (b *DockerBuilder) Name() string { } // Detect checks if a Dockerfile exists in the directory. -func (b *DockerBuilder) Detect(dir string) (bool, error) { +func (b *DockerBuilder) Detect(fs io.Medium, dir string) (bool, error) { dockerfilePath := filepath.Join(dir, "Dockerfile") - if _, err := os.Stat(dockerfilePath); err == nil { + if fs.IsFile(dockerfilePath) { return true, nil } return false, nil @@ -53,7 +54,7 @@ func (b *DockerBuilder) Build(ctx context.Context, cfg *build.Config, targets [] } // Validate Dockerfile exists - if _, err := os.Stat(dockerfile); err != nil { + if !cfg.FS.IsFile(dockerfile) { return nil, fmt.Errorf("docker.Build: Dockerfile not found: %s", dockerfile) } @@ -150,7 +151,7 @@ func (b *DockerBuilder) Build(ctx context.Context, cfg *build.Config, targets [] args = append(args, cfg.ProjectDir) // Create output directory - if err := os.MkdirAll(cfg.OutputDir, 0755); err != nil { + if err := cfg.FS.EnsureDir(cfg.OutputDir); err != nil { return nil, fmt.Errorf("docker.Build: failed to create output directory: %w", err) } diff --git a/pkg/build/builders/go.go b/pkg/build/builders/go.go index 63275d96..b937f3b2 100644 --- a/pkg/build/builders/go.go +++ b/pkg/build/builders/go.go @@ -10,6 +10,7 @@ import ( "strings" "github.com/host-uk/core/pkg/build" + "github.com/host-uk/core/pkg/io" ) // GoBuilder implements the Builder interface for Go projects. @@ -27,8 +28,8 @@ func (b *GoBuilder) Name() string { // Detect checks if this builder can handle the project in the given directory. // Uses IsGoProject from the build package which checks for go.mod or wails.json. -func (b *GoBuilder) Detect(dir string) (bool, error) { - return build.IsGoProject(dir), nil +func (b *GoBuilder) Detect(fs io.Medium, dir string) (bool, error) { + return build.IsGoProject(fs, dir), nil } // Build compiles the Go project for the specified targets. @@ -44,7 +45,7 @@ func (b *GoBuilder) Build(ctx context.Context, cfg *build.Config, targets []buil } // Ensure output directory exists - if err := os.MkdirAll(cfg.OutputDir, 0755); err != nil { + if err := cfg.FS.EnsureDir(cfg.OutputDir); err != nil { return nil, fmt.Errorf("builders.GoBuilder.Build: failed to create output directory: %w", err) } @@ -76,7 +77,7 @@ func (b *GoBuilder) buildTarget(ctx context.Context, cfg *build.Config, target b // Create platform-specific output path: output/os_arch/binary platformDir := filepath.Join(cfg.OutputDir, fmt.Sprintf("%s_%s", target.OS, target.Arch)) - if err := os.MkdirAll(platformDir, 0755); err != nil { + if err := cfg.FS.EnsureDir(platformDir); err != nil { return build.Artifact{}, fmt.Errorf("failed to create platform directory: %w", err) } diff --git a/pkg/build/builders/go_test.go b/pkg/build/builders/go_test.go index c46ad3b0..62373cca 100644 --- a/pkg/build/builders/go_test.go +++ b/pkg/build/builders/go_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/host-uk/core/pkg/build" + "github.com/host-uk/core/pkg/io" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -44,13 +45,14 @@ func TestGoBuilder_Name_Good(t *testing.T) { } func TestGoBuilder_Detect_Good(t *testing.T) { + fs := io.Local t.Run("detects Go project with go.mod", func(t *testing.T) { dir := t.TempDir() err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module test"), 0644) require.NoError(t, err) builder := NewGoBuilder() - detected, err := builder.Detect(dir) + detected, err := builder.Detect(fs, dir) assert.NoError(t, err) assert.True(t, detected) }) @@ -61,7 +63,7 @@ func TestGoBuilder_Detect_Good(t *testing.T) { require.NoError(t, err) builder := NewGoBuilder() - detected, err := builder.Detect(dir) + detected, err := builder.Detect(fs, dir) assert.NoError(t, err) assert.True(t, detected) }) @@ -73,7 +75,7 @@ func TestGoBuilder_Detect_Good(t *testing.T) { require.NoError(t, err) builder := NewGoBuilder() - detected, err := builder.Detect(dir) + detected, err := builder.Detect(fs, dir) assert.NoError(t, err) assert.False(t, detected) }) @@ -82,7 +84,7 @@ func TestGoBuilder_Detect_Good(t *testing.T) { dir := t.TempDir() builder := NewGoBuilder() - detected, err := builder.Detect(dir) + detected, err := builder.Detect(fs, dir) assert.NoError(t, err) assert.False(t, detected) }) @@ -99,6 +101,7 @@ func TestGoBuilder_Build_Good(t *testing.T) { builder := NewGoBuilder() cfg := &build.Config{ + FS: io.Local, ProjectDir: projectDir, OutputDir: outputDir, Name: "testbinary", @@ -133,6 +136,7 @@ func TestGoBuilder_Build_Good(t *testing.T) { builder := NewGoBuilder() cfg := &build.Config{ + FS: io.Local, ProjectDir: projectDir, OutputDir: outputDir, Name: "multitest", @@ -160,6 +164,7 @@ func TestGoBuilder_Build_Good(t *testing.T) { builder := NewGoBuilder() cfg := &build.Config{ + FS: io.Local, ProjectDir: projectDir, OutputDir: outputDir, Name: "wintest", @@ -183,6 +188,7 @@ func TestGoBuilder_Build_Good(t *testing.T) { builder := NewGoBuilder() cfg := &build.Config{ + FS: io.Local, ProjectDir: projectDir, OutputDir: outputDir, Name: "", // Empty name @@ -209,6 +215,7 @@ func TestGoBuilder_Build_Good(t *testing.T) { builder := NewGoBuilder() cfg := &build.Config{ + FS: io.Local, ProjectDir: projectDir, OutputDir: outputDir, Name: "ldflagstest", @@ -230,6 +237,7 @@ func TestGoBuilder_Build_Good(t *testing.T) { builder := NewGoBuilder() cfg := &build.Config{ + FS: io.Local, ProjectDir: projectDir, OutputDir: outputDir, Name: "nestedtest", @@ -261,6 +269,7 @@ func TestGoBuilder_Build_Bad(t *testing.T) { builder := NewGoBuilder() cfg := &build.Config{ + FS: io.Local, ProjectDir: projectDir, OutputDir: t.TempDir(), Name: "test", @@ -279,6 +288,7 @@ func TestGoBuilder_Build_Bad(t *testing.T) { builder := NewGoBuilder() cfg := &build.Config{ + FS: io.Local, ProjectDir: "/nonexistent/path", OutputDir: t.TempDir(), Name: "test", @@ -309,6 +319,7 @@ func TestGoBuilder_Build_Bad(t *testing.T) { builder := NewGoBuilder() cfg := &build.Config{ + FS: io.Local, ProjectDir: dir, OutputDir: t.TempDir(), Name: "test", @@ -335,6 +346,7 @@ func TestGoBuilder_Build_Bad(t *testing.T) { builder := NewGoBuilder() cfg := &build.Config{ + FS: io.Local, ProjectDir: projectDir, OutputDir: outputDir, Name: "partialtest", @@ -360,6 +372,7 @@ func TestGoBuilder_Build_Bad(t *testing.T) { builder := NewGoBuilder() cfg := &build.Config{ + FS: io.Local, ProjectDir: projectDir, OutputDir: t.TempDir(), Name: "canceltest", diff --git a/pkg/build/builders/linuxkit.go b/pkg/build/builders/linuxkit.go index 5d2e913c..dca045d5 100644 --- a/pkg/build/builders/linuxkit.go +++ b/pkg/build/builders/linuxkit.go @@ -10,6 +10,7 @@ import ( "strings" "github.com/host-uk/core/pkg/build" + "github.com/host-uk/core/pkg/io" ) // LinuxKitBuilder builds LinuxKit images. @@ -26,14 +27,22 @@ func (b *LinuxKitBuilder) Name() string { } // Detect checks if a linuxkit.yml or .yml config exists in the directory. -func (b *LinuxKitBuilder) Detect(dir string) (bool, error) { +func (b *LinuxKitBuilder) Detect(fs io.Medium, dir string) (bool, error) { // Check for linuxkit.yml - if _, err := os.Stat(filepath.Join(dir, "linuxkit.yml")); err == nil { + if fs.IsFile(filepath.Join(dir, "linuxkit.yml")) { return true, nil } - // Check for .core/linuxkit/*.yml - if matches, _ := filepath.Glob(filepath.Join(dir, ".core", "linuxkit", "*.yml")); len(matches) > 0 { - return true, nil + // Check for .core/linuxkit/ + lkDir := filepath.Join(dir, ".core", "linuxkit") + if fs.IsDir(lkDir) { + entries, err := fs.List(lkDir) + if err == nil { + for _, entry := range entries { + if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".yml") { + return true, nil + } + } + } } return false, nil } @@ -49,13 +58,21 @@ func (b *LinuxKitBuilder) Build(ctx context.Context, cfg *build.Config, targets configPath := cfg.LinuxKitConfig if configPath == "" { // Auto-detect - if _, err := os.Stat(filepath.Join(cfg.ProjectDir, "linuxkit.yml")); err == nil { + if cfg.FS.IsFile(filepath.Join(cfg.ProjectDir, "linuxkit.yml")) { configPath = filepath.Join(cfg.ProjectDir, "linuxkit.yml") } else { // Look in .core/linuxkit/ - matches, _ := filepath.Glob(filepath.Join(cfg.ProjectDir, ".core", "linuxkit", "*.yml")) - if len(matches) > 0 { - configPath = matches[0] + lkDir := filepath.Join(cfg.ProjectDir, ".core", "linuxkit") + if cfg.FS.IsDir(lkDir) { + entries, err := cfg.FS.List(lkDir) + if err == nil { + for _, entry := range entries { + if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".yml") { + configPath = filepath.Join(lkDir, entry.Name()) + break + } + } + } } } } @@ -65,7 +82,7 @@ func (b *LinuxKitBuilder) Build(ctx context.Context, cfg *build.Config, targets } // Validate config file exists - if _, err := os.Stat(configPath); err != nil { + if !cfg.FS.IsFile(configPath) { return nil, fmt.Errorf("linuxkit.Build: config file not found: %s", configPath) } @@ -80,7 +97,7 @@ func (b *LinuxKitBuilder) Build(ctx context.Context, cfg *build.Config, targets if outputDir == "" { outputDir = filepath.Join(cfg.ProjectDir, "dist") } - if err := os.MkdirAll(outputDir, 0755); err != nil { + if err := cfg.FS.EnsureDir(outputDir); err != nil { return nil, fmt.Errorf("linuxkit.Build: failed to create output directory: %w", err) } @@ -125,9 +142,9 @@ func (b *LinuxKitBuilder) Build(ctx context.Context, cfg *build.Config, targets artifactPath := b.getArtifactPath(outputDir, outputName, format) // Verify the artifact was created - if _, err := os.Stat(artifactPath); err != nil { + if !cfg.FS.Exists(artifactPath) { // Try alternate naming conventions - artifactPath = b.findArtifact(outputDir, outputName, format) + artifactPath = b.findArtifact(cfg.FS, outputDir, outputName, format) if artifactPath == "" { return nil, fmt.Errorf("linuxkit.Build: artifact not found after build: expected %s", b.getArtifactPath(outputDir, outputName, format)) } @@ -175,7 +192,7 @@ func (b *LinuxKitBuilder) getArtifactPath(outputDir, outputName, format string) } // findArtifact searches for the built artifact with various naming conventions. -func (b *LinuxKitBuilder) findArtifact(outputDir, outputName, format string) string { +func (b *LinuxKitBuilder) findArtifact(fs io.Medium, outputDir, outputName, format string) string { // LinuxKit can create files with different suffixes extensions := []string{ b.getFormatExtension(format), @@ -185,18 +202,23 @@ func (b *LinuxKitBuilder) findArtifact(outputDir, outputName, format string) str for _, ext := range extensions { path := filepath.Join(outputDir, outputName+ext) - if _, err := os.Stat(path); err == nil { + if fs.Exists(path) { return path } } // Try to find any file matching the output name - matches, _ := filepath.Glob(filepath.Join(outputDir, outputName+"*")) - for _, match := range matches { - // Return first match that looks like an image - ext := filepath.Ext(match) - if ext == ".iso" || ext == ".qcow2" || ext == ".raw" || ext == ".vmdk" || ext == ".vhd" { - return match + entries, err := fs.List(outputDir) + if err == nil { + for _, entry := range entries { + if strings.HasPrefix(entry.Name(), outputName) { + match := filepath.Join(outputDir, entry.Name()) + // Return first match that looks like an image + ext := filepath.Ext(match) + if ext == ".iso" || ext == ".qcow2" || ext == ".raw" || ext == ".vmdk" || ext == ".vhd" { + return match + } + } } } diff --git a/pkg/build/builders/taskfile.go b/pkg/build/builders/taskfile.go index 41888ab9..6079cefe 100644 --- a/pkg/build/builders/taskfile.go +++ b/pkg/build/builders/taskfile.go @@ -10,6 +10,7 @@ import ( "strings" "github.com/host-uk/core/pkg/build" + "github.com/host-uk/core/pkg/io" ) // TaskfileBuilder builds projects using Taskfile (https://taskfile.dev/). @@ -27,7 +28,7 @@ func (b *TaskfileBuilder) Name() string { } // Detect checks if a Taskfile exists in the directory. -func (b *TaskfileBuilder) Detect(dir string) (bool, error) { +func (b *TaskfileBuilder) Detect(fs io.Medium, dir string) (bool, error) { // Check for Taskfile.yml, Taskfile.yaml, or Taskfile taskfiles := []string{ "Taskfile.yml", @@ -38,7 +39,7 @@ func (b *TaskfileBuilder) Detect(dir string) (bool, error) { } for _, tf := range taskfiles { - if _, err := os.Stat(filepath.Join(dir, tf)); err == nil { + if fs.IsFile(filepath.Join(dir, tf)) { return true, nil } } @@ -57,7 +58,7 @@ func (b *TaskfileBuilder) Build(ctx context.Context, cfg *build.Config, targets if outputDir == "" { outputDir = filepath.Join(cfg.ProjectDir, "dist") } - if err := os.MkdirAll(outputDir, 0755); err != nil { + if err := cfg.FS.EnsureDir(outputDir); err != nil { return nil, fmt.Errorf("taskfile.Build: failed to create output directory: %w", err) } @@ -70,7 +71,7 @@ func (b *TaskfileBuilder) Build(ctx context.Context, cfg *build.Config, targets } // Try to find artifacts in output directory - found := b.findArtifacts(outputDir) + found := b.findArtifacts(cfg.FS, outputDir) artifacts = append(artifacts, found...) } else { // Run build task for each target @@ -80,7 +81,7 @@ func (b *TaskfileBuilder) Build(ctx context.Context, cfg *build.Config, targets } // Try to find artifacts for this target - found := b.findArtifactsForTarget(outputDir, target) + found := b.findArtifactsForTarget(cfg.FS, outputDir, target) artifacts = append(artifacts, found...) } } @@ -147,10 +148,10 @@ func (b *TaskfileBuilder) runTask(ctx context.Context, cfg *build.Config, goos, } // findArtifacts searches for built artifacts in the output directory. -func (b *TaskfileBuilder) findArtifacts(outputDir string) []build.Artifact { +func (b *TaskfileBuilder) findArtifacts(fs io.Medium, outputDir string) []build.Artifact { var artifacts []build.Artifact - entries, err := os.ReadDir(outputDir) + entries, err := fs.List(outputDir) if err != nil { return artifacts } @@ -177,13 +178,13 @@ func (b *TaskfileBuilder) findArtifacts(outputDir string) []build.Artifact { } // findArtifactsForTarget searches for built artifacts for a specific target. -func (b *TaskfileBuilder) findArtifactsForTarget(outputDir string, target build.Target) []build.Artifact { +func (b *TaskfileBuilder) findArtifactsForTarget(fs io.Medium, outputDir string, target build.Target) []build.Artifact { var artifacts []build.Artifact // 1. Look for platform-specific subdirectory: output/os_arch/ platformSubdir := filepath.Join(outputDir, fmt.Sprintf("%s_%s", target.OS, target.Arch)) - if info, err := os.Stat(platformSubdir); err == nil && info.IsDir() { - entries, _ := os.ReadDir(platformSubdir) + if fs.IsDir(platformSubdir) { + entries, _ := fs.List(platformSubdir) for _, entry := range entries { if entry.IsDir() { // Handle .app bundles on macOS @@ -219,18 +220,22 @@ func (b *TaskfileBuilder) findArtifactsForTarget(outputDir string, target build. } for _, pattern := range patterns { - matches, _ := filepath.Glob(filepath.Join(outputDir, pattern)) - for _, match := range matches { - info, err := os.Stat(match) - if err != nil || info.IsDir() { - continue - } + entries, _ := fs.List(outputDir) + for _, entry := range entries { + match := entry.Name() + // Simple glob matching + if b.matchPattern(match, pattern) { + fullPath := filepath.Join(outputDir, match) + if fs.IsDir(fullPath) { + continue + } - artifacts = append(artifacts, build.Artifact{ - Path: match, - OS: target.OS, - Arch: target.Arch, - }) + artifacts = append(artifacts, build.Artifact{ + Path: fullPath, + OS: target.OS, + Arch: target.Arch, + }) + } } if len(artifacts) > 0 { @@ -241,6 +246,12 @@ func (b *TaskfileBuilder) findArtifactsForTarget(outputDir string, target build. return artifacts } +// matchPattern implements glob matching for Taskfile artifacts. +func (b *TaskfileBuilder) matchPattern(name, pattern string) bool { + matched, _ := filepath.Match(pattern, name) + return matched +} + // validateTaskCli checks if the task CLI is available. func (b *TaskfileBuilder) validateTaskCli() error { // Check PATH first diff --git a/pkg/build/builders/wails.go b/pkg/build/builders/wails.go index d14c90a5..e8a0f998 100644 --- a/pkg/build/builders/wails.go +++ b/pkg/build/builders/wails.go @@ -4,12 +4,12 @@ package builders import ( "context" "fmt" - "os" "os/exec" "path/filepath" "strings" "github.com/host-uk/core/pkg/build" + "github.com/host-uk/core/pkg/io" ) // WailsBuilder implements the Builder interface for Wails v3 projects. @@ -27,8 +27,8 @@ func (b *WailsBuilder) Name() string { // Detect checks if this builder can handle the project in the given directory. // Uses IsWailsProject from the build package which checks for wails.json. -func (b *WailsBuilder) Detect(dir string) (bool, error) { - return build.IsWailsProject(dir), nil +func (b *WailsBuilder) Detect(fs io.Medium, dir string) (bool, error) { + return build.IsWailsProject(fs, dir), nil } // Build compiles the Wails project for the specified targets. @@ -45,12 +45,12 @@ func (b *WailsBuilder) Build(ctx context.Context, cfg *build.Config, targets []b } // Detect Wails version - isV3 := b.isWailsV3(cfg.ProjectDir) + isV3 := b.isWailsV3(cfg.FS, cfg.ProjectDir) if isV3 { // Wails v3 strategy: Delegate to Taskfile taskBuilder := NewTaskfileBuilder() - if detected, _ := taskBuilder.Detect(cfg.ProjectDir); detected { + if detected, _ := taskBuilder.Detect(cfg.FS, cfg.ProjectDir); detected { return taskBuilder.Build(ctx, cfg, targets) } return nil, fmt.Errorf("wails v3 projects require a Taskfile for building") @@ -58,7 +58,7 @@ func (b *WailsBuilder) Build(ctx context.Context, cfg *build.Config, targets []b // Wails v2 strategy: Use 'wails build' // Ensure output directory exists - if err := os.MkdirAll(cfg.OutputDir, 0755); err != nil { + if err := cfg.FS.EnsureDir(cfg.OutputDir); err != nil { return nil, fmt.Errorf("builders.WailsBuilder.Build: failed to create output directory: %w", err) } @@ -78,13 +78,13 @@ func (b *WailsBuilder) Build(ctx context.Context, cfg *build.Config, targets []b } // isWailsV3 checks if the project uses Wails v3 by inspecting go.mod. -func (b *WailsBuilder) isWailsV3(dir string) bool { +func (b *WailsBuilder) isWailsV3(fs io.Medium, dir string) bool { goModPath := filepath.Join(dir, "go.mod") - data, err := os.ReadFile(goModPath) + content, err := fs.Read(goModPath) if err != nil { return false } - return strings.Contains(string(data), "github.com/wailsapp/wails/v3") + return strings.Contains(content, "github.com/wailsapp/wails/v3") } // buildV2Target compiles for a single target platform using wails (v2). @@ -123,7 +123,7 @@ func (b *WailsBuilder) buildV2Target(ctx context.Context, cfg *build.Config, tar wailsOutputDir := filepath.Join(cfg.ProjectDir, "build", "bin") // Find the artifact in Wails output dir - sourcePath, err := b.findArtifact(wailsOutputDir, binaryName, target) + sourcePath, err := b.findArtifact(cfg.FS, wailsOutputDir, binaryName, target) if err != nil { return build.Artifact{}, fmt.Errorf("failed to find Wails v2 build artifact: %w", err) } @@ -131,18 +131,18 @@ func (b *WailsBuilder) buildV2Target(ctx context.Context, cfg *build.Config, tar // Move/Copy to our output dir // Create platform specific dir in our output platformDir := filepath.Join(cfg.OutputDir, fmt.Sprintf("%s_%s", target.OS, target.Arch)) - if err := os.MkdirAll(platformDir, 0755); err != nil { + if err := cfg.FS.EnsureDir(platformDir); err != nil { return build.Artifact{}, fmt.Errorf("failed to create output dir: %w", err) } destPath := filepath.Join(platformDir, filepath.Base(sourcePath)) - // Simple copy - input, err := os.ReadFile(sourcePath) + // Simple copy using the medium + content, err := cfg.FS.Read(sourcePath) if err != nil { return build.Artifact{}, err } - if err := os.WriteFile(destPath, input, 0755); err != nil { + if err := cfg.FS.Write(destPath, content); err != nil { return build.Artifact{}, err } @@ -154,7 +154,7 @@ func (b *WailsBuilder) buildV2Target(ctx context.Context, cfg *build.Config, tar } // findArtifact locates the built artifact based on the target platform. -func (b *WailsBuilder) findArtifact(platformDir, binaryName string, target build.Target) (string, error) { +func (b *WailsBuilder) findArtifact(fs io.Medium, platformDir, binaryName string, target build.Target) (string, error) { var candidates []string switch target.OS { @@ -181,13 +181,13 @@ func (b *WailsBuilder) findArtifact(platformDir, binaryName string, target build // Try each candidate for _, candidate := range candidates { - if fileOrDirExists(candidate) { + if fs.Exists(candidate) { return candidate, nil } } // If no specific candidate found, try to find any executable or package in the directory - entries, err := os.ReadDir(platformDir) + entries, err := fs.List(platformDir) if err != nil { return "", fmt.Errorf("failed to read platform directory: %w", err) } @@ -221,7 +221,7 @@ func (b *WailsBuilder) findArtifact(platformDir, binaryName string, target build // detectPackageManager detects the frontend package manager based on lock files. // Returns "bun", "pnpm", "yarn", or "npm" (default). -func detectPackageManager(dir string) string { +func detectPackageManager(fs io.Medium, dir string) string { // Check in priority order: bun, pnpm, yarn, npm lockFiles := []struct { file string @@ -234,7 +234,7 @@ func detectPackageManager(dir string) string { } for _, lf := range lockFiles { - if fileExists(filepath.Join(dir, lf.file)) { + if fs.IsFile(filepath.Join(dir, lf.file)) { return lf.manager } } @@ -243,20 +243,5 @@ func detectPackageManager(dir string) string { return "npm" } -// fileExists checks if a file exists and is not a directory. -func fileExists(path string) bool { - info, err := os.Stat(path) - if err != nil { - return false - } - return !info.IsDir() -} - -// fileOrDirExists checks if a file or directory exists. -func fileOrDirExists(path string) bool { - _, err := os.Stat(path) - return err == nil -} - // Ensure WailsBuilder implements the Builder interface. var _ build.Builder = (*WailsBuilder)(nil) diff --git a/pkg/build/builders/wails_test.go b/pkg/build/builders/wails_test.go index 921c2d3e..c3e2365a 100644 --- a/pkg/build/builders/wails_test.go +++ b/pkg/build/builders/wails_test.go @@ -9,6 +9,7 @@ import ( "testing" "github.com/host-uk/core/pkg/build" + "github.com/host-uk/core/pkg/io" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -91,6 +92,7 @@ func TestWailsBuilder_Build_Taskfile_Good(t *testing.T) { } t.Run("delegates to Taskfile if present", func(t *testing.T) { + fs := io.Local projectDir := setupWailsTestProject(t) outputDir := t.TempDir() @@ -107,6 +109,7 @@ tasks: builder := NewWailsBuilder() cfg := &build.Config{ + FS: fs, ProjectDir: projectDir, OutputDir: outputDir, Name: "testapp", @@ -136,11 +139,13 @@ func TestWailsBuilder_Build_V2_Good(t *testing.T) { } t.Run("builds v2 project", func(t *testing.T) { + fs := io.Local projectDir := setupWailsV2TestProject(t) outputDir := t.TempDir() builder := NewWailsBuilder() cfg := &build.Config{ + FS: fs, ProjectDir: projectDir, OutputDir: outputDir, Name: "testapp", @@ -158,13 +163,14 @@ func TestWailsBuilder_Build_V2_Good(t *testing.T) { } func TestWailsBuilder_Detect_Good(t *testing.T) { + fs := io.Local t.Run("detects Wails project with wails.json", func(t *testing.T) { dir := t.TempDir() err := os.WriteFile(filepath.Join(dir, "wails.json"), []byte("{}"), 0644) require.NoError(t, err) builder := NewWailsBuilder() - detected, err := builder.Detect(dir) + detected, err := builder.Detect(fs, dir) assert.NoError(t, err) assert.True(t, detected) }) @@ -175,7 +181,7 @@ func TestWailsBuilder_Detect_Good(t *testing.T) { require.NoError(t, err) builder := NewWailsBuilder() - detected, err := builder.Detect(dir) + detected, err := builder.Detect(fs, dir) assert.NoError(t, err) assert.False(t, detected) }) @@ -186,7 +192,7 @@ func TestWailsBuilder_Detect_Good(t *testing.T) { require.NoError(t, err) builder := NewWailsBuilder() - detected, err := builder.Detect(dir) + detected, err := builder.Detect(fs, dir) assert.NoError(t, err) assert.False(t, detected) }) @@ -195,19 +201,20 @@ func TestWailsBuilder_Detect_Good(t *testing.T) { dir := t.TempDir() builder := NewWailsBuilder() - detected, err := builder.Detect(dir) + detected, err := builder.Detect(fs, dir) assert.NoError(t, err) assert.False(t, detected) }) } func TestDetectPackageManager_Good(t *testing.T) { + fs := io.Local t.Run("detects bun from bun.lockb", func(t *testing.T) { dir := t.TempDir() err := os.WriteFile(filepath.Join(dir, "bun.lockb"), []byte(""), 0644) require.NoError(t, err) - result := detectPackageManager(dir) + result := detectPackageManager(fs, dir) assert.Equal(t, "bun", result) }) @@ -216,7 +223,7 @@ func TestDetectPackageManager_Good(t *testing.T) { err := os.WriteFile(filepath.Join(dir, "pnpm-lock.yaml"), []byte(""), 0644) require.NoError(t, err) - result := detectPackageManager(dir) + result := detectPackageManager(fs, dir) assert.Equal(t, "pnpm", result) }) @@ -225,7 +232,7 @@ func TestDetectPackageManager_Good(t *testing.T) { err := os.WriteFile(filepath.Join(dir, "yarn.lock"), []byte(""), 0644) require.NoError(t, err) - result := detectPackageManager(dir) + result := detectPackageManager(fs, dir) assert.Equal(t, "yarn", result) }) @@ -234,14 +241,14 @@ func TestDetectPackageManager_Good(t *testing.T) { err := os.WriteFile(filepath.Join(dir, "package-lock.json"), []byte(""), 0644) require.NoError(t, err) - result := detectPackageManager(dir) + result := detectPackageManager(fs, dir) assert.Equal(t, "npm", result) }) t.Run("defaults to npm when no lock file", func(t *testing.T) { dir := t.TempDir() - result := detectPackageManager(dir) + result := detectPackageManager(fs, dir) assert.Equal(t, "npm", result) }) @@ -252,7 +259,7 @@ func TestDetectPackageManager_Good(t *testing.T) { require.NoError(t, os.WriteFile(filepath.Join(dir, "yarn.lock"), []byte(""), 0644)) require.NoError(t, os.WriteFile(filepath.Join(dir, "package-lock.json"), []byte(""), 0644)) - result := detectPackageManager(dir) + result := detectPackageManager(fs, dir) assert.Equal(t, "bun", result) }) @@ -263,7 +270,7 @@ func TestDetectPackageManager_Good(t *testing.T) { require.NoError(t, os.WriteFile(filepath.Join(dir, "yarn.lock"), []byte(""), 0644)) require.NoError(t, os.WriteFile(filepath.Join(dir, "package-lock.json"), []byte(""), 0644)) - result := detectPackageManager(dir) + result := detectPackageManager(fs, dir) assert.Equal(t, "pnpm", result) }) @@ -273,7 +280,7 @@ func TestDetectPackageManager_Good(t *testing.T) { require.NoError(t, os.WriteFile(filepath.Join(dir, "yarn.lock"), []byte(""), 0644)) require.NoError(t, os.WriteFile(filepath.Join(dir, "package-lock.json"), []byte(""), 0644)) - result := detectPackageManager(dir) + result := detectPackageManager(fs, dir) assert.Equal(t, "yarn", result) }) } @@ -293,6 +300,7 @@ func TestWailsBuilder_Build_Bad(t *testing.T) { builder := NewWailsBuilder() cfg := &build.Config{ + FS: io.Local, ProjectDir: projectDir, OutputDir: t.TempDir(), Name: "test", @@ -321,6 +329,7 @@ func TestWailsBuilder_Build_Good(t *testing.T) { builder := NewWailsBuilder() cfg := &build.Config{ + FS: io.Local, ProjectDir: projectDir, OutputDir: outputDir, Name: "testapp", @@ -359,6 +368,7 @@ func TestWailsBuilder_Ugly(t *testing.T) { builder := NewWailsBuilder() cfg := &build.Config{ + FS: io.Local, ProjectDir: dir, OutputDir: t.TempDir(), Name: "test", @@ -386,6 +396,7 @@ func TestWailsBuilder_Ugly(t *testing.T) { builder := NewWailsBuilder() cfg := &build.Config{ + FS: io.Local, ProjectDir: projectDir, OutputDir: t.TempDir(), Name: "canceltest", diff --git a/pkg/build/checksum.go b/pkg/build/checksum.go index 3e882f52..6610edff 100644 --- a/pkg/build/checksum.go +++ b/pkg/build/checksum.go @@ -6,20 +6,21 @@ import ( "encoding/hex" "fmt" "io" - "os" "path/filepath" + + io_interface "github.com/host-uk/core/pkg/io" "sort" "strings" ) // Checksum computes SHA256 for an artifact and returns the artifact with the Checksum field filled. -func Checksum(artifact Artifact) (Artifact, error) { +func Checksum(fs io_interface.Medium, artifact Artifact) (Artifact, error) { if artifact.Path == "" { return Artifact{}, fmt.Errorf("build.Checksum: artifact path is empty") } // Open the file - file, err := os.Open(artifact.Path) + file, err := fs.Open(artifact.Path) if err != nil { return Artifact{}, fmt.Errorf("build.Checksum: failed to open file: %w", err) } @@ -43,14 +44,14 @@ func Checksum(artifact Artifact) (Artifact, error) { // ChecksumAll computes checksums for all artifacts. // Returns a slice of artifacts with their Checksum fields filled. -func ChecksumAll(artifacts []Artifact) ([]Artifact, error) { +func ChecksumAll(fs io_interface.Medium, artifacts []Artifact) ([]Artifact, error) { if len(artifacts) == 0 { return nil, nil } var checksummed []Artifact for _, artifact := range artifacts { - cs, err := Checksum(artifact) + cs, err := Checksum(fs, artifact) if err != nil { return checksummed, fmt.Errorf("build.ChecksumAll: failed to checksum %s: %w", artifact.Path, err) } @@ -67,7 +68,7 @@ func ChecksumAll(artifacts []Artifact) ([]Artifact, error) { // // The artifacts should have their Checksum fields filled (call ChecksumAll first). // Filenames are relative to the output directory (just the basename). -func WriteChecksumFile(artifacts []Artifact, path string) error { +func WriteChecksumFile(fs io_interface.Medium, artifacts []Artifact, path string) error { if len(artifacts) == 0 { return nil } @@ -87,14 +88,8 @@ func WriteChecksumFile(artifacts []Artifact, path string) error { content := strings.Join(lines, "\n") + "\n" - // Ensure directory exists - dir := filepath.Dir(path) - if err := os.MkdirAll(dir, 0755); err != nil { - return fmt.Errorf("build.WriteChecksumFile: failed to create directory: %w", err) - } - - // Write the file - if err := os.WriteFile(path, []byte(content), 0644); err != nil { + // Write the file using the medium (which handles directory creation in Write) + if err := fs.Write(path, content); err != nil { return fmt.Errorf("build.WriteChecksumFile: failed to write file: %w", err) } diff --git a/pkg/build/checksum_test.go b/pkg/build/checksum_test.go index 499c67d3..6f756ce2 100644 --- a/pkg/build/checksum_test.go +++ b/pkg/build/checksum_test.go @@ -6,6 +6,7 @@ import ( "strings" "testing" + "github.com/host-uk/core/pkg/io" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -23,6 +24,7 @@ func setupChecksumTestFile(t *testing.T, content string) string { } func TestChecksum_Good(t *testing.T) { + fs := io.Local t.Run("computes SHA256 checksum", func(t *testing.T) { // Known SHA256 of "Hello, World!\n" path := setupChecksumTestFile(t, "Hello, World!\n") @@ -34,7 +36,7 @@ func TestChecksum_Good(t *testing.T) { Arch: "amd64", } - result, err := Checksum(artifact) + result, err := Checksum(fs, artifact) require.NoError(t, err) assert.Equal(t, expectedChecksum, result.Checksum) }) @@ -48,7 +50,7 @@ func TestChecksum_Good(t *testing.T) { Arch: "arm64", } - result, err := Checksum(artifact) + result, err := Checksum(fs, artifact) require.NoError(t, err) assert.Equal(t, path, result.Path) @@ -62,7 +64,7 @@ func TestChecksum_Good(t *testing.T) { artifact := Artifact{Path: path, OS: "linux", Arch: "amd64"} - result, err := Checksum(artifact) + result, err := Checksum(fs, artifact) require.NoError(t, err) // SHA256 produces 32 bytes = 64 hex characters @@ -73,10 +75,10 @@ func TestChecksum_Good(t *testing.T) { path1 := setupChecksumTestFile(t, "content one") path2 := setupChecksumTestFile(t, "content two") - result1, err := Checksum(Artifact{Path: path1, OS: "linux", Arch: "amd64"}) + result1, err := Checksum(fs, Artifact{Path: path1, OS: "linux", Arch: "amd64"}) require.NoError(t, err) - result2, err := Checksum(Artifact{Path: path2, OS: "linux", Arch: "amd64"}) + result2, err := Checksum(fs, Artifact{Path: path2, OS: "linux", Arch: "amd64"}) require.NoError(t, err) assert.NotEqual(t, result1.Checksum, result2.Checksum) @@ -87,10 +89,10 @@ func TestChecksum_Good(t *testing.T) { path1 := setupChecksumTestFile(t, content) path2 := setupChecksumTestFile(t, content) - result1, err := Checksum(Artifact{Path: path1, OS: "linux", Arch: "amd64"}) + result1, err := Checksum(fs, Artifact{Path: path1, OS: "linux", Arch: "amd64"}) require.NoError(t, err) - result2, err := Checksum(Artifact{Path: path2, OS: "linux", Arch: "amd64"}) + result2, err := Checksum(fs, Artifact{Path: path2, OS: "linux", Arch: "amd64"}) require.NoError(t, err) assert.Equal(t, result1.Checksum, result2.Checksum) @@ -98,6 +100,7 @@ func TestChecksum_Good(t *testing.T) { } func TestChecksum_Bad(t *testing.T) { + fs := io.Local t.Run("returns error for empty path", func(t *testing.T) { artifact := Artifact{ Path: "", @@ -105,7 +108,7 @@ func TestChecksum_Bad(t *testing.T) { Arch: "amd64", } - result, err := Checksum(artifact) + result, err := Checksum(fs, artifact) assert.Error(t, err) assert.Contains(t, err.Error(), "artifact path is empty") assert.Empty(t, result.Checksum) @@ -118,7 +121,7 @@ func TestChecksum_Bad(t *testing.T) { Arch: "amd64", } - result, err := Checksum(artifact) + result, err := Checksum(fs, artifact) assert.Error(t, err) assert.Contains(t, err.Error(), "failed to open file") assert.Empty(t, result.Checksum) @@ -126,6 +129,7 @@ func TestChecksum_Bad(t *testing.T) { } func TestChecksumAll_Good(t *testing.T) { + fs := io.Local t.Run("checksums multiple artifacts", func(t *testing.T) { paths := []string{ setupChecksumTestFile(t, "content one"), @@ -139,7 +143,7 @@ func TestChecksumAll_Good(t *testing.T) { {Path: paths[2], OS: "windows", Arch: "amd64"}, } - results, err := ChecksumAll(artifacts) + results, err := ChecksumAll(fs, artifacts) require.NoError(t, err) require.Len(t, results, 3) @@ -152,19 +156,20 @@ func TestChecksumAll_Good(t *testing.T) { }) t.Run("returns nil for empty slice", func(t *testing.T) { - results, err := ChecksumAll([]Artifact{}) + results, err := ChecksumAll(fs, []Artifact{}) assert.NoError(t, err) assert.Nil(t, results) }) t.Run("returns nil for nil slice", func(t *testing.T) { - results, err := ChecksumAll(nil) + results, err := ChecksumAll(fs, nil) assert.NoError(t, err) assert.Nil(t, results) }) } func TestChecksumAll_Bad(t *testing.T) { + fs := io.Local t.Run("returns partial results on error", func(t *testing.T) { path := setupChecksumTestFile(t, "valid content") @@ -173,7 +178,7 @@ func TestChecksumAll_Bad(t *testing.T) { {Path: "/nonexistent/file", OS: "linux", Arch: "arm64"}, // This will fail } - results, err := ChecksumAll(artifacts) + results, err := ChecksumAll(fs, artifacts) assert.Error(t, err) // Should have the first successful result assert.Len(t, results, 1) @@ -182,6 +187,7 @@ func TestChecksumAll_Bad(t *testing.T) { } func TestWriteChecksumFile_Good(t *testing.T) { + fs := io.Local t.Run("writes checksum file with correct format", func(t *testing.T) { dir := t.TempDir() checksumPath := filepath.Join(dir, "CHECKSUMS.txt") @@ -191,7 +197,7 @@ func TestWriteChecksumFile_Good(t *testing.T) { {Path: "/output/app_darwin_arm64.tar.gz", Checksum: "789xyz000111", OS: "darwin", Arch: "arm64"}, } - err := WriteChecksumFile(artifacts, checksumPath) + err := WriteChecksumFile(fs, artifacts, checksumPath) require.NoError(t, err) // Read and verify content @@ -214,7 +220,7 @@ func TestWriteChecksumFile_Good(t *testing.T) { {Path: "/output/app.tar.gz", Checksum: "abc123", OS: "linux", Arch: "amd64"}, } - err := WriteChecksumFile(artifacts, checksumPath) + err := WriteChecksumFile(fs, artifacts, checksumPath) require.NoError(t, err) assert.FileExists(t, checksumPath) }) @@ -223,7 +229,7 @@ func TestWriteChecksumFile_Good(t *testing.T) { dir := t.TempDir() checksumPath := filepath.Join(dir, "CHECKSUMS.txt") - err := WriteChecksumFile([]Artifact{}, checksumPath) + err := WriteChecksumFile(fs, []Artifact{}, checksumPath) require.NoError(t, err) // File should not exist @@ -235,7 +241,7 @@ func TestWriteChecksumFile_Good(t *testing.T) { dir := t.TempDir() checksumPath := filepath.Join(dir, "CHECKSUMS.txt") - err := WriteChecksumFile(nil, checksumPath) + err := WriteChecksumFile(fs, nil, checksumPath) require.NoError(t, err) }) @@ -247,7 +253,7 @@ func TestWriteChecksumFile_Good(t *testing.T) { {Path: "/some/deep/nested/path/myapp_linux_amd64.tar.gz", Checksum: "checksum123", OS: "linux", Arch: "amd64"}, } - err := WriteChecksumFile(artifacts, checksumPath) + err := WriteChecksumFile(fs, artifacts, checksumPath) require.NoError(t, err) content, err := os.ReadFile(checksumPath) @@ -260,6 +266,7 @@ func TestWriteChecksumFile_Good(t *testing.T) { } func TestWriteChecksumFile_Bad(t *testing.T) { + fs := io.Local t.Run("returns error for artifact without checksum", func(t *testing.T) { dir := t.TempDir() checksumPath := filepath.Join(dir, "CHECKSUMS.txt") @@ -268,7 +275,7 @@ func TestWriteChecksumFile_Bad(t *testing.T) { {Path: "/output/app.tar.gz", Checksum: "", OS: "linux", Arch: "amd64"}, // No checksum } - err := WriteChecksumFile(artifacts, checksumPath) + err := WriteChecksumFile(fs, artifacts, checksumPath) assert.Error(t, err) assert.Contains(t, err.Error(), "has no checksum") }) diff --git a/pkg/build/config.go b/pkg/build/config.go index 3a396a0a..c777b697 100644 --- a/pkg/build/config.go +++ b/pkg/build/config.go @@ -8,6 +8,7 @@ import ( "path/filepath" "github.com/host-uk/core/pkg/build/signing" + "github.com/host-uk/core/pkg/io" "gopkg.in/yaml.v3" ) @@ -68,10 +69,10 @@ type TargetConfig struct { // LoadConfig loads build configuration from the .core/build.yaml file in the given directory. // If the config file does not exist, it returns DefaultConfig(). // Returns an error if the file exists but cannot be parsed. -func LoadConfig(dir string) (*BuildConfig, error) { +func LoadConfig(fs io.Medium, dir string) (*BuildConfig, error) { configPath := filepath.Join(dir, ConfigDir, ConfigFileName) - data, err := os.ReadFile(configPath) + content, err := fs.Read(configPath) if err != nil { if os.IsNotExist(err) { return DefaultConfig(), nil @@ -80,6 +81,7 @@ func LoadConfig(dir string) (*BuildConfig, error) { } var cfg BuildConfig + data := []byte(content) if err := yaml.Unmarshal(data, &cfg); err != nil { return nil, fmt.Errorf("build.LoadConfig: failed to parse config file: %w", err) } @@ -153,8 +155,8 @@ func ConfigPath(dir string) string { } // ConfigExists checks if a build config file exists in the given directory. -func ConfigExists(dir string) bool { - return fileExists(ConfigPath(dir)) +func ConfigExists(fs io.Medium, dir string) bool { + return fileExists(fs, ConfigPath(dir)) } // ToTargets converts TargetConfig slice to Target slice for use with builders. diff --git a/pkg/build/config_test.go b/pkg/build/config_test.go index 3b51c2e3..9a962e63 100644 --- a/pkg/build/config_test.go +++ b/pkg/build/config_test.go @@ -5,6 +5,7 @@ import ( "path/filepath" "testing" + "github.com/host-uk/core/pkg/io" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -28,6 +29,7 @@ func setupConfigTestDir(t *testing.T, configContent string) string { } func TestLoadConfig_Good(t *testing.T) { + fs := io.Local t.Run("loads valid config", func(t *testing.T) { content := ` version: 1 @@ -54,7 +56,7 @@ targets: ` dir := setupConfigTestDir(t, content) - cfg, err := LoadConfig(dir) + cfg, err := LoadConfig(fs, dir) require.NoError(t, err) require.NotNil(t, cfg) @@ -77,7 +79,7 @@ targets: t.Run("returns defaults when config file missing", func(t *testing.T) { dir := t.TempDir() - cfg, err := LoadConfig(dir) + cfg, err := LoadConfig(fs, dir) require.NoError(t, err) require.NotNil(t, cfg) @@ -98,7 +100,7 @@ project: ` dir := setupConfigTestDir(t, content) - cfg, err := LoadConfig(dir) + cfg, err := LoadConfig(fs, dir) require.NoError(t, err) require.NotNil(t, cfg) @@ -128,7 +130,7 @@ targets: ` dir := setupConfigTestDir(t, content) - cfg, err := LoadConfig(dir) + cfg, err := LoadConfig(fs, dir) require.NoError(t, err) require.NotNil(t, cfg) @@ -141,6 +143,7 @@ targets: } func TestLoadConfig_Bad(t *testing.T) { + fs := io.Local t.Run("returns error for invalid YAML", func(t *testing.T) { content := ` version: 1 @@ -149,7 +152,7 @@ project: ` dir := setupConfigTestDir(t, content) - cfg, err := LoadConfig(dir) + cfg, err := LoadConfig(fs, dir) assert.Error(t, err) assert.Nil(t, cfg) assert.Contains(t, err.Error(), "failed to parse config file") @@ -166,7 +169,7 @@ project: err = os.Mkdir(configPath, 0755) require.NoError(t, err) - cfg, err := LoadConfig(dir) + cfg, err := LoadConfig(fs, dir) assert.Error(t, err) assert.Nil(t, cfg) assert.Contains(t, err.Error(), "failed to read config file") @@ -217,19 +220,20 @@ func TestConfigPath_Good(t *testing.T) { } func TestConfigExists_Good(t *testing.T) { + fs := io.Local t.Run("returns true when config exists", func(t *testing.T) { dir := setupConfigTestDir(t, "version: 1") - assert.True(t, ConfigExists(dir)) + assert.True(t, ConfigExists(fs, dir)) }) t.Run("returns false when config missing", func(t *testing.T) { dir := t.TempDir() - assert.False(t, ConfigExists(dir)) + assert.False(t, ConfigExists(fs, dir)) }) t.Run("returns false when .core dir missing", func(t *testing.T) { dir := t.TempDir() - assert.False(t, ConfigExists(dir)) + assert.False(t, ConfigExists(fs, dir)) }) } @@ -249,7 +253,7 @@ sign: ` _ = os.WriteFile(filepath.Join(coreDir, "build.yaml"), []byte(configContent), 0644) - cfg, err := LoadConfig(tmpDir) + cfg, err := LoadConfig(io.Local, tmpDir) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -298,8 +302,12 @@ func TestBuildConfig_ToTargets_Good(t *testing.T) { // TestLoadConfig_Testdata tests loading from the testdata fixture. func TestLoadConfig_Testdata(t *testing.T) { + fs := io.Local + abs, err := filepath.Abs("testdata/config-project") + require.NoError(t, err) + t.Run("loads config-project fixture", func(t *testing.T) { - cfg, err := LoadConfig("testdata/config-project") + cfg, err := LoadConfig(fs, abs) require.NoError(t, err) require.NotNil(t, cfg) diff --git a/pkg/build/discovery.go b/pkg/build/discovery.go index ba90b4df..ea4ee121 100644 --- a/pkg/build/discovery.go +++ b/pkg/build/discovery.go @@ -1,9 +1,10 @@ package build import ( - "os" "path/filepath" "slices" + + "github.com/host-uk/core/pkg/io" ) // Marker files for project type detection. @@ -32,12 +33,12 @@ var markers = []projectMarker{ // Discover detects project types in the given directory by checking for marker files. // Returns a slice of detected project types, ordered by priority (most specific first). // For example, a Wails project returns [wails, go] since it has both wails.json and go.mod. -func Discover(dir string) ([]ProjectType, error) { +func Discover(fs io.Medium, dir string) ([]ProjectType, error) { var detected []ProjectType for _, m := range markers { path := filepath.Join(dir, m.file) - if fileExists(path) { + if fileExists(fs, path) { // Avoid duplicates (shouldn't happen with current markers, but defensive) if !slices.Contains(detected, m.projectType) { detected = append(detected, m.projectType) @@ -50,8 +51,8 @@ func Discover(dir string) ([]ProjectType, error) { // PrimaryType returns the most specific project type detected in the directory. // Returns empty string if no project type is detected. -func PrimaryType(dir string) (ProjectType, error) { - types, err := Discover(dir) +func PrimaryType(fs io.Medium, dir string) (ProjectType, error) { + types, err := Discover(fs, dir) if err != nil { return "", err } @@ -62,31 +63,27 @@ func PrimaryType(dir string) (ProjectType, error) { } // IsGoProject checks if the directory contains a Go project (go.mod or wails.json). -func IsGoProject(dir string) bool { - return fileExists(filepath.Join(dir, markerGoMod)) || - fileExists(filepath.Join(dir, markerWails)) +func IsGoProject(fs io.Medium, dir string) bool { + return fileExists(fs, filepath.Join(dir, markerGoMod)) || + fileExists(fs, filepath.Join(dir, markerWails)) } // IsWailsProject checks if the directory contains a Wails project. -func IsWailsProject(dir string) bool { - return fileExists(filepath.Join(dir, markerWails)) +func IsWailsProject(fs io.Medium, dir string) bool { + return fileExists(fs, filepath.Join(dir, markerWails)) } // IsNodeProject checks if the directory contains a Node.js project. -func IsNodeProject(dir string) bool { - return fileExists(filepath.Join(dir, markerNodePackage)) +func IsNodeProject(fs io.Medium, dir string) bool { + return fileExists(fs, filepath.Join(dir, markerNodePackage)) } // IsPHPProject checks if the directory contains a PHP project. -func IsPHPProject(dir string) bool { - return fileExists(filepath.Join(dir, markerComposer)) +func IsPHPProject(fs io.Medium, dir string) bool { + return fileExists(fs, filepath.Join(dir, markerComposer)) } // fileExists checks if a file exists and is not a directory. -func fileExists(path string) bool { - info, err := os.Stat(path) - if err != nil { - return false - } - return !info.IsDir() +func fileExists(fs io.Medium, path string) bool { + return fs.IsFile(path) } diff --git a/pkg/build/discovery_test.go b/pkg/build/discovery_test.go index dc1a1f9d..414b1a33 100644 --- a/pkg/build/discovery_test.go +++ b/pkg/build/discovery_test.go @@ -5,6 +5,7 @@ import ( "path/filepath" "testing" + "github.com/host-uk/core/pkg/io" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -22,52 +23,54 @@ func setupTestDir(t *testing.T, markers ...string) string { } func TestDiscover_Good(t *testing.T) { + fs := io.Local t.Run("detects Go project", func(t *testing.T) { dir := setupTestDir(t, "go.mod") - types, err := Discover(dir) + types, err := Discover(fs, dir) assert.NoError(t, err) assert.Equal(t, []ProjectType{ProjectTypeGo}, types) }) t.Run("detects Wails project with priority over Go", func(t *testing.T) { dir := setupTestDir(t, "wails.json", "go.mod") - types, err := Discover(dir) + types, err := Discover(fs, dir) assert.NoError(t, err) assert.Equal(t, []ProjectType{ProjectTypeWails, ProjectTypeGo}, types) }) t.Run("detects Node.js project", func(t *testing.T) { dir := setupTestDir(t, "package.json") - types, err := Discover(dir) + types, err := Discover(fs, dir) assert.NoError(t, err) assert.Equal(t, []ProjectType{ProjectTypeNode}, types) }) t.Run("detects PHP project", func(t *testing.T) { dir := setupTestDir(t, "composer.json") - types, err := Discover(dir) + types, err := Discover(fs, dir) assert.NoError(t, err) assert.Equal(t, []ProjectType{ProjectTypePHP}, types) }) t.Run("detects multiple project types", func(t *testing.T) { dir := setupTestDir(t, "go.mod", "package.json") - types, err := Discover(dir) + types, err := Discover(fs, dir) assert.NoError(t, err) assert.Equal(t, []ProjectType{ProjectTypeGo, ProjectTypeNode}, types) }) t.Run("empty directory returns empty slice", func(t *testing.T) { dir := t.TempDir() - types, err := Discover(dir) + types, err := Discover(fs, dir) assert.NoError(t, err) assert.Empty(t, types) }) } func TestDiscover_Bad(t *testing.T) { + fs := io.Local t.Run("non-existent directory returns empty slice", func(t *testing.T) { - types, err := Discover("/non/existent/path") + types, err := Discover(fs, "/non/existent/path") assert.NoError(t, err) // os.Stat fails silently in fileExists assert.Empty(t, types) }) @@ -78,85 +81,90 @@ func TestDiscover_Bad(t *testing.T) { err := os.Mkdir(filepath.Join(dir, "go.mod"), 0755) require.NoError(t, err) - types, err := Discover(dir) + types, err := Discover(fs, dir) assert.NoError(t, err) assert.Empty(t, types) }) } func TestPrimaryType_Good(t *testing.T) { + fs := io.Local t.Run("returns wails for wails project", func(t *testing.T) { dir := setupTestDir(t, "wails.json", "go.mod") - primary, err := PrimaryType(dir) + primary, err := PrimaryType(fs, dir) assert.NoError(t, err) assert.Equal(t, ProjectTypeWails, primary) }) t.Run("returns go for go-only project", func(t *testing.T) { dir := setupTestDir(t, "go.mod") - primary, err := PrimaryType(dir) + primary, err := PrimaryType(fs, dir) assert.NoError(t, err) assert.Equal(t, ProjectTypeGo, primary) }) t.Run("returns empty string for empty directory", func(t *testing.T) { dir := t.TempDir() - primary, err := PrimaryType(dir) + primary, err := PrimaryType(fs, dir) assert.NoError(t, err) assert.Empty(t, primary) }) } func TestIsGoProject_Good(t *testing.T) { + fs := io.Local t.Run("true with go.mod", func(t *testing.T) { dir := setupTestDir(t, "go.mod") - assert.True(t, IsGoProject(dir)) + assert.True(t, IsGoProject(fs, dir)) }) t.Run("true with wails.json", func(t *testing.T) { dir := setupTestDir(t, "wails.json") - assert.True(t, IsGoProject(dir)) + assert.True(t, IsGoProject(fs, dir)) }) t.Run("false without markers", func(t *testing.T) { dir := t.TempDir() - assert.False(t, IsGoProject(dir)) + assert.False(t, IsGoProject(fs, dir)) }) } func TestIsWailsProject_Good(t *testing.T) { + fs := io.Local t.Run("true with wails.json", func(t *testing.T) { dir := setupTestDir(t, "wails.json") - assert.True(t, IsWailsProject(dir)) + assert.True(t, IsWailsProject(fs, dir)) }) t.Run("false with only go.mod", func(t *testing.T) { dir := setupTestDir(t, "go.mod") - assert.False(t, IsWailsProject(dir)) + assert.False(t, IsWailsProject(fs, dir)) }) } func TestIsNodeProject_Good(t *testing.T) { + fs := io.Local t.Run("true with package.json", func(t *testing.T) { dir := setupTestDir(t, "package.json") - assert.True(t, IsNodeProject(dir)) + assert.True(t, IsNodeProject(fs, dir)) }) t.Run("false without package.json", func(t *testing.T) { dir := t.TempDir() - assert.False(t, IsNodeProject(dir)) + assert.False(t, IsNodeProject(fs, dir)) }) } func TestIsPHPProject_Good(t *testing.T) { + fs := io.Local t.Run("true with composer.json", func(t *testing.T) { dir := setupTestDir(t, "composer.json") - assert.True(t, IsPHPProject(dir)) + assert.True(t, IsPHPProject(fs, dir)) }) t.Run("false without composer.json", func(t *testing.T) { dir := t.TempDir() - assert.False(t, IsPHPProject(dir)) + assert.False(t, IsPHPProject(fs, dir)) }) } @@ -166,28 +174,31 @@ func TestTarget_Good(t *testing.T) { } func TestFileExists_Good(t *testing.T) { + fs := io.Local t.Run("returns true for existing file", func(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "test.txt") err := os.WriteFile(path, []byte("content"), 0644) require.NoError(t, err) - assert.True(t, fileExists(path)) + assert.True(t, fileExists(fs, path)) }) t.Run("returns false for directory", func(t *testing.T) { dir := t.TempDir() - assert.False(t, fileExists(dir)) + assert.False(t, fileExists(fs, dir)) }) t.Run("returns false for non-existent path", func(t *testing.T) { - assert.False(t, fileExists("/non/existent/file")) + assert.False(t, fileExists(fs, "/non/existent/file")) }) } // TestDiscover_Testdata tests discovery using the testdata fixtures. // These serve as integration tests with realistic project structures. func TestDiscover_Testdata(t *testing.T) { - testdataDir := "testdata" + fs := io.Local + testdataDir, err := filepath.Abs("testdata") + require.NoError(t, err) tests := []struct { name string @@ -205,7 +216,7 @@ func TestDiscover_Testdata(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { dir := filepath.Join(testdataDir, tt.dir) - types, err := Discover(dir) + types, err := Discover(fs, dir) assert.NoError(t, err) if len(tt.expected) == 0 { assert.Empty(t, types) diff --git a/pkg/build/signing/codesign.go b/pkg/build/signing/codesign.go index 81f8325f..11581c7d 100644 --- a/pkg/build/signing/codesign.go +++ b/pkg/build/signing/codesign.go @@ -3,9 +3,10 @@ package signing import ( "context" "fmt" - "os" "os/exec" "runtime" + + "github.com/host-uk/core/pkg/io" ) // MacOSSigner signs binaries using macOS codesign. @@ -39,7 +40,7 @@ func (s *MacOSSigner) Available() bool { } // Sign codesigns a binary with hardened runtime. -func (s *MacOSSigner) Sign(ctx context.Context, binary string) error { +func (s *MacOSSigner) Sign(ctx context.Context, fs io.Medium, binary string) error { if !s.Available() { return fmt.Errorf("codesign.Sign: codesign not available") } @@ -62,7 +63,7 @@ func (s *MacOSSigner) Sign(ctx context.Context, binary string) error { // Notarize submits binary to Apple for notarization and staples the ticket. // This blocks until Apple responds (typically 1-5 minutes). -func (s *MacOSSigner) Notarize(ctx context.Context, binary string) error { +func (s *MacOSSigner) Notarize(ctx context.Context, fs io.Medium, binary string) error { if s.config.AppleID == "" || s.config.TeamID == "" || s.config.AppPassword == "" { return fmt.Errorf("codesign.Notarize: missing Apple credentials (apple_id, team_id, app_password)") } @@ -73,7 +74,7 @@ func (s *MacOSSigner) Notarize(ctx context.Context, binary string) error { if output, err := zipCmd.CombinedOutput(); err != nil { return fmt.Errorf("codesign.Notarize: failed to create zip: %w\nOutput: %s", err, string(output)) } - defer func() { _ = os.Remove(zipPath) }() + defer func() { _ = fs.Delete(zipPath) }() // Submit to Apple and wait submitCmd := exec.CommandContext(ctx, "xcrun", "notarytool", "submit", diff --git a/pkg/build/signing/codesign_test.go b/pkg/build/signing/codesign_test.go index 61dcb418..49ffc18e 100644 --- a/pkg/build/signing/codesign_test.go +++ b/pkg/build/signing/codesign_test.go @@ -5,6 +5,7 @@ import ( "runtime" "testing" + "github.com/host-uk/core/pkg/io" "github.com/stretchr/testify/assert" ) @@ -34,17 +35,19 @@ func TestMacOSSigner_Sign_Bad(t *testing.T) { if runtime.GOOS == "darwin" { t.Skip("skipping on macOS") } + fs := io.Local s := NewMacOSSigner(MacOSConfig{Identity: "test"}) - err := s.Sign(context.Background(), "test") + err := s.Sign(context.Background(), fs, "test") assert.Error(t, err) assert.Contains(t, err.Error(), "not available") }) } func TestMacOSSigner_Notarize_Bad(t *testing.T) { + fs := io.Local t.Run("fails with missing credentials", func(t *testing.T) { s := NewMacOSSigner(MacOSConfig{}) - err := s.Notarize(context.Background(), "test") + err := s.Notarize(context.Background(), fs, "test") assert.Error(t, err) assert.Contains(t, err.Error(), "missing Apple credentials") }) diff --git a/pkg/build/signing/gpg.go b/pkg/build/signing/gpg.go index 80f48fb7..eb61bbc3 100644 --- a/pkg/build/signing/gpg.go +++ b/pkg/build/signing/gpg.go @@ -4,6 +4,8 @@ import ( "context" "fmt" "os/exec" + + "github.com/host-uk/core/pkg/io" ) // GPGSigner signs files using GPG. @@ -35,7 +37,7 @@ func (s *GPGSigner) Available() bool { // Sign creates a detached ASCII-armored signature. // For file.txt, creates file.txt.asc -func (s *GPGSigner) Sign(ctx context.Context, file string) error { +func (s *GPGSigner) Sign(ctx context.Context, fs io.Medium, file string) error { if !s.Available() { return fmt.Errorf("gpg.Sign: gpg not available or key not configured") } diff --git a/pkg/build/signing/gpg_test.go b/pkg/build/signing/gpg_test.go index 6293f7c0..d44d39ae 100644 --- a/pkg/build/signing/gpg_test.go +++ b/pkg/build/signing/gpg_test.go @@ -4,6 +4,7 @@ import ( "context" "testing" + "github.com/host-uk/core/pkg/io" "github.com/stretchr/testify/assert" ) @@ -23,9 +24,10 @@ func TestGPGSigner_Bad_NoKey(t *testing.T) { } func TestGPGSigner_Sign_Bad(t *testing.T) { + fs := io.Local t.Run("fails when no key", func(t *testing.T) { s := NewGPGSigner("") - err := s.Sign(context.Background(), "test.txt") + err := s.Sign(context.Background(), fs, "test.txt") assert.Error(t, err) assert.Contains(t, err.Error(), "not available or key not configured") }) diff --git a/pkg/build/signing/sign.go b/pkg/build/signing/sign.go index 65e82c96..a2122565 100644 --- a/pkg/build/signing/sign.go +++ b/pkg/build/signing/sign.go @@ -4,6 +4,8 @@ import ( "context" "fmt" "runtime" + + "github.com/host-uk/core/pkg/io" ) // Artifact represents a build output that can be signed. @@ -16,7 +18,7 @@ type Artifact struct { // SignBinaries signs macOS binaries in the artifacts list. // Only signs darwin binaries when running on macOS with a configured identity. -func SignBinaries(ctx context.Context, cfg SignConfig, artifacts []Artifact) error { +func SignBinaries(ctx context.Context, fs io.Medium, cfg SignConfig, artifacts []Artifact) error { if !cfg.Enabled { return nil } @@ -37,7 +39,7 @@ func SignBinaries(ctx context.Context, cfg SignConfig, artifacts []Artifact) err } fmt.Printf(" Signing %s...\n", artifact.Path) - if err := signer.Sign(ctx, artifact.Path); err != nil { + if err := signer.Sign(ctx, fs, artifact.Path); err != nil { return fmt.Errorf("failed to sign %s: %w", artifact.Path, err) } } @@ -46,7 +48,7 @@ func SignBinaries(ctx context.Context, cfg SignConfig, artifacts []Artifact) err } // NotarizeBinaries notarizes macOS binaries if enabled. -func NotarizeBinaries(ctx context.Context, cfg SignConfig, artifacts []Artifact) error { +func NotarizeBinaries(ctx context.Context, fs io.Medium, cfg SignConfig, artifacts []Artifact) error { if !cfg.Enabled || !cfg.MacOS.Notarize { return nil } @@ -66,7 +68,7 @@ func NotarizeBinaries(ctx context.Context, cfg SignConfig, artifacts []Artifact) } fmt.Printf(" Notarizing %s (this may take a few minutes)...\n", artifact.Path) - if err := signer.Notarize(ctx, artifact.Path); err != nil { + if err := signer.Notarize(ctx, fs, artifact.Path); err != nil { return fmt.Errorf("failed to notarize %s: %w", artifact.Path, err) } } @@ -75,7 +77,7 @@ func NotarizeBinaries(ctx context.Context, cfg SignConfig, artifacts []Artifact) } // SignChecksums signs the checksums file with GPG. -func SignChecksums(ctx context.Context, cfg SignConfig, checksumFile string) error { +func SignChecksums(ctx context.Context, fs io.Medium, cfg SignConfig, checksumFile string) error { if !cfg.Enabled { return nil } @@ -86,7 +88,7 @@ func SignChecksums(ctx context.Context, cfg SignConfig, checksumFile string) err } fmt.Printf(" Signing %s with GPG...\n", checksumFile) - if err := signer.Sign(ctx, checksumFile); err != nil { + if err := signer.Sign(ctx, fs, checksumFile); err != nil { return fmt.Errorf("failed to sign checksums: %w", err) } diff --git a/pkg/build/signing/signer.go b/pkg/build/signing/signer.go index 80213a9d..4ec6ddd1 100644 --- a/pkg/build/signing/signer.go +++ b/pkg/build/signing/signer.go @@ -5,6 +5,8 @@ import ( "context" "os" "strings" + + "github.com/host-uk/core/pkg/io" ) // Signer defines the interface for code signing implementations. @@ -14,7 +16,7 @@ type Signer interface { // Available checks if this signer can be used. Available() bool // Sign signs the artifact at the given path. - Sign(ctx context.Context, path string) error + Sign(ctx context.Context, fs io.Medium, path string) error } // SignConfig holds signing configuration from .core/build.yaml. diff --git a/pkg/build/signing/signing_test.go b/pkg/build/signing/signing_test.go index 90a09ee8..d581df2b 100644 --- a/pkg/build/signing/signing_test.go +++ b/pkg/build/signing/signing_test.go @@ -5,11 +5,13 @@ import ( "runtime" "testing" + "github.com/host-uk/core/pkg/io" "github.com/stretchr/testify/assert" ) func TestSignBinaries_Good_SkipsNonDarwin(t *testing.T) { ctx := context.Background() + fs := io.Local cfg := SignConfig{ Enabled: true, MacOS: MacOSConfig{ @@ -23,7 +25,7 @@ func TestSignBinaries_Good_SkipsNonDarwin(t *testing.T) { } // Should not error even though binary doesn't exist (skips non-darwin) - err := SignBinaries(ctx, cfg, artifacts) + err := SignBinaries(ctx, fs, cfg, artifacts) if err != nil { t.Errorf("unexpected error: %v", err) } @@ -31,6 +33,7 @@ func TestSignBinaries_Good_SkipsNonDarwin(t *testing.T) { func TestSignBinaries_Good_DisabledConfig(t *testing.T) { ctx := context.Background() + fs := io.Local cfg := SignConfig{ Enabled: false, } @@ -39,7 +42,7 @@ func TestSignBinaries_Good_DisabledConfig(t *testing.T) { {Path: "/tmp/test-binary", OS: "darwin", Arch: "arm64"}, } - err := SignBinaries(ctx, cfg, artifacts) + err := SignBinaries(ctx, fs, cfg, artifacts) if err != nil { t.Errorf("unexpected error: %v", err) } @@ -51,6 +54,7 @@ func TestSignBinaries_Good_SkipsOnNonMacOS(t *testing.T) { } ctx := context.Background() + fs := io.Local cfg := SignConfig{ Enabled: true, MacOS: MacOSConfig{ @@ -62,7 +66,7 @@ func TestSignBinaries_Good_SkipsOnNonMacOS(t *testing.T) { {Path: "/tmp/test-binary", OS: "darwin", Arch: "arm64"}, } - err := SignBinaries(ctx, cfg, artifacts) + err := SignBinaries(ctx, fs, cfg, artifacts) if err != nil { t.Errorf("unexpected error: %v", err) } @@ -70,6 +74,7 @@ func TestSignBinaries_Good_SkipsOnNonMacOS(t *testing.T) { func TestNotarizeBinaries_Good_DisabledConfig(t *testing.T) { ctx := context.Background() + fs := io.Local cfg := SignConfig{ Enabled: false, } @@ -78,7 +83,7 @@ func TestNotarizeBinaries_Good_DisabledConfig(t *testing.T) { {Path: "/tmp/test-binary", OS: "darwin", Arch: "arm64"}, } - err := NotarizeBinaries(ctx, cfg, artifacts) + err := NotarizeBinaries(ctx, fs, cfg, artifacts) if err != nil { t.Errorf("unexpected error: %v", err) } @@ -86,6 +91,7 @@ func TestNotarizeBinaries_Good_DisabledConfig(t *testing.T) { func TestNotarizeBinaries_Good_NotarizeDisabled(t *testing.T) { ctx := context.Background() + fs := io.Local cfg := SignConfig{ Enabled: true, MacOS: MacOSConfig{ @@ -97,7 +103,7 @@ func TestNotarizeBinaries_Good_NotarizeDisabled(t *testing.T) { {Path: "/tmp/test-binary", OS: "darwin", Arch: "arm64"}, } - err := NotarizeBinaries(ctx, cfg, artifacts) + err := NotarizeBinaries(ctx, fs, cfg, artifacts) if err != nil { t.Errorf("unexpected error: %v", err) } @@ -105,6 +111,7 @@ func TestNotarizeBinaries_Good_NotarizeDisabled(t *testing.T) { func TestSignChecksums_Good_SkipsNoKey(t *testing.T) { ctx := context.Background() + fs := io.Local cfg := SignConfig{ Enabled: true, GPG: GPGConfig{ @@ -113,7 +120,7 @@ func TestSignChecksums_Good_SkipsNoKey(t *testing.T) { } // Should silently skip when no key - err := SignChecksums(ctx, cfg, "/tmp/CHECKSUMS.txt") + err := SignChecksums(ctx, fs, cfg, "/tmp/CHECKSUMS.txt") if err != nil { t.Errorf("unexpected error: %v", err) } @@ -121,11 +128,12 @@ func TestSignChecksums_Good_SkipsNoKey(t *testing.T) { func TestSignChecksums_Good_Disabled(t *testing.T) { ctx := context.Background() + fs := io.Local cfg := SignConfig{ Enabled: false, } - err := SignChecksums(ctx, cfg, "/tmp/CHECKSUMS.txt") + err := SignChecksums(ctx, fs, cfg, "/tmp/CHECKSUMS.txt") if err != nil { t.Errorf("unexpected error: %v", err) } @@ -146,8 +154,9 @@ func TestSignConfig_ExpandEnv(t *testing.T) { } func TestWindowsSigner_Good(t *testing.T) { + fs := io.Local s := NewWindowsSigner(WindowsConfig{}) assert.Equal(t, "signtool", s.Name()) assert.False(t, s.Available()) - assert.NoError(t, s.Sign(context.Background(), "test.exe")) + assert.NoError(t, s.Sign(context.Background(), fs, "test.exe")) } diff --git a/pkg/build/signing/signtool.go b/pkg/build/signing/signtool.go index 9d426b6a..5e3c790f 100644 --- a/pkg/build/signing/signtool.go +++ b/pkg/build/signing/signtool.go @@ -2,6 +2,8 @@ package signing import ( "context" + + "github.com/host-uk/core/pkg/io" ) // WindowsSigner signs binaries using Windows signtool (placeholder). @@ -28,7 +30,7 @@ func (s *WindowsSigner) Available() bool { } // Sign is a placeholder that does nothing. -func (s *WindowsSigner) Sign(ctx context.Context, binary string) error { +func (s *WindowsSigner) Sign(ctx context.Context, fs io.Medium, binary string) error { // TODO: Implement Windows signing return nil } diff --git a/pkg/io/io.go b/pkg/io/io.go index 2436452e..36b907c6 100644 --- a/pkg/io/io.go +++ b/pkg/io/io.go @@ -22,9 +22,6 @@ type Medium interface { // Write saves the given content to a file, overwriting it if it exists. Write(path, content string) error - // Open opens a file for reading. - Open(path string) (goio.ReadCloser, error) - // EnsureDir makes sure a directory exists, creating it if necessary. EnsureDir(path string) error @@ -52,6 +49,12 @@ type Medium interface { // Stat returns file information for the given path. Stat(path string) (fs.FileInfo, error) + // Open opens the named file for reading. + Open(path string) (fs.File, error) + + // Create creates or truncates the named file. + Create(path string) (goio.WriteCloser, error) + // Exists checks if a path exists (file or directory). Exists(path string) bool @@ -173,15 +176,6 @@ func (m *MockMedium) Write(path, content string) error { return nil } -// Open opens a file for reading in the mock filesystem. -func (m *MockMedium) Open(path string) (goio.ReadCloser, error) { - content, ok := m.Files[path] - if !ok { - return nil, coreerr.E("io.MockMedium.Open", "file not found: "+path, os.ErrNotExist) - } - return goio.NopCloser(strings.NewReader(content)), nil -} - // EnsureDir records that a directory exists in the mock filesystem. func (m *MockMedium) EnsureDir(path string) error { m.Dirs[path] = true @@ -320,6 +314,70 @@ func (m *MockMedium) Rename(oldPath, newPath string) error { return coreerr.E("io.MockMedium.Rename", "path not found: "+oldPath, os.ErrNotExist) } +// Open opens a file from the mock filesystem. +func (m *MockMedium) Open(path string) (fs.File, error) { + content, ok := m.Files[path] + if !ok { + return nil, coreerr.E("io.MockMedium.Open", "file not found: "+path, os.ErrNotExist) + } + return &MockFile{ + name: filepath.Base(path), + content: []byte(content), + }, nil +} + +// Create creates a file in the mock filesystem. +func (m *MockMedium) Create(path string) (goio.WriteCloser, error) { + return &MockWriteCloser{ + medium: m, + path: path, + }, nil +} + +// MockFile implements fs.File for MockMedium. +type MockFile struct { + name string + content []byte + offset int64 +} + +func (f *MockFile) Stat() (fs.FileInfo, error) { + return FileInfo{ + name: f.name, + size: int64(len(f.content)), + }, nil +} + +func (f *MockFile) Read(b []byte) (int, error) { + if f.offset >= int64(len(f.content)) { + return 0, goio.EOF + } + n := copy(b, f.content[f.offset:]) + f.offset += int64(n) + return n, nil +} + +func (f *MockFile) Close() error { + return nil +} + +// MockWriteCloser implements WriteCloser for MockMedium. +type MockWriteCloser struct { + medium *MockMedium + path string + data []byte +} + +func (w *MockWriteCloser) Write(p []byte) (int, error) { + w.data = append(w.data, p...) + return len(p), nil +} + +func (w *MockWriteCloser) Close() error { + w.medium.Files[w.path] = string(w.data) + return nil +} + // List returns directory entries for the mock filesystem. func (m *MockMedium) List(path string) ([]fs.DirEntry, error) { if _, ok := m.Dirs[path]; !ok { diff --git a/pkg/io/local/client.go b/pkg/io/local/client.go index 771152cd..872b9617 100644 --- a/pkg/io/local/client.go +++ b/pkg/io/local/client.go @@ -30,6 +30,15 @@ func (m *Medium) path(p string) string { if p == "" { return m.root } + + // If the path is relative and the medium is rooted at "/", + // treat it as relative to the current working directory. + // This makes io.Local behave more like the standard 'os' package. + if m.root == "/" && !filepath.IsAbs(p) { + cwd, _ := os.Getwd() + return filepath.Join(cwd, p) + } + // Use filepath.Clean with a leading slash to resolve all .. and . internally // before joining with the root. This is a standard way to sandbox paths. clean := filepath.Clean("/" + p) @@ -39,6 +48,7 @@ func (m *Medium) path(p string) string { return clean } + // Join cleaned relative path with root return filepath.Join(m.root, clean) } @@ -107,11 +117,6 @@ func (m *Medium) Write(p, content string) error { return os.WriteFile(full, []byte(content), 0644) } -// Open opens a file for reading. -func (m *Medium) Open(p string) (goio.ReadCloser, error) { - return os.Open(m.path(p)) -} - // EnsureDir creates directory if it doesn't exist. func (m *Medium) EnsureDir(p string) error { full, err := m.validatePath(p) @@ -175,6 +180,27 @@ func (m *Medium) Stat(p string) (fs.FileInfo, error) { return os.Stat(full) } +// Open opens the named file for reading. +func (m *Medium) Open(p string) (fs.File, error) { + full, err := m.validatePath(p) + if err != nil { + return nil, err + } + return os.Open(full) +} + +// Create creates or truncates the named file. +func (m *Medium) Create(p string) (goio.WriteCloser, error) { + full, err := m.validatePath(p) + if err != nil { + return nil, err + } + if err := os.MkdirAll(filepath.Dir(full), 0755); err != nil { + return nil, err + } + return os.Create(full) +} + // Delete removes a file or empty directory. func (m *Medium) Delete(p string) error { full, err := m.validatePath(p) diff --git a/pkg/io/local/client_test.go b/pkg/io/local/client_test.go index 5308cdb1..7471174c 100644 --- a/pkg/io/local/client_test.go +++ b/pkg/io/local/client_test.go @@ -40,8 +40,9 @@ func TestPath_RootFilesystem(t *testing.T) { assert.Equal(t, "/etc/passwd", m.path("/etc/passwd")) assert.Equal(t, "/home/user/file.txt", m.path("/home/user/file.txt")) - // Relative paths still work - assert.Equal(t, "/file.txt", m.path("file.txt")) + // Relative paths are relative to CWD when root is "/" + cwd, _ := os.Getwd() + assert.Equal(t, filepath.Join(cwd, "file.txt"), m.path("file.txt")) } func TestReadWrite(t *testing.T) { diff --git a/pkg/release/release.go b/pkg/release/release.go index 2538f8a5..7237ffd8 100644 --- a/pkg/release/release.go +++ b/pkg/release/release.go @@ -7,7 +7,6 @@ import ( "context" "fmt" "path/filepath" - "sort" "strings" "github.com/host-uk/core/pkg/build" @@ -216,9 +215,9 @@ func Run(ctx context.Context, cfg *Config, dryRun bool) (*Release, error) { } // buildArtifacts builds all artifacts for the release. -func buildArtifacts(ctx context.Context, m io.Medium, cfg *Config, projectDir, version string) ([]build.Artifact, error) { +func buildArtifacts(ctx context.Context, fs io.Medium, cfg *Config, projectDir, version string) ([]build.Artifact, error) { // Load build configuration - buildCfg, err := build.LoadConfig(projectDir) + buildCfg, err := build.LoadConfig(fs, projectDir) if err != nil { return nil, fmt.Errorf("failed to load build config: %w", err) } @@ -257,7 +256,7 @@ func buildArtifacts(ctx context.Context, m io.Medium, cfg *Config, projectDir, v outputDir := filepath.Join(projectDir, "dist") // Get builder (detect project type) - projectType, err := build.PrimaryType(projectDir) + projectType, err := build.PrimaryType(fs, projectDir) if err != nil { return nil, fmt.Errorf("failed to detect project type: %w", err) } @@ -269,6 +268,7 @@ func buildArtifacts(ctx context.Context, m io.Medium, cfg *Config, projectDir, v // Build configuration buildConfig := &build.Config{ + FS: fs, ProjectDir: projectDir, OutputDir: outputDir, Name: binaryName, @@ -283,29 +283,20 @@ func buildArtifacts(ctx context.Context, m io.Medium, cfg *Config, projectDir, v } // Archive artifacts - archivedArtifacts, err := build.ArchiveAll(artifacts) + archivedArtifacts, err := build.ArchiveAll(fs, artifacts) if err != nil { return nil, fmt.Errorf("archive failed: %w", err) } // Compute checksums - checksummedArtifacts, err := build.ChecksumAll(archivedArtifacts) + checksummedArtifacts, err := build.ChecksumAll(fs, archivedArtifacts) if err != nil { return nil, fmt.Errorf("checksum failed: %w", err) } // Write CHECKSUMS.txt checksumPath := filepath.Join(outputDir, "CHECKSUMS.txt") - var lines []string - for _, artifact := range checksummedArtifacts { - if artifact.Checksum != "" { - lines = append(lines, fmt.Sprintf("%s %s", artifact.Checksum, filepath.Base(artifact.Path))) - } - } - sort.Strings(lines) - content := strings.Join(lines, "\n") + "\n" - - if err := m.Write(checksumPath, content); err != nil { + if err := build.WriteChecksumFile(fs, checksummedArtifacts, checksumPath); err != nil { return nil, fmt.Errorf("failed to write checksums file: %w", err) }