Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions cmd/optiqor/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -565,12 +565,16 @@ func newWatchCmd() *cobra.Command {
func newCompareCmd() *cobra.Command {
var jsonOut bool
cmd := &cobra.Command{
Use: "compare <a> <b>",
Short: "Side-by-side comparison of two charts (currently a diff alias)",
Args: cobra.ExactArgs(2),
Example: ` optiqor compare ./before/values.yaml ./after/values.yaml`,
Use: "compare <a> <b>",
Short: "Side-by-side comparison of two charts or values files",
Long: `Runs analysis on both inputs and renders a side-by-side comparison:
findings unique to each chart, findings shared by both, cost totals,
and a winner declaration. Use ` + "`" + `diff` + "`" + ` for machine-friendly cost-delta output.`,
Example: ` optiqor compare ./values.dev.yaml ./values.prod.yaml
optiqor compare ./values.dev.yaml ./values.prod.yaml --json`,
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
rep, err := analyze.DiffPaths(args[0], args[1])
rep, err := analyze.ComparePaths(args[0], args[1])
if err != nil {
return err
}
Expand Down
152 changes: 152 additions & 0 deletions internal/analyze/compare.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
package analyze

import (
"fmt"
"sort"

"github.com/optiqor/optiqor-cli/internal/render"
"github.com/optiqor/optiqor-cli/pkg/rules"
)

// findingKey uniquely identifies a finding for cross-chart matching.
// Two findings match when they share the same workload name and
// detector ID — regardless of severity, detail, or dollar estimate,
// which may legitimately differ between chart variants.
type findingKey struct {
Workload string
DetectorID string
}

// CompareReport is the output of ComparePaths / Compare.
type CompareReport struct {
A string `json:"a"` // label / path of the first chart
B string `json:"b"` // label / path of the second chart

// Cost totals (sum of MonthlyUSDCents across all findings).
CostA int64 `json:"cost_a_monthly_usd_cents"`
CostB int64 `json:"cost_b_monthly_usd_cents"`

// Winner is "a", "b", or "tie".
Winner string `json:"winner"`

// OnlyInA are findings that appear in A but not in B (same workload+detector).
OnlyInA []rules.Finding `json:"only_in_a"`
// OnlyInB are findings that appear in B but not in A.
OnlyInB []rules.Finding `json:"only_in_b"`
// InBoth are findings present in both charts (matched by workload+detector).
// The Finding values come from chart A.
InBoth []rules.Finding `json:"in_both"`

// ReportA / ReportB hold the full per-chart reports so callers
// can access workload counts, source paths, etc.
ReportA render.Report `json:"report_a"`
ReportB render.Report `json:"report_b"`
}

// Compare runs analysis on both readers and partitions findings.
func Compare(a, b render.Report) CompareReport {
// Build a set of finding keys for each chart.
setA := make(map[findingKey]rules.Finding, len(a.Findings))
for _, f := range a.Findings {
setA[findingKey{f.Workload, f.DetectorID}] = f
}
setB := make(map[findingKey]rules.Finding, len(b.Findings))
for _, f := range b.Findings {
setB[findingKey{f.Workload, f.DetectorID}] = f
}

var onlyA, onlyB, both []rules.Finding

for k, f := range setA {
if _, ok := setB[k]; ok {
both = append(both, f)
} else {
onlyA = append(onlyA, f)
}
}
for k, f := range setB {
if _, ok := setA[k]; !ok {
onlyB = append(onlyB, f)
}
}

// Sort all three slices for deterministic output.
sortFindings(onlyA)
sortFindings(onlyB)
sortFindings(both)

var costA, costB int64
for _, f := range a.Findings {
costA += f.MonthlyUSDCents
}
for _, f := range b.Findings {
costB += f.MonthlyUSDCents
}

winner := declareWinner(a, b, costA, costB)

return CompareReport{
A: a.Source,
B: b.Source,
CostA: costA,
CostB: costB,
Winner: winner,
OnlyInA: onlyA,
OnlyInB: onlyB,
InBoth: both,
ReportA: a,
ReportB: b,
}
}

// ComparePaths opens both paths, runs analysis, and returns the CompareReport.
func ComparePaths(a, b string) (CompareReport, error) {
repA, err := RunPath(a)
if err != nil {
return CompareReport{}, fmt.Errorf("compare: analyze %s: %w", a, err)
}
repB, err := RunPath(b)
if err != nil {
return CompareReport{}, fmt.Errorf("compare: analyze %s: %w", b, err)
}
return Compare(repA, repB), nil
}

// declareWinner picks the better chart. Lower cost wins; ties go to
// fewer HIGH findings; absolute ties are reported as "tie".
func declareWinner(a, b render.Report, costA, costB int64) string {
if costA < costB {
return "a"
}
if costB < costA {
return "b"
}
// Costs equal — compare HIGH finding counts.
highA, highB := countHigh(a.Findings), countHigh(b.Findings)
if highA < highB {
return "a"
}
if highB < highA {
return "b"
}
return "tie"
}

func countHigh(findings []rules.Finding) int {
n := 0
for _, f := range findings {
if f.Severity == rules.SeverityHigh {
n++
}
}
return n
}

func sortFindings(fs []rules.Finding) {
sort.SliceStable(fs, func(i, j int) bool {
if fs[i].Workload != fs[j].Workload {
return fs[i].Workload < fs[j].Workload
}
return fs[i].DetectorID < fs[j].DetectorID
})
}
179 changes: 179 additions & 0 deletions internal/analyze/compare_render.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
package analyze

import (
"encoding/json"
"fmt"
"io"
"strings"

"github.com/charmbracelet/lipgloss"

"github.com/optiqor/optiqor-cli/internal/render"
"github.com/optiqor/optiqor-cli/internal/render/style"
"github.com/optiqor/optiqor-cli/pkg/rules"
)

// WriteText renders the CompareReport as a human-readable side-by-side
// comparison. The accuracy disclosure is mandatory (CLAUDE.md hard rule).
func (r CompareReport) WriteText(w io.Writer, opts render.Options) error {
t := style.NewTheme(opts.Color)
width := opts.Width
if width <= 0 {
width = 80
}

var b strings.Builder

// Header
fmt.Fprintf(&b, "%s\n", t.DividerLine(width))
label := fmt.Sprintf("compare: %s vs %s", shortPath(r.A), shortPath(r.B))
fmt.Fprintf(&b, "%s\n", t.SectionRule(label, width, t.SectionPrimary))
fmt.Fprintf(&b, "%s\n\n", t.DividerLine(width))

// Cost summary
aLabel := t.Workload.Render(shortPath(r.A))
bLabel := t.Workload.Render(shortPath(r.B))

fmt.Fprintf(&b, " %s %s\n", t.Muted.Render("Chart A:"), aLabel)
fmt.Fprintf(&b, " %s %s\n\n", t.Muted.Render("Chart B:"), bLabel)

fmt.Fprintf(&b, " %s\n", t.Muted.Render("Estimated monthly cost (±40%):"))
fmt.Fprintf(&b, " %s %s\n", t.Muted.Render("A:"), t.BigSavings.Render("$"+formatCents(r.CostA)+"/mo"))
fmt.Fprintf(&b, " %s %s\n\n", t.Muted.Render("B:"), t.BigSavings.Render("$"+formatCents(r.CostB)+"/mo"))

// Winner
switch r.Winner {
case "a":
savings := r.CostB - r.CostA
fmt.Fprintf(&b, " %s %s saves ~%s/mo vs B (±40%%)\n\n",
t.OK.Render("✓ Winner: A"),
t.Muted.Render("—"),
t.Savings.Render("$"+formatCents(savings)),
)
case "b":
savings := r.CostA - r.CostB
fmt.Fprintf(&b, " %s %s saves ~%s/mo vs A (±40%%)\n\n",
t.OK.Render("✓ Winner: B"),
t.Muted.Render("—"),
t.Savings.Render("$"+formatCents(savings)),
)
default:
fmt.Fprintf(&b, " %s\n\n", t.Muted.Render("✓ Tie — identical cost and HIGH findings"))
}

// Findings only in A
writeCompareSection(&b, t, width,
fmt.Sprintf("Findings only in A (%d)", len(r.OnlyInA)),
r.OnlyInA,
t.SectionPrimary,
)

// Findings only in B
writeCompareSection(&b, t, width,
fmt.Sprintf("Findings only in B (%d)", len(r.OnlyInB)),
r.OnlyInB,
t.SectionBonus,
)

// Findings in both
writeCompareSection(&b, t, width,
fmt.Sprintf("Findings in both (%d)", len(r.InBoth)),
r.InBoth,
t.SectionSubtle,
)

// Footer
fmt.Fprintf(&b, "%s\n", t.DividerLine(width))
fmt.Fprintf(&b, " %s\n", t.Disclosure.Render(render.AccuracyDisclosure))

_, err := io.WriteString(w, b.String())
return err
}

// WriteJSON emits the CompareReport as machine-readable JSON with the
// mandatory accuracy disclosure.
func (r CompareReport) WriteJSON(w io.Writer) error {
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
return enc.Encode(struct {
AccuracyDisclosure string `json:"accuracy_disclosure"`
A string `json:"a"`
B string `json:"b"`
CostAMonthlyUSD float64 `json:"cost_a_monthly_usd"`
CostBMonthlyUSD float64 `json:"cost_b_monthly_usd"`
Winner string `json:"winner"`
OnlyInA []rules.Finding `json:"only_in_a"`
OnlyInB []rules.Finding `json:"only_in_b"`
InBoth []rules.Finding `json:"in_both"`
}{
AccuracyDisclosure: render.AccuracyDisclosure,
A: r.A,
B: r.B,
CostAMonthlyUSD: float64(r.CostA) / 100.0,
CostBMonthlyUSD: float64(r.CostB) / 100.0,
Winner: r.Winner,
OnlyInA: r.OnlyInA,
OnlyInB: r.OnlyInB,
InBoth: r.InBoth,
})
}

// writeCompareSection renders one findings group as a labelled section
// with compact one-liners. accent is a lipgloss.Style used for the
// section rule colour.
func writeCompareSection(b *strings.Builder, t style.Theme, width int, label string, findings []rules.Finding, accent lipgloss.Style) {
b.WriteString("\n")
fmt.Fprintf(b, "%s\n", t.SectionRule(label, width, accent))
if len(findings) == 0 {
fmt.Fprintf(b, " %s\n", t.Muted.Render("none"))
return
}

// Compute workload column width.
maxWL := 0
for _, f := range findings {
if n := len([]rune(f.Workload)); n > maxWL {
maxWL = n
}
}
if maxWL > 20 {
maxWL = 20
}

for _, f := range findings {
wl := f.Workload
if len([]rune(wl)) > maxWL {
wl = string([]rune(wl)[:maxWL-1]) + "…"
}
wlPadded := wl + strings.Repeat(" ", maxWL-len([]rune(wl)))

savStr := ""
if f.MonthlyUSDCents > 0 {
savStr = " " + t.Savings.Render("save ~$"+formatCents(f.MonthlyUSDCents)+"/mo")
}

title := f.Title
if title == "" {
title = f.DetectorID
}

fmt.Fprintf(b, " %s %s %s %s%s\n",
t.SeverityBadge(string(f.Severity)),
t.Workload.Render(wlPadded),
t.ConfidenceGlyph(string(f.Confidence)),
t.Detail.Render(title),
savStr,
)
}
}

// shortPath trims the path to the last two path components so headers
// stay readable on narrow terminals.
func shortPath(p string) string {
p = strings.ReplaceAll(p, "\\", "/")
parts := strings.Split(p, "/")
if len(parts) <= 2 {
return p
}
return "…/" + strings.Join(parts[len(parts)-2:], "/")
}
Loading