diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 8f98454e..2a95ec5f 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -15,7 +15,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v6 with: - go-version-file: 'go.work' + go-version-file: 'go.mod' - name: Setup Task uses: arduino/setup-task@v1 @@ -25,6 +25,12 @@ jobs: sudo apt-get update sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev + - name: Build CLI + run: | + go generate ./pkg/updater/... + task cli:build + echo "$(pwd)/bin" >> $GITHUB_PATH + - name: Run coverage run: task cov diff --git a/pkg/cli/ansi.go b/pkg/cli/ansi.go index 5a28a065..2e58b3ed 100644 --- a/pkg/cli/ansi.go +++ b/pkg/cli/ansi.go @@ -100,17 +100,19 @@ func (s *AnsiStyle) Render(text string) string { return strings.Join(codes, "") + text + ansiReset } -// Hex color support +// fgColorHex converts a hex string to an ANSI foreground color code. func fgColorHex(hex string) string { r, g, b := hexToRGB(hex) return fmt.Sprintf("\033[38;2;%d;%d;%dm", r, g, b) } +// bgColorHex converts a hex string to an ANSI background color code. func bgColorHex(hex string) string { r, g, b := hexToRGB(hex) return fmt.Sprintf("\033[48;2;%d;%d;%dm", r, g, b) } +// hexToRGB converts a hex string to RGB values. func hexToRGB(hex string) (int, int, int) { hex = strings.TrimPrefix(hex, "#") if len(hex) != 6 { @@ -120,4 +122,4 @@ func hexToRGB(hex string) (int, int, int) { g, _ := strconv.ParseInt(hex[2:4], 16, 64) b, _ := strconv.ParseInt(hex[4:6], 16, 64) return int(r), int(g), int(b) -} +} \ No newline at end of file diff --git a/pkg/cli/check.go b/pkg/cli/check.go index 499cd890..a6c9e9e1 100644 --- a/pkg/cli/check.go +++ b/pkg/cli/check.go @@ -88,4 +88,4 @@ func (c *CheckBuilder) String() string { // Print outputs the check result. func (c *CheckBuilder) Print() { fmt.Println(c.String()) -} +} \ No newline at end of file diff --git a/pkg/cli/check_test.go b/pkg/cli/check_test.go new file mode 100644 index 00000000..760853c3 --- /dev/null +++ b/pkg/cli/check_test.go @@ -0,0 +1,49 @@ +package cli + +import "testing" + +func TestCheckBuilder(t *testing.T) { + UseASCII() // Deterministic output + + // Pass + c := Check("foo").Pass() + got := c.String() + if got == "" { + t.Error("Empty output for Pass") + } + + // Fail + c = Check("foo").Fail() + got = c.String() + if got == "" { + t.Error("Empty output for Fail") + } + + // Skip + c = Check("foo").Skip() + got = c.String() + if got == "" { + t.Error("Empty output for Skip") + } + + // Warn + c = Check("foo").Warn() + got = c.String() + if got == "" { + t.Error("Empty output for Warn") + } + + // Duration + c = Check("foo").Pass().Duration("1s") + got = c.String() + if got == "" { + t.Error("Empty output for Duration") + } + + // Message + c = Check("foo").Message("status") + got = c.String() + if got == "" { + t.Error("Empty output for Message") + } +} diff --git a/pkg/cli/glyph.go b/pkg/cli/glyph.go index c3af44fe..28ca5fd9 100644 --- a/pkg/cli/glyph.go +++ b/pkg/cli/glyph.go @@ -9,16 +9,24 @@ import ( type GlyphTheme int const ( + // ThemeUnicode uses standard Unicode symbols. ThemeUnicode GlyphTheme = iota + // ThemeEmoji uses Emoji symbols. ThemeEmoji + // ThemeASCII uses ASCII fallback symbols. ThemeASCII ) var currentTheme = ThemeUnicode +// UseUnicode switches the glyph theme to Unicode. func UseUnicode() { currentTheme = ThemeUnicode } -func UseEmoji() { currentTheme = ThemeEmoji } -func UseASCII() { currentTheme = ThemeASCII } + +// UseEmoji switches the glyph theme to Emoji. +func UseEmoji() { currentTheme = ThemeEmoji } + +// UseASCII switches the glyph theme to ASCII. +func UseASCII() { currentTheme = ThemeASCII } func glyphMap() map[string]string { switch currentTheme { @@ -31,7 +39,7 @@ func glyphMap() map[string]string { } } -// Glyph converts a shortcode to its symbol. +// Glyph converts a shortcode (e.g. ":check:") to its symbol based on the current theme. func Glyph(code string) string { if sym, ok := glyphMap()[code]; ok { return sym @@ -78,4 +86,4 @@ func replaceGlyph(input *bytes.Buffer) string { return Glyph(code.String()) } } -} +} \ No newline at end of file diff --git a/pkg/cli/layout.go b/pkg/cli/layout.go index 736dc940..d4feb575 100644 --- a/pkg/cli/layout.go +++ b/pkg/cli/layout.go @@ -6,10 +6,15 @@ import "fmt" type Region rune const ( + // RegionHeader is the top region of the layout. RegionHeader Region = 'H' + // RegionLeft is the left sidebar region. RegionLeft Region = 'L' + // RegionContent is the main content region. RegionContent Region = 'C' + // RegionRight is the right sidebar region. RegionRight Region = 'R' + // RegionFooter is the bottom region of the layout. RegionFooter Region = 'F' ) @@ -37,6 +42,7 @@ type Renderable interface { // StringBlock is a simple string that implements Renderable. type StringBlock string +// Render returns the string content. func (s StringBlock) Render() string { return string(s) } // Layout creates a new layout from a variant string. @@ -138,4 +144,4 @@ func toRenderable(item any) Renderable { default: return StringBlock(fmt.Sprint(v)) } -} +} \ No newline at end of file diff --git a/pkg/cli/output_test.go b/pkg/cli/output_test.go new file mode 100644 index 00000000..25f1cfe7 --- /dev/null +++ b/pkg/cli/output_test.go @@ -0,0 +1,98 @@ +package cli + +import ( + "bytes" + "io" + "os" + "testing" +) + +func captureOutput(f func()) string { + old := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + f() + + w.Close() + os.Stdout = old + + var buf bytes.Buffer + io.Copy(&buf, r) + return buf.String() +} + +func TestSemanticOutput(t *testing.T) { + UseASCII() + + // Test Success + out := captureOutput(func() { + Success("done") + }) + if out == "" { + t.Error("Success output empty") + } + + // Test Error + out = captureOutput(func() { + Error("fail") + }) + if out == "" { + t.Error("Error output empty") + } + + // Test Warn + out = captureOutput(func() { + Warn("warn") + }) + if out == "" { + t.Error("Warn output empty") + } + + // Test Info + out = captureOutput(func() { + Info("info") + }) + if out == "" { + t.Error("Info output empty") + } + + // Test Task + out = captureOutput(func() { + Task("task", "msg") + }) + if out == "" { + t.Error("Task output empty") + } + + // Test Section + out = captureOutput(func() { + Section("section") + }) + if out == "" { + t.Error("Section output empty") + } + + // Test Hint + out = captureOutput(func() { + Hint("hint", "msg") + }) + if out == "" { + t.Error("Hint output empty") + } + + // Test Result + out = captureOutput(func() { + Result(true, "pass") + }) + if out == "" { + t.Error("Result(true) output empty") + } + + out = captureOutput(func() { + Result(false, "fail") + }) + if out == "" { + t.Error("Result(false) output empty") + } +} diff --git a/pkg/docs/cmd_scan.go b/pkg/docs/cmd_scan.go index b6fd6107..f2fc5284 100644 --- a/pkg/docs/cmd_scan.go +++ b/pkg/docs/cmd_scan.go @@ -25,14 +25,14 @@ type RepoDocInfo struct { func loadRegistry(registryPath string) (*repos.Registry, string, error) { var reg *repos.Registry var err error - var basePath string + var registryDir string if registryPath != "" { reg, err = repos.LoadRegistry(registryPath) if err != nil { return nil, "", cli.Wrap(err, i18n.T("i18n.fail.load", "registry")) } - basePath = filepath.Dir(registryPath) + registryDir = filepath.Dir(registryPath) } else { registryPath, err = repos.FindRegistry() if err == nil { @@ -40,14 +40,44 @@ func loadRegistry(registryPath string) (*repos.Registry, string, error) { if err != nil { return nil, "", cli.Wrap(err, i18n.T("i18n.fail.load", "registry")) } - basePath = filepath.Dir(registryPath) + registryDir = filepath.Dir(registryPath) } else { cwd, _ := os.Getwd() reg, err = repos.ScanDirectory(cwd) if err != nil { return nil, "", cli.Wrap(err, i18n.T("i18n.fail.scan", "directory")) } - basePath = cwd + registryDir = cwd + } + } + + // Load workspace config to respect packages_dir + wsConfig, err := repos.LoadWorkspaceConfig(registryDir) + if err != nil { + return nil, "", cli.Wrap(err, i18n.T("i18n.fail.load", "workspace config")) + } + + basePath := registryDir + + if wsConfig.PackagesDir != "" { + pkgDir := wsConfig.PackagesDir + + // Expand ~ + if strings.HasPrefix(pkgDir, "~/") { + home, _ := os.UserHomeDir() + pkgDir = filepath.Join(home, pkgDir[2:]) + } + + if !filepath.IsAbs(pkgDir) { + pkgDir = filepath.Join(registryDir, pkgDir) + } + basePath = pkgDir + + // Update repo paths if they were relative to registry + // This ensures consistency when packages_dir overrides the default + reg.BasePath = basePath + for _, repo := range reg.Repos { + repo.Path = filepath.Join(basePath, repo.Name) } } diff --git a/pkg/framework/core/ipc_test.go b/pkg/framework/core/ipc_test.go new file mode 100644 index 00000000..87b65707 --- /dev/null +++ b/pkg/framework/core/ipc_test.go @@ -0,0 +1,77 @@ +package core + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" +) + +type IPCTestQuery struct{ Value string } +type IPCTestTask struct{ Value string } + +func TestIPC_Query(t *testing.T) { + c, _ := New() + + // No handler + res, handled, err := c.QUERY(IPCTestQuery{}) + assert.False(t, handled) + assert.Nil(t, res) + assert.Nil(t, err) + + // With handler + c.RegisterQuery(func(c *Core, q Query) (any, bool, error) { + if tq, ok := q.(IPCTestQuery); ok { + return tq.Value + "-response", true, nil + } + return nil, false, nil + }) + + res, handled, err = c.QUERY(IPCTestQuery{Value: "test"}) + assert.True(t, handled) + assert.Nil(t, err) + assert.Equal(t, "test-response", res) +} + +func TestIPC_QueryAll(t *testing.T) { + c, _ := New() + + c.RegisterQuery(func(c *Core, q Query) (any, bool, error) { + return "h1", true, nil + }) + c.RegisterQuery(func(c *Core, q Query) (any, bool, error) { + return "h2", true, nil + }) + + results, err := c.QUERYALL(IPCTestQuery{}) + assert.Nil(t, err) + assert.Len(t, results, 2) + assert.Contains(t, results, "h1") + assert.Contains(t, results, "h2") +} + +func TestIPC_Perform(t *testing.T) { + c, _ := New() + + c.RegisterTask(func(c *Core, task Task) (any, bool, error) { + if tt, ok := task.(IPCTestTask); ok { + if tt.Value == "error" { + return nil, true, errors.New("task error") + } + return "done", true, nil + } + return nil, false, nil + }) + + // Success + res, handled, err := c.PERFORM(IPCTestTask{Value: "run"}) + assert.True(t, handled) + assert.Nil(t, err) + assert.Equal(t, "done", res) + + // Error + res, handled, err = c.PERFORM(IPCTestTask{Value: "error"}) + assert.True(t, handled) + assert.Error(t, err) + assert.Nil(t, res) +} diff --git a/pkg/php/container.go b/pkg/php/container.go index fe8637ca..37a1d73e 100644 --- a/pkg/php/container.go +++ b/pkg/php/container.go @@ -349,6 +349,12 @@ func IsPHPProject(dir string) bool { return err == nil } +// commonLinuxKitPaths defines default search locations for linuxkit. +var commonLinuxKitPaths = []string{ + "/usr/local/bin/linuxkit", + "/opt/homebrew/bin/linuxkit", +} + // lookupLinuxKit finds the linuxkit binary. func lookupLinuxKit() (string, error) { // Check PATH first @@ -356,13 +362,7 @@ func lookupLinuxKit() (string, error) { return path, nil } - // Check common locations - paths := []string{ - "/usr/local/bin/linuxkit", - "/opt/homebrew/bin/linuxkit", - } - - for _, p := range paths { + for _, p := range commonLinuxKitPaths { if _, err := os.Stat(p); err == nil { return p, nil } diff --git a/pkg/php/container_test.go b/pkg/php/container_test.go index 939e5c1b..f1a2c5c0 100644 --- a/pkg/php/container_test.go +++ b/pkg/php/container_test.go @@ -102,16 +102,22 @@ func TestIsPHPProject_Container_Bad(t *testing.T) { func TestLookupLinuxKit_Bad(t *testing.T) { t.Run("returns error when linuxkit not found", func(t *testing.T) { - // Save original PATH and restore after test + // Save original PATH and paths origPath := os.Getenv("PATH") - defer os.Setenv("PATH", origPath) + origCommonPaths := commonLinuxKitPaths + defer func() { + os.Setenv("PATH", origPath) + commonLinuxKitPaths = origCommonPaths + }() - // Set PATH to empty to ensure linuxkit isn't found + // Set PATH to empty and clear common paths os.Setenv("PATH", "") + commonLinuxKitPaths = []string{} _, err := lookupLinuxKit() - assert.Error(t, err) - assert.Contains(t, err.Error(), "linuxkit not found") + if assert.Error(t, err) { + assert.Contains(t, err.Error(), "linuxkit not found") + } }) } diff --git a/pkg/php/packages_test.go b/pkg/php/packages_test.go index 4cd6af38..4c26b450 100644 --- a/pkg/php/packages_test.go +++ b/pkg/php/packages_test.go @@ -50,7 +50,7 @@ func TestReadComposerJSON_Bad(t *testing.T) { dir := t.TempDir() _, err := readComposerJSON(dir) assert.Error(t, err) - assert.Contains(t, err.Error(), "failed to read composer.json") + assert.Contains(t, err.Error(), "Failed to read composer.json") }) t.Run("invalid JSON", func(t *testing.T) { @@ -60,7 +60,7 @@ func TestReadComposerJSON_Bad(t *testing.T) { _, err = readComposerJSON(dir) assert.Error(t, err) - assert.Contains(t, err.Error(), "failed to parse composer.json") + assert.Contains(t, err.Error(), "Failed to parse composer.json") }) } @@ -104,10 +104,9 @@ func TestWriteComposerJSON_Bad(t *testing.T) { err := writeComposerJSON("/non/existent/path", raw) assert.Error(t, err) - assert.Contains(t, err.Error(), "failed to write composer.json") + assert.Contains(t, err.Error(), "Failed to write composer.json") }) } - func TestGetRepositories_Good(t *testing.T) { t.Run("returns empty slice when no repositories", func(t *testing.T) { raw := make(map[string]json.RawMessage) @@ -149,7 +148,7 @@ func TestGetRepositories_Bad(t *testing.T) { _, err := getRepositories(raw) assert.Error(t, err) - assert.Contains(t, err.Error(), "failed to parse repositories") + assert.Contains(t, err.Error(), "Failed to parse repositories") }) } @@ -212,17 +211,17 @@ func TestGetPackageInfo_Bad(t *testing.T) { dir := t.TempDir() _, _, err := getPackageInfo(dir) assert.Error(t, err) - assert.Contains(t, err.Error(), "failed to read package composer.json") + assert.Contains(t, err.Error(), "Failed to read package composer.json") }) t.Run("invalid JSON", func(t *testing.T) { dir := t.TempDir() - err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte("invalid{"), 0644) + err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte("not json{"), 0644) require.NoError(t, err) _, _, err = getPackageInfo(dir) assert.Error(t, err) - assert.Contains(t, err.Error(), "failed to parse package composer.json") + assert.Contains(t, err.Error(), "Failed to parse package composer.json") }) t.Run("missing name", func(t *testing.T) { diff --git a/pkg/php/services_extended_test.go b/pkg/php/services_extended_test.go index 199d54d3..db2c42be 100644 --- a/pkg/php/services_extended_test.go +++ b/pkg/php/services_extended_test.go @@ -99,9 +99,8 @@ func TestBaseService_Logs_Bad(t *testing.T) { t.Run("returns error when log file doesn't exist", func(t *testing.T) { s := &baseService{logPath: "/nonexistent/path/log.log"} _, err := s.Logs(false) - assert.Error(t, err) - assert.Contains(t, err.Error(), "failed to open log file") + assert.Contains(t, err.Error(), "Failed to open log file") }) } diff --git a/pkg/php/ssl_extended_test.go b/pkg/php/ssl_extended_test.go index ae1edfab..6f30503f 100644 --- a/pkg/php/ssl_extended_test.go +++ b/pkg/php/ssl_extended_test.go @@ -22,7 +22,7 @@ func TestGetSSLDir_Bad(t *testing.T) { opts := SSLOptions{Dir: "/dev/null/cannot/create"} _, err := GetSSLDir(opts) assert.Error(t, err) - assert.Contains(t, err.Error(), "failed to create SSL directory") + assert.Contains(t, err.Error(), "Failed to create SSL directory") }) } diff --git a/pkg/release/publishers/github.go b/pkg/release/publishers/github.go index f041174f..b1eaf703 100644 --- a/pkg/release/publishers/github.go +++ b/pkg/release/publishers/github.go @@ -26,11 +26,6 @@ func (p *GitHubPublisher) Name() string { // Publish publishes the release to GitHub. // Uses the gh CLI for creating releases and uploading assets. func (p *GitHubPublisher) Publish(ctx context.Context, release *Release, pubCfg PublisherConfig, relCfg ReleaseConfig, dryRun bool) error { - // Validate gh CLI is available - if err := validateGhCli(); err != nil { - return err - } - // Determine repository repo := "" if relCfg != nil { @@ -49,6 +44,11 @@ func (p *GitHubPublisher) Publish(ctx context.Context, release *Release, pubCfg return p.dryRunPublish(release, pubCfg, repo) } + // Validate gh CLI is available and authenticated for actual publish + if err := validateGhCli(); err != nil { + return err + } + return p.executePublish(ctx, release, pubCfg, repo) } diff --git a/pkg/repos/workspace.go b/pkg/repos/workspace.go new file mode 100644 index 00000000..5f7233c9 --- /dev/null +++ b/pkg/repos/workspace.go @@ -0,0 +1,47 @@ +package repos + +import ( + "fmt" + "os" + "path/filepath" + + "gopkg.in/yaml.v3" +) + +// WorkspaceConfig holds workspace-level configuration. +type WorkspaceConfig struct { + Version int `yaml:"version"` + Active string `yaml:"active"` + PackagesDir string `yaml:"packages_dir"` +} + +// DefaultWorkspaceConfig returns a config with default values. +func DefaultWorkspaceConfig() *WorkspaceConfig { + return &WorkspaceConfig{ + Version: 1, + PackagesDir: "./packages", + } +} + +// LoadWorkspaceConfig tries to load workspace.yaml from the given directory's .core subfolder. +func LoadWorkspaceConfig(dir string) (*WorkspaceConfig, error) { + path := filepath.Join(dir, ".core", "workspace.yaml") + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return DefaultWorkspaceConfig(), nil + } + return nil, fmt.Errorf("failed to read workspace config: %w", err) + } + + config := DefaultWorkspaceConfig() + if err := yaml.Unmarshal(data, config); err != nil { + return nil, fmt.Errorf("failed to parse workspace config: %w", err) + } + + if config.Version != 1 { + return nil, fmt.Errorf("unsupported workspace config version: %d", config.Version) + } + + return config, nil +} diff --git a/pkg/repos/workspace_test.go b/pkg/repos/workspace_test.go new file mode 100644 index 00000000..fa3c1055 --- /dev/null +++ b/pkg/repos/workspace_test.go @@ -0,0 +1,56 @@ +package repos + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestLoadWorkspaceConfig_Good(t *testing.T) { + // Setup temp dir + tmpDir := t.TempDir() + coreDir := filepath.Join(tmpDir, ".core") + err := os.MkdirAll(coreDir, 0755) + assert.NoError(t, err) + + // Write valid config + configContent := ` +version: 1 +active: core-php +packages_dir: ./custom-packages +` + err = os.WriteFile(filepath.Join(coreDir, "workspace.yaml"), []byte(configContent), 0644) + assert.NoError(t, err) + + // Load + cfg, err := LoadWorkspaceConfig(tmpDir) + assert.NoError(t, err) + assert.Equal(t, 1, cfg.Version) + assert.Equal(t, "core-php", cfg.Active) + assert.Equal(t, "./custom-packages", cfg.PackagesDir) +} + +func TestLoadWorkspaceConfig_Default(t *testing.T) { + tmpDir := t.TempDir() + + // Load non-existent + cfg, err := LoadWorkspaceConfig(tmpDir) + assert.NoError(t, err) + assert.Equal(t, 1, cfg.Version) + assert.Equal(t, "./packages", cfg.PackagesDir) +} + +func TestLoadWorkspaceConfig_BadVersion(t *testing.T) { + tmpDir := t.TempDir() + coreDir := filepath.Join(tmpDir, ".core") + os.MkdirAll(coreDir, 0755) + + configContent := `version: 2` + os.WriteFile(filepath.Join(coreDir, "workspace.yaml"), []byte(configContent), 0644) + + _, err := LoadWorkspaceConfig(tmpDir) + assert.Error(t, err) + assert.Contains(t, err.Error(), "unsupported workspace config version") +} diff --git a/pkg/setup/cmd_registry.go b/pkg/setup/cmd_registry.go index be57c26d..d1008789 100644 --- a/pkg/setup/cmd_registry.go +++ b/pkg/setup/cmd_registry.go @@ -33,21 +33,34 @@ func runRegistrySetupWithReg(ctx context.Context, reg *repos.Registry, registryP fmt.Printf("%s %s\n", dimStyle.Render(i18n.Label("registry")), registryPath) fmt.Printf("%s %s\n", dimStyle.Render(i18n.T("cmd.setup.org_label")), reg.Org) + registryDir := filepath.Dir(registryPath) + // Determine base path for cloning basePath := reg.BasePath if basePath == "" { - basePath = "./packages" - } - // Resolve relative to registry location - if !filepath.IsAbs(basePath) { - basePath = filepath.Join(filepath.Dir(registryPath), basePath) + // Load workspace config to see if packages_dir is set + wsConfig, err := repos.LoadWorkspaceConfig(registryDir) + if err != nil { + return fmt.Errorf("failed to load workspace config: %w", err) + } + if wsConfig.PackagesDir != "" { + basePath = wsConfig.PackagesDir + } else { + basePath = "./packages" + } } + // Expand ~ if strings.HasPrefix(basePath, "~/") { home, _ := os.UserHomeDir() basePath = filepath.Join(home, basePath[2:]) } + // Resolve relative to registry location + if !filepath.IsAbs(basePath) { + basePath = filepath.Join(registryDir, basePath) + } + fmt.Printf("%s %s\n", dimStyle.Render(i18n.Label("target")), basePath) // Parse type filter