Skip to content

Commit e23d537

Browse files
authored
chore(cmd): refactored main program (#26)
* chore(cmd): refactored main program Another baby step forward: * refactored error and flags handling in main program * this improves testability, so we add a unit test for main There is still some room for improvement (coverage, I/O injection for better control in a test environment). Essential options in main are covered by tests. Signed-off-by: Frédéric BIDON <fredbi@yahoo.com> * addressed typos & other hiccups detected by copilot. Signed-off-by: Frederic BIDON <fredbi@yahoo.com> --------- Signed-off-by: Frédéric BIDON <fredbi@yahoo.com> Signed-off-by: Frederic BIDON <fredbi@yahoo.com>
1 parent 3f1c615 commit e23d537

3 files changed

Lines changed: 272 additions & 64 deletions

File tree

cmd/go-ctrf-json-reporter/main.go

Lines changed: 98 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -1,99 +1,92 @@
11
package main
22

33
import (
4+
"errors"
45
"flag"
56
"fmt"
7+
"io"
8+
"log"
69
"os"
710

811
"github.com/ctrf-io/go-ctrf-json-reporter/ctrf"
912
"github.com/ctrf-io/go-ctrf-json-reporter/reporter"
1013
)
1114

12-
var buildFailed bool
15+
// commandContext holds the global context of the command.
16+
//
17+
// For now, this boils down to just CLI flags and default stdin/stdout.
18+
type commandContext struct {
19+
commandFlags
1320

14-
func main() {
15-
var outputFile string
16-
var verbose bool
17-
var quiet bool
18-
flag.BoolVar(&verbose, "verbose", false, "Enable verbose output")
19-
flag.BoolVar(&verbose, "v", false, "Enable verbose output (shorthand)")
20-
flag.BoolVar(&quiet, "quiet", false, "Disable all log output")
21-
flag.BoolVar(&quiet, "q", false, "Disable all log output (shorthand)")
22-
flag.StringVar(&outputFile, "output", "ctrf-report.json", "The output file for the test results")
23-
flag.StringVar(&outputFile, "o", "ctrf-report.json", "The output file for the test results (shorthand)")
24-
25-
var tempAppName, tempAppVersion, tempOSPlatform, tempOSRelease, tempOSVersion, tempBuildName, tempBuildNumber string
26-
27-
flag.StringVar(&tempAppName, "appName", "", "The name of the application being tested.")
28-
flag.StringVar(&tempAppVersion, "appVersion", "", "The version of the application being tested.")
29-
flag.StringVar(&tempOSPlatform, "osPlatform", "", "The operating system platform (e.g., Windows, Linux).")
30-
flag.StringVar(&tempOSRelease, "osRelease", "", "The release version of the operating system.")
31-
flag.StringVar(&tempOSVersion, "osVersion", "", "The version number of the operating system.")
32-
flag.StringVar(&tempBuildName, "buildName", "", "The name of the build (e.g., feature branch name).")
33-
flag.StringVar(&tempBuildNumber, "buildNumber", "", "The build number or identifier.")
21+
reader io.Reader
22+
writer io.Writer // makes it easier to test execute() independently
23+
}
3424

35-
flag.Parse()
25+
// commandFlags stores parsed command line flags.
26+
type commandFlags struct {
27+
outputFile string
28+
verbose bool
29+
quiet bool
30+
appName string
31+
appVersion string
32+
oSPlatform string
33+
oSRelease string
34+
oSVersion string
35+
buildName string
36+
buildNumber string
37+
}
3638

37-
var env *ctrf.Environment
39+
// NOTE(fredbi)
40+
// Suggestions (future enhancements):
41+
//
42+
// - outputFile could be provided as an io.Writer: this makes the package easier to test
43+
// - outputFile is currently required but could default to stdout
44+
// - outputFile set to "-" would also mean stdout (common with unix-like tools)
45+
//
46+
// A similar approach could work for stdin, which is currently not an option, when CLI args (not flags)
47+
// could represent the input files (e.g. could be useful when used with xargs for example).
3848

39-
if tempAppName != "" || tempAppVersion != "" || tempOSPlatform != "" ||
40-
tempOSRelease != "" || tempOSVersion != "" || tempBuildName != "" || tempBuildNumber != "" {
41-
env = &ctrf.Environment{}
49+
func main() {
50+
var ctx commandContext
51+
ctx.reader = os.Stdin
52+
ctx.writer = os.Stdout
4253

43-
if tempAppName != "" {
44-
env.AppName = tempAppName
45-
}
46-
if tempAppVersion != "" {
47-
env.AppVersion = tempAppVersion
48-
}
49-
if tempOSPlatform != "" {
50-
env.OSPlatform = tempOSPlatform
51-
}
52-
if tempOSRelease != "" {
53-
env.OSRelease = tempOSRelease
54-
}
55-
if tempOSVersion != "" {
56-
env.OSVersion = tempOSVersion
57-
}
58-
if tempBuildName != "" {
59-
env.BuildName = tempBuildName
60-
}
61-
if tempBuildNumber != "" {
62-
env.BuildNumber = tempBuildNumber
54+
registerFlags(&ctx.commandFlags)
55+
56+
if err := execute(&ctx); err != nil {
57+
if ctx.quiet {
58+
os.Exit(1) // exit silently
6359
}
60+
61+
log.Fatalf("%v", err)
6462
}
63+
}
6564

66-
effectiveVerbose := verbose && !quiet
65+
func execute(cmd *commandContext) error {
66+
env := ctrfEnvFromFlags(cmd)
67+
effectiveVerbose := cmd.verbose && !cmd.quiet
6768

68-
report, err := reporter.ParseTestResults(os.Stdin, effectiveVerbose, env)
69+
report, err := reporter.ParseTestResults(cmd.reader, effectiveVerbose, env)
6970
if err != nil {
70-
if !quiet {
71-
_, _ = fmt.Fprintf(os.Stderr, "Error parsing test results: %v\n", err)
72-
}
73-
os.Exit(1)
71+
return fmt.Errorf("error parsing test results: %w", err)
7472
}
7573

76-
err = reporter.WriteReportToFile(outputFile, report)
74+
err = reporter.WriteReportToFile(cmd.outputFile, report)
7775
if err != nil {
78-
if !quiet {
79-
_, _ = fmt.Fprintf(os.Stderr, "Error writing the report to file: %v\n", err)
80-
}
81-
os.Exit(1)
76+
return fmt.Errorf("error writing the report to file: %w", err)
8277
}
8378

84-
if !verbose && !quiet {
79+
if !cmd.verbose && !cmd.quiet { // when verbose is enabled, output is already written during parsing
8580
buildOutput := reporter.GetBuildOutput()
86-
fmt.Println(buildOutput)
81+
fmt.Fprint(cmd.writer, buildOutput)
8782
}
8883

84+
var buildFailed bool
8985
if report.Results.Extra != nil {
9086
extraMap, isMap := report.Results.Extra.(map[string]any)
9187
if !isMap {
9288
err = fmt.Errorf("expected a map, but got %T instead", report.Results.Extra)
93-
if !quiet {
94-
_, _ = fmt.Fprintf(os.Stderr, "Error writing the report to file: %v\n", err)
95-
}
96-
os.Exit(1)
89+
return fmt.Errorf("error extracting report results: %w", err)
9790
}
9891
if _, ok := extraMap["buildFail"]; ok {
9992
buildFailed = true
@@ -108,6 +101,47 @@ func main() {
108101
}
109102

110103
if buildFailed {
111-
os.Exit(1)
104+
return errors.New("build failed")
105+
}
106+
107+
return nil
108+
}
109+
110+
func registerFlags(flags *commandFlags) {
111+
flag.BoolVar(&flags.verbose, "verbose", false, "Enable verbose output")
112+
flag.BoolVar(&flags.verbose, "v", false, "Enable verbose output (shorthand)")
113+
flag.BoolVar(&flags.quiet, "quiet", false, "Disable all log output")
114+
flag.BoolVar(&flags.quiet, "q", false, "Disable all log output (shorthand)")
115+
116+
flag.StringVar(&flags.outputFile, "output", "ctrf-report.json", "The output file for the test results")
117+
flag.StringVar(&flags.outputFile, "o", "ctrf-report.json", "The output file for the test results (shorthand)")
118+
119+
flag.StringVar(&flags.appName, "appName", "", "The name of the application being tested.")
120+
flag.StringVar(&flags.appVersion, "appVersion", "", "The version of the application being tested.")
121+
flag.StringVar(&flags.oSPlatform, "osPlatform", "", "The operating system platform (e.g., Windows, Linux).")
122+
flag.StringVar(&flags.oSRelease, "osRelease", "", "The release version of the operating system.")
123+
flag.StringVar(&flags.oSVersion, "osVersion", "", "The version number of the operating system.")
124+
flag.StringVar(&flags.buildName, "buildName", "", "The name of the build (e.g., feature branch name).")
125+
flag.StringVar(&flags.buildNumber, "buildNumber", "", "The build number or identifier.")
126+
127+
// parsing errors result in os.Exit(1). Perhaps we should call the flagset version and capture the error instead.
128+
flag.Parse()
129+
}
130+
131+
func ctrfEnvFromFlags(cmd *commandContext) *ctrf.Environment {
132+
if cmd.appName == "" && cmd.appVersion == "" && cmd.oSPlatform == "" &&
133+
cmd.oSRelease == "" && cmd.oSVersion == "" && cmd.buildName == "" &&
134+
cmd.buildNumber == "" {
135+
return nil
136+
}
137+
138+
return &ctrf.Environment{
139+
AppName: cmd.appName,
140+
AppVersion: cmd.appVersion,
141+
OSPlatform: cmd.oSPlatform,
142+
OSRelease: cmd.oSRelease,
143+
OSVersion: cmd.oSVersion,
144+
BuildName: cmd.buildName,
145+
BuildNumber: cmd.buildNumber,
112146
}
113147
}
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
package main
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"io"
7+
"os"
8+
"path/filepath"
9+
"testing"
10+
11+
"github.com/stretchr/testify/require"
12+
)
13+
14+
func TestExecute(t *testing.T) {
15+
t.Parallel()
16+
tempDir := t.TempDir() // auto-cleanup when the test tears down
17+
18+
t.Run("with no flags", func(t *testing.T) {
19+
t.Run("should error because no output file is provided", func(t *testing.T) {
20+
ctx := freshContext(nil, nil)
21+
22+
err := execute(ctx)
23+
require.Error(t, err)
24+
require.ErrorContains(t, err, "no such file")
25+
})
26+
27+
t.Run("should error because no report data is provided", func(t *testing.T) {
28+
ctx := freshContext(nil, nil)
29+
output := filepath.Join(tempDir, "test-report-ko.json")
30+
ctx.outputFile = output
31+
32+
err := execute(ctx)
33+
require.Error(t, err)
34+
require.ErrorContains(t, err, "report is invalid")
35+
})
36+
37+
t.Run("should parse go test json data", func(t *testing.T) {
38+
fixture, err := os.Open(filepath.Join("testdata", "test.json"))
39+
require.NoError(t, err)
40+
defer func() {
41+
_ = fixture.Close()
42+
}()
43+
44+
var stdout bytes.Buffer
45+
ctx := freshContext(&stdout, fixture)
46+
output := filepath.Join(tempDir, "test-report-ok.json")
47+
ctx.outputFile = output
48+
49+
err = execute(ctx)
50+
require.NoError(t, err)
51+
require.FileExists(t, output)
52+
53+
t.Run("stdout should contain some go test output", func(t *testing.T) {
54+
require.Contains(t, stdout.String(), "=== RUN TestExecute")
55+
})
56+
57+
t.Run("report file should be valid JSON", func(t *testing.T) {
58+
buf, err := os.ReadFile(output)
59+
require.NoError(t, err)
60+
61+
jazon := make(map[string]any)
62+
require.NoError(t, json.Unmarshal(buf, &jazon))
63+
require.Contains(t, jazon, "reportFormat")
64+
})
65+
})
66+
})
67+
68+
t.Run("with appName flag", func(t *testing.T) {
69+
t.Run("should parse go test json data", func(t *testing.T) {
70+
fixture, err := os.Open(filepath.Join("testdata", "test.json"))
71+
require.NoError(t, err)
72+
defer func() {
73+
_ = fixture.Close()
74+
}()
75+
output := filepath.Join(tempDir, "test-report-app.json")
76+
77+
ctx := freshContext(nil, fixture)
78+
ctx.appName = "my-app"
79+
ctx.outputFile = output
80+
81+
err = execute(ctx)
82+
require.NoError(t, err)
83+
require.FileExists(t, output)
84+
85+
t.Run("report file should be valid JSON", func(t *testing.T) {
86+
buf, err := os.ReadFile(output)
87+
require.NoError(t, err)
88+
89+
jazon := make(map[string]any)
90+
require.NoError(t, json.Unmarshal(buf, &jazon))
91+
raw, ok := jazon["results"]
92+
require.True(t, ok)
93+
results, ok := raw.(map[string]any)
94+
require.True(t, ok)
95+
raw, ok = results["environment"]
96+
require.True(t, ok)
97+
environment, ok := raw.(map[string]any)
98+
require.True(t, ok)
99+
raw, ok = environment["appName"]
100+
require.True(t, ok)
101+
appName, ok := raw.(string)
102+
require.True(t, ok)
103+
require.Equal(t, "my-app", appName)
104+
})
105+
})
106+
})
107+
108+
t.Run("with verbose flag", func(t *testing.T) {
109+
t.Run("should parse go test json data", func(t *testing.T) {
110+
fixture, err := os.Open(filepath.Join("testdata", "test.json"))
111+
require.NoError(t, err)
112+
defer func() {
113+
_ = fixture.Close()
114+
}()
115+
output := filepath.Join(tempDir, "test-report-verbose.json")
116+
117+
ctx := freshContext(nil, fixture)
118+
ctx.verbose = true
119+
ctx.outputFile = output
120+
121+
err = execute(ctx)
122+
require.NoError(t, err)
123+
require.FileExists(t, output)
124+
125+
t.Run("report file should be valid JSON, without environment", func(t *testing.T) {
126+
buf, err := os.ReadFile(output)
127+
require.NoError(t, err)
128+
129+
jazon := make(map[string]any)
130+
require.NoError(t, json.Unmarshal(buf, &jazon))
131+
raw, ok := jazon["results"]
132+
require.True(t, ok)
133+
results, ok := raw.(map[string]any)
134+
require.True(t, ok)
135+
_, ok = results["environment"]
136+
require.False(t, ok)
137+
})
138+
})
139+
})
140+
}
141+
142+
func freshContext(writer io.Writer, reader io.Reader) *commandContext {
143+
if reader == nil {
144+
reader = os.Stdin
145+
}
146+
if writer == nil {
147+
writer = new(bytes.Buffer)
148+
}
149+
150+
return &commandContext{
151+
writer: writer,
152+
reader: reader,
153+
}
154+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
{"Time":"2025-11-24T23:37:16.670113757+01:00","Action":"start","Package":"github.com/ctrf-io/go-ctrf-json-reporter/cmd/go-ctrf-json-reporter"}
2+
{"Time":"2025-11-24T23:37:16.673715443+01:00","Action":"run","Package":"github.com/ctrf-io/go-ctrf-json-reporter/cmd/go-ctrf-json-reporter","Test":"TestExecute"}
3+
{"Time":"2025-11-24T23:37:16.673781823+01:00","Action":"output","Package":"github.com/ctrf-io/go-ctrf-json-reporter/cmd/go-ctrf-json-reporter","Test":"TestExecute","Output":"=== RUN TestExecute\n"}
4+
{"Time":"2025-11-24T23:37:16.673799437+01:00","Action":"run","Package":"github.com/ctrf-io/go-ctrf-json-reporter/cmd/go-ctrf-json-reporter","Test":"TestExecute/with_no_flags"}
5+
{"Time":"2025-11-24T23:37:16.673806229+01:00","Action":"output","Package":"github.com/ctrf-io/go-ctrf-json-reporter/cmd/go-ctrf-json-reporter","Test":"TestExecute/with_no_flags","Output":"=== RUN TestExecute/with_no_flags\n"}
6+
{"Time":"2025-11-24T23:37:16.673819864+01:00","Action":"run","Package":"github.com/ctrf-io/go-ctrf-json-reporter/cmd/go-ctrf-json-reporter","Test":"TestExecute/with_no_flags/should_error_because_no_output_file_is_provided"}
7+
{"Time":"2025-11-24T23:37:16.673827269+01:00","Action":"output","Package":"github.com/ctrf-io/go-ctrf-json-reporter/cmd/go-ctrf-json-reporter","Test":"TestExecute/with_no_flags/should_error_because_no_output_file_is_provided","Output":"=== RUN TestExecute/with_no_flags/should_error_because_no_output_file_is_provided\n"}
8+
{"Time":"2025-11-24T23:37:16.674023516+01:00","Action":"run","Package":"github.com/ctrf-io/go-ctrf-json-reporter/cmd/go-ctrf-json-reporter","Test":"TestExecute/with_no_flags/should_error_because_no_report_data_is_provided"}
9+
{"Time":"2025-11-24T23:37:16.674036664+01:00","Action":"output","Package":"github.com/ctrf-io/go-ctrf-json-reporter/cmd/go-ctrf-json-reporter","Test":"TestExecute/with_no_flags/should_error_because_no_report_data_is_provided","Output":"=== RUN TestExecute/with_no_flags/should_error_because_no_report_data_is_provided\n"}
10+
{"Time":"2025-11-24T23:37:16.674482902+01:00","Action":"output","Package":"github.com/ctrf-io/go-ctrf-json-reporter/cmd/go-ctrf-json-reporter","Test":"TestExecute","Output":"--- PASS: TestExecute (0.00s)\n"}
11+
{"Time":"2025-11-24T23:37:16.674498998+01:00","Action":"output","Package":"github.com/ctrf-io/go-ctrf-json-reporter/cmd/go-ctrf-json-reporter","Test":"TestExecute/with_no_flags","Output":" --- PASS: TestExecute/with_no_flags (0.00s)\n"}
12+
{"Time":"2025-11-24T23:37:16.674509071+01:00","Action":"output","Package":"github.com/ctrf-io/go-ctrf-json-reporter/cmd/go-ctrf-json-reporter","Test":"TestExecute/with_no_flags/should_error_because_no_output_file_is_provided","Output":" --- PASS: TestExecute/with_no_flags/should_error_because_no_output_file_is_provided (0.00s)\n"}
13+
{"Time":"2025-11-24T23:37:16.674517604+01:00","Action":"pass","Package":"github.com/ctrf-io/go-ctrf-json-reporter/cmd/go-ctrf-json-reporter","Test":"TestExecute/with_no_flags/should_error_because_no_output_file_is_provided","Elapsed":0}
14+
{"Time":"2025-11-24T23:37:16.674526483+01:00","Action":"output","Package":"github.com/ctrf-io/go-ctrf-json-reporter/cmd/go-ctrf-json-reporter","Test":"TestExecute/with_no_flags/should_error_because_no_report_data_is_provided","Output":" --- PASS: TestExecute/with_no_flags/should_error_because_no_report_data_is_provided (0.00s)\n"}
15+
{"Time":"2025-11-24T23:37:16.674533735+01:00","Action":"pass","Package":"github.com/ctrf-io/go-ctrf-json-reporter/cmd/go-ctrf-json-reporter","Test":"TestExecute/with_no_flags/should_error_because_no_report_data_is_provided","Elapsed":0}
16+
{"Time":"2025-11-24T23:37:16.674540707+01:00","Action":"pass","Package":"github.com/ctrf-io/go-ctrf-json-reporter/cmd/go-ctrf-json-reporter","Test":"TestExecute/with_no_flags","Elapsed":0}
17+
{"Time":"2025-11-24T23:37:16.674549626+01:00","Action":"pass","Package":"github.com/ctrf-io/go-ctrf-json-reporter/cmd/go-ctrf-json-reporter","Test":"TestExecute","Elapsed":0}
18+
{"Time":"2025-11-24T23:37:16.674556683+01:00","Action":"output","Package":"github.com/ctrf-io/go-ctrf-json-reporter/cmd/go-ctrf-json-reporter","Output":"PASS\n"}
19+
{"Time":"2025-11-24T23:37:16.675106679+01:00","Action":"output","Package":"github.com/ctrf-io/go-ctrf-json-reporter/cmd/go-ctrf-json-reporter","Output":"ok \tgithub.com/ctrf-io/go-ctrf-json-reporter/cmd/go-ctrf-json-reporter\t0.005s\n"}
20+
{"Time":"2025-11-24T23:37:16.67514418+01:00","Action":"pass","Package":"github.com/ctrf-io/go-ctrf-json-reporter/cmd/go-ctrf-json-reporter","Elapsed":0.005}

0 commit comments

Comments
 (0)