Repository: losisin/helm-values-schema-json (go)

Issue:
Read from stdin via `-f -` and write to stdout via `-o -`
A small common utility to read/write via stdin/stdout is specifying a dash `-`

Would be nice to have it here too, just for consistency across CLI apps.

Not sure if this needs to go in v2.0.0 or if it can wait, as it would be a breaking change in case someone uses files named `-` (highly unlikely).

Pull request description:
feat: Read from stdin with `-f -`, write to stdout with `-o -`, and use stderr for logs
- Read from stdin
- Error if passing `-f -` multiple times
- Write to stdout
- BREAKING: Change log to stderr, so it doesn't collide with `-o -`

Closes #185


Patch:
diff --git a/docs/upgrading.md b/docs/upgrading.md
index d74dfea..4a77e8a 100644
--- a/docs/upgrading.md
+++ b/docs/upgrading.md
@@ -4,3 +4,4 @@ List of breaking changes introduced in different versions
 and how to remedy them.
 
 - [Upgrade v1.9.x to v2.0.x](./upgrading/v1.9-to-v2.0.md)
+- [Upgrade v2.1.x to v2.2.x](./upgrading/v2.1-to-v2.2.md)
diff --git a/docs/upgrading/v2.1-to-v2.2.md b/docs/upgrading/v2.1-to-v2.2.md
new file mode 100644
index 0000000..aea3f32
--- /dev/null
+++ b/docs/upgrading/v2.1-to-v2.2.md
@@ -0,0 +1,36 @@
+# Upgrade `helm schema` from v2.1 to v2.2
+
+Upgrading from any v2.1.x version to v2.2.0.
+
+## Logs
+
+Logs and errors are now printed to STDERR instead of STDOUT.
+This is because we introduced support for `-o -` to mean
+"write output to STDOUT", so logs were moved to STDERR.
+
+This allows you to pipe the result from `helm schema` more easily:
+
+```bash
+helm schema -o - | jq .properties
+```
+
+If you were previously capturing the logs from `helm schema` in a script,
+then you need to update your scripts. Such as:
+
+```diff
+diff script.sh (bash/zsh)
+- HELM_SCHEMA_OUTPUT="$(helm schema)"
++ HELM_SCHEMA_OUTPUT="$(helm schema 2>&1)"
+```
+
+```diff
+diff script.fish (fish)
+- set HELM_SCHEMA_OUTPUT $(helm schema)
++ set HELM_SCHEMA_OUTPUT $(helm schema 2>&1)
+```
+
+```diff
+diff script.ps1 (powershell)
+- $HELM_SCHEMA_OUTPUT = helm schema
++ $HELM_SCHEMA_OUTPUT = helm schema 2>&1
+```
diff --git a/main.go b/main.go
index d47102c..d426d95 100644
--- a/main.go
+++ b/main.go
@@ -20,7 +20,7 @@ func main() {
 	cmd.SetOut(os.Stdout)
 	cmd.SetErr(os.Stderr)
 	if err := cmd.Execute(); err != nil {
-		fmt.Println("Error:", err)
+		fmt.Fprintln(os.Stderr, "Error:", err)
 	}
 }
 
diff --git a/pkg/generator.go b/pkg/generator.go
index d182058..6853668 100644
--- a/pkg/generator.go
+++ b/pkg/generator.go
@@ -6,6 +6,7 @@ import (
 	"encoding/json"
 	"errors"
 	"fmt"
+	"io"
 	"os"
 	"path/filepath"
 	"strings"
@@ -19,6 +20,9 @@ func GenerateJsonSchema(ctx context.Context, config *Config) error {
 	if len(config.Values) == 0 {
 		return errors.New("values flag is required")
 	}
+	if countOccurrencesSlice(config.Values, "-") > 1 {
+		return errors.New("values flag must not contain multiple stdin (\"-f -\")")
+	}
 
 	// Determine the schema URL based on the draft version
 	schemaURL, err := getSchemaURL(config.Draft)
@@ -40,14 +44,9 @@ func GenerateJsonSchema(ctx context.Context, config *Config) error {
 
 	// Iterate over the input YAML files
 	for _, filePath := range config.Values {
-		filePathAbs, err := filepath.Abs(filepath.FromSlash(filePath))
-		if err != nil {
-			return fmt.Errorf("%s: get absolute path: %w", filePath, err)
-		}
-
-		content, err := os.ReadFile(filepath.Clean(filePath))
+		fileReferrer, content, err := readInputFile(os.Stdin, filePath)
 		if err != nil {
-			return errors.New("error reading YAML file(s)")
+			return fmt.Errorf("read --values=%q: %w", filePath, err)
 		}
 
 		// Change Window's CRLF to LF line endings
@@ -94,7 +93,7 @@ func GenerateJsonSchema(ctx context.Context, config *Config) error {
 			ID:          config.SchemaRoot.ID,
 		}
 
-		tempSchema.SetReferrer(ReferrerDir(filepath.Dir(filePathAbs)))
+		tempSchema.SetReferrer(fileReferrer)
 		// Set root $ref after updating the referrer on all other $refs
 		if config.SchemaRoot.Ref != "" {
 			tempSchema.Ref = config.SchemaRoot.Ref
@@ -133,6 +132,34 @@ func GenerateJsonSchema(ctx context.Context, config *Config) error {
 	return WriteOutput(ctx, mergedSchema, filepath.FromSlash(config.Output), indentString)
 }
 
+func readInputFile(stdin io.Reader, filePath string) (Referrer, []byte, error) {
+	if filePath == "-" {
+		content, err := io.ReadAll(stdin)
+		if err != nil {
+			return Referrer{}, nil, fmt.Errorf("error reading from stdin: %w", err)
+		}
+
+		cwd, err := os.Getwd()
+		if err != nil {
+			return Referrer{}, nil, fmt.Errorf("get directory as referrer for stdin: %w", err)
+		}
+
+		return ReferrerDir(cwd), content, nil
+	}
+
+	filePathAbs, err := filepath.Abs(filepath.FromSlash(filePath))
+	if err != nil {
+		return Referrer{}, nil, fmt.Errorf("get absolute path: %w", err)
+	}
+
+	content, err := os.ReadFile(filepath.Clean(filePath))
+	if err != nil {
+		return Referrer{}, nil, fmt.Errorf("error reading YAML file(s): %w", err)
+	}
+
+	return ReferrerDir(filepath.Dir(filePathAbs)), content, nil
+}
+
 func WriteOutput(ctx context.Context, mergedSchema *Schema, outputPath, indent string) error {
 	logger := LoggerFromContext(ctx)
 
@@ -144,10 +171,24 @@ func WriteOutput(ctx context.Context, mergedSchema *Schema, outputPath, indent s
 	jsonBytes = append(jsonBytes, '\n')
 
 	// Write the JSON schema to the output file
-	if err := os.WriteFile(outputPath, jsonBytes, 0600); err != nil {
+	if err := writeOutputFile(os.Stdout, outputPath, jsonBytes); err != nil {
 		return fmt.Errorf("write output schema: %w", err)
 	}
 
 	logger.Log("JSON schema successfully generated")
 	return nil
 }
+
+func writeOutputFile(stdout io.Writer, path string, content []byte) error {
+	if path == "-" {
+		if _, err := stdout.Write(content); err != nil {
+			return fmt.Errorf("write schema to stdout: %w", err)
+		}
+		return nil
+	}
+
+	if err := os.WriteFile(path, content, 0600); err != nil {
+		return fmt.Errorf("write output schema: %w", err)
+	}
+	return nil
+}
diff --git a/pkg/log.go b/pkg/log.go
index b4ab6d5..97a11c7 100644
--- a/pkg/log.go
+++ b/pkg/log.go
@@ -34,7 +34,7 @@ func ContextWithLogger(parent context.Context, logger Logger) context.Context {
 func LoggerFromContext(ctx context.Context) Logger {
 	logger := ctx.Value(loggerContextValue)
 	if logger == nil {
-		return NewLogger(os.Stdout)
+		return NewLogger(os.Stderr)
 	}
 	return logger.(Logger)
 }
diff --git a/pkg/utils.go b/pkg/utils.go
index 7c5bfa2..d90eee5 100644
--- a/pkg/utils.go
+++ b/pkg/utils.go
@@ -94,3 +94,13 @@ func comparePointer[T comparable](a, b *T) bool {
 	}
 	return false
 }
+
+func countOccurrencesSlice[T comparable](slice []T, item T) int {
+	var count int
+	for _, value := range slice {
+		if value == item {
+			count++
+		}
+	}
+	return count
+}


Test patch:
diff --git a/pkg/generator_test.go b/pkg/generator_test.go
index f1449c4..3b900c8 100644
--- a/pkg/generator_test.go
+++ b/pkg/generator_test.go
@@ -1,10 +1,13 @@
 package pkg
 
 import (
+	"bytes"
 	"encoding/json"
 	"errors"
+	"io"
 	"os"
 	"path/filepath"
+	"strings"
 	"testing"
 
 	"github.com/losisin/helm-values-schema-json/v2/internal/testutil"
@@ -360,6 +363,15 @@ func TestGenerateJsonSchema_Errors(t *testing.T) {
 			},
 			expectedErr: errors.New("values flag is required"),
 		},
+		{
+			name: "Too many stdin values",
+			config: &Config{
+				Values: []string{"-", "some-file.txt", "-"},
+				Draft:  2020,
+				Indent: 0,
+			},
+			expectedErr: errors.New("values flag must not contain multiple stdin (\"-f -\")"),
+		},
 		{
 			name: "Invalid draft version",
 			config: &Config{
@@ -543,7 +555,7 @@ func TestGenerateJsonSchema_AbsInputError(t *testing.T) {
 		Draft:  2020,
 		Indent: 4,
 	})
-	require.ErrorContains(t, err, "foo/bar.yaml: get absolute path")
+	require.ErrorContains(t, err, "read --values=\"foo/bar.yaml\": get absolute path:")
 	require.ErrorIs(t, err, os.ErrNotExist)
 }
 
@@ -657,6 +669,63 @@ func TestGenerateJsonSchema_AdditionalProperties(t *testing.T) {
 	}
 }
 
+func TestReadInputFile_ReadFile(t *testing.T) {
+	cwd, err := os.Getwd()
+	require.NoError(t, err)
+
+	var stdin io.Reader = nil // should not be used
+	referrer, content, err := readInputFile(stdin, "generator_test.go")
+	require.NoError(t, err)
+
+	assert.Equal(t, cwd, referrer.dir, "Referrer")
+	assert.NotEmpty(t, content)
+}
+
+func TestReadInputFile_FileNotFound(t *testing.T) {
+	var stdin io.Reader = nil // should not be used
+	_, _, err := readInputFile(stdin, "file_that_does_not_exist.txt")
+	require.ErrorIs(t, err, os.ErrNotExist)
+}
+
+func TestReadInputFile_Stdin(t *testing.T) {
+	stdin := strings.NewReader("hello this is text from stdin\n")
+
+	cwd, err := os.Getwd()
+	require.NoError(t, err)
+
+	referrer, content, err := readInputFile(stdin, "-")
+	require.NoError(t, err)
+
+	assert.Equal(t, "hello this is text from stdin\n", string(content), "Stdin content should equal")
+	assert.Equal(t, cwd, referrer.dir, "Referrer")
+}
+
+type ReaderWriterWithError struct {
+	Err error
+}
+
+func (r ReaderWriterWithError) Read([]byte) (int, error) {
+	return 0, r.Err
+}
+
+func (r ReaderWriterWithError) Write([]byte) (int, error) {
+	return 0, r.Err
+}
+
+func TestReadInputFile_StdinError(t *testing.T) {
+	stdin := ReaderWriterWithError{errors.New("testing error")}
+	_, _, err := readInputFile(stdin, "-")
+	require.ErrorContains(t, err, "error reading from stdin: testing error")
+}
+
+func TestReadInputFile_StdinCwdError(t *testing.T) {
+	testutil.MakeGetwdFail(t)
+
+	stdin := strings.NewReader("hello\n")
+	_, _, err := readInputFile(stdin, "-")
+	require.ErrorContains(t, err, "get directory as referrer for stdin:")
+}
+
 func TestWriteOutput_JSONError(t *testing.T) {
 	schema := &Schema{Type: func() {}}
 
@@ -664,3 +733,41 @@ func TestWriteOutput_JSONError(t *testing.T) {
 	err := WriteOutput(ctx, schema, os.DevNull, "  ")
 	require.ErrorContains(t, err, "unsupported type: func()")
 }
+
+func TestWriteOutputFile_WriteToFile(t *testing.T) {
+	var stdout io.Writer = nil
+	err := writeOutputFile(stdout, "some_example_output.txt", []byte("some content\n"))
+	require.NoError(t, err)
+	require.FileExists(t, "some_example_output.txt")
+	content, err := os.ReadFile("some_example_output.txt")
+	require.NoError(t, err)
+	assert.Equal(t, "some content\n", string(content))
+
+	defer func() {
+		_ = os.Remove("some_example_output.txt")
+	}()
+}
+
+func TestWriteOutputFile_WriteToStdout(t *testing.T) {
+	var stdout bytes.Buffer
+	err := writeOutputFile(&stdout, "-", []byte("some stdout content\n"))
+	require.NoError(t, err)
+	assert.Equal(t, "some stdout content\n", stdout.String())
+	assert.NoFileExists(t, "-")
+
+	defer func() {
+		// Just in case it creates a file
+		_ = os.Remove("-")
+	}()
+}
+
+func TestWriteOutputFile_WriteToStdoutError(t *testing.T) {
+	stdout := ReaderWriterWithError{errors.New("testing error")}
+	err := writeOutputFile(&stdout, "-", []byte("some stdout content\n"))
+	require.ErrorContains(t, err, "write schema to stdout: testing error")
+
+	defer func() {
+		// Just in case it creates a file
+		_ = os.Remove("-")
+	}()
+}
diff --git a/pkg/log_test.go b/pkg/log_test.go
index 095bbf2..71b4b28 100644
--- a/pkg/log_test.go
+++ b/pkg/log_test.go
@@ -28,7 +28,7 @@ func TestWriterLogger(t *testing.T) {
 	t.Run("default logger from empty context", func(t *testing.T) {
 		logger := LoggerFromContext(context.Background())
 		require.IsType(t, WriterLogger{}, logger)
-		assert.Same(t, os.Stdout, logger.(WriterLogger).Output)
+		assert.Same(t, os.Stderr, logger.(WriterLogger).Output)
 	})
 
 	t.Run("logger from context", func(t *testing.T) {
diff --git a/pkg/utils_test.go b/pkg/utils_test.go
index 7e7d62d..b59911d 100644
--- a/pkg/utils_test.go
+++ b/pkg/utils_test.go
@@ -127,3 +127,26 @@ func TestComparePointer(t *testing.T) {
 		})
 	}
 }
+
+func TestCountOccurencesSlice(t *testing.T) {
+	tests := []struct {
+		name  string
+		slice []int
+		item  int
+		want  int
+	}{
+		{name: "nil", slice: nil, item: 5, want: 0},
+		{name: "no matches", slice: []int{1, 2, 3, 4, 6, 7, 8, 9}, item: 5, want: 0},
+		{name: "all matches", slice: []int{5, 5, 5, 5, 5, 5, 5, 5}, item: 5, want: 8},
+		{name: "some matches", slice: []int{5, 2, 3, 4, 5, 6, 5, 8, 5}, item: 5, want: 4},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got := countOccurrencesSlice(tt.slice[:], tt.item)
+			if got != tt.want {
+				t.Errorf("wrong result\nwant: %d\ngot:  %d", tt.want, got)
+			}
+		})
+	}
+}