diff --git a/internal/cmd/dev/cmd_vm.go b/internal/cmd/dev/cmd_vm.go index 71a4ac23..52ef2104 100644 --- a/internal/cmd/dev/cmd_vm.go +++ b/internal/cmd/dev/cmd_vm.go @@ -9,6 +9,7 @@ import ( "github.com/host-uk/core/pkg/cli" "github.com/host-uk/core/pkg/devops" "github.com/host-uk/core/pkg/i18n" + "github.com/host-uk/core/pkg/io" ) // addVMCommands adds the dev environment VM commands to the dev parent command. @@ -40,7 +41,7 @@ func addVMInstallCommand(parent *cli.Command) { } func runVMInstall() error { - d, err := devops.New() + d, err := devops.New(io.Local) if err != nil { return err } @@ -112,7 +113,7 @@ func addVMBootCommand(parent *cli.Command) { } func runVMBoot(memory, cpus int, fresh bool) error { - d, err := devops.New() + d, err := devops.New(io.Local) if err != nil { return err } @@ -163,7 +164,7 @@ func addVMStopCommand(parent *cli.Command) { } func runVMStop() error { - d, err := devops.New() + d, err := devops.New(io.Local) if err != nil { return err } @@ -204,7 +205,7 @@ func addVMStatusCommand(parent *cli.Command) { } func runVMStatus() error { - d, err := devops.New() + d, err := devops.New(io.Local) if err != nil { return err } @@ -283,7 +284,7 @@ func addVMShellCommand(parent *cli.Command) { } func runVMShell(console bool, command []string) error { - d, err := devops.New() + d, err := devops.New(io.Local) if err != nil { return err } @@ -321,7 +322,7 @@ func addVMServeCommand(parent *cli.Command) { } func runVMServe(port int, path string) error { - d, err := devops.New() + d, err := devops.New(io.Local) if err != nil { return err } @@ -360,7 +361,7 @@ func addVMTestCommand(parent *cli.Command) { } func runVMTest(name string, command []string) error { - d, err := devops.New() + d, err := devops.New(io.Local) if err != nil { return err } @@ -405,7 +406,7 @@ func addVMClaudeCommand(parent *cli.Command) { } func runVMClaude(noAuth bool, model string, authFlags []string) error { - d, err := devops.New() + d, err := devops.New(io.Local) if err != nil { return err } @@ -445,7 +446,7 @@ func addVMUpdateCommand(parent *cli.Command) { } func runVMUpdate(apply bool) error { - d, err := devops.New() + d, err := devops.New(io.Local) if err != nil { return err } diff --git a/pkg/devops/config.go b/pkg/devops/config.go index ab91790c..ee6a5178 100644 --- a/pkg/devops/config.go +++ b/pkg/devops/config.go @@ -62,15 +62,15 @@ func ConfigPath() (string, error) { return filepath.Join(home, ".core", "config.yaml"), nil } -// LoadConfig loads configuration from ~/.core/config.yaml. +// LoadConfig loads configuration from ~/.core/config.yaml using the provided medium. // Returns default config if file doesn't exist. -func LoadConfig() (*Config, error) { +func LoadConfig(m io.Medium) (*Config, error) { configPath, err := ConfigPath() if err != nil { return DefaultConfig(), nil } - content, err := io.Local.Read(configPath) + content, err := m.Read(configPath) if err != nil { if os.IsNotExist(err) { return DefaultConfig(), nil diff --git a/pkg/devops/config_test.go b/pkg/devops/config_test.go index cdd4ec7b..5ca5fa2b 100644 --- a/pkg/devops/config_test.go +++ b/pkg/devops/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" ) @@ -30,7 +31,7 @@ func TestLoadConfig_Good(t *testing.T) { t.Setenv("HOME", tempHome) defer func() { _ = os.Setenv("HOME", origHome) }() - cfg, err := LoadConfig() + cfg, err := LoadConfig(io.Local) assert.NoError(t, err) assert.Equal(t, DefaultConfig(), cfg) }) @@ -53,7 +54,7 @@ images: err = os.WriteFile(filepath.Join(coreDir, "config.yaml"), []byte(configData), 0644) require.NoError(t, err) - cfg, err := LoadConfig() + cfg, err := LoadConfig(io.Local) assert.NoError(t, err) assert.Equal(t, 2, cfg.Version) assert.Equal(t, "cdn", cfg.Images.Source) @@ -73,7 +74,7 @@ func TestLoadConfig_Bad(t *testing.T) { err = os.WriteFile(filepath.Join(coreDir, "config.yaml"), []byte("invalid: yaml: :"), 0644) require.NoError(t, err) - _, err = LoadConfig() + _, err = LoadConfig(io.Local) assert.Error(t, err) }) } @@ -127,7 +128,7 @@ images: err = os.WriteFile(filepath.Join(coreDir, "config.yaml"), []byte(configData), 0644) require.NoError(t, err) - cfg, err := LoadConfig() + cfg, err := LoadConfig(io.Local) assert.NoError(t, err) assert.Equal(t, 1, cfg.Version) assert.Equal(t, "github", cfg.Images.Source) @@ -197,7 +198,7 @@ images: err = os.WriteFile(filepath.Join(coreDir, "config.yaml"), []byte(tt.config), 0644) require.NoError(t, err) - cfg, err := LoadConfig() + cfg, err := LoadConfig(io.Local) assert.NoError(t, err) tt.check(t, cfg) }) @@ -246,7 +247,7 @@ func TestLoadConfig_Bad_UnreadableFile(t *testing.T) { err = os.WriteFile(configPath, []byte("version: 1"), 0000) require.NoError(t, err) - _, err = LoadConfig() + _, err = LoadConfig(io.Local) assert.Error(t, err) // Restore permissions so cleanup works diff --git a/pkg/devops/devops.go b/pkg/devops/devops.go index 9b0491c4..39e87aec 100644 --- a/pkg/devops/devops.go +++ b/pkg/devops/devops.go @@ -15,19 +15,20 @@ import ( // DevOps manages the portable development environment. type DevOps struct { + medium io.Medium config *Config images *ImageManager container *container.LinuxKitManager } -// New creates a new DevOps instance. -func New() (*DevOps, error) { - cfg, err := LoadConfig() +// New creates a new DevOps instance using the provided medium. +func New(m io.Medium) (*DevOps, error) { + cfg, err := LoadConfig(m) if err != nil { return nil, fmt.Errorf("devops.New: failed to load config: %w", err) } - images, err := NewImageManager(cfg) + images, err := NewImageManager(m, cfg) if err != nil { return nil, fmt.Errorf("devops.New: failed to create image manager: %w", err) } @@ -38,6 +39,7 @@ func New() (*DevOps, error) { } return &DevOps{ + medium: m, config: cfg, images: images, container: mgr, @@ -76,7 +78,7 @@ func (d *DevOps) IsInstalled() bool { if err != nil { return false } - return io.Local.IsFile(path) + return d.medium.IsFile(path) } // Install downloads and installs the dev image. diff --git a/pkg/devops/devops_test.go b/pkg/devops/devops_test.go index 4b75b8d0..fb28675b 100644 --- a/pkg/devops/devops_test.go +++ b/pkg/devops/devops_test.go @@ -10,6 +10,7 @@ import ( "time" "github.com/host-uk/core/pkg/container" + "github.com/host-uk/core/pkg/io" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -69,7 +70,7 @@ func TestIsInstalled_Bad(t *testing.T) { t.Setenv("CORE_IMAGES_DIR", tempDir) // Create devops instance manually to avoid loading real config/images - d := &DevOps{} + d := &DevOps{medium: io.Local} assert.False(t, d.IsInstalled()) }) } @@ -84,7 +85,7 @@ func TestIsInstalled_Good(t *testing.T) { err := os.WriteFile(imagePath, []byte("fake image data"), 0644) require.NoError(t, err) - d := &DevOps{} + d := &DevOps{medium: io.Local} assert.True(t, d.IsInstalled()) }) } @@ -102,7 +103,7 @@ func TestDevOps_Status_Good(t *testing.T) { t.Setenv("CORE_IMAGES_DIR", tempDir) cfg := DefaultConfig() - mgr, err := NewImageManager(cfg) + mgr, err := NewImageManager(io.Local, cfg) require.NoError(t, err) // Setup mock container manager @@ -111,7 +112,7 @@ func TestDevOps_Status_Good(t *testing.T) { h := &mockHypervisor{} cm := container.NewLinuxKitManagerWithHypervisor(state, h) - d := &DevOps{ + d := &DevOps{medium: io.Local, images: mgr, container: cm, } @@ -143,7 +144,7 @@ func TestDevOps_Status_Good_NotInstalled(t *testing.T) { t.Setenv("CORE_IMAGES_DIR", tempDir) cfg := DefaultConfig() - mgr, err := NewImageManager(cfg) + mgr, err := NewImageManager(io.Local, cfg) require.NoError(t, err) statePath := filepath.Join(tempDir, "containers.json") @@ -151,7 +152,7 @@ func TestDevOps_Status_Good_NotInstalled(t *testing.T) { h := &mockHypervisor{} cm := container.NewLinuxKitManagerWithHypervisor(state, h) - d := &DevOps{ + d := &DevOps{medium: io.Local, images: mgr, container: cm, } @@ -174,7 +175,7 @@ func TestDevOps_Status_Good_NoContainer(t *testing.T) { require.NoError(t, err) cfg := DefaultConfig() - mgr, err := NewImageManager(cfg) + mgr, err := NewImageManager(io.Local, cfg) require.NoError(t, err) statePath := filepath.Join(tempDir, "containers.json") @@ -182,7 +183,7 @@ func TestDevOps_Status_Good_NoContainer(t *testing.T) { h := &mockHypervisor{} cm := container.NewLinuxKitManagerWithHypervisor(state, h) - d := &DevOps{ + d := &DevOps{medium: io.Local, images: mgr, container: cm, } @@ -200,7 +201,7 @@ func TestDevOps_IsRunning_Good(t *testing.T) { t.Setenv("CORE_IMAGES_DIR", tempDir) cfg := DefaultConfig() - mgr, err := NewImageManager(cfg) + mgr, err := NewImageManager(io.Local, cfg) require.NoError(t, err) statePath := filepath.Join(tempDir, "containers.json") @@ -208,7 +209,7 @@ func TestDevOps_IsRunning_Good(t *testing.T) { h := &mockHypervisor{} cm := container.NewLinuxKitManagerWithHypervisor(state, h) - d := &DevOps{ + d := &DevOps{medium: io.Local, images: mgr, container: cm, } @@ -233,7 +234,7 @@ func TestDevOps_IsRunning_Bad_NotRunning(t *testing.T) { t.Setenv("CORE_IMAGES_DIR", tempDir) cfg := DefaultConfig() - mgr, err := NewImageManager(cfg) + mgr, err := NewImageManager(io.Local, cfg) require.NoError(t, err) statePath := filepath.Join(tempDir, "containers.json") @@ -241,7 +242,7 @@ func TestDevOps_IsRunning_Bad_NotRunning(t *testing.T) { h := &mockHypervisor{} cm := container.NewLinuxKitManagerWithHypervisor(state, h) - d := &DevOps{ + d := &DevOps{medium: io.Local, images: mgr, container: cm, } @@ -256,7 +257,7 @@ func TestDevOps_IsRunning_Bad_ContainerStopped(t *testing.T) { t.Setenv("CORE_IMAGES_DIR", tempDir) cfg := DefaultConfig() - mgr, err := NewImageManager(cfg) + mgr, err := NewImageManager(io.Local, cfg) require.NoError(t, err) statePath := filepath.Join(tempDir, "containers.json") @@ -264,7 +265,7 @@ func TestDevOps_IsRunning_Bad_ContainerStopped(t *testing.T) { h := &mockHypervisor{} cm := container.NewLinuxKitManagerWithHypervisor(state, h) - d := &DevOps{ + d := &DevOps{medium: io.Local, images: mgr, container: cm, } @@ -289,7 +290,7 @@ func TestDevOps_findContainer_Good(t *testing.T) { t.Setenv("CORE_IMAGES_DIR", tempDir) cfg := DefaultConfig() - mgr, err := NewImageManager(cfg) + mgr, err := NewImageManager(io.Local, cfg) require.NoError(t, err) statePath := filepath.Join(tempDir, "containers.json") @@ -297,7 +298,7 @@ func TestDevOps_findContainer_Good(t *testing.T) { h := &mockHypervisor{} cm := container.NewLinuxKitManagerWithHypervisor(state, h) - d := &DevOps{ + d := &DevOps{medium: io.Local, images: mgr, container: cm, } @@ -324,7 +325,7 @@ func TestDevOps_findContainer_Bad_NotFound(t *testing.T) { t.Setenv("CORE_IMAGES_DIR", tempDir) cfg := DefaultConfig() - mgr, err := NewImageManager(cfg) + mgr, err := NewImageManager(io.Local, cfg) require.NoError(t, err) statePath := filepath.Join(tempDir, "containers.json") @@ -332,7 +333,7 @@ func TestDevOps_findContainer_Bad_NotFound(t *testing.T) { h := &mockHypervisor{} cm := container.NewLinuxKitManagerWithHypervisor(state, h) - d := &DevOps{ + d := &DevOps{medium: io.Local, images: mgr, container: cm, } @@ -347,7 +348,7 @@ func TestDevOps_Stop_Bad_NotFound(t *testing.T) { t.Setenv("CORE_IMAGES_DIR", tempDir) cfg := DefaultConfig() - mgr, err := NewImageManager(cfg) + mgr, err := NewImageManager(io.Local, cfg) require.NoError(t, err) statePath := filepath.Join(tempDir, "containers.json") @@ -355,7 +356,7 @@ func TestDevOps_Stop_Bad_NotFound(t *testing.T) { h := &mockHypervisor{} cm := container.NewLinuxKitManagerWithHypervisor(state, h) - d := &DevOps{ + d := &DevOps{medium: io.Local, images: mgr, container: cm, } @@ -404,7 +405,7 @@ func TestDevOps_Boot_Bad_NotInstalled(t *testing.T) { t.Setenv("CORE_IMAGES_DIR", tempDir) cfg := DefaultConfig() - mgr, err := NewImageManager(cfg) + mgr, err := NewImageManager(io.Local, cfg) require.NoError(t, err) statePath := filepath.Join(tempDir, "containers.json") @@ -412,7 +413,7 @@ func TestDevOps_Boot_Bad_NotInstalled(t *testing.T) { h := &mockHypervisor{} cm := container.NewLinuxKitManagerWithHypervisor(state, h) - d := &DevOps{ + d := &DevOps{medium: io.Local, images: mgr, container: cm, } @@ -432,7 +433,7 @@ func TestDevOps_Boot_Bad_AlreadyRunning(t *testing.T) { require.NoError(t, err) cfg := DefaultConfig() - mgr, err := NewImageManager(cfg) + mgr, err := NewImageManager(io.Local, cfg) require.NoError(t, err) statePath := filepath.Join(tempDir, "containers.json") @@ -440,7 +441,7 @@ func TestDevOps_Boot_Bad_AlreadyRunning(t *testing.T) { h := &mockHypervisor{} cm := container.NewLinuxKitManagerWithHypervisor(state, h) - d := &DevOps{ + d := &DevOps{medium: io.Local, images: mgr, container: cm, } @@ -471,7 +472,7 @@ func TestDevOps_Status_Good_WithImageVersion(t *testing.T) { require.NoError(t, err) cfg := DefaultConfig() - mgr, err := NewImageManager(cfg) + mgr, err := NewImageManager(io.Local, cfg) require.NoError(t, err) // Manually set manifest with version info @@ -485,7 +486,7 @@ func TestDevOps_Status_Good_WithImageVersion(t *testing.T) { h := &mockHypervisor{} cm := container.NewLinuxKitManagerWithHypervisor(state, h) - d := &DevOps{ + d := &DevOps{medium: io.Local, config: cfg, images: mgr, container: cm, @@ -502,7 +503,7 @@ func TestDevOps_findContainer_Good_MultipleContainers(t *testing.T) { t.Setenv("CORE_IMAGES_DIR", tempDir) cfg := DefaultConfig() - mgr, err := NewImageManager(cfg) + mgr, err := NewImageManager(io.Local, cfg) require.NoError(t, err) statePath := filepath.Join(tempDir, "containers.json") @@ -510,7 +511,7 @@ func TestDevOps_findContainer_Good_MultipleContainers(t *testing.T) { h := &mockHypervisor{} cm := container.NewLinuxKitManagerWithHypervisor(state, h) - d := &DevOps{ + d := &DevOps{medium: io.Local, images: mgr, container: cm, } @@ -547,7 +548,7 @@ func TestDevOps_Status_Good_ContainerWithUptime(t *testing.T) { t.Setenv("CORE_IMAGES_DIR", tempDir) cfg := DefaultConfig() - mgr, err := NewImageManager(cfg) + mgr, err := NewImageManager(io.Local, cfg) require.NoError(t, err) statePath := filepath.Join(tempDir, "containers.json") @@ -555,7 +556,7 @@ func TestDevOps_Status_Good_ContainerWithUptime(t *testing.T) { h := &mockHypervisor{} cm := container.NewLinuxKitManagerWithHypervisor(state, h) - d := &DevOps{ + d := &DevOps{medium: io.Local, images: mgr, container: cm, } @@ -584,7 +585,7 @@ func TestDevOps_IsRunning_Bad_DifferentContainerName(t *testing.T) { t.Setenv("CORE_IMAGES_DIR", tempDir) cfg := DefaultConfig() - mgr, err := NewImageManager(cfg) + mgr, err := NewImageManager(io.Local, cfg) require.NoError(t, err) statePath := filepath.Join(tempDir, "containers.json") @@ -592,7 +593,7 @@ func TestDevOps_IsRunning_Bad_DifferentContainerName(t *testing.T) { h := &mockHypervisor{} cm := container.NewLinuxKitManagerWithHypervisor(state, h) - d := &DevOps{ + d := &DevOps{medium: io.Local, images: mgr, container: cm, } @@ -626,7 +627,7 @@ func TestDevOps_Boot_Good_FreshFlag(t *testing.T) { require.NoError(t, err) cfg := DefaultConfig() - mgr, err := NewImageManager(cfg) + mgr, err := NewImageManager(io.Local, cfg) require.NoError(t, err) statePath := filepath.Join(tempDir, "containers.json") @@ -634,7 +635,7 @@ func TestDevOps_Boot_Good_FreshFlag(t *testing.T) { h := &mockHypervisor{} cm := container.NewLinuxKitManagerWithHypervisor(state, h) - d := &DevOps{ + d := &DevOps{medium: io.Local, images: mgr, container: cm, } @@ -668,7 +669,7 @@ func TestDevOps_Stop_Bad_ContainerNotRunning(t *testing.T) { t.Setenv("CORE_IMAGES_DIR", tempDir) cfg := DefaultConfig() - mgr, err := NewImageManager(cfg) + mgr, err := NewImageManager(io.Local, cfg) require.NoError(t, err) statePath := filepath.Join(tempDir, "containers.json") @@ -676,7 +677,7 @@ func TestDevOps_Stop_Bad_ContainerNotRunning(t *testing.T) { h := &mockHypervisor{} cm := container.NewLinuxKitManagerWithHypervisor(state, h) - d := &DevOps{ + d := &DevOps{medium: io.Local, images: mgr, container: cm, } @@ -710,7 +711,7 @@ func TestDevOps_Boot_Good_FreshWithNoExisting(t *testing.T) { require.NoError(t, err) cfg := DefaultConfig() - mgr, err := NewImageManager(cfg) + mgr, err := NewImageManager(io.Local, cfg) require.NoError(t, err) statePath := filepath.Join(tempDir, "containers.json") @@ -718,7 +719,7 @@ func TestDevOps_Boot_Good_FreshWithNoExisting(t *testing.T) { h := &mockHypervisor{} cm := container.NewLinuxKitManagerWithHypervisor(state, h) - d := &DevOps{ + d := &DevOps{medium: io.Local, images: mgr, container: cm, } @@ -750,10 +751,10 @@ func TestDevOps_Install_Delegates(t *testing.T) { t.Setenv("CORE_IMAGES_DIR", tempDir) cfg := DefaultConfig() - mgr, err := NewImageManager(cfg) + mgr, err := NewImageManager(io.Local, cfg) require.NoError(t, err) - d := &DevOps{ + d := &DevOps{medium: io.Local, images: mgr, } @@ -768,10 +769,10 @@ func TestDevOps_CheckUpdate_Delegates(t *testing.T) { t.Setenv("CORE_IMAGES_DIR", tempDir) cfg := DefaultConfig() - mgr, err := NewImageManager(cfg) + mgr, err := NewImageManager(io.Local, cfg) require.NoError(t, err) - d := &DevOps{ + d := &DevOps{medium: io.Local, images: mgr, } @@ -792,7 +793,7 @@ func TestDevOps_Boot_Good_Success(t *testing.T) { require.NoError(t, err) cfg := DefaultConfig() - mgr, err := NewImageManager(cfg) + mgr, err := NewImageManager(io.Local, cfg) require.NoError(t, err) statePath := filepath.Join(tempDir, "containers.json") @@ -800,7 +801,7 @@ func TestDevOps_Boot_Good_Success(t *testing.T) { h := &mockHypervisor{} cm := container.NewLinuxKitManagerWithHypervisor(state, h) - d := &DevOps{ + d := &DevOps{medium: io.Local, images: mgr, container: cm, } @@ -816,10 +817,10 @@ func TestDevOps_Config(t *testing.T) { t.Setenv("CORE_IMAGES_DIR", tempDir) cfg := DefaultConfig() - mgr, err := NewImageManager(cfg) + mgr, err := NewImageManager(io.Local, cfg) require.NoError(t, err) - d := &DevOps{ + d := &DevOps{medium: io.Local, config: cfg, images: mgr, } diff --git a/pkg/devops/images.go b/pkg/devops/images.go index e6a93edc..7f2b5745 100644 --- a/pkg/devops/images.go +++ b/pkg/devops/images.go @@ -14,6 +14,7 @@ import ( // ImageManager handles image downloads and updates. type ImageManager struct { + medium io.Medium config *Config manifest *Manifest sources []sources.ImageSource @@ -21,6 +22,7 @@ type ImageManager struct { // Manifest tracks installed images. type Manifest struct { + medium io.Medium Images map[string]ImageInfo `json:"images"` path string } @@ -34,20 +36,20 @@ type ImageInfo struct { } // NewImageManager creates a new image manager. -func NewImageManager(cfg *Config) (*ImageManager, error) { +func NewImageManager(m io.Medium, cfg *Config) (*ImageManager, error) { imagesDir, err := ImagesDir() if err != nil { return nil, err } // Ensure images directory exists - if err := io.Local.EnsureDir(imagesDir); err != nil { + if err := m.EnsureDir(imagesDir); err != nil { return nil, err } // Load or create manifest manifestPath := filepath.Join(imagesDir, "manifest.json") - manifest, err := loadManifest(manifestPath) + manifest, err := loadManifest(m, manifestPath) if err != nil { return nil, err } @@ -75,6 +77,7 @@ func NewImageManager(cfg *Config) (*ImageManager, error) { } return &ImageManager{ + medium: m, config: cfg, manifest: manifest, sources: srcs, @@ -87,7 +90,7 @@ func (m *ImageManager) IsInstalled() bool { if err != nil { return false } - return io.Local.IsFile(path) + return m.medium.IsFile(path) } // Install downloads and installs the dev image. @@ -118,7 +121,7 @@ func (m *ImageManager) Install(ctx context.Context, progress func(downloaded, to fmt.Printf("Downloading %s from %s...\n", ImageName(), src.Name()) // Download - if err := src.Download(ctx, imagesDir, progress); err != nil { + if err := src.Download(ctx, m.medium, imagesDir, progress); err != nil { return err } @@ -161,26 +164,28 @@ func (m *ImageManager) CheckUpdate(ctx context.Context) (current, latest string, return current, latest, hasUpdate, nil } -func loadManifest(path string) (*Manifest, error) { - m := &Manifest{ +func loadManifest(m io.Medium, path string) (*Manifest, error) { + manifest := &Manifest{ + medium: m, Images: make(map[string]ImageInfo), path: path, } - content, err := io.Local.Read(path) + content, err := m.Read(path) if err != nil { if os.IsNotExist(err) { - return m, nil + return manifest, nil } return nil, err } - if err := json.Unmarshal([]byte(content), m); err != nil { + if err := json.Unmarshal([]byte(content), manifest); err != nil { return nil, err } - m.path = path + manifest.medium = m + manifest.path = path - return m, nil + return manifest, nil } // Save writes the manifest to disk. @@ -189,5 +194,5 @@ func (m *Manifest) Save() error { if err != nil { return err } - return io.Local.Write(m.path, string(data)) + return m.medium.Write(m.path, string(data)) } diff --git a/pkg/devops/images_test.go b/pkg/devops/images_test.go index 8252efb5..72eeb3df 100644 --- a/pkg/devops/images_test.go +++ b/pkg/devops/images_test.go @@ -8,6 +8,7 @@ import ( "time" "github.com/host-uk/core/pkg/devops/sources" + "github.com/host-uk/core/pkg/io" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -17,7 +18,7 @@ func TestImageManager_Good_IsInstalled(t *testing.T) { t.Setenv("CORE_IMAGES_DIR", tmpDir) cfg := DefaultConfig() - mgr, err := NewImageManager(cfg) + mgr, err := NewImageManager(io.Local, cfg) require.NoError(t, err) // Not installed yet @@ -40,7 +41,7 @@ func TestNewImageManager_Good(t *testing.T) { cfg := DefaultConfig() cfg.Images.Source = "cdn" - mgr, err := NewImageManager(cfg) + mgr, err := NewImageManager(io.Local, cfg) assert.NoError(t, err) assert.NotNil(t, mgr) assert.Len(t, mgr.sources, 1) @@ -54,7 +55,7 @@ func TestNewImageManager_Good(t *testing.T) { cfg := DefaultConfig() cfg.Images.Source = "github" - mgr, err := NewImageManager(cfg) + mgr, err := NewImageManager(io.Local, cfg) assert.NoError(t, err) assert.NotNil(t, mgr) assert.Len(t, mgr.sources, 1) @@ -67,6 +68,7 @@ func TestManifest_Save(t *testing.T) { path := filepath.Join(tmpDir, "manifest.json") m := &Manifest{ + medium: io.Local, Images: make(map[string]ImageInfo), path: path, } @@ -84,7 +86,7 @@ func TestManifest_Save(t *testing.T) { assert.NoError(t, err) // Reload - m2, err := loadManifest(path) + m2, err := loadManifest(io.Local, path) assert.NoError(t, err) assert.Equal(t, "1.0.0", m2.Images["test.img"].Version) } @@ -96,7 +98,7 @@ func TestLoadManifest_Bad(t *testing.T) { err := os.WriteFile(path, []byte("invalid json"), 0644) require.NoError(t, err) - _, err = loadManifest(path) + _, err = loadManifest(io.Local, path) assert.Error(t, err) }) } @@ -107,7 +109,7 @@ func TestCheckUpdate_Bad(t *testing.T) { t.Setenv("CORE_IMAGES_DIR", tmpDir) cfg := DefaultConfig() - mgr, err := NewImageManager(cfg) + mgr, err := NewImageManager(io.Local, cfg) require.NoError(t, err) _, _, _, err = mgr.CheckUpdate(context.Background()) @@ -123,7 +125,7 @@ func TestNewImageManager_Good_AutoSource(t *testing.T) { cfg := DefaultConfig() cfg.Images.Source = "auto" - mgr, err := NewImageManager(cfg) + mgr, err := NewImageManager(io.Local, cfg) assert.NoError(t, err) assert.NotNil(t, mgr) assert.Len(t, mgr.sources, 2) // github and cdn @@ -136,7 +138,7 @@ func TestNewImageManager_Good_UnknownSourceFallsToAuto(t *testing.T) { cfg := DefaultConfig() cfg.Images.Source = "unknown" - mgr, err := NewImageManager(cfg) + mgr, err := NewImageManager(io.Local, cfg) assert.NoError(t, err) assert.NotNil(t, mgr) assert.Len(t, mgr.sources, 2) // falls to default (auto) which is github + cdn @@ -146,7 +148,7 @@ func TestLoadManifest_Good_Empty(t *testing.T) { tmpDir := t.TempDir() path := filepath.Join(tmpDir, "nonexistent.json") - m, err := loadManifest(path) + m, err := loadManifest(io.Local, path) assert.NoError(t, err) assert.NotNil(t, m) assert.NotNil(t, m.Images) @@ -162,7 +164,7 @@ func TestLoadManifest_Good_ExistingData(t *testing.T) { err := os.WriteFile(path, []byte(data), 0644) require.NoError(t, err) - m, err := loadManifest(path) + m, err := loadManifest(io.Local, path) assert.NoError(t, err) assert.NotNil(t, m) assert.Equal(t, "2.0.0", m.Images["test.img"].Version) @@ -187,6 +189,7 @@ func TestManifest_Save_Good_CreatesDirs(t *testing.T) { nestedPath := filepath.Join(tmpDir, "nested", "dir", "manifest.json") m := &Manifest{ + medium: io.Local, Images: make(map[string]ImageInfo), path: nestedPath, } @@ -207,6 +210,7 @@ func TestManifest_Save_Good_Overwrite(t *testing.T) { // First save m1 := &Manifest{ + medium: io.Local, Images: make(map[string]ImageInfo), path: path, } @@ -216,6 +220,7 @@ func TestManifest_Save_Good_Overwrite(t *testing.T) { // Second save with different data m2 := &Manifest{ + medium: io.Local, Images: make(map[string]ImageInfo), path: path, } @@ -224,7 +229,7 @@ func TestManifest_Save_Good_Overwrite(t *testing.T) { require.NoError(t, err) // Verify second data - loaded, err := loadManifest(path) + loaded, err := loadManifest(io.Local, path) assert.NoError(t, err) assert.Equal(t, "2.0.0", loaded.Images["other.img"].Version) _, exists := loaded.Images["test.img"] @@ -237,8 +242,9 @@ func TestImageManager_Install_Bad_NoSourceAvailable(t *testing.T) { // Create manager with empty sources mgr := &ImageManager{ + medium: io.Local, config: DefaultConfig(), - manifest: &Manifest{Images: make(map[string]ImageInfo), path: filepath.Join(tmpDir, "manifest.json")}, + manifest: &Manifest{medium: io.Local, Images: make(map[string]ImageInfo), path: filepath.Join(tmpDir, "manifest.json")}, sources: nil, // no sources } @@ -253,7 +259,7 @@ func TestNewImageManager_Good_CreatesDir(t *testing.T) { t.Setenv("CORE_IMAGES_DIR", imagesDir) cfg := DefaultConfig() - mgr, err := NewImageManager(cfg) + mgr, err := NewImageManager(io.Local, cfg) assert.NoError(t, err) assert.NotNil(t, mgr) @@ -277,7 +283,7 @@ func (m *mockImageSource) Available() bool { return m.available } func (m *mockImageSource) LatestVersion(ctx context.Context) (string, error) { return m.latestVersion, m.latestErr } -func (m *mockImageSource) Download(ctx context.Context, dest string, progress func(downloaded, total int64)) error { +func (m *mockImageSource) Download(ctx context.Context, medium io.Medium, dest string, progress func(downloaded, total int64)) error { if m.downloadErr != nil { return m.downloadErr } @@ -297,8 +303,9 @@ func TestImageManager_Install_Good_WithMockSource(t *testing.T) { } mgr := &ImageManager{ + medium: io.Local, config: DefaultConfig(), - manifest: &Manifest{Images: make(map[string]ImageInfo), path: filepath.Join(tmpDir, "manifest.json")}, + manifest: &Manifest{medium: io.Local, Images: make(map[string]ImageInfo), path: filepath.Join(tmpDir, "manifest.json")}, sources: []sources.ImageSource{mock}, } @@ -325,8 +332,9 @@ func TestImageManager_Install_Bad_DownloadError(t *testing.T) { } mgr := &ImageManager{ + medium: io.Local, config: DefaultConfig(), - manifest: &Manifest{Images: make(map[string]ImageInfo), path: filepath.Join(tmpDir, "manifest.json")}, + manifest: &Manifest{medium: io.Local, Images: make(map[string]ImageInfo), path: filepath.Join(tmpDir, "manifest.json")}, sources: []sources.ImageSource{mock}, } @@ -345,8 +353,9 @@ func TestImageManager_Install_Bad_VersionError(t *testing.T) { } mgr := &ImageManager{ + medium: io.Local, config: DefaultConfig(), - manifest: &Manifest{Images: make(map[string]ImageInfo), path: filepath.Join(tmpDir, "manifest.json")}, + manifest: &Manifest{medium: io.Local, Images: make(map[string]ImageInfo), path: filepath.Join(tmpDir, "manifest.json")}, sources: []sources.ImageSource{mock}, } @@ -370,8 +379,9 @@ func TestImageManager_Install_Good_SkipsUnavailableSource(t *testing.T) { } mgr := &ImageManager{ + medium: io.Local, config: DefaultConfig(), - manifest: &Manifest{Images: make(map[string]ImageInfo), path: filepath.Join(tmpDir, "manifest.json")}, + manifest: &Manifest{medium: io.Local, Images: make(map[string]ImageInfo), path: filepath.Join(tmpDir, "manifest.json")}, sources: []sources.ImageSource{unavailableMock, availableMock}, } @@ -394,8 +404,10 @@ func TestImageManager_CheckUpdate_Good_WithMockSource(t *testing.T) { } mgr := &ImageManager{ + medium: io.Local, config: DefaultConfig(), manifest: &Manifest{ + medium: io.Local, Images: map[string]ImageInfo{ ImageName(): {Version: "v1.0.0", Source: "mock"}, }, @@ -422,8 +434,10 @@ func TestImageManager_CheckUpdate_Good_NoUpdate(t *testing.T) { } mgr := &ImageManager{ + medium: io.Local, config: DefaultConfig(), manifest: &Manifest{ + medium: io.Local, Images: map[string]ImageInfo{ ImageName(): {Version: "v1.0.0", Source: "mock"}, }, @@ -449,8 +463,10 @@ func TestImageManager_CheckUpdate_Bad_NoSource(t *testing.T) { } mgr := &ImageManager{ + medium: io.Local, config: DefaultConfig(), manifest: &Manifest{ + medium: io.Local, Images: map[string]ImageInfo{ ImageName(): {Version: "v1.0.0", Source: "mock"}, }, @@ -475,8 +491,10 @@ func TestImageManager_CheckUpdate_Bad_VersionError(t *testing.T) { } mgr := &ImageManager{ + medium: io.Local, config: DefaultConfig(), manifest: &Manifest{ + medium: io.Local, Images: map[string]ImageInfo{ ImageName(): {Version: "v1.0.0", Source: "mock"}, }, @@ -495,8 +513,9 @@ func TestImageManager_Install_Bad_EmptySources(t *testing.T) { t.Setenv("CORE_IMAGES_DIR", tmpDir) mgr := &ImageManager{ + medium: io.Local, config: DefaultConfig(), - manifest: &Manifest{Images: make(map[string]ImageInfo), path: filepath.Join(tmpDir, "manifest.json")}, + manifest: &Manifest{medium: io.Local, Images: make(map[string]ImageInfo), path: filepath.Join(tmpDir, "manifest.json")}, sources: []sources.ImageSource{}, // Empty slice, not nil } @@ -513,8 +532,9 @@ func TestImageManager_Install_Bad_AllUnavailable(t *testing.T) { mock2 := &mockImageSource{name: "mock2", available: false} mgr := &ImageManager{ + medium: io.Local, config: DefaultConfig(), - manifest: &Manifest{Images: make(map[string]ImageInfo), path: filepath.Join(tmpDir, "manifest.json")}, + manifest: &Manifest{medium: io.Local, Images: make(map[string]ImageInfo), path: filepath.Join(tmpDir, "manifest.json")}, sources: []sources.ImageSource{mock1, mock2}, } @@ -531,8 +551,10 @@ func TestImageManager_CheckUpdate_Good_FirstSourceUnavailable(t *testing.T) { available := &mockImageSource{name: "available", available: true, latestVersion: "v2.0.0"} mgr := &ImageManager{ + medium: io.Local, config: DefaultConfig(), manifest: &Manifest{ + medium: io.Local, Images: map[string]ImageInfo{ ImageName(): {Version: "v1.0.0", Source: "available"}, }, diff --git a/pkg/devops/serve.go b/pkg/devops/serve.go index 78f784b1..1e0dc802 100644 --- a/pkg/devops/serve.go +++ b/pkg/devops/serve.go @@ -6,6 +6,8 @@ import ( "os" "os/exec" "path/filepath" + + "github.com/host-uk/core/pkg/io" ) // ServeOptions configures the dev server. @@ -39,7 +41,7 @@ func (d *DevOps) Serve(ctx context.Context, projectDir string, opts ServeOptions } // Detect and run serve command - serveCmd := DetectServeCommand(servePath) + serveCmd := DetectServeCommand(d.medium, servePath) fmt.Printf("Starting server: %s\n", serveCmd) fmt.Printf("Listening on http://localhost:%d\n", opts.Port) @@ -69,36 +71,36 @@ func (d *DevOps) mountProject(ctx context.Context, path string) error { } // DetectServeCommand auto-detects the serve command for a project. -func DetectServeCommand(projectDir string) string { +func DetectServeCommand(m io.Medium, projectDir string) string { // Laravel/Octane - if hasFile(projectDir, "artisan") { + if hasFile(m, projectDir, "artisan") { return "php artisan octane:start --host=0.0.0.0 --port=8000" } // Node.js with dev script - if hasFile(projectDir, "package.json") { - if hasPackageScript(projectDir, "dev") { + if hasFile(m, projectDir, "package.json") { + if hasPackageScript(m, projectDir, "dev") { return "npm run dev -- --host 0.0.0.0" } - if hasPackageScript(projectDir, "start") { + if hasPackageScript(m, projectDir, "start") { return "npm start" } } // PHP with composer - if hasFile(projectDir, "composer.json") { + if hasFile(m, projectDir, "composer.json") { return "frankenphp php-server -l :8000" } // Go - if hasFile(projectDir, "go.mod") { - if hasFile(projectDir, "main.go") { + if hasFile(m, projectDir, "go.mod") { + if hasFile(m, projectDir, "main.go") { return "go run ." } } // Python Django - if hasFile(projectDir, "manage.py") { + if hasFile(m, projectDir, "manage.py") { return "python manage.py runserver 0.0.0.0:8000" } diff --git a/pkg/devops/serve_test.go b/pkg/devops/serve_test.go index 54e1949f..57dc8362 100644 --- a/pkg/devops/serve_test.go +++ b/pkg/devops/serve_test.go @@ -5,6 +5,7 @@ import ( "path/filepath" "testing" + "github.com/host-uk/core/pkg/io" "github.com/stretchr/testify/assert" ) @@ -13,7 +14,7 @@ func TestDetectServeCommand_Good_Laravel(t *testing.T) { err := os.WriteFile(filepath.Join(tmpDir, "artisan"), []byte("#!/usr/bin/env php"), 0644) assert.NoError(t, err) - cmd := DetectServeCommand(tmpDir) + cmd := DetectServeCommand(io.Local, tmpDir) assert.Equal(t, "php artisan octane:start --host=0.0.0.0 --port=8000", cmd) } @@ -23,7 +24,7 @@ func TestDetectServeCommand_Good_NodeDev(t *testing.T) { err := os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte(packageJSON), 0644) assert.NoError(t, err) - cmd := DetectServeCommand(tmpDir) + cmd := DetectServeCommand(io.Local, tmpDir) assert.Equal(t, "npm run dev -- --host 0.0.0.0", cmd) } @@ -33,7 +34,7 @@ func TestDetectServeCommand_Good_NodeStart(t *testing.T) { err := os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte(packageJSON), 0644) assert.NoError(t, err) - cmd := DetectServeCommand(tmpDir) + cmd := DetectServeCommand(io.Local, tmpDir) assert.Equal(t, "npm start", cmd) } @@ -42,7 +43,7 @@ func TestDetectServeCommand_Good_PHP(t *testing.T) { err := os.WriteFile(filepath.Join(tmpDir, "composer.json"), []byte(`{"require":{}}`), 0644) assert.NoError(t, err) - cmd := DetectServeCommand(tmpDir) + cmd := DetectServeCommand(io.Local, tmpDir) assert.Equal(t, "frankenphp php-server -l :8000", cmd) } @@ -53,7 +54,7 @@ func TestDetectServeCommand_Good_GoMain(t *testing.T) { err = os.WriteFile(filepath.Join(tmpDir, "main.go"), []byte("package main"), 0644) assert.NoError(t, err) - cmd := DetectServeCommand(tmpDir) + cmd := DetectServeCommand(io.Local, tmpDir) assert.Equal(t, "go run .", cmd) } @@ -63,7 +64,7 @@ func TestDetectServeCommand_Good_GoWithoutMain(t *testing.T) { assert.NoError(t, err) // No main.go, so falls through to fallback - cmd := DetectServeCommand(tmpDir) + cmd := DetectServeCommand(io.Local, tmpDir) assert.Equal(t, "python3 -m http.server 8000", cmd) } @@ -72,14 +73,14 @@ func TestDetectServeCommand_Good_Django(t *testing.T) { err := os.WriteFile(filepath.Join(tmpDir, "manage.py"), []byte("#!/usr/bin/env python"), 0644) assert.NoError(t, err) - cmd := DetectServeCommand(tmpDir) + cmd := DetectServeCommand(io.Local, tmpDir) assert.Equal(t, "python manage.py runserver 0.0.0.0:8000", cmd) } func TestDetectServeCommand_Good_Fallback(t *testing.T) { tmpDir := t.TempDir() - cmd := DetectServeCommand(tmpDir) + cmd := DetectServeCommand(io.Local, tmpDir) assert.Equal(t, "python3 -m http.server 8000", cmd) } @@ -91,7 +92,7 @@ func TestDetectServeCommand_Good_Priority(t *testing.T) { err = os.WriteFile(filepath.Join(tmpDir, "composer.json"), []byte(`{"require":{}}`), 0644) assert.NoError(t, err) - cmd := DetectServeCommand(tmpDir) + cmd := DetectServeCommand(io.Local, tmpDir) assert.Equal(t, "php artisan octane:start --host=0.0.0.0 --port=8000", cmd) } @@ -116,13 +117,13 @@ func TestHasFile_Good(t *testing.T) { err := os.WriteFile(testFile, []byte("content"), 0644) assert.NoError(t, err) - assert.True(t, hasFile(tmpDir, "test.txt")) + assert.True(t, hasFile(io.Local, tmpDir, "test.txt")) } func TestHasFile_Bad(t *testing.T) { tmpDir := t.TempDir() - assert.False(t, hasFile(tmpDir, "nonexistent.txt")) + assert.False(t, hasFile(io.Local, tmpDir, "nonexistent.txt")) } func TestHasFile_Bad_Directory(t *testing.T) { @@ -132,5 +133,5 @@ func TestHasFile_Bad_Directory(t *testing.T) { assert.NoError(t, err) // hasFile correctly returns false for directories (only true for regular files) - assert.False(t, hasFile(tmpDir, "subdir")) + assert.False(t, hasFile(io.Local, tmpDir, "subdir")) } diff --git a/pkg/devops/sources/cdn.go b/pkg/devops/sources/cdn.go index 41269624..8408cf88 100644 --- a/pkg/devops/sources/cdn.go +++ b/pkg/devops/sources/cdn.go @@ -54,7 +54,7 @@ func (s *CDNSource) LatestVersion(ctx context.Context) (string, error) { } // Download downloads the image from CDN. -func (s *CDNSource) Download(ctx context.Context, dest string, progress func(downloaded, total int64)) error { +func (s *CDNSource) Download(ctx context.Context, m io.Medium, dest string, progress func(downloaded, total int64)) error { url := fmt.Sprintf("%s/%s", s.config.CDNURL, s.config.ImageName) req, err := http.NewRequestWithContext(ctx, "GET", url, nil) @@ -73,7 +73,7 @@ func (s *CDNSource) Download(ctx context.Context, dest string, progress func(dow } // Ensure dest directory exists - if err := io.Local.EnsureDir(dest); err != nil { + if err := m.EnsureDir(dest); err != nil { return fmt.Errorf("cdn.Download: %w", err) } diff --git a/pkg/devops/sources/cdn_test.go b/pkg/devops/sources/cdn_test.go index de9c9639..2fe33c85 100644 --- a/pkg/devops/sources/cdn_test.go +++ b/pkg/devops/sources/cdn_test.go @@ -9,6 +9,7 @@ import ( "path/filepath" "testing" + "github.com/host-uk/core/pkg/io" "github.com/stretchr/testify/assert" ) @@ -71,7 +72,7 @@ func TestCDNSource_Download_Good(t *testing.T) { }) var progressCalled bool - err := src.Download(context.Background(), dest, func(downloaded, total int64) { + err := src.Download(context.Background(), io.Local, dest, func(downloaded, total int64) { progressCalled = true }) @@ -97,7 +98,7 @@ func TestCDNSource_Download_Bad(t *testing.T) { ImageName: "test.img", }) - err := src.Download(context.Background(), dest, nil) + err := src.Download(context.Background(), io.Local, dest, nil) assert.Error(t, err) assert.Contains(t, err.Error(), "HTTP 500") }) @@ -109,7 +110,7 @@ func TestCDNSource_Download_Bad(t *testing.T) { ImageName: "test.img", }) - err := src.Download(context.Background(), dest, nil) + err := src.Download(context.Background(), io.Local, dest, nil) assert.Error(t, err) }) } @@ -162,7 +163,7 @@ func TestCDNSource_Download_Good_NoProgress(t *testing.T) { }) // nil progress callback should be handled gracefully - err := src.Download(context.Background(), dest, nil) + err := src.Download(context.Background(), io.Local, dest, nil) assert.NoError(t, err) data, err := os.ReadFile(filepath.Join(dest, "test.img")) @@ -192,7 +193,7 @@ func TestCDNSource_Download_Good_LargeFile(t *testing.T) { var progressCalls int var lastDownloaded int64 - err := src.Download(context.Background(), dest, func(downloaded, total int64) { + err := src.Download(context.Background(), io.Local, dest, func(downloaded, total int64) { progressCalls++ lastDownloaded = downloaded }) @@ -227,7 +228,7 @@ func TestCDNSource_Download_Bad_HTTPErrorCodes(t *testing.T) { ImageName: "test.img", }) - err := src.Download(context.Background(), dest, nil) + err := src.Download(context.Background(), io.Local, dest, nil) assert.Error(t, err) assert.Contains(t, err.Error(), fmt.Sprintf("HTTP %d", tc.statusCode)) }) @@ -281,7 +282,7 @@ func TestCDNSource_Download_Good_CreatesDestDir(t *testing.T) { ImageName: "test.img", }) - err := src.Download(context.Background(), dest, nil) + err := src.Download(context.Background(), io.Local, dest, nil) assert.NoError(t, err) // Verify nested dir was created diff --git a/pkg/devops/sources/github.go b/pkg/devops/sources/github.go index 98a86b67..323f2dda 100644 --- a/pkg/devops/sources/github.go +++ b/pkg/devops/sources/github.go @@ -6,6 +6,8 @@ import ( "os" "os/exec" "strings" + + "github.com/host-uk/core/pkg/io" ) // GitHubSource downloads images from GitHub Releases. @@ -52,7 +54,7 @@ func (s *GitHubSource) LatestVersion(ctx context.Context) (string, error) { } // Download downloads the image from the latest release. -func (s *GitHubSource) Download(ctx context.Context, dest string, progress func(downloaded, total int64)) error { +func (s *GitHubSource) Download(ctx context.Context, m io.Medium, dest string, progress func(downloaded, total int64)) error { // Get release assets to find our image cmd := exec.CommandContext(ctx, "gh", "release", "download", "-R", s.config.GitHubRepo, diff --git a/pkg/devops/sources/source.go b/pkg/devops/sources/source.go index 94e4ff68..f5ca4460 100644 --- a/pkg/devops/sources/source.go +++ b/pkg/devops/sources/source.go @@ -3,6 +3,8 @@ package sources import ( "context" + + "github.com/host-uk/core/pkg/io" ) // ImageSource defines the interface for downloading dev images. @@ -15,7 +17,7 @@ type ImageSource interface { LatestVersion(ctx context.Context) (string, error) // Download downloads the image to the destination path. // Reports progress via the callback if provided. - Download(ctx context.Context, dest string, progress func(downloaded, total int64)) error + Download(ctx context.Context, m io.Medium, dest string, progress func(downloaded, total int64)) error } // SourceConfig holds configuration for a source. diff --git a/pkg/devops/test.go b/pkg/devops/test.go index e424472e..89d1726c 100644 --- a/pkg/devops/test.go +++ b/pkg/devops/test.go @@ -47,7 +47,7 @@ func (d *DevOps) Test(ctx context.Context, projectDir string, opts TestOptions) if len(opts.Command) > 0 { cmd = strings.Join(opts.Command, " ") } else if opts.Name != "" { - cfg, err := LoadTestConfig(projectDir) + cfg, err := LoadTestConfig(d.medium, projectDir) if err != nil { return err } @@ -61,7 +61,7 @@ func (d *DevOps) Test(ctx context.Context, projectDir string, opts TestOptions) return fmt.Errorf("test command %q not found in .core/test.yaml", opts.Name) } } else { - cmd = DetectTestCommand(projectDir) + cmd = DetectTestCommand(d.medium, projectDir) if cmd == "" { return fmt.Errorf("could not detect test command (create .core/test.yaml)") } @@ -72,39 +72,39 @@ func (d *DevOps) Test(ctx context.Context, projectDir string, opts TestOptions) } // DetectTestCommand auto-detects the test command for a project. -func DetectTestCommand(projectDir string) string { +func DetectTestCommand(m io.Medium, projectDir string) string { // 1. Check .core/test.yaml - cfg, err := LoadTestConfig(projectDir) + cfg, err := LoadTestConfig(m, projectDir) if err == nil && cfg.Command != "" { return cfg.Command } // 2. Check composer.json for test script - if hasFile(projectDir, "composer.json") { - if hasComposerScript(projectDir, "test") { + if hasFile(m, projectDir, "composer.json") { + if hasComposerScript(m, projectDir, "test") { return "composer test" } } // 3. Check package.json for test script - if hasFile(projectDir, "package.json") { - if hasPackageScript(projectDir, "test") { + if hasFile(m, projectDir, "package.json") { + if hasPackageScript(m, projectDir, "test") { return "npm test" } } // 4. Check go.mod - if hasFile(projectDir, "go.mod") { + if hasFile(m, projectDir, "go.mod") { return "go test ./..." } // 5. Check pytest - if hasFile(projectDir, "pytest.ini") || hasFile(projectDir, "pyproject.toml") { + if hasFile(m, projectDir, "pytest.ini") || hasFile(m, projectDir, "pyproject.toml") { return "pytest" } // 6. Check Taskfile - if hasFile(projectDir, "Taskfile.yaml") || hasFile(projectDir, "Taskfile.yml") { + if hasFile(m, projectDir, "Taskfile.yaml") || hasFile(m, projectDir, "Taskfile.yml") { return "task test" } @@ -112,14 +112,14 @@ func DetectTestCommand(projectDir string) string { } // LoadTestConfig loads .core/test.yaml. -func LoadTestConfig(projectDir string) (*TestConfig, error) { +func LoadTestConfig(m io.Medium, projectDir string) (*TestConfig, error) { path := filepath.Join(projectDir, ".core", "test.yaml") absPath, err := filepath.Abs(path) if err != nil { return nil, err } - content, err := io.Local.Read(absPath) + content, err := m.Read(absPath) if err != nil { return nil, err } @@ -132,23 +132,23 @@ func LoadTestConfig(projectDir string) (*TestConfig, error) { return &cfg, nil } -func hasFile(dir, name string) bool { +func hasFile(m io.Medium, dir, name string) bool { path := filepath.Join(dir, name) absPath, err := filepath.Abs(path) if err != nil { return false } - return io.Local.IsFile(absPath) + return m.IsFile(absPath) } -func hasPackageScript(projectDir, script string) bool { +func hasPackageScript(m io.Medium, projectDir, script string) bool { path := filepath.Join(projectDir, "package.json") absPath, err := filepath.Abs(path) if err != nil { return false } - content, err := io.Local.Read(absPath) + content, err := m.Read(absPath) if err != nil { return false } @@ -164,14 +164,14 @@ func hasPackageScript(projectDir, script string) bool { return ok } -func hasComposerScript(projectDir, script string) bool { +func hasComposerScript(m io.Medium, projectDir, script string) bool { path := filepath.Join(projectDir, "composer.json") absPath, err := filepath.Abs(path) if err != nil { return false } - content, err := io.Local.Read(absPath) + content, err := m.Read(absPath) if err != nil { return false } diff --git a/pkg/devops/test_test.go b/pkg/devops/test_test.go index 2a20e6e2..8f4cff77 100644 --- a/pkg/devops/test_test.go +++ b/pkg/devops/test_test.go @@ -4,13 +4,15 @@ import ( "os" "path/filepath" "testing" + + "github.com/host-uk/core/pkg/io" ) func TestDetectTestCommand_Good_ComposerJSON(t *testing.T) { tmpDir := t.TempDir() _ = os.WriteFile(filepath.Join(tmpDir, "composer.json"), []byte(`{"scripts":{"test":"pest"}}`), 0644) - cmd := DetectTestCommand(tmpDir) + cmd := DetectTestCommand(io.Local, tmpDir) if cmd != "composer test" { t.Errorf("expected 'composer test', got %q", cmd) } @@ -20,7 +22,7 @@ func TestDetectTestCommand_Good_PackageJSON(t *testing.T) { tmpDir := t.TempDir() _ = os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte(`{"scripts":{"test":"vitest"}}`), 0644) - cmd := DetectTestCommand(tmpDir) + cmd := DetectTestCommand(io.Local, tmpDir) if cmd != "npm test" { t.Errorf("expected 'npm test', got %q", cmd) } @@ -30,7 +32,7 @@ func TestDetectTestCommand_Good_GoMod(t *testing.T) { tmpDir := t.TempDir() _ = os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte("module example"), 0644) - cmd := DetectTestCommand(tmpDir) + cmd := DetectTestCommand(io.Local, tmpDir) if cmd != "go test ./..." { t.Errorf("expected 'go test ./...', got %q", cmd) } @@ -42,7 +44,7 @@ func TestDetectTestCommand_Good_CoreTestYaml(t *testing.T) { _ = os.MkdirAll(coreDir, 0755) _ = os.WriteFile(filepath.Join(coreDir, "test.yaml"), []byte("command: custom-test"), 0644) - cmd := DetectTestCommand(tmpDir) + cmd := DetectTestCommand(io.Local, tmpDir) if cmd != "custom-test" { t.Errorf("expected 'custom-test', got %q", cmd) } @@ -52,7 +54,7 @@ func TestDetectTestCommand_Good_Pytest(t *testing.T) { tmpDir := t.TempDir() _ = os.WriteFile(filepath.Join(tmpDir, "pytest.ini"), []byte("[pytest]"), 0644) - cmd := DetectTestCommand(tmpDir) + cmd := DetectTestCommand(io.Local, tmpDir) if cmd != "pytest" { t.Errorf("expected 'pytest', got %q", cmd) } @@ -62,7 +64,7 @@ func TestDetectTestCommand_Good_Taskfile(t *testing.T) { tmpDir := t.TempDir() _ = os.WriteFile(filepath.Join(tmpDir, "Taskfile.yaml"), []byte("version: '3'"), 0644) - cmd := DetectTestCommand(tmpDir) + cmd := DetectTestCommand(io.Local, tmpDir) if cmd != "task test" { t.Errorf("expected 'task test', got %q", cmd) } @@ -71,7 +73,7 @@ func TestDetectTestCommand_Good_Taskfile(t *testing.T) { func TestDetectTestCommand_Bad_NoFiles(t *testing.T) { tmpDir := t.TempDir() - cmd := DetectTestCommand(tmpDir) + cmd := DetectTestCommand(io.Local, tmpDir) if cmd != "" { t.Errorf("expected empty string, got %q", cmd) } @@ -85,7 +87,7 @@ func TestDetectTestCommand_Good_Priority(t *testing.T) { _ = os.WriteFile(filepath.Join(coreDir, "test.yaml"), []byte("command: my-custom-test"), 0644) _ = os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte("module example"), 0644) - cmd := DetectTestCommand(tmpDir) + cmd := DetectTestCommand(io.Local, tmpDir) if cmd != "my-custom-test" { t.Errorf("expected 'my-custom-test' (from .core/test.yaml), got %q", cmd) } @@ -108,7 +110,7 @@ env: ` _ = os.WriteFile(filepath.Join(coreDir, "test.yaml"), []byte(configYAML), 0644) - cfg, err := LoadTestConfig(tmpDir) + cfg, err := LoadTestConfig(io.Local, tmpDir) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -133,7 +135,7 @@ env: func TestLoadTestConfig_Bad_NotFound(t *testing.T) { tmpDir := t.TempDir() - _, err := LoadTestConfig(tmpDir) + _, err := LoadTestConfig(io.Local, tmpDir) if err == nil { t.Error("expected error for missing config, got nil") } @@ -143,10 +145,10 @@ func TestHasPackageScript_Good(t *testing.T) { tmpDir := t.TempDir() _ = os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte(`{"scripts":{"test":"jest","build":"webpack"}}`), 0644) - if !hasPackageScript(tmpDir, "test") { + if !hasPackageScript(io.Local, tmpDir, "test") { t.Error("expected to find 'test' script") } - if !hasPackageScript(tmpDir, "build") { + if !hasPackageScript(io.Local, tmpDir, "build") { t.Error("expected to find 'build' script") } } @@ -155,7 +157,7 @@ func TestHasPackageScript_Bad_MissingScript(t *testing.T) { tmpDir := t.TempDir() _ = os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte(`{"scripts":{"build":"webpack"}}`), 0644) - if hasPackageScript(tmpDir, "test") { + if hasPackageScript(io.Local, tmpDir, "test") { t.Error("expected not to find 'test' script") } } @@ -164,7 +166,7 @@ func TestHasComposerScript_Good(t *testing.T) { tmpDir := t.TempDir() _ = os.WriteFile(filepath.Join(tmpDir, "composer.json"), []byte(`{"scripts":{"test":"pest","post-install-cmd":"@php artisan migrate"}}`), 0644) - if !hasComposerScript(tmpDir, "test") { + if !hasComposerScript(io.Local, tmpDir, "test") { t.Error("expected to find 'test' script") } } @@ -173,7 +175,7 @@ func TestHasComposerScript_Bad_MissingScript(t *testing.T) { tmpDir := t.TempDir() _ = os.WriteFile(filepath.Join(tmpDir, "composer.json"), []byte(`{"scripts":{"build":"@php build.php"}}`), 0644) - if hasComposerScript(tmpDir, "test") { + if hasComposerScript(io.Local, tmpDir, "test") { t.Error("expected not to find 'test' script") } } @@ -229,7 +231,7 @@ func TestDetectTestCommand_Good_TaskfileYml(t *testing.T) { tmpDir := t.TempDir() _ = os.WriteFile(filepath.Join(tmpDir, "Taskfile.yml"), []byte("version: '3'"), 0644) - cmd := DetectTestCommand(tmpDir) + cmd := DetectTestCommand(io.Local, tmpDir) if cmd != "task test" { t.Errorf("expected 'task test', got %q", cmd) } @@ -239,7 +241,7 @@ func TestDetectTestCommand_Good_Pyproject(t *testing.T) { tmpDir := t.TempDir() _ = os.WriteFile(filepath.Join(tmpDir, "pyproject.toml"), []byte("[tool.pytest]"), 0644) - cmd := DetectTestCommand(tmpDir) + cmd := DetectTestCommand(io.Local, tmpDir) if cmd != "pytest" { t.Errorf("expected 'pytest', got %q", cmd) } @@ -248,7 +250,7 @@ func TestDetectTestCommand_Good_Pyproject(t *testing.T) { func TestHasPackageScript_Bad_NoFile(t *testing.T) { tmpDir := t.TempDir() - if hasPackageScript(tmpDir, "test") { + if hasPackageScript(io.Local, tmpDir, "test") { t.Error("expected false for missing package.json") } } @@ -257,7 +259,7 @@ func TestHasPackageScript_Bad_InvalidJSON(t *testing.T) { tmpDir := t.TempDir() _ = os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte(`invalid json`), 0644) - if hasPackageScript(tmpDir, "test") { + if hasPackageScript(io.Local, tmpDir, "test") { t.Error("expected false for invalid JSON") } } @@ -266,7 +268,7 @@ func TestHasPackageScript_Bad_NoScripts(t *testing.T) { tmpDir := t.TempDir() _ = os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte(`{"name":"test"}`), 0644) - if hasPackageScript(tmpDir, "test") { + if hasPackageScript(io.Local, tmpDir, "test") { t.Error("expected false for missing scripts section") } } @@ -274,7 +276,7 @@ func TestHasPackageScript_Bad_NoScripts(t *testing.T) { func TestHasComposerScript_Bad_NoFile(t *testing.T) { tmpDir := t.TempDir() - if hasComposerScript(tmpDir, "test") { + if hasComposerScript(io.Local, tmpDir, "test") { t.Error("expected false for missing composer.json") } } @@ -283,7 +285,7 @@ func TestHasComposerScript_Bad_InvalidJSON(t *testing.T) { tmpDir := t.TempDir() _ = os.WriteFile(filepath.Join(tmpDir, "composer.json"), []byte(`invalid json`), 0644) - if hasComposerScript(tmpDir, "test") { + if hasComposerScript(io.Local, tmpDir, "test") { t.Error("expected false for invalid JSON") } } @@ -292,7 +294,7 @@ func TestHasComposerScript_Bad_NoScripts(t *testing.T) { tmpDir := t.TempDir() _ = os.WriteFile(filepath.Join(tmpDir, "composer.json"), []byte(`{"name":"test/pkg"}`), 0644) - if hasComposerScript(tmpDir, "test") { + if hasComposerScript(io.Local, tmpDir, "test") { t.Error("expected false for missing scripts section") } } @@ -303,7 +305,7 @@ func TestLoadTestConfig_Bad_InvalidYAML(t *testing.T) { _ = os.MkdirAll(coreDir, 0755) _ = os.WriteFile(filepath.Join(coreDir, "test.yaml"), []byte("invalid: yaml: :"), 0644) - _, err := LoadTestConfig(tmpDir) + _, err := LoadTestConfig(io.Local, tmpDir) if err == nil { t.Error("expected error for invalid YAML") } @@ -315,7 +317,7 @@ func TestLoadTestConfig_Good_MinimalConfig(t *testing.T) { _ = os.MkdirAll(coreDir, 0755) _ = os.WriteFile(filepath.Join(coreDir, "test.yaml"), []byte("version: 1"), 0644) - cfg, err := LoadTestConfig(tmpDir) + cfg, err := LoadTestConfig(io.Local, tmpDir) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -332,7 +334,7 @@ func TestDetectTestCommand_Good_ComposerWithoutScript(t *testing.T) { // composer.json without test script should not return composer test _ = os.WriteFile(filepath.Join(tmpDir, "composer.json"), []byte(`{"name":"test/pkg"}`), 0644) - cmd := DetectTestCommand(tmpDir) + cmd := DetectTestCommand(io.Local, tmpDir) // Falls through to empty (no match) if cmd != "" { t.Errorf("expected empty string, got %q", cmd) @@ -344,7 +346,7 @@ func TestDetectTestCommand_Good_PackageJSONWithoutScript(t *testing.T) { // package.json without test or dev script _ = os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte(`{"name":"test"}`), 0644) - cmd := DetectTestCommand(tmpDir) + cmd := DetectTestCommand(io.Local, tmpDir) // Falls through to empty if cmd != "" { t.Errorf("expected empty string, got %q", cmd)