summaryrefslogtreecommitdiff
path: root/internal/reporter
diff options
context:
space:
mode:
Diffstat (limited to 'internal/reporter')
-rw-r--r--internal/reporter/reporter.go117
-rw-r--r--internal/reporter/reporter_test.go114
2 files changed, 231 insertions, 0 deletions
diff --git a/internal/reporter/reporter.go b/internal/reporter/reporter.go
new file mode 100644
index 0000000..4ba66e0
--- /dev/null
+++ b/internal/reporter/reporter.go
@@ -0,0 +1,117 @@
+package reporter
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "text/tabwriter"
+ "time"
+
+ "github.com/claudomator/claudomator/internal/storage"
+)
+
+// Reporter generates reports from execution data.
+type Reporter interface {
+ Generate(w io.Writer, executions []*storage.Execution) error
+}
+
+// ConsoleReporter outputs a formatted table.
+type ConsoleReporter struct{}
+
+func (r *ConsoleReporter) Generate(w io.Writer, executions []*storage.Execution) error {
+ if len(executions) == 0 {
+ fmt.Fprintln(w, "No executions found.")
+ return nil
+ }
+
+ tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
+ fmt.Fprintln(tw, "ID\tTASK\tSTATUS\tEXIT\tCOST\tDURATION\tSTARTED")
+
+ var totalCost float64
+ var completed, failed int
+
+ for _, e := range executions {
+ dur := e.EndTime.Sub(e.StartTime)
+ totalCost += e.CostUSD
+ if e.Status == "COMPLETED" {
+ completed++
+ } else {
+ failed++
+ }
+
+ fmt.Fprintf(tw, "%.8s\t%.8s\t%s\t%d\t$%.4f\t%v\t%s\n",
+ e.ID, e.TaskID, e.Status, e.ExitCode, e.CostUSD,
+ dur.Round(time.Second), e.StartTime.Format("2006-01-02 15:04"))
+ }
+ tw.Flush()
+
+ fmt.Fprintf(w, "\nSummary: %d completed, %d failed, total cost $%.4f\n", completed, failed, totalCost)
+ return nil
+}
+
+// JSONReporter outputs JSON.
+type JSONReporter struct {
+ Pretty bool
+}
+
+func (r *JSONReporter) Generate(w io.Writer, executions []*storage.Execution) error {
+ enc := json.NewEncoder(w)
+ if r.Pretty {
+ enc.SetIndent("", " ")
+ }
+ return enc.Encode(executions)
+}
+
+// HTMLReporter generates a standalone HTML report.
+type HTMLReporter struct{}
+
+func (r *HTMLReporter) Generate(w io.Writer, executions []*storage.Execution) error {
+ var totalCost float64
+ var completed, failed int
+ for _, e := range executions {
+ totalCost += e.CostUSD
+ if e.Status == "COMPLETED" {
+ completed++
+ } else {
+ failed++
+ }
+ }
+
+ fmt.Fprint(w, `<!DOCTYPE html>
+<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Claudomator Report</title>
+<style>
+ * { box-sizing: border-box; margin: 0; padding: 0; }
+ body { font-family: -apple-system, BlinkMacSystemFont, sans-serif; background: #0f172a; color: #e2e8f0; padding: 1rem; }
+ .container { max-width: 960px; margin: 0 auto; }
+ h1 { font-size: 1.5rem; margin-bottom: 1rem; color: #7dd3fc; }
+ .summary { display: flex; gap: 1rem; margin-bottom: 1.5rem; flex-wrap: wrap; }
+ .stat { background: #1e293b; padding: 1rem; border-radius: 0.5rem; flex: 1; min-width: 120px; }
+ .stat .label { font-size: 0.75rem; color: #94a3b8; text-transform: uppercase; }
+ .stat .value { font-size: 1.5rem; font-weight: bold; margin-top: 0.25rem; }
+ .ok { color: #4ade80; } .fail { color: #f87171; } .cost { color: #fbbf24; }
+ table { width: 100%; border-collapse: collapse; background: #1e293b; border-radius: 0.5rem; overflow: hidden; }
+ th { background: #334155; padding: 0.75rem; text-align: left; font-size: 0.75rem; text-transform: uppercase; color: #94a3b8; }
+ td { padding: 0.75rem; border-top: 1px solid #334155; font-size: 0.875rem; }
+ tr:hover { background: #334155; }
+ .status-COMPLETED { color: #4ade80; } .status-FAILED { color: #f87171; } .status-TIMED_OUT { color: #fbbf24; }
+</style></head><body><div class="container">
+<h1>Claudomator Report</h1>
+<div class="summary">`)
+
+ fmt.Fprintf(w, `<div class="stat"><div class="label">Completed</div><div class="value ok">%d</div></div>`, completed)
+ fmt.Fprintf(w, `<div class="stat"><div class="label">Failed</div><div class="value fail">%d</div></div>`, failed)
+ fmt.Fprintf(w, `<div class="stat"><div class="label">Total Cost</div><div class="value cost">$%.4f</div></div>`, totalCost)
+ fmt.Fprintf(w, `<div class="stat"><div class="label">Total</div><div class="value">%d</div></div>`, len(executions))
+
+ fmt.Fprint(w, `</div><table><thead><tr><th>ID</th><th>Task</th><th>Status</th><th>Exit</th><th>Cost</th><th>Duration</th><th>Started</th></tr></thead><tbody>`)
+
+ for _, e := range executions {
+ dur := e.EndTime.Sub(e.StartTime).Round(time.Second)
+ fmt.Fprintf(w, `<tr><td>%.8s</td><td>%.8s</td><td class="status-%s">%s</td><td>%d</td><td>$%.4f</td><td>%v</td><td>%s</td></tr>`,
+ e.ID, e.TaskID, e.Status, e.Status, e.ExitCode, e.CostUSD, dur, e.StartTime.Format("2006-01-02 15:04"))
+ }
+
+ fmt.Fprint(w, `</tbody></table></div></body></html>`)
+ return nil
+}
diff --git a/internal/reporter/reporter_test.go b/internal/reporter/reporter_test.go
new file mode 100644
index 0000000..1ddce23
--- /dev/null
+++ b/internal/reporter/reporter_test.go
@@ -0,0 +1,114 @@
+package reporter
+
+import (
+ "bytes"
+ "encoding/json"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/claudomator/claudomator/internal/storage"
+)
+
+func sampleExecutions() []*storage.Execution {
+ now := time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC)
+ return []*storage.Execution{
+ {
+ ID: "exec-1", TaskID: "task-1", Status: "COMPLETED",
+ StartTime: now, EndTime: now.Add(2 * time.Minute),
+ ExitCode: 0, CostUSD: 0.25,
+ },
+ {
+ ID: "exec-2", TaskID: "task-2", Status: "FAILED",
+ StartTime: now, EndTime: now.Add(30 * time.Second),
+ ExitCode: 1, CostUSD: 0.10, ErrorMsg: "something broke",
+ },
+ }
+}
+
+func TestConsoleReporter_WithExecutions(t *testing.T) {
+ r := &ConsoleReporter{}
+ var buf bytes.Buffer
+ err := r.Generate(&buf, sampleExecutions())
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ output := buf.String()
+ if !strings.Contains(output, "COMPLETED") {
+ t.Error("missing COMPLETED status")
+ }
+ if !strings.Contains(output, "FAILED") {
+ t.Error("missing FAILED status")
+ }
+ if !strings.Contains(output, "1 completed, 1 failed") {
+ t.Errorf("missing summary in output: %s", output)
+ }
+ if !strings.Contains(output, "$0.3500") {
+ t.Errorf("missing total cost in output: %s", output)
+ }
+}
+
+func TestConsoleReporter_Empty(t *testing.T) {
+ r := &ConsoleReporter{}
+ var buf bytes.Buffer
+ r.Generate(&buf, []*storage.Execution{})
+ if !strings.Contains(buf.String(), "No executions") {
+ t.Error("expected 'No executions' message")
+ }
+}
+
+func TestJSONReporter(t *testing.T) {
+ r := &JSONReporter{Pretty: false}
+ var buf bytes.Buffer
+ err := r.Generate(&buf, sampleExecutions())
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ var result []storage.Execution
+ if err := json.Unmarshal(buf.Bytes(), &result); err != nil {
+ t.Fatalf("invalid JSON: %v", err)
+ }
+ if len(result) != 2 {
+ t.Errorf("want 2 results, got %d", len(result))
+ }
+ if result[0].Status != "COMPLETED" {
+ t.Errorf("want COMPLETED, got %q", result[0].Status)
+ }
+}
+
+func TestJSONReporter_Pretty(t *testing.T) {
+ r := &JSONReporter{Pretty: true}
+ var buf bytes.Buffer
+ r.Generate(&buf, sampleExecutions())
+ if !strings.Contains(buf.String(), " ") {
+ t.Error("expected indented JSON")
+ }
+}
+
+func TestHTMLReporter(t *testing.T) {
+ r := &HTMLReporter{}
+ var buf bytes.Buffer
+ err := r.Generate(&buf, sampleExecutions())
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ html := buf.String()
+ if !strings.Contains(html, "<!DOCTYPE html>") {
+ t.Error("missing DOCTYPE")
+ }
+ if !strings.Contains(html, "Claudomator Report") {
+ t.Error("missing title")
+ }
+ if !strings.Contains(html, "COMPLETED") {
+ t.Error("missing COMPLETED status")
+ }
+ if !strings.Contains(html, "FAILED") {
+ t.Error("missing FAILED status")
+ }
+ if !strings.Contains(html, "$0.3500") {
+ t.Error("missing total cost")
+ }
+}