Repository: score-spec/score-compose (go)

Issue:
resources list command reuse table library for UX standardization
In `provisioners list` command we introduced `github.com/olekukonko/tablewriter` to display the data,

To keep the same behavior across commands we can reuse it for `resources list` command

Pull request description:
refactor: resources list update output format
<!--- Provide a general summary of your changes in the Title above -->

#### Description
<!--- Describe your changes in detail -->

Fix #257
update `score-compose resources list` command to reuse behavior added in provisioners list command 

```bash
~$ go run cmd/score-compose/main.go resources list
+-----------------------------+
|             UID             |
+-----------------------------+
| postgres.default#example.db |
+-----------------------------+

~$ go run cmd/score-compose/main.go resources list -f json
[
  {
    "UID": "postgres.default#example.db"
  }
]
```
#### What does this PR do?
<!--- Why is this change required? What problem does it solve? -->
<!--- If it fixes an open issue, please link to the issue here. -->



#### Types of changes
<!--- What types of changes does your code introduce? Put an `x` in all the boxes that apply: -->
- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to change)
- [x] New chore (expected functionality to be implemented)

#### Checklist:
<!--- Go over all the following points, and put an `x` in all the boxes that apply. -->
<!--- If you're unsure about any of these, don't hesitate to ask. We're here to help! -->
- [ ] My change requires a change to the documentation.
  - [ ] I have updated the documentation accordingly.
- [x] I've signed off with an email address that matches the commit author.


Patch:
diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index 9b8f511..96401a2 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -18,7 +18,7 @@ jobs:
       - name: Fetch all tags
         run: git fetch --force --tags
       - name: Set up Go
-        uses: actions/setup-go@f111f3307d8850f501ac008e886eec1fd1932a34 # v5.3.0
+        uses: actions/setup-go@f111f3307d8850f501ac008e886eec1fd1932a34 # v5.3.0
         with:
           go-version-file: 'go.mod'
       - name: Run Go Vet
@@ -26,7 +26,7 @@ jobs:
       - name: Run Go Tests
         run: go test ./... -cover -race
       - name: Build binary
-        uses: goreleaser/goreleaser-action@90a3faa9d0182683851fbfa97ca1a2cb983bfca3 # v6.2.1
+        uses: goreleaser/goreleaser-action@90a3faa9d0182683851fbfa97ca1a2cb983bfca3 # v6.2.1
         with:
           distribution: goreleaser
           version: '~> v2'
@@ -36,12 +36,14 @@ jobs:
       - name: Check licenses
         run: addlicense -l apache -check -v -ignore '**/*.yaml' -c Humanitec ./cmd ./internal/
       - name: Build docker image
-        uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6.15.0
+        uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6.15.0
         with:
           context: .
           push: false
           load: true
           tags: score-compose:test
+          build-args: |
+            "VERSION=test"
       - name: Test docker image
         run: |
           docker run --rm score-compose:test --version
diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml
index 7976fff..e3d0cf6 100644
--- a/.github/workflows/release.yaml
+++ b/.github/workflows/release.yaml
@@ -52,6 +52,8 @@ jobs:
           tags: |
             ghcr.io/score-spec/score-compose:${{ github.ref_name }}
             ghcr.io/score-spec/score-compose:latest
+          build-args: |
+            "VERSION=${{ github.ref_name }}"
       - name: Sign container image
         run: |
           cosign sign --yes ghcr.io/score-spec/score-compose@${{ steps.build-push-container.outputs.digest }}
diff --git a/Dockerfile b/Dockerfile
index 7b3ffcf..2f71a71 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,7 +1,6 @@
-# Use the official Golang image to create a build artifact.
-# This is based on Debian and sets the GOPATH to /go.
-# https://hub.docker.com/_/golang
-FROM golang:1.24.0@sha256:3f7444391c51a11a039bf0359ee81cc64e663c17d787ad0e637a4de1a3f62a71 AS builder
+FROM golang:1.24.0-alpine@sha256:2d40d4fc278dad38be0777d5e2a88a2c6dee51b0b29c97a764fc6c6a11ca893c AS builder
+
+ARG VERSION=0.0.0
 
 # Set the current working directory inside the container.
 WORKDIR /go/src/github.com/score-spec/score-compose
@@ -12,7 +11,7 @@ RUN go mod download
 
 # Copy the entire project and build it.
 COPY . .
-RUN CGO_ENABLED=0 GOOS=linux go build -o /usr/local/bin/score-compose ./cmd/score-compose
+RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-X github.com/score-spec/score-compose/internal/version.Version=${VERSION}" -o /usr/local/bin/score-compose ./cmd/score-compose
 
 # We can use gcr.io/distroless/static since we don't rely on any linux libs or state, but we need ca-certificates to connect to https/oci with the init command.
 FROM gcr.io/distroless/static:530158861eebdbbf149f7e7e67bfe45eb433a35c@sha256:5c7e2b465ac6a2a4e5f4f7f722ce43b147dabe87cb21ac6c4007ae5178a1fa58
diff --git a/internal/command/provisioners.go b/internal/command/provisioners.go
index 2d09a45..b01e588 100644
--- a/internal/command/provisioners.go
+++ b/internal/command/provisioners.go
@@ -68,15 +68,11 @@ func listProvisioners(cmd *cobra.Command, args []string) error {
 	}
 
 	outputFormat := cmd.Flag("format").Value.String()
-
-	displayProvisioners(provisioners, outputFormat)
-
-	return nil
+	return displayProvisioners(provisioners, outputFormat)
 }
 
-func displayProvisioners(loadedProvisioners []provisioners.Provisioner, outputFormat string) {
+func displayProvisioners(loadedProvisioners []provisioners.Provisioner, outputFormat string) error {
 	var outputFormatter util.OutputFormatter
-
 	sortedProvisioners := sortProvisionersByType(loadedProvisioners)
 
 	switch outputFormat {
@@ -103,15 +99,13 @@ func displayProvisioners(loadedProvisioners []provisioners.Provisioner, outputFo
 		for _, provisioner := range sortedProvisioners {
 			rows = append(rows, []string{provisioner.Type(), provisioner.Class(), strings.Join(provisioner.Params(), ", "), strings.Join(provisioner.Outputs(), ", ")})
 		}
-
 		headers := []string{"Type", "Class", "Params", "Outputs"}
-
 		outputFormatter = &util.TableOutputFormatter{
 			Headers: headers,
 			Rows:    rows,
 		}
 	}
-	outputFormatter.Display()
+	return outputFormatter.Display()
 }
 
 func sortProvisionersByType(provisioners []provisioners.Provisioner) []provisioners.Provisioner {
diff --git a/internal/command/resources.go b/internal/command/resources.go
index 48f0a9a..0de495e 100644
--- a/internal/command/resources.go
+++ b/internal/command/resources.go
@@ -15,18 +15,18 @@
 package command
 
 import (
-	"encoding/json"
 	"fmt"
 	"log/slog"
+	"slices"
 	"strings"
 	"text/template"
 
 	"github.com/Masterminds/sprig/v3"
 	"github.com/score-spec/score-go/framework"
 	"github.com/spf13/cobra"
-	"gopkg.in/yaml.v3"
 
 	"github.com/score-spec/score-compose/internal/project"
+	"github.com/score-spec/score-compose/internal/util"
 )
 
 const (
@@ -60,11 +60,7 @@ after 'init' or 'generate' has been run. The list of uids will be empty if no re
 			if err != nil {
 				return fmt.Errorf("failed to sort resources: %w", err)
 			}
-			for _, id := range resIds {
-				_, _ = cmd.OutOrStdout().Write([]byte(id))
-				_, _ = cmd.OutOrStdout().Write([]byte("\n"))
-			}
-			return nil
+			return displayResourcesList(resIds, *currentState, cmd)
 		},
 	}
 	getResourceOutputs = &cobra.Command{
@@ -89,35 +85,114 @@ be returned as json.
 				if outputs == nil {
 					outputs = make(map[string]interface{})
 				}
-				formatValue := cmd.Flags().Lookup(getOutputsCmdFormatFlag).Value.String()
-				switch formatValue {
-				case "json":
-					return json.NewEncoder(cmd.OutOrStdout()).Encode(outputs)
-				case "yaml":
-					return yaml.NewEncoder(cmd.OutOrStdout()).Encode(outputs)
-				default:
-					// ensure there is a new line at the end if one is not already present
-					if !strings.HasSuffix(formatValue, "\n") {
-						formatValue += "\n"
-					}
-					prepared, err := template.New("").Funcs(sprig.FuncMap()).Parse(formatValue)
-					if err != nil {
-						return fmt.Errorf("failed to parse format template: %w", err)
-					}
-					if err := prepared.Execute(cmd.OutOrStdout(), outputs); err != nil {
-						return fmt.Errorf("failed to execute template: %w", err)
-					}
-					return nil
-				}
+				return displayResourcesOutputs(outputs, cmd)
+			}
+			resourceOuptuts, err := getResourceOutputsByUid(framework.ResourceUid(args[0]), &sd.State)
+			if err != nil {
+				return fmt.Errorf("no such resource '%s'", args[0])
 			}
-			return fmt.Errorf("no such resource '%s'", args[0])
+			return displayResourcesOutputs(resourceOuptuts, cmd)
 		},
 	}
 )
 
+func getResourceOutputsByUid(uid framework.ResourceUid, state *project.State) (map[string]interface{}, error) {
+	if res, ok := state.Resources[uid]; ok {
+		outputs := res.Outputs
+		if outputs == nil {
+			outputs = make(map[string]interface{})
+		}
+		return outputs, nil
+	}
+	return nil, fmt.Errorf("no such resource '%s'", uid)
+}
+
+func getResourceOutputsKeys(uid framework.ResourceUid, state *project.State) ([]string, error) {
+	outputs, err := getResourceOutputsByUid(uid, state)
+	if err != nil {
+		return nil, err
+	}
+	keys := make([]string, 0, len(outputs))
+	for key, _ := range outputs {
+		keys = append(keys, key)
+	}
+	slices.Sort(keys)
+	return keys, nil
+}
+
+func displayResourcesOutputs(outputs map[string]interface{}, cmd *cobra.Command) error {
+	outputFormat := cmd.Flags().Lookup(getOutputsCmdFormatFlag).Value.String()
+	var outputFormatter util.OutputFormatter
+	switch outputFormat {
+	case "json":
+		outputFormatter = &util.JSONOutputFormatter[map[string]interface{}]{Data: outputs, Out: cmd.OutOrStdout()}
+	case "yaml":
+		outputFormatter = &util.YAMLOutputFormatter[map[string]interface{}]{Data: outputs, Out: cmd.OutOrStdout()}
+	default:
+		// ensure there is a new line at the end if one is not already present
+		if !strings.HasSuffix(outputFormat, "\n") {
+			outputFormat += "\n"
+		}
+		prepared, err := template.New("").Funcs(sprig.FuncMap()).Parse(outputFormat)
+		if err != nil {
+			return fmt.Errorf("failed to parse format template: %w", err)
+		}
+		if err := prepared.Execute(cmd.OutOrStdout(), outputs); err != nil {
+			return fmt.Errorf("failed to execute template: %w", err)
+		}
+		return nil
+	}
+
+	return outputFormatter.Display()
+}
+
+func displayResourcesList(resources []framework.ResourceUid, state project.State, cmd *cobra.Command) error {
+	outputFormat := cmd.Flag("format").Value.String()
+	var outputFormatter util.OutputFormatter
+
+	switch outputFormat {
+	case "json":
+		type jsonData struct {
+			UID     string
+			Outputs []string
+		}
+		var outputs []jsonData
+		for _, resource := range resources {
+
+			keys, err := getResourceOutputsKeys(resource, &state)
+			if err != nil {
+				return fmt.Errorf("failed to get outputs for resource '%s': %w", resource, err)
+			}
+			outputs = append(outputs, jsonData{
+				UID:     string(resource),
+				Outputs: keys,
+			})
+		}
+		outputFormatter = &util.JSONOutputFormatter[[]jsonData]{Data: outputs, Out: cmd.OutOrStdout()}
+	default:
+		var rows [][]string
+		for _, resource := range resources {
+			keys, err := getResourceOutputsKeys(resource, &state)
+			if err != nil {
+				return fmt.Errorf("failed to get outputs for resource '%s': %w", resource, err)
+			}
+			row := []string{string(resource), strings.Join(keys, ", ")}
+			rows = append(rows, row)
+		}
+		outputFormatter = &util.TableOutputFormatter{
+			Headers: []string{"UID", "Outputs"},
+			Rows:    rows,
+			Out:     cmd.OutOrStdout(),
+		}
+	}
+
+	return outputFormatter.Display()
+}
+
 func init() {
 	getResourceOutputs.Flags().StringP(getOutputsCmdFormatFlag, "f", "json", "Format of the output: json, yaml, or a Go template with sprig functions")
 	resourcesGroup.AddCommand(listResources)
+	listResources.Flags().StringP("format", "f", "table", "Format of the output: table (default), json")
 	resourcesGroup.AddCommand(getResourceOutputs)
 	rootCmd.AddCommand(resourcesGroup)
 }
diff --git a/internal/util/output_format.go b/internal/util/output_format.go
index c21d666..c7e9676 100644
--- a/internal/util/output_format.go
+++ b/internal/util/output_format.go
@@ -16,28 +16,39 @@ package util
 
 import (
 	"encoding/json"
-	"fmt"
-	"log/slog"
+	"io"
 	"os"
 
 	"github.com/olekukonko/tablewriter"
+	"gopkg.in/yaml.v3"
 )
 
 type OutputFormatter interface {
-	Display()
+	Display() error
 }
 
 type JSONOutputFormatter[T any] struct {
 	Data T
+	Out  io.Writer
+}
+
+type YAMLOutputFormatter[T any] struct {
+	Data T
+	Out  io.Writer
 }
 
 type TableOutputFormatter struct {
 	Headers []string
 	Rows    [][]string
+	Out     io.Writer
 }
 
-func (t *TableOutputFormatter) Display() {
-	table := tablewriter.NewWriter(os.Stdout)
+func (t *TableOutputFormatter) Display() error {
+	// Default to stdout if no output is provided
+	if t.Out == nil {
+		t.Out = os.Stdout
+	}
+	table := tablewriter.NewWriter(t.Out)
 	table.SetHeader(t.Headers)
 	table.AppendBulk(t.Rows)
 	table.SetAutoWrapText(false)
@@ -46,13 +57,31 @@ func (t *TableOutputFormatter) Display() {
 	table.SetColumnSeparator("|")
 	table.SetRowSeparator("-")
 	table.Render()
+	return nil
+}
+
+func (j *JSONOutputFormatter[T]) Display() error {
+	// Default to stdout if no output is provided
+	if j.Out == nil {
+		j.Out = os.Stdout
+	}
+	encoder := json.NewEncoder(j.Out)
+	encoder.SetIndent("", "  ")
+	if err := encoder.Encode(j.Data); err != nil {
+		return err
+	}
+	return nil
 }
 
-func (j *JSONOutputFormatter[T]) Display() {
-	output, err := json.MarshalIndent(j.Data, "", "  ")
-	if err != nil {
-		slog.Error(fmt.Sprintf("Failed to marshal data: %v", err))
-		return
+func (y *YAMLOutputFormatter[T]) Display() error {
+	// Default to stdout if no output is provided
+	if y.Out == nil {
+		y.Out = os.Stdout
+	}
+
+	encoder := yaml.NewEncoder(y.Out)
+	if err := encoder.Encode(y.Data); err != nil {
+		return err
 	}
-	fmt.Println(string(output))
+	return nil
 }
diff --git a/internal/version/version.go b/internal/version/version.go
index 4dc52a0..27a1e29 100644
--- a/internal/version/version.go
+++ b/internal/version/version.go
@@ -25,9 +25,6 @@ var (
 func BuildVersionString() string {
 	versionNumber, buildTime, gitSha, isDirtySuffix := Version, "local", "unknown", ""
 	if info, ok := debug.ReadBuildInfo(); ok {
-		if info.Main.Version != "" && info.Main.Version != "(devel)" {
-			versionNumber = info.Main.Version
-		}
 		for _, setting := range info.Settings {
 			switch setting.Key {
 			case "vcs.time":


Test patch:
diff --git a/internal/command/resources_test.go b/internal/command/resources_test.go
index e0a1a81..75f1724 100644
--- a/internal/command/resources_test.go
+++ b/internal/command/resources_test.go
@@ -76,11 +76,37 @@ resources:
 	_, _, err = executeAndResetCommand(context.Background(), rootCmd, []string{"generate", "score.yaml"})
 	assert.NoError(t, err)
 
-	t.Run("list", func(t *testing.T) {
+	t.Run("list table", func(t *testing.T) {
 		stdout, _, err := executeAndResetCommand(context.Background(), rootCmd, []string{"resources", "list"})
 		assert.NoError(t, err)
-		assert.Equal(t, `dns.default#example.dns
-volume.default#example.vol
+		assert.Equal(t, `+----------------------------+--------------+
+|            UID             |   OUTPUTS    |
++----------------------------+--------------+
+| dns.default#example.dns    | host         |
++----------------------------+--------------+
+| volume.default#example.vol | source, type |
++----------------------------+--------------+
+`, stdout)
+	})
+
+	t.Run("list json", func(t *testing.T) {
+		stdout, _, err := executeAndResetCommand(context.Background(), rootCmd, []string{"resources", "list", "-f", "json"})
+		assert.NoError(t, err)
+		assert.Equal(t, `[
+  {
+    "UID": "dns.default#example.dns",
+    "Outputs": [
+      "host"
+    ]
+  },
+  {
+    "UID": "volume.default#example.vol",
+    "Outputs": [
+      "source",
+      "type"
+    ]
+  }
+]
 `, stdout)
 	})