[config_parser] Improve config_validator errors

Make them more structured and easier to parse.

Also, support more than one error from a particular step

Change-Id: Idafc4984a3055e6f107dea7fa5890123adb8bfa2
Reviewed-on: https://fuchsia-review.googlesource.com/c/cobalt/+/483577
Reviewed-by: Alexandre Zani <azani@google.com>
Commit-Queue: Zach Bush <zmbush@google.com>
Fuchsia-Auto-Submit: Zach Bush <zmbush@google.com>
diff --git a/src/bin/config_parser/src/BUILD.gn b/src/bin/config_parser/src/BUILD.gn
index 93a38f7..2c38db6 100644
--- a/src/bin/config_parser/src/BUILD.gn
+++ b/src/bin/config_parser/src/BUILD.gn
@@ -79,6 +79,7 @@
     "int_buckets_test.go",
     "metric_definitions_test.go",
     "report_definitions_test.go",
+    "validator_test.go",
   ]
 
   deps = [ ":config_parser" ]
@@ -89,6 +90,7 @@
   deps = [
     ":config_validator",
     "//third_party/golibs:github.com/golang/glog",
+    "//third_party/golibs:github.com/google/go-cmp",
   ]
 }
 
diff --git a/src/bin/config_parser/src/config_parser_main.go b/src/bin/config_parser/src/config_parser_main.go
index f85edcc..8811641 100644
--- a/src/bin/config_parser/src/config_parser_main.go
+++ b/src/bin/config_parser/src/config_parser_main.go
@@ -25,6 +25,11 @@
 	forClient      = flag.Bool("for_client", false, "Filters out the hide_on_client tagged fields")
 )
 
+func fail(step string, err error) {
+	glog.Errorf("\n\nFailed to %s:\n%v\n\n", step, err)
+	os.Exit(1)
+}
+
 func main() {
 	flag.Parse()
 
@@ -32,30 +37,30 @@
 	if *depFile != "" {
 		configFiles, err := config_parser.GetConfigFilesListFromFlags()
 		if err != nil {
-			glog.Exit(err)
+			fail("get config files", err)
 		}
 		if err := source_generator.WriteDepFileFromFlags(configFiles, *depFile); err != nil {
-			glog.Exit(err)
+			fail("write dep file", err)
 		}
 	}
 
 	// First, we parse the configuration from the specified location.
 	configs, err := config_parser.ParseConfigFromFlags()
 	if err != nil {
-		glog.Exit(err)
+		fail("parse registry", err)
 	}
 
 	// Unless otherwise specified, validate the registry.
 	if !*skipValidation {
 		if err = config_validator.ValidateProjectConfigs(configs); err != nil {
-			glog.Exit(err)
+			fail("validate registry", err)
 		}
 	}
 
 	// Compute and write the |prob_bit_flip| and |num_index_points| fields for each Cobalt 1.1 ReportDefinition
 	// in |configs|.
 	if err := config_parser.PopulatePrivacyParams(configs); err != nil {
-		glog.Exit(err)
+		fail("populate privacy parameters", err)
 	}
 
 	// Merge the list of project configs in a single config.CobaltRegistry.
@@ -69,6 +74,7 @@
 
 	// Write the registry depending upon the specified flags.
 	if err := source_generator.WriteConfigFromFlags(&c, filtered); err != nil {
+		fail("write generated registry files", err)
 		glog.Exit(err)
 	}
 
diff --git a/src/bin/config_parser/src/config_validator/metric_definitions.go b/src/bin/config_parser/src/config_validator/metric_definitions.go
index 5a03107..a34bba6 100644
--- a/src/bin/config_parser/src/config_validator/metric_definitions.go
+++ b/src/bin/config_parser/src/config_validator/metric_definitions.go
@@ -33,95 +33,110 @@
 
 // Validate a list of MetricDefinitions.
 func validateConfiguredMetricDefinitions(metrics []*config.MetricDefinition) (err error) {
+	metricErrors := newValidationErrors("metric")
+
 	metricIds := map[uint32]int{}
 	metricNames := map[string]bool{}
 	metricLookup := map[uint32]*config.MetricDefinition{}
 	for i, metric := range metrics {
 		if _, ok := metricIds[metric.Id]; ok {
-			return fmt.Errorf("There are two metrics with id=%v.", metric.Id)
+			metricErrors.addError(fmt.Sprintf("Metric %d", metric.Id), fmt.Errorf("there are two metrics with id=%v", metric.Id))
+			continue
 		}
 		metricIds[metric.Id] = i
 
 		if _, ok := metricNames[metric.MetricName]; ok {
-			return fmt.Errorf("There are two metrics with name=%v.", metric.MetricName)
+			metricErrors.addError(metric.MetricName, fmt.Errorf("there are two metrics with name=%v", metric.MetricName))
+			continue
 		}
 		metricNames[metric.MetricName] = true
 
 		metricLookup[metric.Id] = metric
 
 		if err = validateMetricDefinition(*metric); err != nil {
-			return fmt.Errorf("Error validating metric '%s': %v", metric.MetricName, err)
+			metricErrors.addError(metric.MetricName, err)
 		}
 
 	}
 
+	replacementIds := map[uint32]string{}
 	for _, metric := range metrics {
-		if err = validateReplacementMetricId(*metric, metricLookup); err != nil {
-			return fmt.Errorf("Error validating metric '%s': %v", metric.MetricName, err)
+		if metric.ReplacementMetricId != 0 {
+			if s, ok := replacementIds[metric.ReplacementMetricId]; ok {
+				metricErrors.addError(metric.MetricName, fmt.Errorf("found metric with duplicate replacement_metric_id %s", s))
+			}
+			replacementIds[metric.ReplacementMetricId] = metric.MetricName
+		}
+		if err = validateReplacementMetricID(*metric, metricLookup); err != nil {
+			metricErrors.addError(metric.MetricName, err)
 		}
 	}
 
-	return nil
+	return metricErrors.err()
 }
 
 // Validate a single MetricDefinition.
 func validateMetricDefinition(m config.MetricDefinition) (err error) {
+	validationErrors := newValidationErrors("field")
 	if !validNameRegexp.MatchString(m.MetricName) {
-		return fmt.Errorf("Invalid metric name. Metric names must match the regular expression '%v'.", validNameRegexp)
+		validationErrors.addError("metric_name", fmt.Errorf("metric names must match the regular expression '%v'", validNameRegexp))
 	}
 
 	if m.Id == 0 {
-		return fmt.Errorf("Metric ID of zero is not allowed. Please specify a positive metric ID.")
+		validationErrors.addError("id", fmt.Errorf("metric ID of zero is not allowed. Please specify a positive metric ID"))
 	}
 
 	if m.MetricType == config.MetricDefinition_UNSET {
-		return fmt.Errorf("metric_type is not set.")
+		validationErrors.addError("metric_type", fmt.Errorf("is not set"))
 	}
 
 	if m.MetaData == nil {
-		return fmt.Errorf("meta_data is not set, additionally, in meta_data: %v", validateMetadata(config.MetricDefinition_Metadata{}))
-	}
-
-	if err := validateMetadata(*m.MetaData); err != nil {
-		return fmt.Errorf("in meta_data: %v", err)
+		validationErrors.addError("meta_data", fmt.Errorf("meta_data is not set, additionally, in meta_data: %v", validateMetadata(config.MetricDefinition_Metadata{})))
+	} else if err := validateMetadata(*m.MetaData); err != nil {
+		validationErrors.addError("meta_data", err)
 	}
 
 	if err := validateMetricUnits(m); err != nil {
-		return err
+		validationErrors.addError("metric_units", err)
 	}
 
 	if m.IntBuckets != nil && m.MetricType != config.MetricDefinition_INT_HISTOGRAM && m.MetricType != config.MetricDefinition_INTEGER_HISTOGRAM {
-		return fmt.Errorf("Metric %s has int_buckets set. int_buckets can only be set for metrics for metric type INT_HISTOGRAM and INTEGER_HISTOGRAM.", m.MetricName)
+		validationErrors.addError("int_buckets", fmt.Errorf("int_buckets can only be set for metrics for metric type INT_HISTOGRAM and INTEGER_HISTOGRAM"))
 	}
 
 	if m.ProtoName != "" && m.MetricType != config.MetricDefinition_CUSTOM {
-		return fmt.Errorf("Metric %s has proto_name set. proto_name can only be set for metrics for metric type CUSTOM.", m.MetricName)
+		validationErrors.addError("proto_name", fmt.Errorf("proto_name can only be set for metrics for metric type CUSTOM"))
 	}
 
 	if config_parser.Cobalt11MetricTypesSet[m.MetricType] {
 		if m.MetricSemantics == nil || len(m.MetricSemantics) == 0 {
-			return fmt.Errorf("Metric %s does not have metric_semantics. metric_semantics is required for this metric.", m.MetricName)
+			validationErrors.addError("metric_semantics", fmt.Errorf("metric_semantics is required for this metric"))
 		}
 	} else {
 		if len(m.MetricSemantics) > 0 {
-			return fmt.Errorf("Metric %s has metric_semantics set. metric_semantics can only be set for Cobalt 1.1 metrics.", m.MetricName)
+			validationErrors.addError("metric_semantics", fmt.Errorf("metric_semantics can only be set for Cobalt 1.1 metrics"))
 		}
 	}
 
 	if err := validateMetricDefinitionForType(m); err != nil {
-		return fmt.Errorf("Metric %s: %v", m.MetricName, err)
+		validationErrors.addError("other", err)
 	}
 
 	if m.MetricType != config.MetricDefinition_CUSTOM {
 		if err := validateMetricDimensions(m); err != nil {
-			return fmt.Errorf("Metric %s: %v", m.MetricName, err)
+			validationErrors.addError("metric_dimensions", err)
 		}
 	}
 
+	err = validationErrors.err()
+	if err != nil {
+		return err
+	}
+
 	return validateReportDefinitions(m)
 }
 
-func validateReplacementMetricId(m config.MetricDefinition, mapping map[uint32]*config.MetricDefinition) (err error) {
+func validateReplacementMetricID(m config.MetricDefinition, mapping map[uint32]*config.MetricDefinition) (err error) {
 	if m.ReplacementMetricId == 0 {
 		return nil
 	}
@@ -159,7 +174,7 @@
 		if replacement.MetricType == config.MetricDefinition_STRING {
 			for _, report := range m.Reports {
 				if len(report.CandidateList) == 0 && len(report.CandidateFile) == 0 {
-					return fmt.Errorf("Error in report %v: metrics mapped to STRING must contain either candidate_list or candidate_file in all reports.", report.Id)
+					return fmt.Errorf("report `%v`: metrics mapped to STRING must contain either candidate_list or candidate_file in all reports", report.Id)
 				}
 			}
 		}
@@ -281,17 +296,17 @@
 		}
 
 		if len(md.EventCodes) == 0 && md.MaxEventCode == 0 {
-			return fmt.Errorf("For metric dimension %v, you must either define max_event_code or explicitly define at least one event code.", name)
+			return fmt.Errorf("for metric dimension %v, you must either define max_event_code or explicitly define at least one event code", name)
 		}
 		if md.MaxEventCode != 0 {
-			for j, _ := range md.EventCodes {
+			for j := range md.EventCodes {
 				if j > md.MaxEventCode {
 					return fmt.Errorf("event index %v in metric_dimension %v is greater than max_event_code %v", j, name, md.MaxEventCode)
 				}
 			}
 			numOptions *= int(md.MaxEventCode + 1)
 		} else {
-			for j, _ := range md.EventCodes {
+			for j := range md.EventCodes {
 				if cobalt10MetricType && j >= 1024 && len(m.MetricDimensions) > 4 {
 					return fmt.Errorf("event index %v in metric_dimension %v is greater than 1023, which means that this metric cannot support more than 4 metric_dimensions", j, name)
 				}
@@ -360,16 +375,16 @@
 
 func validateEventOccurred(m config.MetricDefinition) error {
 	if len(m.MetricDimensions) == 0 || m.MetricDimensions[0].MaxEventCode == 0 {
-		return fmt.Errorf("No max_event_code specified for metric of type EVENT_OCCURRED.")
+		return fmt.Errorf("no max_event_code specified for metric of type EVENT_OCCURRED")
 	}
 
 	if len(m.MetricDimensions) > 1 {
-		return fmt.Errorf("Too many metric dimensions specified for metric of type EVENT_OCCURRED.")
+		return fmt.Errorf("too many metric dimensions specified for metric of type EVENT_OCCURRED")
 	}
 
 	for i, md := range m.MetricDimensions {
 		if md.MaxEventCode == 0 {
-			return fmt.Errorf("No max_event_code specified for metric_dimension %v.", i)
+			return fmt.Errorf("no max_event_code specified for metric_dimension %v", i)
 		}
 	}
 
@@ -378,7 +393,7 @@
 
 func validateIntHistogram(m config.MetricDefinition) error {
 	if m.IntBuckets == nil {
-		return fmt.Errorf("No int_buckets specified for metric of type INT_HISTOGRAM.")
+		return fmt.Errorf("no int_buckets specified for metric of type INT_HISTOGRAM")
 	}
 
 	// TODO(azani): Validate bucket definition.
@@ -394,10 +409,10 @@
 
 func validateString(m config.MetricDefinition) error {
 	if m.StringCandidateFile == "" {
-		return fmt.Errorf("No string_candidate_file specified for metric of type STRING.")
+		return fmt.Errorf("no string_candidate_file specified for metric of type STRING")
 	}
 	if m.StringBufferMax == 0 {
-		return fmt.Errorf("No string_buffer_max specified for metric of type STRING.")
+		return fmt.Errorf("no string_buffer_max specified for metric of type STRING")
 	}
 
 	return nil
@@ -409,11 +424,11 @@
 	}
 
 	if m.ProtoName == "" {
-		return fmt.Errorf("Missing proto_name. Proto names are required for metrics of type CUSTOM.")
+		return fmt.Errorf("missing proto_name. Proto names are required for metrics of type CUSTOM")
 	}
 
 	if !validProtoNameRegexp.MatchString(m.ProtoName) {
-		return fmt.Errorf("Invalid proto_name. Proto names must match the regular expression '%v'.", validProtoNameRegexp)
+		return fmt.Errorf("Invalid proto_name. Proto names must match the regular expression '%v'", validProtoNameRegexp)
 	}
 
 	return nil
diff --git a/src/bin/config_parser/src/config_validator/report_definitions.go b/src/bin/config_parser/src/config_validator/report_definitions.go
index b525992..af2d5d7 100644
--- a/src/bin/config_parser/src/config_validator/report_definitions.go
+++ b/src/bin/config_parser/src/config_validator/report_definitions.go
@@ -88,25 +88,30 @@
 }
 
 func validateReportDefinitions(m config.MetricDefinition) error {
+	reportErrors := newValidationErrors("report")
+
 	reportIds := map[uint32]int{}
 	reportNames := map[string]bool{}
 	for i, r := range m.Reports {
 		if _, ok := reportIds[r.Id]; ok {
-			return fmt.Errorf("There are two reports with id=%v.", r.Id)
+			reportErrors.addError(fmt.Sprintf("Report %d", r.Id), fmt.Errorf("there are two reports with id=%v", r.Id))
+			continue
 		}
 		reportIds[r.Id] = i
 
 		if _, ok := reportNames[r.ReportName]; ok {
-			return fmt.Errorf("There are two reports with name=%v.", r.ReportName)
+			reportErrors.addError(r.ReportName, fmt.Errorf("there are two reports with name=%v", r.ReportName))
+			continue
 		}
 		reportNames[r.ReportName] = true
 
 		if err := validateReportDefinitionForMetric(m, *r); err != nil {
-			return fmt.Errorf("Error validating report '%s': %v", r.ReportName, err)
+			reportErrors.addError(r.ReportName, err)
+			continue
 		}
 	}
 
-	return nil
+	return reportErrors.err()
 }
 
 // Validate a single instance of a ReportDefinition with its associated metric.
@@ -129,7 +134,7 @@
 // Validate a single instance of a ReportDefinition.
 func validateReportDefinition(m config.MetricDefinition, r config.ReportDefinition) error {
 	if !validNameRegexp.MatchString(r.ReportName) {
-		return fmt.Errorf("Invalid report name. Report names must match the regular expression '%v'.", validNameRegexp)
+		return fmt.Errorf("Invalid report name. Report names must match the regular expression '%v'", validNameRegexp)
 	}
 
 	if r.Id == 0 {
@@ -137,7 +142,7 @@
 	}
 
 	if err := validateReportDefinitionForType(m, r); err != nil {
-		return fmt.Errorf("Report %s: %v", r.ReportName, err)
+		return err
 	}
 
 	return nil
@@ -146,16 +151,16 @@
 // Validates that the MetricType and ReportType provided are compatible.
 func validateReportType(mt config.MetricDefinition_MetricType, rt config.ReportDefinition_ReportType) error {
 	if rt == config.ReportDefinition_REPORT_TYPE_UNSET {
-		return fmt.Errorf("report_type is not set.")
+		return fmt.Errorf("report_type is not set")
 	}
 
 	rts, ok := allowedReportTypes[mt]
 	if !ok {
-		return fmt.Errorf("Unknown metric type %s.", mt)
+		return fmt.Errorf("unknown metric type %s", mt)
 	}
 
 	if _, ok = rts[rt]; !ok {
-		return fmt.Errorf("Reports of type %s cannot be used with metrics of type %s", rt, mt)
+		return fmt.Errorf("reports of type %s cannot be used with metrics of type %s", rt, mt)
 	}
 
 	return nil
diff --git a/src/bin/config_parser/src/config_validator/validator.go b/src/bin/config_parser/src/config_validator/validator.go
index 5d8bb0c..7e76732 100644
--- a/src/bin/config_parser/src/config_validator/validator.go
+++ b/src/bin/config_parser/src/config_validator/validator.go
@@ -7,17 +7,84 @@
 import (
 	"config"
 	"config_parser"
+	"errors"
 	"fmt"
+	"sort"
+	"strings"
 )
 
+// validationErrors is used to package up nested validation errors in a structured way
+//
+// The method validationErrors::err() converts this into an error type (or nil if no entries have been added to errors)
+type validationErrors struct {
+	ty     string
+	errors map[string][]error
+}
+
+func (ve validationErrors) addError(key string, value error) {
+	ve.errors[key] = append(ve.errors[key], value)
+}
+
+func (ve *validationErrors) Error() string {
+	if ve == nil {
+		return ""
+	}
+
+	projects := []string{}
+	for project := range ve.errors {
+		projects = append(projects, project)
+	}
+	sort.Strings(projects)
+	result := []string{}
+	for _, project := range projects {
+		errs := ve.errors[project]
+		if len(errs) == 1 {
+			var innerErrs *validationErrors
+			err := errs[0]
+			if errors.As(err, &innerErrs) {
+				// Indent the nested validationErrors
+				errStr := strings.Join(strings.Split(err.Error(), "\n"), "\n  ")
+				result = append(result, fmt.Sprintf("%s `%s`:\n  %v", ve.ty, project, errStr))
+			} else {
+				result = append(result, fmt.Sprintf("%s `%s`: %v", ve.ty, project, err.Error()))
+			}
+		} else {
+			errStrs := []string{}
+			for _, err := range errs {
+				errStrs = append(errStrs, err.Error())
+			}
+			errStr := strings.Join(errStrs, "\n")
+			errStr = strings.Join(strings.Split(errStr, "\n"), "\n  ")
+			result = append(result, fmt.Sprintf("%s `%s`:\n  %v", ve.ty, project, errStr))
+		}
+	}
+	return strings.Join(result, "\n")
+}
+
+func newValidationErrors(ty string) validationErrors {
+	return validationErrors{
+		ty:     ty,
+		errors: map[string][]error{},
+	}
+}
+
+func (ve validationErrors) err() error {
+	if len(ve.errors) > 0 {
+		return &ve
+	}
+	return nil
+}
+
 func ValidateProjectConfigs(configs []config_parser.ProjectConfig) error {
+	projectErrors := newValidationErrors("customer/project")
+
 	for _, c := range configs {
 		if err := validateProjectConfig(&c); err != nil {
-			return err
+			projectErrors.addError(fmt.Sprintf("%s/%s", c.CustomerName, c.ProjectName), err)
 		}
 	}
 
-	return nil
+	return projectErrors.err()
 }
 
 func validateProjectConfig(c *config_parser.ProjectConfig) (err error) {
@@ -26,11 +93,11 @@
 	}
 
 	if err = validateConfiguredMetricDefinitions(c.ProjectConfig.MetricDefinitions); err != nil {
-		return fmt.Errorf("Error in configuration for project %s: %v", c.ProjectName, err)
+		return err
 	}
 
 	if err = validateReplacementMetricSaturation(c.ProjectConfig.MetricDefinitions); err != nil {
-		return fmt.Errorf("Error in configuration for project %s: %v", c.ProjectName, err)
+		return err
 	}
 	return nil
 }
diff --git a/src/bin/config_parser/src/config_validator/validator_test.go b/src/bin/config_parser/src/config_validator/validator_test.go
new file mode 100644
index 0000000..eb3fe74
--- /dev/null
+++ b/src/bin/config_parser/src/config_validator/validator_test.go
@@ -0,0 +1,66 @@
+package config_validator
+
+import (
+	"fmt"
+	"testing"
+
+	"github.com/google/go-cmp/cmp"
+)
+
+func TestEmptyValidationErrors(t *testing.T) {
+	if newValidationErrors("err").err() != nil {
+		t.Errorf("Empty validation errors .err() should return nil")
+	}
+}
+
+func TestSingleValidationErrors(t *testing.T) {
+	errs := newValidationErrors("err")
+	errs.addError("an", fmt.Errorf("error"))
+	expectedError := "err `an`: error"
+	if diff := cmp.Diff(errs.err().Error(), expectedError); diff != "" {
+		t.Errorf("Output error does not match expected error: %s", diff)
+	}
+}
+
+func TestMultipleValidationErrors(t *testing.T) {
+	errs := newValidationErrors("err")
+	errs.addError("an", fmt.Errorf("error"))
+	errs.addError("another", fmt.Errorf("error"))
+	expectedError := "err `an`: error\nerr `another`: error"
+	if diff := cmp.Diff(errs.err().Error(), expectedError); diff != "" {
+		t.Errorf("Output error does not match expected error: %s", diff)
+	}
+}
+
+func TestNestedValidationErrors(t *testing.T) {
+	innerErr := newValidationErrors("inner")
+	innerErr.addError("an", fmt.Errorf("error"))
+	errs := newValidationErrors("err")
+	errs.addError("nested", innerErr.err())
+	expectedError := "err `nested`:\n  inner `an`: error"
+	if diff := cmp.Diff(errs.err().Error(), expectedError); diff != "" {
+		t.Errorf("Output error does not match expected error: %s", diff)
+	}
+}
+
+func TestMultipleNestedValidationErrors(t *testing.T) {
+	innerErr := newValidationErrors("inner")
+	innerErr.addError("an", fmt.Errorf("error"))
+	errs := newValidationErrors("err")
+	errs.addError("nested2", innerErr.err())
+	errs.addError("nested1", innerErr.err())
+	expectedError := "err `nested1`:\n  inner `an`: error\nerr `nested2`:\n  inner `an`: error"
+	if diff := cmp.Diff(errs.err().Error(), expectedError); diff != "" {
+		t.Errorf("Output error does not match expected error: %s", diff)
+	}
+}
+
+func TestMultipleErrorsForLevel(t *testing.T) {
+	errs := newValidationErrors("err")
+	errs.addError("an", fmt.Errorf("error"))
+	errs.addError("an", fmt.Errorf("error"))
+	expectedError := "err `an`:\n  error\n  error"
+	if diff := cmp.Diff(errs.err().Error(), expectedError); diff != "" {
+		t.Errorf("Output error does not match expected error: %s", diff)
+	}
+}