diff --git a/cmd/complypack/cli/applicability.go b/cmd/complypack/cli/applicability.go index a8a6f88..05424a5 100644 --- a/cmd/complypack/cli/applicability.go +++ b/cmd/complypack/cli/applicability.go @@ -202,6 +202,31 @@ func writeApplicabilityHuman( catalogName string, result *requirement.ApplicabilityGroupResult, ) error { - // TODO: add styled output with lipgloss formatting. - return writeApplicabilityText(w, catalogName, result) + fmt.Fprintln(w, renderHeader(fmt.Sprintf("Applicability: %s", catalogName))) + fmt.Fprintln(w, fmt.Sprintf(" %s %s", renderMetadata("Groups", len(result.Groups)), + renderMetadata("Ungrouped", len(result.Ungrouped))), + ) + + for _, g := range result.Groups { + fmt.Fprintf(w, "\n %s", styleOK.Render(g.ID)) + if g.Title != "" { + fmt.Fprintf(w, " - %s", styleControl.Render(g.Title)) + } + fmt.Fprintln(w) + if g.Description != "" { + fmt.Fprintf(w, " %s\n", styleDim.Render(g.Description)) + } + if len(g.RequirementIDs) > 0 { + fmt.Fprintf(w, " %s: %s\n", styleOK.Render("Requirements"), styleDim.Render(strings.Join(g.RequirementIDs, ", "))) + } + } + + if len(result.Ungrouped) > 0 { + fmt.Fprintln(w, "\n "+ + styleWarn.Render("Ungrouped:")+" "+ + strings.Join(result.Ungrouped, ", "), + ) + } + + return nil } diff --git a/cmd/complypack/cli/applicability_test.go b/cmd/complypack/cli/applicability_test.go index a374e2a..35e549b 100644 --- a/cmd/complypack/cli/applicability_test.go +++ b/cmd/complypack/cli/applicability_test.go @@ -187,25 +187,78 @@ func TestWriteApplicabilityJSON(t *testing.T) { assert.Equal(t, []string{"R3"}, parsed.Ungrouped) } -func TestWriteApplicabilityHuman_DelegatesToText( - t *testing.T, -) { +func TestWriteApplicabilityHuman(t *testing.T) { result := &requirement.ApplicabilityGroupResult{ - Groups: []requirement.ApplicabilityGroupInfo{}, - Ungrouped: []string{}, + Groups: []requirement.ApplicabilityGroupInfo{ + { + ID: "maturity-1", + Title: "Maturity Level 1", + Description: "Basic requirements", + RequirementIDs: []string{"REQ-001", "REQ-002"}, + }, + { + ID: "maturity-2", + Title: "Maturity Level 2", + RequirementIDs: []string{"REQ-003"}, + }, + }, + Ungrouped: []string{"REQ-099"}, } - var textBuf, humanBuf bytes.Buffer - err := writeApplicabilityText( - &textBuf, "cat", result, + var buf bytes.Buffer + err := writeApplicabilityHuman( + &buf, "my-catalog", result, ) require.NoError(t, err) - err = writeApplicabilityHuman( - &humanBuf, "cat", result, + output := buf.String() + + // Verify styled header + assert.Contains(t, output, "my-catalog") + assert.Contains(t, output, "━", + "human output should contain styled header separator") + + // Verify data passes through + assert.Contains(t, output, "Groups:") + assert.Contains(t, output, "maturity-1") + assert.Contains(t, output, "Maturity Level 1") + assert.Contains(t, output, "Basic requirements") + assert.Contains(t, output, "REQ-001") + assert.Contains(t, output, "REQ-002") + assert.Contains(t, output, "maturity-2") + assert.Contains(t, output, "REQ-003") + assert.Contains(t, output, "Ungrouped:") + assert.Contains(t, output, "REQ-099") + + // Verify human output differs from text output + var textBuf bytes.Buffer + err = writeApplicabilityText( + &textBuf, "my-catalog", result, + ) + require.NoError(t, err) + + assert.NotEqual(t, textBuf.String(), buf.String(), + "human format should differ from text format") +} + +func TestWriteApplicabilityHuman_NoGroups(t *testing.T) { + result := &requirement.ApplicabilityGroupResult{ + Groups: []requirement.ApplicabilityGroupInfo{}, + Ungrouped: []string{}, + } + + var buf bytes.Buffer + err := writeApplicabilityHuman( + &buf, "empty-catalog", result, ) require.NoError(t, err) - assert.Equal(t, textBuf.String(), humanBuf.String(), - "human format should delegate to text for now") + output := buf.String() + assert.Contains(t, output, "empty-catalog") + assert.Contains(t, output, "━", + "human output should contain styled header separator") + assert.Contains(t, output, "Groups:") + assert.Contains(t, output, "Ungrouped:") + // Ungrouped section (with requirement list) should not appear + assert.NotContains(t, output, "REQ-") } diff --git a/cmd/complypack/cli/coverage.go b/cmd/complypack/cli/coverage.go index 0a3be9b..acec1dd 100644 --- a/cmd/complypack/cli/coverage.go +++ b/cmd/complypack/cli/coverage.go @@ -20,24 +20,6 @@ import ( "github.com/spf13/cobra" ) -// Output format constants. -const ( - formatHuman = "human" - formatText = "text" - formatJSON = "json" -) - -var ( - styleTitle = lipgloss.NewStyle().Bold(true) - styleControl = lipgloss.NewStyle().Bold(true) - stylePass = lipgloss.NewStyle().Foreground(lipgloss.Color("42")) - styleFail = lipgloss.NewStyle().Foreground(lipgloss.Color("196")) - styleGap = lipgloss.NewStyle().Foreground(lipgloss.Color("214")) - styleOK = lipgloss.NewStyle().Foreground(lipgloss.Color("39")) - styleWarn = lipgloss.NewStyle().Foreground(lipgloss.Color("214")) - styleDim = lipgloss.NewStyle().Faint(true) -) - func coverageCmd() *cobra.Command { var ( policyName string @@ -108,23 +90,6 @@ Examples: return cmd } -// resolveFormat determines the output format from the flag value and environment. -// When no flag is provided, it defaults to "text" if NO_COLOR is set, otherwise "human". -func resolveFormat(flagValue string) (string, error) { - if flagValue != "" { - switch flagValue { - case formatHuman, formatText, formatJSON: - return flagValue, nil - default: - return "", fmt.Errorf("unknown format %q; valid formats: human, text, json", flagValue) - } - } - if os.Getenv("NO_COLOR") != "" { - return formatText, nil - } - return formatHuman, nil -} - // coverageRunParams holds parsed CLI parameters for the coverage command. type coverageRunParams struct { policyName string @@ -320,8 +285,7 @@ func plainStatusIndicator(status coverage.RequirementStatus) string { // writeHuman formats the report as styled text with Unicode symbols and color. func writeHuman(w io.Writer, report *coverage.Report) error { - fmt.Fprintln(w, styleTitle.Render(fmt.Sprintf("Coverage Report: %s", report.PolicyID))) - fmt.Fprintln(w, styleDim.Render(strings.Repeat("━", 50))) + fmt.Fprintln(w, renderHeader(fmt.Sprintf("Coverage Report: %s", report.PolicyID))) type controlGroup struct { controlID string @@ -364,7 +328,7 @@ func writeHuman(w io.Writer, report *coverage.Report) error { } fmt.Fprintln(w) - fmt.Fprintln(w, styleDim.Render(strings.Repeat("─", 50))) + fmt.Fprintln(w, renderSeparator()) covStyle := coverageStyle(report.Metrics.CoveragePercent) fmt.Fprintf(w, " %s\n", covStyle.Render( diff --git a/cmd/complypack/cli/delta.go b/cmd/complypack/cli/delta.go index 36c36ce..b3a72e5 100644 --- a/cmd/complypack/cli/delta.go +++ b/cmd/complypack/cli/delta.go @@ -180,6 +180,16 @@ func writeDeltaHuman( w io.Writer, report *requirement.DeltaReport, ) error { - // TODO: add styled output with lipgloss formatting. - return writeDeltaText(w, report) + fmt.Fprintln(w, renderHeader(fmt.Sprintf("Delta: %s", report.PolicyID))) + fmt.Fprintln(w, " "+renderMetadata("Catalogs Compared", len(report.CatalogsCompared))) + fmt.Fprintln(w, " "+renderMetadata("Comparisons", len(report.Comparisons))) + + for _, c := range report.Comparisons { + fmt.Fprintln(w, fmt.Sprintf("\n %s / %s", styleControl.Render(c.RequirementID), styleDim.Render(c.Label))) + fmt.Fprintln(w, fmt.Sprintf(" %s", renderMetadata("Policy value", c.PolicyValue))) + if c.RequirementText != "" { + fmt.Fprintln(w, fmt.Sprintf(" %s", renderMetadata("Requirement", c.RequirementText))) + } + } + return nil } diff --git a/cmd/complypack/cli/delta_test.go b/cmd/complypack/cli/delta_test.go index dc7ad26..35a60d0 100644 --- a/cmd/complypack/cli/delta_test.go +++ b/cmd/complypack/cli/delta_test.go @@ -147,20 +147,67 @@ func TestWriteDeltaJSON(t *testing.T) { parsed.Comparisons[0].PolicyValue) } -func TestWriteDeltaHuman_DelegatesToText(t *testing.T) { +func TestWriteDeltaHuman(t *testing.T) { report := &requirement.DeltaReport{ PolicyID: "test-policy", - CatalogsCompared: []string{}, - Comparisons: []requirement.ParameterComparison{}, + CatalogsCompared: []string{"catalog-a", "catalog-b"}, + Comparisons: []requirement.ParameterComparison{ + { + RequirementID: "REQ-001", + Label: "tls_version", + PolicyValue: "1.2", + PolicySource: "test-policy", + RequirementText: "Must use TLS", + CatalogSource: "catalog-a", + }, + }, } - var textBuf, humanBuf bytes.Buffer - err := writeDeltaText(&textBuf, report) + var buf bytes.Buffer + err := writeDeltaHuman(&buf, report) require.NoError(t, err) - err = writeDeltaHuman(&humanBuf, report) + output := buf.String() + + // Verify styled header + assert.Contains(t, output, "test-policy") + assert.Contains(t, output, "━", + "human output should contain styled header separator") + + // Verify data passes through + assert.Contains(t, output, "Catalogs Compared:") + assert.Contains(t, output, "Comparisons:") + assert.Contains(t, output, "REQ-001") + assert.Contains(t, output, "tls_version") + assert.Contains(t, output, "Policy value:") + assert.Contains(t, output, "1.2") + assert.Contains(t, output, "Requirement:") + assert.Contains(t, output, "Must use TLS") + + // Verify human output differs from text output + var textBuf bytes.Buffer + err = writeDeltaText(&textBuf, report) require.NoError(t, err) - assert.Equal(t, textBuf.String(), humanBuf.String(), - "human format should delegate to text for now") + assert.NotEqual(t, textBuf.String(), buf.String(), + "human format should differ from text format") +} + +func TestWriteDeltaHuman_NoComparisons(t *testing.T) { + report := &requirement.DeltaReport{ + PolicyID: "empty-policy", + CatalogsCompared: []string{}, + Comparisons: []requirement.ParameterComparison{}, + } + + var buf bytes.Buffer + err := writeDeltaHuman(&buf, report) + require.NoError(t, err) + + output := buf.String() + assert.Contains(t, output, "empty-policy") + assert.Contains(t, output, "━", + "human output should contain styled header separator") + assert.Contains(t, output, "Catalogs Compared:") + assert.Contains(t, output, "Comparisons:") } diff --git a/cmd/complypack/cli/requirements.go b/cmd/complypack/cli/requirements.go index b12cd56..435ec52 100644 --- a/cmd/complypack/cli/requirements.go +++ b/cmd/complypack/cli/requirements.go @@ -199,6 +199,18 @@ func writeRequirementsHuman( catalogName string, results []requirement.AssessmentRequirementInfo, ) error { - // TODO: add styled output with lipgloss formatting. - return writeRequirementsText(w, catalogName, results) + fmt.Fprintln(w, renderHeader(fmt.Sprintf("Requirements: %s", catalogName))) + fmt.Fprintln(w, fmt.Sprintf(" %s", renderMetadata("Count", len(results)))) + + for _, r := range results { + fmt.Fprintf(w, "\n %s %s\n", styleControl.Render(r.ID), styleDim.Render(fmt.Sprintf("(control: %s)", r.ControlID))) + fmt.Fprintf(w, " %s\n", r.Text) + if len(r.Applicability) > 0 { + fmt.Fprintf(w, " %s %s\n", styleOK.Render("Applicability:"), strings.Join(r.Applicability, ", ")) + } + for k, v := range r.Parameters { + fmt.Fprintln(w, " "+renderMetadata(k, v)) + } + } + return nil } diff --git a/cmd/complypack/cli/requirements_test.go b/cmd/complypack/cli/requirements_test.go index 4715a36..b45c342 100644 --- a/cmd/complypack/cli/requirements_test.go +++ b/cmd/complypack/cli/requirements_test.go @@ -132,26 +132,70 @@ func TestWriteRequirementsJSON_Empty(t *testing.T) { assert.Empty(t, parsed) } -func TestWriteRequirementsHuman_DelegatesToText(t *testing.T) { +func TestWriteRequirementsHuman(t *testing.T) { results := []requirement.AssessmentRequirementInfo{ { - ID: "AR-001", - ControlID: "CTL-001", - Text: "Some text", + ID: "AR-001", + ControlID: "CTL-001", + Text: "Requirement text one", + Applicability: []string{"maturity-1"}, + Parameters: map[string]string{"key": "val"}, + }, + { + ID: "AR-002", + ControlID: "CTL-002", + Text: "Requirement text two", }, } - var textBuf, humanBuf bytes.Buffer - err := writeRequirementsText( - &textBuf, "cat", results, + var buf bytes.Buffer + err := writeRequirementsHuman( + &buf, "my-catalog", results, ) require.NoError(t, err) - err = writeRequirementsHuman( - &humanBuf, "cat", results, + output := buf.String() + + // Verify styled header + assert.Contains(t, output, "my-catalog") + assert.Contains(t, output, "━", + "human output should contain styled header separator") + + // Verify data passes through + assert.Contains(t, output, "Count:") + assert.Contains(t, output, "AR-001") + assert.Contains(t, output, "CTL-001") + assert.Contains(t, output, "Requirement text one") + assert.Contains(t, output, "maturity-1") + assert.Contains(t, output, "key:") + assert.Contains(t, output, "val") + assert.Contains(t, output, "AR-002") + assert.Contains(t, output, "CTL-002") + assert.Contains(t, output, "Applicability:") + + // Verify human output differs from text output + var textBuf bytes.Buffer + err = writeRequirementsText( + &textBuf, "my-catalog", results, ) require.NoError(t, err) - assert.Equal(t, textBuf.String(), humanBuf.String(), - "human format should delegate to text for now") + assert.NotEqual(t, textBuf.String(), buf.String(), + "human format should differ from text format") +} + +func TestWriteRequirementsHuman_Empty(t *testing.T) { + var buf bytes.Buffer + err := writeRequirementsHuman( + &buf, "empty-catalog", + []requirement.AssessmentRequirementInfo{}, + ) + require.NoError(t, err) + + output := buf.String() + assert.Contains(t, output, "empty-catalog") + assert.Contains(t, output, "━", + "human output should contain styled header separator") + assert.Contains(t, output, "Count:") + assert.NotContains(t, output, "Applicability:") } diff --git a/cmd/complypack/cli/styling.go b/cmd/complypack/cli/styling.go new file mode 100644 index 0000000..3e1842b --- /dev/null +++ b/cmd/complypack/cli/styling.go @@ -0,0 +1,59 @@ +package cli + +import ( + "fmt" + "os" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// Output format constants. +const ( + formatHuman = "human" + formatText = "text" + formatJSON = "json" +) + +var ( + styleTitle = lipgloss.NewStyle().Bold(true) + styleControl = lipgloss.NewStyle().Bold(true) + stylePass = lipgloss.NewStyle().Foreground(lipgloss.Color("42")) + styleFail = lipgloss.NewStyle().Foreground(lipgloss.Color("196")) + styleGap = lipgloss.NewStyle().Foreground(lipgloss.Color("214")) + styleOK = lipgloss.NewStyle().Foreground(lipgloss.Color("39")) + styleWarn = lipgloss.NewStyle().Foreground(lipgloss.Color("214")) + styleDim = lipgloss.NewStyle().Faint(true) +) + +// Renders common header for human-readable reports +func renderHeader(title string) string { + return styleTitle.Render(title) + "\n" + styleDim.Render(strings.Repeat("━", 50)) +} + +// Renders common separator for human-readable reports +func renderSeparator() string { + return styleDim.Render(strings.Repeat("─", 50)) +} + +// Renders human-readable report metadata +func renderMetadata(key string, value interface{}) string { + return fmt.Sprintf("%s %v", styleDim.Render(key+":"), value) +} + +// resolveFormat determines the output format from the flag value and environment. +// When no flag is provided, it defaults to "text" if NO_COLOR is set, otherwise "human". +func resolveFormat(flagValue string) (string, error) { + if flagValue != "" { + switch flagValue { + case formatHuman, formatText, formatJSON: + return flagValue, nil + default: + return "", fmt.Errorf("unknown format %q; valid formats: human, text, json", flagValue) + } + } + if os.Getenv("NO_COLOR") != "" { + return formatText, nil + } + return formatHuman, nil +} diff --git a/cmd/complypack/cli/triage.go b/cmd/complypack/cli/triage.go index 0483bdc..7e94881 100644 --- a/cmd/complypack/cli/triage.go +++ b/cmd/complypack/cli/triage.go @@ -180,6 +180,30 @@ func writeTriageHuman( w io.Writer, result *requirement.TriageResult, ) error { - // TODO: add styled output with lipgloss formatting. - return writeTriageText(w, result) + fmt.Fprintln(w, renderHeader(fmt.Sprintf("Triage: %s", result.PolicyID))) + fmt.Fprintln(w, fmt.Sprintf(" %s %s %s", renderMetadata("Automated", result.Counts.Automated), + renderMetadata("Manual", result.Counts.Manual), + renderMetadata("Total", result.Counts.Total)), + ) + + if len(result.Automated) > 0 { + fmt.Fprintln(w, fmt.Sprintf("\n %s", styleControl.Render("Automated Plans:"))) + for _, p := range result.Automated { + fmt.Fprintf(w, " %s -> %s (%s)\n", styleOK.Render(p.PlanID), + styleControl.Render(p.RequirementID), + renderMetadata("method", p.EvaluationMethod), + ) + } + } + + if len(result.Manual) > 0 { + fmt.Fprintln(w, fmt.Sprintf("\n %s", styleControl.Render("Manual Plans:"))) + for _, p := range result.Manual { + fmt.Fprintf(w, " %s -> %s\n", + styleDim.Render(p.PlanID), + styleControl.Render(p.RequirementID), + ) + } + } + return nil } diff --git a/cmd/complypack/cli/triage_test.go b/cmd/complypack/cli/triage_test.go index 52b5f3b..4dc913f 100644 --- a/cmd/complypack/cli/triage_test.go +++ b/cmd/complypack/cli/triage_test.go @@ -142,21 +142,81 @@ func TestWriteTriageJSON(t *testing.T) { assert.Equal(t, 1, parsed.Counts.Total) } -func TestWriteTriageHuman_DelegatesToText(t *testing.T) { +func TestWriteTriageHuman(t *testing.T) { result := &requirement.TriageResult{ - PolicyID: "test-policy", - Automated: []requirement.TriagedPlan{}, - Manual: []requirement.TriagedPlan{}, - Counts: requirement.TriageCounts{}, + PolicyID: "test-policy", + Automated: []requirement.TriagedPlan{ + { + PlanID: "plan-1", + RequirementID: "REQ-001", + EvaluationMethod: "conftest", + Executor: "opa", + }, + }, + Manual: []requirement.TriagedPlan{ + { + PlanID: "plan-2", + RequirementID: "REQ-002", + }, + }, + Counts: requirement.TriageCounts{ + Automated: 1, + Manual: 1, + Total: 2, + }, } - var textBuf, humanBuf bytes.Buffer - err := writeTriageText(&textBuf, result) + var buf bytes.Buffer + err := writeTriageHuman(&buf, result) + require.NoError(t, err) + + output := buf.String() + + // Verify styled header + assert.Contains(t, output, "test-policy") + assert.Contains(t, output, "━", + "human output should contain styled header separator") + + // Verify data passes through + assert.Contains(t, output, "plan-1") + assert.Contains(t, output, "REQ-001") + assert.Contains(t, output, "conftest") + assert.Contains(t, output, "plan-2") + assert.Contains(t, output, "REQ-002") + + // Verify section headers + assert.Contains(t, output, "Automated Plans:") + assert.Contains(t, output, "Manual Plans:") + + // Verify human output differs from text output + var textBuf bytes.Buffer + err = writeTriageText(&textBuf, result) require.NoError(t, err) - err = writeTriageHuman(&humanBuf, result) + assert.NotEqual(t, textBuf.String(), buf.String(), + "human format should differ from text format") +} + +func TestWriteTriageHuman_NoPlans(t *testing.T) { + result := &requirement.TriageResult{ + PolicyID: "empty-policy", + Automated: []requirement.TriagedPlan{}, + Manual: []requirement.TriagedPlan{}, + Counts: requirement.TriageCounts{ + Automated: 0, + Manual: 0, + Total: 0, + }, + } + + var buf bytes.Buffer + err := writeTriageHuman(&buf, result) require.NoError(t, err) - assert.Equal(t, textBuf.String(), humanBuf.String(), - "human format should delegate to text for now") + output := buf.String() + assert.Contains(t, output, "empty-policy") + assert.Contains(t, output, "━", + "human output should contain styled header separator") + assert.NotContains(t, output, "Automated Plans:") + assert.NotContains(t, output, "Manual Plans:") }