Repository: helm/helm (go)

Issue:
Helm search should have the option to exit with 1 if no results found
`helm version`: `version.BuildInfo{Version:"v3.0.1", GitCommit:"7c22ef9ce89e0ebeb7125ba2ebf7d421f3e82ffa", GitTreeState:"clean", GoVersion:"go1.13.4"}`

`helm search repo` and `helm search hub` should have an option to exit with `1` when nothing is found. This is useful in automation scripts. Something like `--fail` or `--fail-if-no-results-found`.

Pull request description:
helm search: new CLI flag `--fail-on-no-result` to fail when no results found for search (repo and hub)
**What this PR does / why we need it**:

Add new CLI flag `--fail-on-no-result` for failing the `helm search` when there are no results found. This works with:
1. `helm search repo`
2. `helm search hub`

Closes #7197 

**Special notes for your reviewer**:
- Since this is a new flag altogether, it should be backward-compatible.
- I believe no documentation is required as the flag is self-explanatory.

**If applicable**:
- [ ] this PR contains documentation
- [x] this PR contains unit tests
- [x] this PR has been tested for backwards compatibility

@giggio @hickeyma @roubles @joejulian @gjenkins8 Please validate whether these changes achieve the expected result. Thanks!


Patch:
diff --git a/cmd/helm/search_hub.go b/cmd/helm/search_hub.go
index b8887efd5..1618a4c9f 100644
--- a/cmd/helm/search_hub.go
+++ b/cmd/helm/search_hub.go
@@ -54,6 +54,7 @@ type searchHubOptions struct {
 	maxColWidth    uint
 	outputFormat   output.Format
 	listRepoURL    bool
+	failOnNoResult bool
 }
 
 func newSearchHubCmd(out io.Writer) *cobra.Command {
@@ -72,6 +73,7 @@ func newSearchHubCmd(out io.Writer) *cobra.Command {
 	f.StringVar(&o.searchEndpoint, "endpoint", "https://hub.helm.sh", "Hub instance to query for charts")
 	f.UintVar(&o.maxColWidth, "max-col-width", 50, "maximum column width for output table")
 	f.BoolVar(&o.listRepoURL, "list-repo-url", false, "print charts repository URL")
+	f.BoolVar(&o.failOnNoResult, "fail-on-no-result", false, "search fails if no results are found")
 
 	bindOutputFlag(cmd, &o.outputFormat)
 
@@ -91,7 +93,7 @@ func (o *searchHubOptions) run(out io.Writer, args []string) error {
 		return fmt.Errorf("unable to perform search against %q", o.searchEndpoint)
 	}
 
-	return o.outputFormat.Write(out, newHubSearchWriter(results, o.searchEndpoint, o.maxColWidth, o.listRepoURL))
+	return o.outputFormat.Write(out, newHubSearchWriter(results, o.searchEndpoint, o.maxColWidth, o.listRepoURL, o.failOnNoResult))
 }
 
 type hubChartRepo struct {
@@ -108,12 +110,13 @@ type hubChartElement struct {
 }
 
 type hubSearchWriter struct {
-	elements    []hubChartElement
-	columnWidth uint
-	listRepoURL bool
+	elements       []hubChartElement
+	columnWidth    uint
+	listRepoURL    bool
+	failOnNoResult bool
 }
 
-func newHubSearchWriter(results []monocular.SearchResult, endpoint string, columnWidth uint, listRepoURL bool) *hubSearchWriter {
+func newHubSearchWriter(results []monocular.SearchResult, endpoint string, columnWidth uint, listRepoURL, failOnNoResult bool) *hubSearchWriter {
 	var elements []hubChartElement
 	for _, r := range results {
 		// Backwards compatibility for Monocular
@@ -126,11 +129,16 @@ func newHubSearchWriter(results []monocular.SearchResult, endpoint string, colum
 
 		elements = append(elements, hubChartElement{url, r.Relationships.LatestChartVersion.Data.Version, r.Relationships.LatestChartVersion.Data.AppVersion, r.Attributes.Description, hubChartRepo{URL: r.Attributes.Repo.URL, Name: r.Attributes.Repo.Name}})
 	}
-	return &hubSearchWriter{elements, columnWidth, listRepoURL}
+	return &hubSearchWriter{elements, columnWidth, listRepoURL, failOnNoResult}
 }
 
 func (h *hubSearchWriter) WriteTable(out io.Writer) error {
 	if len(h.elements) == 0 {
+		// Fail if no results found and --fail-on-no-result is enabled
+		if h.failOnNoResult {
+			return fmt.Errorf("no results found")
+		}
+
 		_, err := out.Write([]byte("No results found\n"))
 		if err != nil {
 			return fmt.Errorf("unable to write results: %s", err)
@@ -165,6 +173,11 @@ func (h *hubSearchWriter) WriteYAML(out io.Writer) error {
 }
 
 func (h *hubSearchWriter) encodeByFormat(out io.Writer, format output.Format) error {
+	// Fail if no results found and --fail-on-no-result is enabled
+	if len(h.elements) == 0 && h.failOnNoResult {
+		return fmt.Errorf("no results found")
+	}
+
 	// Initialize the array so no results returns an empty array instead of null
 	chartList := make([]hubChartElement, 0, len(h.elements))
 
diff --git a/cmd/helm/search_repo.go b/cmd/helm/search_repo.go
index f794f6bca..bc913887e 100644
--- a/cmd/helm/search_repo.go
+++ b/cmd/helm/search_repo.go
@@ -64,14 +64,15 @@ Repositories are managed with 'helm repo' commands.
 const searchMaxScore = 25
 
 type searchRepoOptions struct {
-	versions     bool
-	regexp       bool
-	devel        bool
-	version      string
-	maxColWidth  uint
-	repoFile     string
-	repoCacheDir string
-	outputFormat output.Format
+	versions       bool
+	regexp         bool
+	devel          bool
+	version        string
+	maxColWidth    uint
+	repoFile       string
+	repoCacheDir   string
+	outputFormat   output.Format
+	failOnNoResult bool
 }
 
 func newSearchRepoCmd(out io.Writer) *cobra.Command {
@@ -94,6 +95,8 @@ func newSearchRepoCmd(out io.Writer) *cobra.Command {
 	f.BoolVar(&o.devel, "devel", false, "use development versions (alpha, beta, and release candidate releases), too. Equivalent to version '>0.0.0-0'. If --version is set, this is ignored")
 	f.StringVar(&o.version, "version", "", "search using semantic versioning constraints on repositories you have added")
 	f.UintVar(&o.maxColWidth, "max-col-width", 50, "maximum column width for output table")
+	f.BoolVar(&o.failOnNoResult, "fail-on-no-result", false, "search fails if no results are found")
+
 	bindOutputFlag(cmd, &o.outputFormat)
 
 	return cmd
@@ -124,7 +127,7 @@ func (o *searchRepoOptions) run(out io.Writer, args []string) error {
 		return err
 	}
 
-	return o.outputFormat.Write(out, &repoSearchWriter{data, o.maxColWidth})
+	return o.outputFormat.Write(out, &repoSearchWriter{data, o.maxColWidth, o.failOnNoResult})
 }
 
 func (o *searchRepoOptions) setupSearchedVersion() {
@@ -205,12 +208,18 @@ type repoChartElement struct {
 }
 
 type repoSearchWriter struct {
-	results     []*search.Result
-	columnWidth uint
+	results        []*search.Result
+	columnWidth    uint
+	failOnNoResult bool
 }
 
 func (r *repoSearchWriter) WriteTable(out io.Writer) error {
 	if len(r.results) == 0 {
+		// Fail if no results found and --fail-on-no-result is enabled
+		if r.failOnNoResult {
+			return fmt.Errorf("no results found")
+		}
+
 		_, err := out.Write([]byte("No results found\n"))
 		if err != nil {
 			return fmt.Errorf("unable to write results: %s", err)
@@ -235,6 +244,11 @@ func (r *repoSearchWriter) WriteYAML(out io.Writer) error {
 }
 
 func (r *repoSearchWriter) encodeByFormat(out io.Writer, format output.Format) error {
+	// Fail if no results found and --fail-on-no-result is enabled
+	if len(r.results) == 0 && r.failOnNoResult {
+		return fmt.Errorf("no results found")
+	}
+
 	// Initialize the array so no results returns an empty array instead of null
 	chartList := make([]repoChartElement, 0, len(r.results))
 


Test patch:
diff --git a/cmd/helm/search_hub_test.go b/cmd/helm/search_hub_test.go
index 7df54ea8f..89ce2b3e5 100644
--- a/cmd/helm/search_hub_test.go
+++ b/cmd/helm/search_hub_test.go
@@ -90,3 +90,98 @@ func TestSearchHubOutputCompletion(t *testing.T) {
 func TestSearchHubFileCompletion(t *testing.T) {
 	checkFileCompletion(t, "search hub", true) // File completion may be useful when inputting a keyword
 }
+
+func TestSearchHubCmd_FailOnNoResponseTests(t *testing.T) {
+	var (
+		searchResult            = `{"data":[]}`
+		noResultFoundErr        = "Error: no results found\n"
+		noResultFoundWarn       = "No results found\n"
+		noResultFoundWarnInList = "[]\n"
+	)
+
+	type testCase struct {
+		name     string
+		cmd      string
+		response string
+		expected string
+		wantErr  bool
+	}
+
+	var tests = []testCase{
+		{
+			name:     "Search hub with no results in response",
+			cmd:      `search hub maria`,
+			response: searchResult,
+			expected: noResultFoundWarn,
+			wantErr:  false,
+		},
+		{
+			name:     "Search hub with no results in response and output JSON",
+			cmd:      `search hub maria --output json`,
+			response: searchResult,
+			expected: noResultFoundWarnInList,
+			wantErr:  false,
+		},
+		{
+			name:     "Search hub with no results in response and output YAML",
+			cmd:      `search hub maria --output yaml`,
+			response: searchResult,
+			expected: noResultFoundWarnInList,
+			wantErr:  false,
+		},
+		{
+			name:     "Search hub with no results in response and --fail-on-no-result enabled, expected failure",
+			cmd:      `search hub maria --fail-on-no-result`,
+			response: searchResult,
+			expected: noResultFoundErr,
+			wantErr:  true,
+		},
+		{
+			name:     "Search hub with no results in response, output JSON and --fail-on-no-result enabled, expected failure",
+			cmd:      `search hub maria --fail-on-no-result --output json`,
+			response: searchResult,
+			expected: noResultFoundErr,
+			wantErr:  true,
+		},
+		{
+			name:     "Search hub with no results in response, output YAML and --fail-on-no-result enabled, expected failure",
+			cmd:      `search hub maria --fail-on-no-result --output yaml`,
+			response: searchResult,
+			expected: noResultFoundErr,
+			wantErr:  true,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			// Setup a mock search service
+			ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				fmt.Fprintln(w, tt.response)
+			}))
+			defer ts.Close()
+
+			// Add mock server URL to command
+			tt.cmd += " --endpoint " + ts.URL
+
+			storage := storageFixture()
+
+			_, out, err := executeActionCommandC(storage, tt.cmd)
+			if tt.wantErr {
+				if err == nil {
+					t.Errorf("expected error due to no record in response, got nil")
+				}
+			} else {
+				if err != nil {
+					t.Errorf("unexpected error, got %q", err)
+				}
+			}
+
+			if out != tt.expected {
+				t.Errorf("expected and actual output did not match\n"+
+					"expected: %q\n"+
+					"actual  : %q",
+					tt.expected, out)
+			}
+		})
+	}
+}
diff --git a/cmd/helm/search_repo_test.go b/cmd/helm/search_repo_test.go
index 58ba3a715..9039842f0 100644
--- a/cmd/helm/search_repo_test.go
+++ b/cmd/helm/search_repo_test.go
@@ -56,6 +56,20 @@ func TestSearchRepositoriesCmd(t *testing.T) {
 		name:   "search for 'syzygy', expect no matches",
 		cmd:    "search repo syzygy",
 		golden: "output/search-not-found.txt",
+	}, {
+		name:      "search for 'syzygy' with --fail-on-no-result, expect failure for no results",
+		cmd:       "search repo syzygy --fail-on-no-result",
+		golden:    "output/search-not-found-error.txt",
+		wantError: true,
+	}, {name: "search for 'syzygy' with json output and --fail-on-no-result, expect failure for no results",
+		cmd:       "search repo syzygy --output json --fail-on-no-result",
+		golden:    "output/search-not-found-error.txt",
+		wantError: true,
+	}, {
+		name:      "search for 'syzygy' with yaml output --fail-on-no-result, expect failure for no results",
+		cmd:       "search repo syzygy --output yaml --fail-on-no-result",
+		golden:    "output/search-not-found-error.txt",
+		wantError: true,
 	}, {
 		name:   "search for 'alp[a-z]+', expect two matches",
 		cmd:    "search repo alp[a-z]+ --regexp",
diff --git a/cmd/helm/testdata/output/search-not-found-error.txt b/cmd/helm/testdata/output/search-not-found-error.txt
new file mode 100644
index 000000000..8b586bea3
--- /dev/null
+++ b/cmd/helm/testdata/output/search-not-found-error.txt
@@ -0,0 +1,1 @@
+Error: no results found