diff --git a/installer/apt_installer.go b/installer/apt_installer.go index 645bc00..48ef366 100644 --- a/installer/apt_installer.go +++ b/installer/apt_installer.go @@ -21,6 +21,11 @@ const ( PackageManagerApt PackageManager = "apt" ) +func (i *AptInstaller) Validate() []ValidationError { + errors := i.BaseValidate() + return errors +} + // Install implements IInstaller. func (i *AptInstaller) Install() error { name := *i.Info.Name diff --git a/installer/brew_installer.go b/installer/brew_installer.go index 80823ba..00cc775 100644 --- a/installer/brew_installer.go +++ b/installer/brew_installer.go @@ -1,6 +1,8 @@ package installer import ( + "strings" + "github.com/chenasraf/sofmani/appconfig" "github.com/chenasraf/sofmani/utils" ) @@ -15,6 +17,18 @@ type BrewOpts struct { Tap *string } +func (i *BrewInstaller) Validate() []ValidationError { + errors := i.BaseValidate() + info := i.GetData() + opts := i.GetOpts() + if opts.Tap != nil { + if !strings.Contains(*opts.Tap, "/") || len(*opts.Tap) < 3 { + errors = append(errors, ValidationError{FieldName: "tap", Message: validationInvalidFormat(), InstallerName: *info.Name}) + } + } + return errors +} + // Install implements IInstaller. func (i *BrewInstaller) Install() error { name := *i.Info.Name diff --git a/installer/git_installer.go b/installer/git_installer.go index 341add4..2c9d92d 100644 --- a/installer/git_installer.go +++ b/installer/git_installer.go @@ -21,6 +21,19 @@ type GitOpts struct { Ref *string } +func (i *GitInstaller) Validate() []ValidationError { + errors := i.BaseValidate() + info := i.GetData() + opts := i.GetOpts() + if opts.Destination == nil || len(*opts.Destination) == 0 { + errors = append(errors, ValidationError{FieldName: "destination", Message: validationIsRequired(), InstallerName: *info.Name}) + } + if opts.Ref == nil || len(*opts.Ref) == 0 { + errors = append(errors, ValidationError{FieldName: "ref", Message: validationIsRequired(), InstallerName: *info.Name}) + } + return errors +} + // Install implements IInstaller. func (i *GitInstaller) Install() error { args := []string{"clone", i.GetRepositoryUrl(), i.GetInstallDir()} diff --git a/installer/github_release_installer.go b/installer/github_release_installer.go index d3de124..bab771f 100644 --- a/installer/github_release_installer.go +++ b/installer/github_release_installer.go @@ -19,7 +19,7 @@ import ( type GitHubReleaseInstaller struct { InstallerBase Config *appconfig.AppConfig - Data *appconfig.InstallerData + Info *appconfig.InstallerData } type GitHubReleaseOpts struct { @@ -37,6 +37,31 @@ const ( GitHubReleaseInstallStrategyZip GitHubReleaseInstallStrategy = "zip" ) +func (i *GitHubReleaseInstaller) Validate() []ValidationError { + errors := i.BaseValidate() + info := i.GetData() + opts := i.GetOpts() + if opts.Repository == nil || len(*opts.Repository) == 0 { + errors = append(errors, ValidationError{FieldName: "repository", Message: validationIsRequired(), InstallerName: *info.Name}) + } + if opts.Destination == nil || len(*opts.Destination) == 0 { + errors = append(errors, ValidationError{FieldName: "destination", Message: validationIsRequired(), InstallerName: *info.Name}) + } + if opts.DownloadFilename == nil || len(*opts.DownloadFilename.Resolve()) == 0 { + errors = append(errors, ValidationError{FieldName: "download_filename", Message: validationIsRequired(), InstallerName: *info.Name}) + } else { + if (*opts.DownloadFilename).Resolve() == nil || len(*(*opts.DownloadFilename).Resolve()) == 0 { + errors = append(errors, ValidationError{FieldName: fmt.Sprintf("download_filename.%s", platform.GetPlatform()), Message: validationIsRequired(), InstallerName: *info.Name}) + } + } + if opts.Strategy != nil { + if *opts.Strategy != GitHubReleaseInstallStrategyNone && *opts.Strategy != GitHubReleaseInstallStrategyTar && *opts.Strategy != GitHubReleaseInstallStrategyZip { + errors = append(errors, ValidationError{FieldName: "strategy", Message: validationInvalidFormat(), InstallerName: *info.Name}) + } + } + return errors +} + // Install implements IInstaller. func (i *GitHubReleaseInstaller) Install() error { opts := i.GetOpts() @@ -60,10 +85,7 @@ func (i *GitHubReleaseInstaller) Install() error { return err } - version := tag - if strings.HasPrefix(tag, "v") { - version = strings.TrimPrefix(tag, "v") - } + version, _ := strings.CutPrefix(tag, "v") filename := i.GetFilename() filename = strings.ReplaceAll(filename, "{tag}", tag) filename = strings.ReplaceAll(filename, "{version}", version) @@ -152,8 +174,8 @@ func (i *GitHubReleaseInstaller) CheckIsInstalled() (bool, error) { if i.HasCustomInstallCheck() { return i.RunCustomInstallCheck() } - logger.Debug("Checking if %s is installed on %s", *i.Data.Name, filepath.Join(i.GetInstallDir(), *i.Data.Name)) - return utils.PathExists(filepath.Join(i.GetInstallDir(), *i.Data.Name)) + logger.Debug("Checking if %s is installed on %s", *i.Info.Name, filepath.Join(i.GetInstallDir(), *i.Info.Name)) + return utils.PathExists(filepath.Join(i.GetInstallDir(), *i.Info.Name)) } // CheckNeedsUpdate implements IInstaller. @@ -179,10 +201,10 @@ func (i *GitHubReleaseInstaller) CheckNeedsUpdate() (bool, error) { } func (i *GitHubReleaseInstaller) GetBinName() string { - if i.Data.BinName != nil { - return *i.Data.BinName + if i.Info.BinName != nil { + return *i.Info.BinName } - return filepath.Base(*i.Data.Name) + return filepath.Base(*i.Info.Name) } func (i *GitHubReleaseInstaller) CopyExtractedFile(out *os.File, tmpDir string) (bool, error) { @@ -205,12 +227,12 @@ func (i *GitHubReleaseInstaller) CopyExtractedFile(out *os.File, tmpDir string) } func (i *GitHubReleaseInstaller) GetCachedTag() (string, error) { - logger.Debug("Getting cached tag for %s", *i.Data.Name) + logger.Debug("Getting cached tag for %s", *i.Info.Name) cacheDir, err := utils.GetCacheDir() if err != nil { return "", err } - cacheFile := fmt.Sprintf("%s/%s", cacheDir, *i.Data.Name) + cacheFile := fmt.Sprintf("%s/%s", cacheDir, *i.Info.Name) exists, err := utils.PathExists(cacheFile) if err != nil { return "", err @@ -223,7 +245,7 @@ func (i *GitHubReleaseInstaller) GetCachedTag() (string, error) { if err != nil { return "", err } - logger.Debug("Got cached tag %s for %s", strings.TrimSpace(string(contents)), *i.Data.Name) + logger.Debug("Got cached tag %s for %s", strings.TrimSpace(string(contents)), *i.Info.Name) return strings.TrimSpace(string(contents)), nil } @@ -232,7 +254,7 @@ func (i *GitHubReleaseInstaller) UpdateCache(tag string) error { if err != nil { return err } - cacheFile := fmt.Sprintf("%s/%s", cacheDir, *i.Data.Name) + cacheFile := fmt.Sprintf("%s/%s", cacheDir, *i.Info.Name) logger.Debug("Updating cache file %s with %s", cacheFile, tag) err = os.WriteFile(cacheFile, []byte(tag), 0644) if err != nil { @@ -243,12 +265,12 @@ func (i *GitHubReleaseInstaller) UpdateCache(tag string) error { // GetData implements IInstaller. func (i *GitHubReleaseInstaller) GetData() *appconfig.InstallerData { - return i.Data + return i.Info } func (i *GitHubReleaseInstaller) GetOpts() *GitHubReleaseOpts { opts := &GitHubReleaseOpts{} - info := i.Data + info := i.Info if info.Opts != nil { if repository, ok := (*info.Opts)["repository"].(string); ok { repository = utils.GetRealPath(i.GetData().Environ(), repository) @@ -328,7 +350,7 @@ func NewGitHubReleaseInstaller(cfg *appconfig.AppConfig, installer *appconfig.In i := &GitHubReleaseInstaller{ InstallerBase: InstallerBase{Data: installer}, Config: cfg, - Data: installer, + Info: installer, } return i diff --git a/installer/group_installer.go b/installer/group_installer.go index 2eb6ded..d1aaed3 100644 --- a/installer/group_installer.go +++ b/installer/group_installer.go @@ -16,6 +16,15 @@ type GroupOpts struct { // } +func (i *GroupInstaller) Validate() []ValidationError { + errors := i.BaseValidate() + info := i.GetData() + if info.Steps == nil || len(*info.Steps) == 0 { + errors = append(errors, ValidationError{FieldName: "steps", Message: "Must have at least one step", InstallerName: *info.Name}) + } + return errors +} + // Install implements IInstaller. func (i *GroupInstaller) Install() error { info := i.GetData() diff --git a/installer/installer.go b/installer/installer.go index 5e8ea68..052bda1 100644 --- a/installer/installer.go +++ b/installer/installer.go @@ -15,6 +15,7 @@ type IInstaller interface { CheckNeedsUpdate() (bool, error) Install() error Update() error + Validate() []ValidationError } type InstallerBase struct { @@ -52,6 +53,15 @@ func (i *InstallerBase) GetData() *appconfig.InstallerData { return i.Data } +func (i *InstallerBase) BaseValidate() []ValidationError { + errors := []ValidationError{} + info := i.GetData() + if info.Name == nil || len(*info.Name) == 0 { + errors = append(errors, ValidationError{FieldName: "name", Message: "Name is required"}) + } + return errors +} + func (i *InstallerBase) RunCustomUpdateCheck() (bool, error) { envShell := utils.GetOSShell(i.GetData().EnvShell) args := utils.GetOSShellArgs(*i.GetData().CheckHasUpdate) diff --git a/installer/installer_test.go b/installer/installer_test.go index 808fbde..71f2783 100644 --- a/installer/installer_test.go +++ b/installer/installer_test.go @@ -5,17 +5,19 @@ import ( "github.com/chenasraf/sofmani/appconfig" "github.com/chenasraf/sofmani/logger" + "github.com/chenasraf/sofmani/platform" "github.com/stretchr/testify/assert" ) type MockInstaller struct { - data *appconfig.InstallerData - isInstalled bool - needsUpdate bool - installError error - updateError error - checkInstall error - checkUpdate error + data *appconfig.InstallerData + isInstalled bool + needsUpdate bool + installError error + updateError error + checkInstall error + checkUpdate error + validationErrors []ValidationError } func (m *MockInstaller) GetData() *appconfig.InstallerData { @@ -38,6 +40,10 @@ func (m *MockInstaller) Update() error { return m.updateError } +func (m *MockInstaller) Validate() []ValidationError { + return m.validationErrors +} + func TestGetInstaller(t *testing.T) { config := &appconfig.AppConfig{} logger.InitLogger(false) @@ -69,6 +75,492 @@ func TestRunInstaller(t *testing.T) { assert.NoError(t, err) } +func TestAptValidation(t *testing.T) { + logger.InitLogger(false) + aptInstaller := &AptInstaller{ + InstallerBase: InstallerBase{ + Data: &appconfig.InstallerData{ + Name: strPtr("test-apt"), + Type: appconfig.InstallerTypeApt, + }, + }, + } + errors := aptInstaller.Validate() + assert.Empty(t, errors) +} + +func newTestBrewInstaller(data *appconfig.InstallerData) *BrewInstaller { + return &BrewInstaller{ + InstallerBase: InstallerBase{ + Data: data, + }, + Info: data, + } +} + +func TestBrewValidation(t *testing.T) { + logger.InitLogger(false) + + // 🟢 Valid: No tap specified (tap is optional) + emptyData := &appconfig.InstallerData{ + Name: strPtr("test-brew-valid"), + Type: appconfig.InstallerTypeBrew, + } + assert.Empty(t, newTestBrewInstaller(emptyData).Validate()) + + // 🟢 Valid: Well-formed tap (contains slash, sufficient length) + validData := &appconfig.InstallerData{ + Name: strPtr("test-brew-valid-tap"), + Type: appconfig.InstallerTypeBrew, + Opts: &map[string]any{"tap": "valid/tap"}, + } + assert.Empty(t, newTestBrewInstaller(validData).Validate()) + + // 🔴 Invalid: Tap is present but malformed (missing slash or too short) + invalidData := &appconfig.InstallerData{ + Name: strPtr("test-brew-invalid-tap"), + Type: appconfig.InstallerTypeBrew, + Opts: &map[string]any{"tap": "invalid-tap"}, + } + assert.NotEmpty(t, newTestBrewInstaller(invalidData).Validate()) +} + +func newTestGitInstaller(data *appconfig.InstallerData) *GitInstaller { + return &GitInstaller{ + InstallerBase: InstallerBase{ + Data: data, + }, + Info: data, + } +} + +func TestGitValidation(t *testing.T) { + logger.InitLogger(false) + + // 🟢 Valid: Both destination and ref are present + validData := &appconfig.InstallerData{ + Name: strPtr("test-git-valid"), + Type: appconfig.InstallerTypeGit, + Opts: &map[string]any{ + "destination": "/some/path", + "ref": "main", + }, + } + errors := newTestGitInstaller(validData).Validate() + assert.Empty(t, errors) + + // 🔴 Invalid: Missing ref + missingRefData := &appconfig.InstallerData{ + Name: strPtr("test-git-missing-ref"), + Type: appconfig.InstallerTypeGit, + Opts: &map[string]any{ + "destination": "/some/path", + }, + } + errors = newTestGitInstaller(missingRefData).Validate() + assert.Len(t, errors, 1) + assert.Equal(t, "ref", errors[0].FieldName) + + // 🔴 Invalid: Missing destination + missingDestData := &appconfig.InstallerData{ + Name: strPtr("test-git-missing-destination"), + Type: appconfig.InstallerTypeGit, + Opts: &map[string]any{ + "ref": "main", + }, + } + errors = newTestGitInstaller(missingDestData).Validate() + assert.Len(t, errors, 1) + assert.Equal(t, "destination", errors[0].FieldName) + + // 🔴 Invalid: Missing both destination and ref + missingBothData := &appconfig.InstallerData{ + Name: strPtr("test-git-missing-both"), + Type: appconfig.InstallerTypeGit, + Opts: &map[string]any{}, + } + errors = newTestGitInstaller(missingBothData).Validate() + assert.Len(t, errors, 2) + assert.ElementsMatch(t, []string{"destination", "ref"}, []string{errors[0].FieldName, errors[1].FieldName}) +} + +func newTestGitHubReleaseInstaller(data *appconfig.InstallerData) *GitHubReleaseInstaller { + return &GitHubReleaseInstaller{ + InstallerBase: InstallerBase{ + Data: data, + }, + Info: data, + } +} + +func TestGitHubReleaseValidation(t *testing.T) { + logger.InitLogger(false) + + // 🟢 Valid + validData := &appconfig.InstallerData{ + Name: strPtr("ghr-valid"), + Type: appconfig.InstallerTypeGitHubRelease, + Opts: &map[string]any{ + "repository": "owner/repo", + "destination": "/some/path", + "download_filename": "file.tar.gz", // valid string + "strategy": "tar", + }, + } + assert.Empty(t, newTestGitHubReleaseInstaller(validData).Validate()) + + // 🔴 Missing repository + missingRepo := &appconfig.InstallerData{ + Name: strPtr("ghr-missing-repo"), + Type: appconfig.InstallerTypeGitHubRelease, + Opts: &map[string]any{ + "destination": "/some/path", + "download_filename": "file.tar.gz", + }, + } + errors := newTestGitHubReleaseInstaller(missingRepo).Validate() + assert.Len(t, errors, 1) + assert.Equal(t, "repository", errors[0].FieldName) + + // 🔴 Missing download_filename + missingDownloadFilename := &appconfig.InstallerData{ + Name: strPtr("ghr-missing-download"), + Type: appconfig.InstallerTypeGitHubRelease, + Opts: &map[string]any{ + "repository": "owner/repo", + "destination": "/some/path", + }, + } + errors = newTestGitHubReleaseInstaller(missingDownloadFilename).Validate() + assert.Len(t, errors, 1) + assert.Equal(t, "download_filename", errors[0].FieldName) + + // 🔴 Empty per-platform download_filename + emptyPlatformFilename := &appconfig.InstallerData{ + Name: strPtr("ghr-empty-platform-filename"), + Type: appconfig.InstallerTypeGitHubRelease, + Opts: &map[string]any{ + "repository": "owner/repo", + "destination": "/some/path", + "download_filename": map[string]*string{ + string(platform.GetPlatform()): strPtr(""), + }, + }, + } + errors = newTestGitHubReleaseInstaller(emptyPlatformFilename).Validate() + assert.Len(t, errors, 1) + assert.Contains(t, errors[0].FieldName, "download_filename") + + // 🔴 Invalid strategy + invalidStrategy := &appconfig.InstallerData{ + Name: strPtr("ghr-invalid-strategy"), + Type: appconfig.InstallerTypeGitHubRelease, + Opts: &map[string]any{ + "repository": "owner/repo", + "destination": "/some/path", + "download_filename": "file.tar.gz", + "strategy": "exe", // invalid + }, + } + errors = newTestGitHubReleaseInstaller(invalidStrategy).Validate() + assert.Len(t, errors, 1) + assert.Equal(t, "strategy", errors[0].FieldName) +} + +func newTestGroupInstaller(data *appconfig.InstallerData) *GroupInstaller { + return &GroupInstaller{ + InstallerBase: InstallerBase{ + Data: data, + }, + Config: nil, + Data: data, + } +} + +func TestGroupValidation(t *testing.T) { + logger.InitLogger(false) + + // 🟢 Valid: one sub-installer + validStep := appconfig.InstallerData{ + Name: strPtr("child-installer"), + Type: appconfig.InstallerTypeBrew, + } + validData := &appconfig.InstallerData{ + Name: strPtr("group-valid"), + Type: appconfig.InstallerTypeGroup, + Steps: &[]appconfig.InstallerData{validStep}, + } + assert.Empty(t, newTestGroupInstaller(validData).Validate()) + + // 🔴 Invalid: empty steps slice + emptySteps := &appconfig.InstallerData{ + Name: strPtr("group-empty"), + Type: appconfig.InstallerTypeGroup, + Steps: &[]appconfig.InstallerData{}, + } + errors := newTestGroupInstaller(emptySteps).Validate() + assert.Len(t, errors, 1) + assert.Equal(t, "steps", errors[0].FieldName) + + // 🔴 Invalid: nil steps + nilSteps := &appconfig.InstallerData{ + Name: strPtr("group-nil"), + Type: appconfig.InstallerTypeGroup, + Steps: nil, + } + errors = newTestGroupInstaller(nilSteps).Validate() + assert.Len(t, errors, 1) + assert.Equal(t, "steps", errors[0].FieldName) +} + +func newTestManifestInstaller(data *appconfig.InstallerData) *ManifestInstaller { + return &ManifestInstaller{ + InstallerBase: InstallerBase{ + Data: data, + }, + Config: nil, + Info: data, + } +} + +func TestManifestValidation(t *testing.T) { + logger.InitLogger(false) + + // 🟢 Valid + validData := &appconfig.InstallerData{ + Name: strPtr("manifest-valid"), + Type: appconfig.InstallerTypeManifest, + Opts: &map[string]any{ + "source": "https://example.com/repo.git", + "path": "manifests/installer.yml", + "ref": "main", + }, + } + assert.Empty(t, newTestManifestInstaller(validData).Validate()) + + // 🔴 Missing source + missingSource := &appconfig.InstallerData{ + Name: strPtr("manifest-missing-source"), + Type: appconfig.InstallerTypeManifest, + Opts: &map[string]any{ + "path": "some/path", + }, + } + errors := newTestManifestInstaller(missingSource).Validate() + assert.Len(t, errors, 1) + assert.Equal(t, "source", errors[0].FieldName) + + // 🔴 Missing path + missingPath := &appconfig.InstallerData{ + Name: strPtr("manifest-missing-path"), + Type: appconfig.InstallerTypeManifest, + Opts: &map[string]any{ + "source": "https://example.com/repo.git", + }, + } + errors = newTestManifestInstaller(missingPath).Validate() + assert.Len(t, errors, 1) + assert.Equal(t, "path", errors[0].FieldName) + + // 🔴 Empty ref (not nil, just empty) + emptyRef := &appconfig.InstallerData{ + Name: strPtr("manifest-empty-ref"), + Type: appconfig.InstallerTypeManifest, + Opts: &map[string]any{ + "source": "https://example.com/repo.git", + "path": "install.yml", + "ref": "", + }, + } + errors = newTestManifestInstaller(emptyRef).Validate() + assert.Len(t, errors, 1) + assert.Equal(t, "ref", errors[0].FieldName) +} + +func newTestNpmInstaller(data *appconfig.InstallerData) *NpmInstaller { + return &NpmInstaller{ + InstallerBase: InstallerBase{ + Data: data, + }, + Config: nil, + PackageManager: PackageManagerNpm, + Info: data, + } +} + +func TestNpmValidation(t *testing.T) { + logger.InitLogger(false) + + // 🟢 Valid npm installer + validData := &appconfig.InstallerData{ + Name: strPtr("some-npm-package"), + Type: appconfig.InstallerTypeNpm, + } + assert.Empty(t, newTestNpmInstaller(validData).Validate()) + + // 🔴 Edge case: nil name (will panic or fail in BaseValidate if implemented to check it) + nilNameData := &appconfig.InstallerData{ + Name: nil, + Type: appconfig.InstallerTypeNpm, + } + errors := newTestNpmInstaller(nilNameData).Validate() + assert.Len(t, errors, 1) + assert.Equal(t, "name", errors[0].FieldName) +} + +func newTestPipxInstaller(data *appconfig.InstallerData) *PipxInstaller { + return &PipxInstaller{ + InstallerBase: InstallerBase{ + Data: data, + }, + Config: nil, + Info: data, + } +} + +func TestPipxValidation(t *testing.T) { + logger.InitLogger(false) + + // 🟢 Valid pipx installer + validData := &appconfig.InstallerData{ + Name: strPtr("some-pipx-package"), + Type: appconfig.InstallerTypePipx, + } + assert.Empty(t, newTestPipxInstaller(validData).Validate()) + + // 🔴 Optional: test nil name if BaseValidate handles it + nilNameData := &appconfig.InstallerData{ + Name: nil, + Type: appconfig.InstallerTypePipx, + } + errors := newTestPipxInstaller(nilNameData).Validate() + // Uncomment if BaseValidate checks for nil + assert.Len(t, errors, 1) + assert.Equal(t, "name", errors[0].FieldName) +} + +func newTestRsyncInstaller(data *appconfig.InstallerData) *RsyncInstaller { + return &RsyncInstaller{ + InstallerBase: InstallerBase{ + Data: data, + }, + Config: nil, + Info: data, + } +} + +func TestRsyncValidation(t *testing.T) { + logger.InitLogger(false) + + // 🟢 Valid rsync config + validData := &appconfig.InstallerData{ + Name: strPtr("rsync-valid"), + Type: appconfig.InstallerTypeRsync, + Opts: &map[string]any{ + "source": "/path/from", + "destination": "/path/to", + "flags": "-avz", + }, + } + assert.Empty(t, newTestRsyncInstaller(validData).Validate()) + + // 🔴 Missing source + missingSource := &appconfig.InstallerData{ + Name: strPtr("rsync-missing-source"), + Type: appconfig.InstallerTypeRsync, + Opts: &map[string]any{ + "destination": "/path/to", + }, + } + errors := newTestRsyncInstaller(missingSource).Validate() + assert.Len(t, errors, 1) + assert.Equal(t, "source", errors[0].FieldName) + + // 🔴 Missing destination + missingDest := &appconfig.InstallerData{ + Name: strPtr("rsync-missing-destination"), + Type: appconfig.InstallerTypeRsync, + Opts: &map[string]any{ + "source": "/path/from", + }, + } + errors = newTestRsyncInstaller(missingDest).Validate() + assert.Len(t, errors, 1) + assert.Equal(t, "destination", errors[0].FieldName) + + // 🔴 Empty flags string + emptyFlags := &appconfig.InstallerData{ + Name: strPtr("rsync-empty-flags"), + Type: appconfig.InstallerTypeRsync, + Opts: &map[string]any{ + "source": "/path/from", + "destination": "/path/to", + "flags": "", + }, + } + errors = newTestRsyncInstaller(emptyFlags).Validate() + assert.Len(t, errors, 1) + assert.Equal(t, "flags", errors[0].FieldName) +} + +func newTestShellInstaller(data *appconfig.InstallerData) *ShellInstaller { + return &ShellInstaller{ + InstallerBase: InstallerBase{Data: data}, + Config: nil, + Info: data, + } +} + +func TestShellValidation(t *testing.T) { + logger.InitLogger(false) + + // 🟢 Valid shell config + validData := &appconfig.InstallerData{ + Name: strPtr("shell-valid"), + Type: appconfig.InstallerTypeShell, + Opts: &map[string]any{ + "command": "echo install", + "update_command": "echo update", + }, + } + assert.Empty(t, newTestShellInstaller(validData).Validate()) + + // 🔴 Missing command + missingCommand := &appconfig.InstallerData{ + Name: strPtr("shell-missing-command"), + Type: appconfig.InstallerTypeShell, + Opts: &map[string]any{ + "update_command": "echo update", + }, + } + errors := newTestShellInstaller(missingCommand).Validate() + assert.Len(t, errors, 1) + assert.Equal(t, "command", errors[0].FieldName) + + // 🔴 Missing update_command + missingUpdate := &appconfig.InstallerData{ + Name: strPtr("shell-missing-update"), + Type: appconfig.InstallerTypeShell, + Opts: &map[string]any{ + "command": "echo install", + }, + } + errors = newTestShellInstaller(missingUpdate).Validate() + assert.Len(t, errors, 1) + assert.Equal(t, "update_command", errors[0].FieldName) + + // 🔴 Missing both + missingBoth := &appconfig.InstallerData{ + Name: strPtr("shell-missing-both"), + Type: appconfig.InstallerTypeShell, + Opts: &map[string]any{}, + } + errors = newTestShellInstaller(missingBoth).Validate() + assert.Len(t, errors, 2) + assert.ElementsMatch(t, []string{"command", "update_command"}, + []string{errors[0].FieldName, errors[1].FieldName}) +} + func strPtr(s string) *string { return &s } diff --git a/installer/manifest_installer.go b/installer/manifest_installer.go index b52e6f0..7d0783e 100644 --- a/installer/manifest_installer.go +++ b/installer/manifest_installer.go @@ -23,6 +23,22 @@ type ManifestOpts struct { Ref *string } +func (i *ManifestInstaller) Validate() []ValidationError { + errors := i.BaseValidate() + info := i.GetData() + opts := i.GetOpts() + if opts.Source == nil || len(*opts.Source) == 0 { + errors = append(errors, ValidationError{FieldName: "source", Message: validationIsRequired(), InstallerName: *info.Name}) + } + if opts.Path == nil || len(*opts.Path) == 0 { + errors = append(errors, ValidationError{FieldName: "path", Message: validationIsRequired(), InstallerName: *info.Name}) + } + if opts.Ref != nil && len(*opts.Ref) == 0 { + errors = append(errors, ValidationError{FieldName: "ref", Message: validationIsNotEmpty(), InstallerName: *info.Name}) + } + return errors +} + // Install implements IInstaller. func (i *ManifestInstaller) Install() error { logger.Debug("Getting manifest info...") diff --git a/installer/npm_installer.go b/installer/npm_installer.go index bf93d31..bd894e0 100644 --- a/installer/npm_installer.go +++ b/installer/npm_installer.go @@ -24,6 +24,11 @@ const ( PackageManagerPnpm PackageManager = "pnpm" ) +func (i *NpmInstaller) Validate() []ValidationError { + errors := i.BaseValidate() + return errors +} + // Install implements IInstaller. func (i *NpmInstaller) Install() error { return i.RunCmdPassThrough(string(i.PackageManager), "install", "--global", *i.Info.Name) diff --git a/installer/pipx_installer.go b/installer/pipx_installer.go index 18711de..5d92e34 100644 --- a/installer/pipx_installer.go +++ b/installer/pipx_installer.go @@ -15,6 +15,11 @@ type PipxOpts struct { // } +func (i *PipxInstaller) Validate() []ValidationError { + errors := i.BaseValidate() + return errors +} + // Install implements IInstaller. func (i *PipxInstaller) Install() error { name := *i.Info.Name diff --git a/installer/rsync_installer.go b/installer/rsync_installer.go index 3419ad2..5a7333c 100644 --- a/installer/rsync_installer.go +++ b/installer/rsync_installer.go @@ -20,6 +20,22 @@ type RsyncOpts struct { Flags *string } +func (i *RsyncInstaller) Validate() []ValidationError { + errors := i.BaseValidate() + info := i.GetData() + opts := i.GetOpts() + if opts.Source == nil || len(*opts.Source) == 0 { + errors = append(errors, ValidationError{FieldName: "source", Message: validationIsRequired(), InstallerName: *info.Name}) + } + if opts.Destination == nil || len(*opts.Destination) == 0 { + errors = append(errors, ValidationError{FieldName: "destination", Message: validationIsRequired(), InstallerName: *info.Name}) + } + if opts.Flags != nil && len(*opts.Flags) == 0 { + errors = append(errors, ValidationError{FieldName: "flags", Message: validationIsNotEmpty(), InstallerName: *info.Name}) + } + return errors +} + // Install implements IInstaller. func (i *RsyncInstaller) Install() error { defaultFlags := "-tr" diff --git a/installer/shell_installer.go b/installer/shell_installer.go index 86ac1dd..0d141b6 100644 --- a/installer/shell_installer.go +++ b/installer/shell_installer.go @@ -16,6 +16,19 @@ type ShellOpts struct { UpdateCommand *string } +func (i *ShellInstaller) Validate() []ValidationError { + errors := i.BaseValidate() + info := i.GetData() + opts := i.GetOpts() + if opts.Command == nil || len(*opts.Command) == 0 { + errors = append(errors, ValidationError{FieldName: "command", Message: validationIsRequired(), InstallerName: *info.Name}) + } + if opts.UpdateCommand == nil || len(*opts.UpdateCommand) == 0 { + errors = append(errors, ValidationError{FieldName: "update_command", Message: validationIsRequired(), InstallerName: *info.Name}) + } + return errors +} + // Install implements IInstaller. func (i *ShellInstaller) Install() error { return i.RunCmdAsFile(*i.GetOpts().Command) diff --git a/installer/validation_error.go b/installer/validation_error.go new file mode 100644 index 0000000..c1d2183 --- /dev/null +++ b/installer/validation_error.go @@ -0,0 +1,25 @@ +package installer + +import "fmt" + +type ValidationError struct { + FieldName string + Message string + InstallerName string +} + +func (v ValidationError) Error() string { + return fmt.Sprintf("Validation Error in %s - Field '%s' is invalid: %s.", v.InstallerName, v.FieldName, v.Message) +} + +func validationIsRequired() string { + return "Must be specified" +} + +func validationInvalidFormat() string { + return "Invalid format" +} + +func validationIsNotEmpty() string { + return "Cannot be empty" +} diff --git a/main.go b/main.go index 86e16b0..2f1fa92 100644 --- a/main.go +++ b/main.go @@ -37,6 +37,9 @@ func main() { } logger.Info("Checking all installers...") + instances := []installer.IInstaller{} + hasValidationErrors := false + for _, i := range cfg.Install { installerInstance, err := installer.GetInstaller(cfg, &i) if err != nil { @@ -46,12 +49,27 @@ func main() { if installerInstance == nil { logger.Warn("Installer type %s is not supported, skipping", i.Type) } else { - err = installer.RunInstaller(cfg, installerInstance) - if err != nil { - logger.Error("%s", err) - os.Exit(1) + errors := installerInstance.Validate() + if len(errors) > 0 { + hasValidationErrors = true + for _, e := range errors { + logger.Error(e.Error()) + } } } } + + if hasValidationErrors { + logger.Error("Validation errors found, exiting. Please fix the errors and try again.") + os.Exit(1) + } + + for _, i := range instances { + err = installer.RunInstaller(cfg, i) + if err != nil { + logger.Error("%s", err) + os.Exit(1) + } + } logger.Info("Complete") } diff --git a/platform/platform.go b/platform/platform.go index 1fd1664..7c7fed3 100644 --- a/platform/platform.go +++ b/platform/platform.go @@ -3,6 +3,7 @@ package platform import ( "fmt" "runtime" + "slices" ) var osValue string = runtime.GOOS @@ -83,14 +84,8 @@ func (o *PlatformMap[T]) ResolveWithFallback(fallback PlatformMap[T]) T { } func ContainsPlatform(platforms *[]Platform, platform Platform) bool { - for _, p := range *platforms { - if p == platform { - return true - } - } - return false + return slices.Contains(*platforms, platform) } - func (p *Platforms) GetShouldRunOnOS(curOS Platform) bool { if p == nil { return true @@ -104,3 +99,21 @@ func (p *Platforms) GetShouldRunOnOS(curOS Platform) bool { } return true } + +func NewPlatformMap[T any](values map[string]T) *PlatformMap[T] { + p := &PlatformMap[T]{} + for k, v := range values { + val := v // capture value for pointer + switch Platform(k) { + case PlatformMacos: + p.MacOS = &val + case PlatformLinux: + p.Linux = &val + case PlatformWindows: + p.Windows = &val + default: + panic(fmt.Sprintf("Unsupported platform key: %q", k)) + } + } + return p +}