From e0bb241b7449453b7fada1b2897c972543f6b1b3 Mon Sep 17 00:00:00 2001 From: Ramon Nogueira Date: Tue, 14 Apr 2026 16:51:49 -0700 Subject: [PATCH] feat: default to pretty output, add --json and --jq flags Change the default output format from JSON to pretty (human-readable tables). Add --json as a shorthand for --format json, and --jq to apply a jq expression to JSON output (powered by gojq, implies --json). --- go.mod | 6 +- go.sum | 11 +- internal/cmd/api/info_test.go | 6 +- internal/cmd/api/ls_test.go | 6 +- internal/cmd/api/testutil_test.go | 2 +- internal/cmd/insights.go | 6 +- internal/cmd/issues.go | 8 +- internal/cmd/root.go | 26 ++++- internal/cmd/root_test.go | 66 +++++++++++- internal/cmd/testutil_test.go | 3 + internal/output/output.go | 44 ++++++++ internal/output/output_test.go | 169 ++++++++++++++++++++++++++++++ 12 files changed, 331 insertions(+), 22 deletions(-) diff --git a/go.mod b/go.mod index de1ef45..79f59f1 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 github.com/hashicorp/yamux v0.1.2 + github.com/itchyny/gojq v0.12.19 github.com/langchain-ai/langsmith-go v0.2.2 github.com/olekukonko/tablewriter v0.0.5 github.com/spf13/cobra v1.8.1 @@ -15,12 +16,15 @@ require ( require ( github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.3.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/itchyny/timefmt-go v0.1.8 // indirect github.com/klauspost/compress v1.18.4 // indirect - github.com/mattn/go-runewidth v0.0.9 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/tidwall/gjson v1.18.0 // indirect github.com/tidwall/match v1.1.1 // indirect diff --git a/go.sum b/go.sum index 4cdd03e..99f2731 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,10 @@ github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1x github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4= +github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= @@ -25,12 +29,17 @@ github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8 github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/itchyny/gojq v0.12.19 h1:ttXA0XCLEMoaLOz5lSeFOZ6u6Q3QxmG46vfgI4O0DEs= +github.com/itchyny/gojq v0.12.19/go.mod h1:5galtVPDywX8SPSOrqjGxkBeDhSxEW1gSxoy7tn1iZY= +github.com/itchyny/timefmt-go v0.1.8 h1:1YEo1JvfXeAHKdjelbYr/uCuhkybaHCeTkH8Bo791OI= +github.com/itchyny/timefmt-go v0.1.8/go.mod h1:5E46Q+zj7vbTgWY8o5YkMeYb4I6GeWLFnetPy5oBrAI= github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/langchain-ai/langsmith-go v0.2.2 h1:WVNUR9dhnuieIaXrKxmZWva6TX1DV/RHCVgc67wbAbs= github.com/langchain-ai/langsmith-go v0.2.2/go.mod h1:xdfOA0EBT7KF9ylz+gGq8EM6srRkv2PtpazQ+4oraWk= -github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0= github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= diff --git a/internal/cmd/api/info_test.go b/internal/cmd/api/info_test.go index 6a2b100..d928e41 100644 --- a/internal/cmd/api/info_test.go +++ b/internal/cmd/api/info_test.go @@ -84,7 +84,7 @@ func TestInfoCmd_JSON(t *testing.T) { var out bytes.Buffer root.SetOut(&out) root.SetErr(&out) - root.SetArgs([]string{"api", "info", "--api-url", ts.URL, "GET", "/api/v1/sessions"}) + root.SetArgs([]string{"api", "info", "--api-url", ts.URL, "--format", "json", "GET", "/api/v1/sessions"}) if err := root.Execute(); err != nil { t.Fatalf("unexpected error: %v", err) @@ -116,7 +116,7 @@ func TestInfoCmd_Shorthand(t *testing.T) { var out bytes.Buffer root.SetOut(&out) root.SetErr(&out) - root.SetArgs([]string{"api", "info", "--api-url", ts.URL, "GET", "sessions"}) + root.SetArgs([]string{"api", "info", "--api-url", ts.URL, "--format", "json", "GET", "sessions"}) if err := root.Execute(); err != nil { t.Fatalf("unexpected error: %v", err) @@ -139,7 +139,7 @@ func TestInfoCmd_WithRequestBody(t *testing.T) { var out bytes.Buffer root.SetOut(&out) root.SetErr(&out) - root.SetArgs([]string{"api", "info", "--api-url", ts.URL, "POST", "runs/query"}) + root.SetArgs([]string{"api", "info", "--api-url", ts.URL, "--format", "json", "POST", "runs/query"}) if err := root.Execute(); err != nil { t.Fatalf("unexpected error: %v", err) diff --git a/internal/cmd/api/ls_test.go b/internal/cmd/api/ls_test.go index b09ec2d..397f580 100644 --- a/internal/cmd/api/ls_test.go +++ b/internal/cmd/api/ls_test.go @@ -44,7 +44,7 @@ func TestLsCmd_JSON(t *testing.T) { var out bytes.Buffer root.SetOut(&out) root.SetErr(&out) - root.SetArgs([]string{"api", "ls", "--api-url", ts.URL, "--refresh"}) + root.SetArgs([]string{"api", "ls", "--api-url", ts.URL, "--format", "json", "--refresh"}) if err := root.Execute(); err != nil { t.Fatalf("unexpected error: %v", err) @@ -67,7 +67,7 @@ func TestLsCmd_FilterByTag(t *testing.T) { var out bytes.Buffer root.SetOut(&out) root.SetErr(&out) - root.SetArgs([]string{"api", "ls", "--api-url", ts.URL, "--tag", "datasets", "--refresh"}) + root.SetArgs([]string{"api", "ls", "--api-url", ts.URL, "--tag", "datasets", "--format", "json", "--refresh"}) if err := root.Execute(); err != nil { t.Fatalf("unexpected error: %v", err) @@ -95,7 +95,7 @@ func TestLsCmd_Search(t *testing.T) { var out bytes.Buffer root.SetOut(&out) root.SetErr(&out) - root.SetArgs([]string{"api", "ls", "--api-url", ts.URL, "--search", "query", "--refresh"}) + root.SetArgs([]string{"api", "ls", "--api-url", ts.URL, "--search", "query", "--format", "json", "--refresh"}) if err := root.Execute(); err != nil { t.Fatalf("unexpected error: %v", err) diff --git a/internal/cmd/api/testutil_test.go b/internal/cmd/api/testutil_test.go index 8151621..c0568da 100644 --- a/internal/cmd/api/testutil_test.go +++ b/internal/cmd/api/testutil_test.go @@ -9,7 +9,7 @@ func newTestRoot() *cobra.Command { root := &cobra.Command{Use: "langsmith"} root.PersistentFlags().String("api-key", "", "") root.PersistentFlags().String("api-url", "", "") - root.PersistentFlags().String("format", "json", "") + root.PersistentFlags().String("format", "pretty", "") root.AddCommand(NewCmd()) return root } diff --git a/internal/cmd/insights.go b/internal/cmd/insights.go index 24c1fa6..ec21abc 100644 --- a/internal/cmd/insights.go +++ b/internal/cmd/insights.go @@ -28,7 +28,7 @@ with an executive summary of key findings and highlighted traces. Examples: langsmith insights list --project my-app langsmith insights get INSIGHT_ID --project my-app - langsmith insights get INSIGHT_ID --project my-app --format pretty`, + langsmith insights get INSIGHT_ID --project my-app --json`, } cmd.AddCommand(newInsightsListCmd()) @@ -53,7 +53,7 @@ and category distribution. Use 'insights get' with the report ID for full details including the executive summary and category breakdown.`, Example: ` langsmith insights list --project my-app langsmith insights list --project my-app --limit 5 - langsmith insights list --project my-app --format pretty`, + langsmith insights list --project my-app --json`, Run: func(cmd *cobra.Command, args []string) { c := MustGetClient() ctx := context.Background() @@ -127,7 +127,7 @@ Returns the executive summary (key findings and highlighted traces), plus a breakdown of all categories and subcategories with their statistics (error rates, latency, costs, token usage, feedback scores).`, Example: ` langsmith insights get e4040294-44af-4866-b1dd-3c566a8d42f0 --project my-app - langsmith insights get e4040294-44af-4866-b1dd-3c566a8d42f0 --project my-app --format pretty`, + langsmith insights get e4040294-44af-4866-b1dd-3c566a8d42f0 --project my-app --json`, Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { insightID := args[0] diff --git a/internal/cmd/issues.go b/internal/cmd/issues.go index 109bb35..5407b6e 100644 --- a/internal/cmd/issues.go +++ b/internal/cmd/issues.go @@ -74,13 +74,13 @@ func newProjectIssuesListCmd() *cobra.Command { Fetches issues from the Issues Board for the specified project. Results can be filtered by status (open/closed) and priority (high/medium/low). -Output is JSON by default; pass --format pretty for a human-readable table. +Output is a human-readable table by default; pass --json for machine-readable JSON. Examples: langsmith project issues list --project my-app langsmith project issues list --project my-app --status open langsmith project issues list --project my-app --priority high --limit 10 - langsmith project issues list --project my-app --format pretty`, + langsmith project issues list --project my-app --json`, Run: func(cmd *cobra.Command, args []string) { c := MustGetClient() ctx := context.Background() @@ -177,12 +177,12 @@ Issue events record user and agent actions on issues: status changes, severity edits, evaluator deployments, and issue creation. The ABM agent reads these on cron runs to update the User Preferences section of the Agent Overview. -Output is JSON by default; pass --format pretty for a human-readable table. +Output is a human-readable table by default; pass --json for machine-readable JSON. Examples: langsmith project issues events --project my-app langsmith project issues events --project my-app --look-back-minutes 1440 - langsmith project issues events --project my-app --limit 50 --format pretty`, + langsmith project issues events --project my-app --limit 50 --json`, Run: func(cmd *cobra.Command, args []string) { c := MustGetClient() ctx := context.Background() diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 9459c1a..8a08b9e 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -6,6 +6,7 @@ import ( "github.com/langchain-ai/langsmith-cli/internal/client" "github.com/langchain-ai/langsmith-cli/internal/cmd/api" + "github.com/langchain-ai/langsmith-cli/internal/output" "github.com/spf13/cobra" ) @@ -14,6 +15,8 @@ var ( flagAPIKey string flagAPIURL string flagOutputFormat string + flagJSON bool + flagJQ string ) // NewRootCmd creates the top-level `langsmith` command. @@ -25,7 +28,6 @@ func NewRootCmd(rawVersion, displayVersion string) *cobra.Command { Designed for AI coding agents and developers who need fast, scriptable access to traces, runs, datasets, evaluators, experiments, and threads. -All commands output JSON by default for easy parsing. Authentication: Set LANGSMITH_API_KEY as an environment variable, or pass --api-key. @@ -41,16 +43,23 @@ Quick start: langsmith experiment list --dataset my-eval-dataset Output: - --format json Machine-readable JSON (default). Best for agents and scripts. - --format pretty Human-readable tables, trees, and syntax-highlighted JSON.`, + --format pretty Human-readable tables, trees, and syntax-highlighted JSON (default). + --format json Machine-readable JSON. Best for agents and scripts. + --json Shorthand for --format json. + --jq EXPR Apply a jq expression to JSON output (implies --json).`, SilenceUsage: true, SilenceErrors: true, Version: displayVersion, + PersistentPreRun: func(cmd *cobra.Command, args []string) { + output.JQExpr = flagJQ + }, } rootCmd.PersistentFlags().StringVar(&flagAPIKey, "api-key", "", "LangSmith API key [env: LANGSMITH_API_KEY]") rootCmd.PersistentFlags().StringVar(&flagAPIURL, "api-url", "", "LangSmith API URL [env: LANGSMITH_ENDPOINT]") - rootCmd.PersistentFlags().StringVar(&flagOutputFormat, "format", "json", "Output format: json or pretty") + rootCmd.PersistentFlags().StringVar(&flagOutputFormat, "format", "pretty", "Output format: pretty or json") + rootCmd.PersistentFlags().BoolVar(&flagJSON, "json", false, "Shorthand for --format json") + rootCmd.PersistentFlags().StringVar(&flagJQ, "jq", "", "Apply a jq expression to JSON output (implies --json)") // Register all subcommand groups rootCmd.AddCommand(newProjectCmd()) @@ -94,10 +103,19 @@ func GetAPIURL() string { } // GetFormat returns the output format. +// --json and --jq flags take precedence as shorthands for --format json. func GetFormat() string { + if flagJSON || flagJQ != "" { + return "json" + } return flagOutputFormat } +// GetJQ returns the --jq expression, or empty string if not set. +func GetJQ() string { + return flagJQ +} + // MustGetClient creates a LangSmith client or exits with an error. func MustGetClient() *client.Client { apiKey := GetAPIKey() diff --git a/internal/cmd/root_test.go b/internal/cmd/root_test.go index e0125e1..92ebc64 100644 --- a/internal/cmd/root_test.go +++ b/internal/cmd/root_test.go @@ -77,8 +77,19 @@ func TestRootCmd_PersistentFlags_Format(t *testing.T) { if f == nil { t.Fatal("--format flag not found") } - if f.DefValue != "json" { - t.Errorf("expected default json, got %q", f.DefValue) + if f.DefValue != "pretty" { + t.Errorf("expected default pretty, got %q", f.DefValue) + } +} + +func TestRootCmd_PersistentFlags_JSON(t *testing.T) { + root := NewRootCmd("dev", "dev") + f := root.PersistentFlags().Lookup("json") + if f == nil { + t.Fatal("--json flag not found") + } + if f.DefValue != "false" { + t.Errorf("expected default false, got %q", f.DefValue) } } @@ -175,6 +186,57 @@ func TestGetFormat_ReturnsValue(t *testing.T) { } } +func TestGetFormat_JSONFlagOverrides(t *testing.T) { + oldFmt := flagOutputFormat + oldJSON := flagJSON + defer func() { flagOutputFormat = oldFmt; flagJSON = oldJSON }() + + flagOutputFormat = "pretty" + flagJSON = true + if got := GetFormat(); got != "json" { + t.Errorf("expected json when --json is set, got %q", got) + } +} + +func TestGetFormat_JQImpliesJSON(t *testing.T) { + oldFmt := flagOutputFormat + oldJSON := flagJSON + oldJQ := flagJQ + defer func() { flagOutputFormat = oldFmt; flagJSON = oldJSON; flagJQ = oldJQ }() + + flagOutputFormat = "pretty" + flagJSON = false + flagJQ = ".name" + if got := GetFormat(); got != "json" { + t.Errorf("expected json when --jq is set, got %q", got) + } +} + +func TestGetFormat_EmptyJQDoesNotImplyJSON(t *testing.T) { + oldFmt := flagOutputFormat + oldJSON := flagJSON + oldJQ := flagJQ + defer func() { flagOutputFormat = oldFmt; flagJSON = oldJSON; flagJQ = oldJQ }() + + flagOutputFormat = "pretty" + flagJSON = false + flagJQ = "" + if got := GetFormat(); got != "pretty" { + t.Errorf("expected pretty when --jq is empty, got %q", got) + } +} + +func TestRootCmd_PersistentFlags_JQ(t *testing.T) { + root := NewRootCmd("dev", "dev") + f := root.PersistentFlags().Lookup("jq") + if f == nil { + t.Fatal("--jq flag not found") + } + if f.DefValue != "" { + t.Errorf("expected default empty, got %q", f.DefValue) + } +} + // ---------- Unknown subcommand ---------- func TestRootCmd_UnknownSubcommand(t *testing.T) { diff --git a/internal/cmd/testutil_test.go b/internal/cmd/testutil_test.go index cb70677..c976401 100644 --- a/internal/cmd/testutil_test.go +++ b/internal/cmd/testutil_test.go @@ -45,15 +45,18 @@ func setupTestEnv(t *testing.T, serverURL string) func() { oldKey := flagAPIKey oldURL := flagAPIURL oldFmt := flagOutputFormat + oldJSON := flagJSON flagAPIKey = "test-api-key" flagAPIURL = serverURL flagOutputFormat = "json" + flagJSON = false return func() { flagAPIKey = oldKey flagAPIURL = oldURL flagOutputFormat = oldFmt + flagJSON = oldJSON } } diff --git a/internal/output/output.go b/internal/output/output.go index b47f4dc..06b4a98 100644 --- a/internal/output/output.go +++ b/internal/output/output.go @@ -8,13 +8,23 @@ import ( "sort" "strings" + "github.com/itchyny/gojq" "github.com/olekukonko/tablewriter" "github.com/xlab/treeprint" ) +// JQExpr is the global --jq expression. Set by the root command before +// subcommands run. When non-empty, OutputJSON applies it automatically. +var JQExpr string + // OutputJSON writes data as indented JSON to stdout or a file. // If filePath is non-empty, writes to file and prints status to stderr. +// If JQExpr is set, the jq expression is applied before output. func OutputJSON(data any, filePath string) { + if JQExpr != "" { + data = applyJQ(data, JQExpr) + } + jsonBytes, err := json.MarshalIndent(data, "", " ") if err != nil { PrintError(fmt.Sprintf("JSON encoding error: %v", err)) @@ -32,6 +42,40 @@ func OutputJSON(data any, filePath string) { } } +// applyJQ parses and runs a jq expression against data. +// On error it prints to stderr and returns the original data. +func applyJQ(data any, expr string) any { + query, err := gojq.Parse(expr) + if err != nil { + PrintError(fmt.Sprintf("jq parse error: %v", err)) + return data + } + + // gojq needs plain Go types; round-trip through JSON to normalise. + raw, _ := json.Marshal(data) + var input any + _ = json.Unmarshal(raw, &input) + + var results []any + iter := query.Run(input) + for { + v, ok := iter.Next() + if !ok { + break + } + if err, isErr := v.(error); isErr { + PrintError(fmt.Sprintf("jq error: %v", err)) + return data + } + results = append(results, v) + } + + if len(results) == 1 { + return results[0] + } + return results +} + // OutputJSONL writes items as JSONL (one JSON object per line). func OutputJSONL(items []map[string]any, filePath string) { if filePath != "" { diff --git a/internal/output/output_test.go b/internal/output/output_test.go index decceac..2ea4b6d 100644 --- a/internal/output/output_test.go +++ b/internal/output/output_test.go @@ -116,6 +116,175 @@ func TestOutputTree(t *testing.T) { OutputTree(runs, "") } +// ---------- applyJQ ---------- + +func TestApplyJQ_FieldAccess(t *testing.T) { + data := map[string]any{"name": "alice", "age": 30} + got := applyJQ(data, ".name") + if got != "alice" { + t.Errorf("expected alice, got %v", got) + } +} + +func TestApplyJQ_ArrayIndex(t *testing.T) { + data := []any{"a", "b", "c"} + got := applyJQ(data, ".[1]") + if got != "b" { + t.Errorf("expected b, got %v", got) + } +} + +func TestApplyJQ_ArrayMap(t *testing.T) { + data := []any{ + map[string]any{"id": "1", "name": "first"}, + map[string]any{"id": "2", "name": "second"}, + } + got := applyJQ(data, ".[].name") + arr, ok := got.([]any) + if !ok { + t.Fatalf("expected []any, got %T", got) + } + if len(arr) != 2 || arr[0] != "first" || arr[1] != "second" { + t.Errorf("expected [first second], got %v", arr) + } +} + +func TestApplyJQ_Select(t *testing.T) { + data := []any{ + map[string]any{"name": "alice", "active": true}, + map[string]any{"name": "bob", "active": false}, + map[string]any{"name": "carol", "active": true}, + } + got := applyJQ(data, "[.[] | select(.active)]") + arr, ok := got.([]any) + if !ok { + t.Fatalf("expected []any, got %T", got) + } + if len(arr) != 2 { + t.Errorf("expected 2 active items, got %d", len(arr)) + } +} + +func TestApplyJQ_Length(t *testing.T) { + data := []any{1, 2, 3, 4, 5} + got := applyJQ(data, "length") + // gojq returns int for length + if got != 5 { + t.Errorf("expected 5, got %v (%T)", got, got) + } +} + +func TestApplyJQ_Pipe(t *testing.T) { + data := map[string]any{ + "users": []any{ + map[string]any{"name": "alice"}, + map[string]any{"name": "bob"}, + }, + } + got := applyJQ(data, ".users | length") + if got != 2 { + t.Errorf("expected 2, got %v", got) + } +} + +func TestApplyJQ_Keys(t *testing.T) { + data := map[string]any{"b": 2, "a": 1, "c": 3} + got := applyJQ(data, "keys") + arr, ok := got.([]any) + if !ok { + t.Fatalf("expected []any, got %T", got) + } + if len(arr) != 3 || arr[0] != "a" { + t.Errorf("expected sorted keys starting with a, got %v", arr) + } +} + +func TestApplyJQ_InvalidExpr_ReturnsOriginal(t *testing.T) { + data := map[string]any{"name": "test"} + got := applyJQ(data, "invalid syntax [[[") + // Should return original data on parse error + m, ok := got.(map[string]any) + if !ok { + t.Fatalf("expected original map on error, got %T", got) + } + if m["name"] != "test" { + t.Errorf("expected original data preserved, got %v", got) + } +} + +func TestApplyJQ_NullInput(t *testing.T) { + got := applyJQ(nil, ".") + if got != nil { + t.Errorf("expected nil, got %v", got) + } +} + +func TestApplyJQ_NestedAccess(t *testing.T) { + data := map[string]any{ + "meta": map[string]any{ + "pagination": map[string]any{ + "total": 42, + }, + }, + } + got := applyJQ(data, ".meta.pagination.total") + // JSON round-trip turns int to float64 + if got != float64(42) { + t.Errorf("expected 42, got %v (%T)", got, got) + } +} + +func TestOutputJSON_WithJQExpr(t *testing.T) { + old := JQExpr + defer func() { JQExpr = old }() + + JQExpr = ".[].name" + + tmpDir := t.TempDir() + fpath := filepath.Join(tmpDir, "jq_out.json") + + data := []map[string]any{ + {"name": "first", "id": "1"}, + {"name": "second", "id": "2"}, + } + OutputJSON(data, fpath) + + content, err := os.ReadFile(fpath) + if err != nil { + t.Fatalf("failed to read output file: %v", err) + } + s := strings.TrimSpace(string(content)) + // Should contain only the names, not the ids + if !strings.Contains(s, "first") || !strings.Contains(s, "second") { + t.Errorf("expected names in output, got: %s", s) + } + if strings.Contains(s, "id") { + t.Errorf("expected jq to filter out id field, got: %s", s) + } +} + +func TestOutputJSON_WithoutJQExpr(t *testing.T) { + old := JQExpr + defer func() { JQExpr = old }() + + JQExpr = "" + + tmpDir := t.TempDir() + fpath := filepath.Join(tmpDir, "no_jq_out.json") + + data := map[string]any{"name": "test", "id": "1"} + OutputJSON(data, fpath) + + content, err := os.ReadFile(fpath) + if err != nil { + t.Fatalf("failed to read output file: %v", err) + } + s := string(content) + if !strings.Contains(s, "name") || !strings.Contains(s, "id") { + t.Errorf("expected full object without jq, got: %s", s) + } +} + func int64Ptr(v int64) *int64 { return &v }