diff --git a/cmd/cliflags/flags.go b/cmd/cliflags/flags.go index 39d67df6..c644282d 100644 --- a/cmd/cliflags/flags.go +++ b/cmd/cliflags/flags.go @@ -57,7 +57,7 @@ const ( FieldsFlagDescription = "Comma-separated list of top-level fields to include in JSON output (e.g., --fields key,name,kind)" FlagFlagDescription = "Default feature flag key" JSONFlagDescription = "Output JSON format (shorthand for --output json)" - OutputFlagDescription = "Output format: json or plaintext (default: plaintext in a terminal, json otherwise)" + OutputFlagDescription = "Output format: json, plaintext, or markdown (default: plaintext in a terminal, json otherwise)" PortFlagDescription = "Port for the dev server to run on" ProjectFlagDescription = "Default project key" SyncOnceFlagDescription = "Only sync new projects. Existing projects will neither be resynced nor have overrides specified by CLI flags applied." diff --git a/cmd/config/testdata/help.golden b/cmd/config/testdata/help.golden index 9cfacf1a..b94c7cea 100644 --- a/cmd/config/testdata/help.golden +++ b/cmd/config/testdata/help.golden @@ -9,7 +9,7 @@ Supported settings: - `dev-stream-uri`: Streaming service endpoint that the dev server uses to obtain authoritative flag data. This may be a LaunchDarkly or Relay Proxy endpoint - `environment`: Default environment key - `flag`: Default feature flag key -- `output`: Output format: json or plaintext (default: plaintext in a terminal, json otherwise) +- `output`: Output format: json, plaintext, or markdown (default: plaintext in a terminal, json otherwise) - `port`: Port for the dev server to run on - `project`: Default project key - `sync-once`: Only sync new projects. Existing projects will neither be resynced nor have overrides specified by CLI flags applied. @@ -29,4 +29,4 @@ Global Flags: --base-uri string LaunchDarkly base URI (default "https://app.launchdarkly.com") --fields strings Comma-separated list of top-level fields to include in JSON output (e.g., --fields key,name,kind) --json Output JSON format (shorthand for --output json) - -o, --output string Output format: json or plaintext (default: plaintext in a terminal, json otherwise) (default "plaintext") + -o, --output string Output format: json, plaintext, or markdown (default: plaintext in a terminal, json otherwise) (default "plaintext") diff --git a/cmd/flags/archive_test.go b/cmd/flags/archive_test.go index 58fff6e0..dd646f86 100644 --- a/cmd/flags/archive_test.go +++ b/cmd/flags/archive_test.go @@ -155,6 +155,29 @@ func TestArchive(t *testing.T) { assert.Contains(t, string(output), "test-flag") }) + t.Run("succeeds with markdown output", func(t *testing.T) { + args := []string{ + "flags", "archive", + "--access-token", "abcd1234", + "--flag", "test-flag", + "--project", "test-proj", + "--output", "markdown", + } + output, err := cmd.CallCmd( + t, + cmd.APIClients{ + ResourcesClient: mockClient, + }, + analytics.NoopClientFn{}.Tracker(), + args, + ) + + require.NoError(t, err) + assert.Contains(t, string(output), "Successfully updated") + assert.Contains(t, string(output), "## test-flag") + assert.Contains(t, string(output), "- **Kind:** boolean") + }) + t.Run("passes dryRun query param when --dry-run is set", func(t *testing.T) { args := []string{ "flags", "archive", diff --git a/cmd/flags/toggle_test.go b/cmd/flags/toggle_test.go index 59de8c52..794c8ab4 100644 --- a/cmd/flags/toggle_test.go +++ b/cmd/flags/toggle_test.go @@ -161,6 +161,31 @@ func TestToggleOn(t *testing.T) { assert.Contains(t, string(output), "test-flag") }) + t.Run("succeeds with markdown output", func(t *testing.T) { + args := []string{ + "flags", "toggle-on", + "--access-token", "abcd1234", + "--environment", "test-env", + "--flag", "test-flag", + "--project", "test-proj", + "--output", "markdown", + } + output, err := cmd.CallCmd( + t, + cmd.APIClients{ + ResourcesClient: mockClient, + }, + analytics.NoopClientFn{}.Tracker(), + args, + ) + + require.NoError(t, err) + assert.Contains(t, string(output), "Successfully updated") + assert.Contains(t, string(output), "## test-flag") + assert.Contains(t, string(output), "- **Kind:** boolean") + assert.Contains(t, string(output), "- **Temporary:** yes") + }) + t.Run("returns error with missing required flags", func(t *testing.T) { args := []string{ "flags", "toggle-on", @@ -332,6 +357,30 @@ func TestToggleOff(t *testing.T) { assert.Contains(t, string(output), "test-flag") }) + t.Run("succeeds with markdown output", func(t *testing.T) { + args := []string{ + "flags", "toggle-off", + "--access-token", "abcd1234", + "--environment", "test-env", + "--flag", "test-flag", + "--project", "test-proj", + "--output", "markdown", + } + output, err := cmd.CallCmd( + t, + cmd.APIClients{ + ResourcesClient: mockClient, + }, + analytics.NoopClientFn{}.Tracker(), + args, + ) + + require.NoError(t, err) + assert.Contains(t, string(output), "Successfully updated") + assert.Contains(t, string(output), "## test-flag") + assert.Contains(t, string(output), "- **Kind:** boolean") + }) + t.Run("passes dryRun query param when --dry-run is set", func(t *testing.T) { args := []string{ "flags", "toggle-off", diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 7fd99561..46b9ef31 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -110,7 +110,7 @@ func TestUpdate(t *testing.T) { t.Run("with an invalid output flag", func(t *testing.T) { _, _, err = c.Update([]string{"output", "invalid"}) - assert.EqualError(t, err, "output is invalid. Use 'json' or 'plaintext'") + assert.EqualError(t, err, "output is invalid. Use 'json', 'plaintext', or 'markdown'") }) t.Run("with an invalid analytics-opt-out flag", func(t *testing.T) { diff --git a/internal/output/markdown.go b/internal/output/markdown.go new file mode 100644 index 00000000..d008eaee --- /dev/null +++ b/internal/output/markdown.go @@ -0,0 +1,248 @@ +package output + +import ( + "fmt" + "sort" + "strings" +) + +// MarkdownTableOutput formats a slice of resources as a GitHub-flavored markdown table. +func MarkdownTableOutput(items []resource, cols []ColumnDef) string { + headers := make([]string, len(cols)) + separators := make([]string, len(cols)) + for i, col := range cols { + headers[i] = col.Header + separators[i] = "---" + } + + var sb strings.Builder + sb.WriteString("| ") + sb.WriteString(strings.Join(headers, " | ")) + sb.WriteString(" |\n| ") + sb.WriteString(strings.Join(separators, " | ")) + sb.WriteString(" |") + + for _, item := range items { + vals := make([]string, len(cols)) + for i, col := range cols { + vals[i] = escapeMDPipe(colValue(item, col)) + } + sb.WriteString("\n| ") + sb.WriteString(strings.Join(vals, " | ")) + sb.WriteString(" |") + } + + return sb.String() +} + +// MarkdownKeyValueOutput formats a single resource as a markdown bullet list of key-value pairs. +func MarkdownKeyValueOutput(r resource, cols []ColumnDef) string { + lines := make([]string, 0, len(cols)) + for _, col := range cols { + val := colValue(r, col) + lines = append(lines, fmt.Sprintf("- **%s:** %s", col.Header, val)) + } + return strings.Join(lines, "\n") +} + +// MarkdownSingularOutput renders a single resource in markdown with a heading and metadata. +// For flags it produces a rich view with environment table; for other resources it uses +// the column registry or a generic fallback. +func MarkdownSingularOutput(r resource, resourceName string) string { + if resourceName == "flags" { + return markdownFlagOutput(r) + } + + heading := markdownHeading(r) + if cols := GetSingularColumns(resourceName); cols != nil { + return heading + "\n\n" + MarkdownKeyValueOutput(r, cols) + } + return heading +} + +// MarkdownMultipleOutput renders a list of resources as a markdown table (if columns are +// registered) or a bullet list. +func MarkdownMultipleOutput(items []resource, resourceName string) string { + if cols := GetListColumns(resourceName); cols != nil { + return MarkdownTableOutput(items, cols) + } + + lines := make([]string, 0, len(items)) + for _, item := range items { + lines = append(lines, fmt.Sprintf("- %s", SingularPlaintextOutputFn(item))) + } + return strings.Join(lines, "\n") +} + +func markdownFlagOutput(r resource) string { + var sb strings.Builder + + key := defaultFormat(r["key"]) + sb.WriteString("## ") + sb.WriteString(key) + + if desc, ok := r["description"]; ok && desc != nil && fmt.Sprint(desc) != "" { + sb.WriteString("\n\n") + sb.WriteString(fmt.Sprint(desc)) + } + + envTable := markdownEnvTable(r) + if envTable != "" { + sb.WriteString("\n\n") + sb.WriteString(envTable) + } + + if meta := markdownFlagMetadata(r); meta != "" { + sb.WriteString("\n\n") + sb.WriteString(meta) + } + + return sb.String() +} + +func markdownEnvTable(r resource) string { + envMap, ok := r["environments"].(map[string]interface{}) + if !ok || len(envMap) == 0 { + return "" + } + + variations := extractVariations(r) + + keys := make([]string, 0, len(envMap)) + for k := range envMap { + keys = append(keys, k) + } + sort.Strings(keys) + + var sb strings.Builder + sb.WriteString("| Environment | Status | Fallthrough | Rules |\n") + sb.WriteString("| --- | --- | --- | --- |") + + for _, envKey := range keys { + envData, ok := envMap[envKey].(map[string]interface{}) + if !ok { + continue + } + + status := "OFF" + if on, ok := envData["on"].(bool); ok && on { + status = "ON" + } + + fallthrough_ := resolveFallthrough(envData, variations) + + rulesCount := 0 + if rules, ok := envData["rules"].([]interface{}); ok { + rulesCount = len(rules) + } + + sb.WriteString(fmt.Sprintf("\n| %s | %s | %s | %d |", + escapeMDPipe(envKey), status, escapeMDPipe(fallthrough_), rulesCount)) + } + + return sb.String() +} + +func markdownFlagMetadata(r resource) string { + var lines []string + + if kind := r["kind"]; kind != nil { + lines = append(lines, fmt.Sprintf("- **Kind:** %s", kind)) + } + if temp, ok := r["temporary"].(bool); ok { + lines = append(lines, fmt.Sprintf("- **Temporary:** %s", boolYesNo(temp))) + } + if tags, ok := r["tags"].([]interface{}); ok && len(tags) > 0 { + strs := make([]string, len(tags)) + for i, t := range tags { + strs[i] = fmt.Sprint(t) + } + lines = append(lines, fmt.Sprintf("- **Tags:** %s", strings.Join(strs, ", "))) + } + if maintainer := extractMaintainer(r); maintainer != "" { + lines = append(lines, fmt.Sprintf("- **Maintainer:** %s", maintainer)) + } + + return strings.Join(lines, "\n") +} + +func extractVariations(r resource) []variation { + raw, ok := r["variations"].([]interface{}) + if !ok { + return nil + } + vars := make([]variation, 0, len(raw)) + for _, v := range raw { + m, ok := v.(map[string]interface{}) + if !ok { + continue + } + name := "" + if n, ok := m["name"].(string); ok { + name = n + } + vars = append(vars, variation{ + Name: name, + Value: m["value"], + }) + } + return vars +} + +type variation struct { + Name string + Value interface{} +} + +func resolveFallthrough(envData map[string]interface{}, variations []variation) string { + ft, ok := envData["fallthrough"].(map[string]interface{}) + if !ok { + return "" + } + varIdx, ok := ft["variation"].(float64) + if !ok { + return "" + } + idx := int(varIdx) + if idx < 0 || idx >= len(variations) { + return fmt.Sprintf("variation %d", idx) + } + v := variations[idx] + if v.Name != "" { + return fmt.Sprintf("%s (%v)", v.Name, v.Value) + } + return fmt.Sprintf("%v", v.Value) +} + +func extractMaintainer(r resource) string { + m, ok := r["_maintainer"].(map[string]interface{}) + if !ok { + return "" + } + if name, ok := m["name"].(string); ok && name != "" { + return name + } + if email, ok := m["email"].(string); ok && email != "" { + return email + } + return "" +} + +func markdownHeading(r resource) string { + key := r["key"] + name := r["name"] + switch { + case name != nil && key != nil: + return fmt.Sprintf("## %s (%s)", fmt.Sprint(name), fmt.Sprint(key)) + case name != nil: + return fmt.Sprintf("## %s", fmt.Sprint(name)) + case key != nil: + return fmt.Sprintf("## %s", fmt.Sprint(key)) + default: + return "## (unknown)" + } +} + +func escapeMDPipe(s string) string { + return strings.ReplaceAll(s, "|", "\\|") +} diff --git a/internal/output/markdown_test.go b/internal/output/markdown_test.go new file mode 100644 index 00000000..1a429f3b --- /dev/null +++ b/internal/output/markdown_test.go @@ -0,0 +1,287 @@ +package output + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestMarkdownTableOutput(t *testing.T) { + t.Run("flags list produces markdown table", func(t *testing.T) { + cols := GetListColumns("flags") + items := []resource{ + { + "key": "my-flag", + "name": "My Feature Flag", + "kind": "boolean", + "temporary": true, + "tags": []interface{}{"beta", "frontend"}, + }, + { + "key": "dark-mode", + "name": "Dark Mode Toggle", + "kind": "boolean", + "temporary": false, + "tags": []interface{}{"ui"}, + }, + } + + result := MarkdownTableOutput(items, cols) + lines := strings.Split(result, "\n") + + assert.Len(t, lines, 4) + assert.Equal(t, "| KEY | NAME | KIND | TEMPORARY | TAGS |", lines[0]) + assert.Equal(t, "| --- | --- | --- | --- | --- |", lines[1]) + assert.Contains(t, lines[2], "my-flag") + assert.Contains(t, lines[2], "My Feature Flag") + assert.Contains(t, lines[2], "yes") + assert.Contains(t, lines[2], "beta, frontend") + assert.Contains(t, lines[3], "dark-mode") + assert.Contains(t, lines[3], "no") + }) + + t.Run("empty items produces header only", func(t *testing.T) { + cols := GetListColumns("flags") + result := MarkdownTableOutput([]resource{}, cols) + lines := strings.Split(result, "\n") + + assert.Len(t, lines, 2) + assert.Contains(t, lines[0], "KEY") + assert.Contains(t, lines[1], "---") + }) + + t.Run("escapes pipe characters in values", func(t *testing.T) { + cols := []ColumnDef{ + {Header: "KEY", Field: "key"}, + {Header: "NAME", Field: "name"}, + } + items := []resource{ + {"key": "a|b", "name": "test"}, + } + + result := MarkdownTableOutput(items, cols) + + assert.Contains(t, result, `a\|b`) + }) +} + +func TestMarkdownKeyValueOutput(t *testing.T) { + t.Run("produces bullet list", func(t *testing.T) { + cols := GetSingularColumns("flags") + r := resource{ + "key": "my-flag", + "name": "My Feature Flag", + "kind": "boolean", + "temporary": true, + "creationDate": float64(1718438400000), + "tags": []interface{}{"beta"}, + } + + result := MarkdownKeyValueOutput(r, cols) + + assert.Contains(t, result, "- **Key:** my-flag") + assert.Contains(t, result, "- **Name:** My Feature Flag") + assert.Contains(t, result, "- **Kind:** boolean") + assert.Contains(t, result, "- **Temporary:** yes") + assert.Contains(t, result, "- **Tags:** beta") + }) +} + +func TestMarkdownSingularOutput(t *testing.T) { + t.Run("flags get produces rich output with env table", func(t *testing.T) { + r := resource{ + "key": "test-feature-flag", + "name": "Test Feature Flag", + "description": "Example description of what the feature flag does.", + "kind": "boolean", + "temporary": true, + "tags": []interface{}{"test-tag"}, + "environments": map[string]interface{}{ + "production": map[string]interface{}{ + "on": true, + "fallthrough": map[string]interface{}{"variation": float64(1)}, + "rules": []interface{}{}, + }, + "staging": map[string]interface{}{ + "on": false, + "fallthrough": map[string]interface{}{"variation": float64(0)}, + "rules": []interface{}{map[string]interface{}{"id": "rule1"}}, + }, + }, + "variations": []interface{}{ + map[string]interface{}{"value": true, "name": "Available"}, + map[string]interface{}{"value": false, "name": "Unavailable"}, + }, + } + + result := MarkdownSingularOutput(r, "flags") + + assert.Contains(t, result, "## test-feature-flag") + assert.Contains(t, result, "Example description of what the feature flag does.") + assert.Contains(t, result, "| Environment | Status | Fallthrough | Rules |") + assert.Contains(t, result, "| production | ON | Unavailable (false) | 0 |") + assert.Contains(t, result, "| staging | OFF | Available (true) | 1 |") + assert.Contains(t, result, "- **Kind:** boolean") + assert.Contains(t, result, "- **Temporary:** yes") + assert.Contains(t, result, "- **Tags:** test-tag") + }) + + t.Run("flags without environments omits env table", func(t *testing.T) { + r := resource{ + "key": "simple-flag", + "kind": "boolean", + "temporary": false, + } + + result := MarkdownSingularOutput(r, "flags") + + assert.Contains(t, result, "## simple-flag") + assert.NotContains(t, result, "| Environment") + assert.Contains(t, result, "- **Kind:** boolean") + assert.Contains(t, result, "- **Temporary:** no") + }) + + t.Run("flags without description omits description", func(t *testing.T) { + r := resource{ + "key": "no-desc-flag", + "kind": "boolean", + "temporary": false, + } + + result := MarkdownSingularOutput(r, "flags") + + assert.Contains(t, result, "## no-desc-flag") + assert.NotContains(t, result, "Example description") + assert.Contains(t, result, "- **Kind:** boolean") + }) + + t.Run("flags with maintainer shows maintainer", func(t *testing.T) { + r := resource{ + "key": "maint-flag", + "kind": "boolean", + "temporary": false, + "_maintainer": map[string]interface{}{"name": "John Doe", "email": "john@example.com"}, + } + + result := MarkdownSingularOutput(r, "flags") + + assert.Contains(t, result, "- **Maintainer:** John Doe") + }) + + t.Run("non-flags resource uses column registry", func(t *testing.T) { + r := resource{ + "key": "production", + "name": "Production", + "color": "FF0000", + } + + result := MarkdownSingularOutput(r, "environments") + + assert.Contains(t, result, "## Production (production)") + assert.Contains(t, result, "- **Key:** production") + assert.Contains(t, result, "- **Name:** Production") + assert.Contains(t, result, "- **Color:** FF0000") + }) + + t.Run("unknown resource uses generic heading", func(t *testing.T) { + r := resource{ + "key": "test-key", + "name": "test-name", + } + + result := MarkdownSingularOutput(r, "unknown-resource") + + assert.Equal(t, "## test-name (test-key)", result) + }) +} + +func TestMarkdownMultipleOutput(t *testing.T) { + t.Run("with registered columns uses markdown table", func(t *testing.T) { + items := []resource{ + {"key": "flag-1", "name": "Flag 1", "kind": "boolean", "temporary": true, "tags": []interface{}{}}, + } + + result := MarkdownMultipleOutput(items, "flags") + + assert.Contains(t, result, "| KEY | NAME | KIND | TEMPORARY | TAGS |") + assert.Contains(t, result, "flag-1") + }) + + t.Run("without registered columns uses bullet list", func(t *testing.T) { + items := []resource{ + {"key": "item-1", "name": "Item 1"}, + {"key": "item-2", "name": "Item 2"}, + } + + result := MarkdownMultipleOutput(items, "unknown-resource") + + assert.Contains(t, result, "- Item 1 (item-1)") + assert.Contains(t, result, "- Item 2 (item-2)") + }) +} + +func TestResolveFallthrough(t *testing.T) { + variations := []variation{ + {Name: "Available", Value: true}, + {Name: "Unavailable", Value: false}, + } + + t.Run("resolves named variation", func(t *testing.T) { + envData := map[string]interface{}{ + "fallthrough": map[string]interface{}{"variation": float64(0)}, + } + result := resolveFallthrough(envData, variations) + assert.Equal(t, "Available (true)", result) + }) + + t.Run("out of range index", func(t *testing.T) { + envData := map[string]interface{}{ + "fallthrough": map[string]interface{}{"variation": float64(5)}, + } + result := resolveFallthrough(envData, variations) + assert.Equal(t, "variation 5", result) + }) + + t.Run("missing fallthrough", func(t *testing.T) { + envData := map[string]interface{}{} + result := resolveFallthrough(envData, variations) + assert.Equal(t, "", result) + }) + + t.Run("unnamed variation shows value only", func(t *testing.T) { + vars := []variation{{Name: "", Value: "red"}} + envData := map[string]interface{}{ + "fallthrough": map[string]interface{}{"variation": float64(0)}, + } + result := resolveFallthrough(envData, vars) + assert.Equal(t, "red", result) + }) +} + +func TestExtractMaintainer(t *testing.T) { + t.Run("name preferred over email", func(t *testing.T) { + r := resource{ + "_maintainer": map[string]interface{}{"name": "Alice", "email": "alice@test.com"}, + } + assert.Equal(t, "Alice", extractMaintainer(r)) + }) + + t.Run("falls back to email", func(t *testing.T) { + r := resource{ + "_maintainer": map[string]interface{}{"email": "bob@test.com"}, + } + assert.Equal(t, "bob@test.com", extractMaintainer(r)) + }) + + t.Run("no maintainer returns empty", func(t *testing.T) { + r := resource{} + assert.Equal(t, "", extractMaintainer(r)) + }) +} + +func TestEscapeMDPipe(t *testing.T) { + assert.Equal(t, `a\|b`, escapeMDPipe("a|b")) + assert.Equal(t, "no pipes", escapeMDPipe("no pipes")) + assert.Equal(t, "", escapeMDPipe("")) +} diff --git a/internal/output/output.go b/internal/output/output.go index 735361d6..c5254b8d 100644 --- a/internal/output/output.go +++ b/internal/output/output.go @@ -6,7 +6,7 @@ import ( "github.com/launchdarkly/ldcli/internal/errors" ) -var ErrInvalidOutputKind = errors.NewError("output is invalid. Use 'json' or 'plaintext'") +var ErrInvalidOutputKind = errors.NewError("output is invalid. Use 'json', 'plaintext', or 'markdown'") type OutputKind string @@ -16,6 +16,7 @@ func (o OutputKind) String() string { var ( OutputKindJSON = OutputKind("json") + OutputKindMarkdown = OutputKind("markdown") OutputKindNull = OutputKind("") OutputKindPlaintext = OutputKind("plaintext") ) @@ -23,6 +24,7 @@ var ( func NewOutputKind(s string) (OutputKind, error) { validKinds := map[string]struct{}{ OutputKindJSON.String(): {}, + OutputKindMarkdown.String(): {}, OutputKindPlaintext.String(): {}, } if _, isValid := validKinds[s]; !isValid { @@ -93,7 +95,7 @@ func outputFromKind(outputKind string, o Outputter) (string, error) { switch outputKind { case "json": return o.JSON(), nil - case "plaintext": + case "plaintext", "markdown": return o.String(), nil } diff --git a/internal/output/output_test.go b/internal/output/output_test.go index a10e2003..54246f0a 100644 --- a/internal/output/output_test.go +++ b/internal/output/output_test.go @@ -22,6 +22,12 @@ func TestNewOutputKind(t *testing.T) { assert.Equal(t, output.OutputKindPlaintext, kind) }) + t.Run("returns markdown for valid markdown input", func(t *testing.T) { + kind, err := output.NewOutputKind("markdown") + require.NoError(t, err) + assert.Equal(t, output.OutputKindMarkdown, kind) + }) + t.Run("returns error for invalid input", func(t *testing.T) { kind, err := output.NewOutputKind("xml") assert.ErrorIs(t, err, output.ErrInvalidOutputKind) @@ -118,4 +124,17 @@ func TestCmdOutputSingular(t *testing.T) { require.NoError(t, err) assert.JSONEq(t, input, result) }) + + t.Run("with markdown output kind returns plaintext representation", func(t *testing.T) { + input := `{"key": "test-key", "name": "test-name"}` + + result, err := output.CmdOutputSingular( + "markdown", + []byte(input), + output.SingularPlaintextOutputFn, + ) + + require.NoError(t, err) + assert.Equal(t, "test-name (test-key)", result) + }) } diff --git a/internal/output/resource_output.go b/internal/output/resource_output.go index 1315f445..02e09514 100644 --- a/internal/output/resource_output.go +++ b/internal/output/resource_output.go @@ -87,6 +87,10 @@ func CmdOutput(action string, outputKind string, input []byte, opts ...CmdOutput // no success message } + if outputKind == "markdown" { + return markdownCmdOutput(maybeResource, maybeResources, resourceName, successMessage, isMultipleResponse) + } + if !isMultipleResponse { if cols := GetSingularColumns(resourceName); cols != nil { kv := KeyValueOutput(maybeResource, cols) @@ -113,35 +117,61 @@ func CmdOutput(action string, outputKind string, input []byte, opts ...CmdOutput body = strings.Join(items, "\n") } - var ( - pagination string - limit int - offset int + if successMessage != "" { + successMessage += "\n" + } + return plaintextOutput(body, successMessage) + paginationSuffix(maybeResources), nil +} + +func paginationSuffix(rs resources) string { + self, ok := rs.Links["self"] + if !ok || rs.TotalCount <= 0 { + return "" + } + selfURL, _ := url.Parse(self["href"]) + limit, _ := strconv.Atoi(selfURL.Query().Get("limit")) + offset, _ := strconv.Atoi(selfURL.Query().Get("offset")) + maxResults := int(math.Min(float64(offset+limit), float64(rs.TotalCount))) + if maxResults == 0 { + maxResults = rs.TotalCount + } + pagination := fmt.Sprintf( + "\nShowing results %d - %d of %d.", + offset+1, + maxResults, + rs.TotalCount, ) - self, ok := maybeResources.Links["self"] - if ok && maybeResources.TotalCount > 0 { - selfURL, _ := url.Parse(self["href"]) - limit, _ = strconv.Atoi(selfURL.Query().Get("limit")) - offset, _ = strconv.Atoi(selfURL.Query().Get("offset")) - maxResults := int(math.Min(float64(offset+limit), float64(maybeResources.TotalCount))) - if maxResults == 0 { - maxResults = maybeResources.TotalCount - } - pagination = fmt.Sprintf( - "\nShowing results %d - %d of %d.", - offset+1, - maxResults, - maybeResources.TotalCount, - ) - if maxResults < maybeResources.TotalCount { - pagination += fmt.Sprintf(" Use --offset %d for additional results.", offset+limit) + if maxResults < rs.TotalCount { + pagination += fmt.Sprintf(" Use --offset %d for additional results.", offset+limit) + } + return pagination +} + +func markdownCmdOutput( + maybeResource resource, + maybeResources resources, + resourceName string, + successMessage string, + isMultipleResponse bool, +) (string, error) { + if !isMultipleResponse { + body := MarkdownSingularOutput(maybeResource, resourceName) + if strings.TrimSpace(successMessage) != "" { + return successMessage + "\n\n" + body, nil } + return body, nil } - if successMessage != "" { - successMessage += "\n" + if len(maybeResources.Items) == 0 { + return "No items found", nil + } + + body := MarkdownMultipleOutput(maybeResources.Items, resourceName) + pagination := paginationSuffix(maybeResources) + if strings.TrimSpace(successMessage) != "" { + return successMessage + "\n\n" + body + pagination, nil } - return plaintextOutput(body, successMessage) + pagination, nil + return body + pagination, nil } func plaintextOutput(out string, successMessage string) string { @@ -175,6 +205,7 @@ func CmdOutputError(outputKind string, err error) string { return string(formattedOutput) } + // plaintext and markdown use the same error format return ErrorPlaintextOutputFn(r) } diff --git a/internal/output/resource_output_test.go b/internal/output/resource_output_test.go index e6781cdb..fdb01187 100644 --- a/internal/output/resource_output_test.go +++ b/internal/output/resource_output_test.go @@ -945,6 +945,204 @@ func TestCmdOutputWithResourceName(t *testing.T) { }) } +func TestCmdOutputMarkdown(t *testing.T) { + t.Run("flags singular produces markdown with env table", func(t *testing.T) { + input := `{ + "key": "test-feature-flag", + "description": "A test flag.", + "kind": "boolean", + "temporary": true, + "tags": ["test-tag"], + "environments": { + "production": { + "on": true, + "fallthrough": {"variation": 1}, + "rules": [] + }, + "staging": { + "on": false, + "fallthrough": {"variation": 0}, + "rules": [{"id": "rule1"}] + } + }, + "variations": [ + {"value": true, "name": "Available"}, + {"value": false, "name": "Unavailable"} + ] + }` + + result, err := output.CmdOutput("get", "markdown", []byte(input), output.CmdOutputOpts{ + ResourceName: "flags", + }) + + require.NoError(t, err) + assert.Contains(t, result, "## test-feature-flag") + assert.Contains(t, result, "A test flag.") + assert.Contains(t, result, "| Environment | Status | Fallthrough | Rules |") + assert.Contains(t, result, "| production | ON | Unavailable (false) | 0 |") + assert.Contains(t, result, "| staging | OFF | Available (true) | 1 |") + assert.Contains(t, result, "- **Kind:** boolean") + assert.Contains(t, result, "- **Temporary:** yes") + assert.Contains(t, result, "- **Tags:** test-tag") + }) + + t.Run("flags singular with update action includes success message", func(t *testing.T) { + input := `{ + "key": "my-flag", + "kind": "boolean", + "temporary": false + }` + + result, err := output.CmdOutput("update", "markdown", []byte(input), output.CmdOutputOpts{ + ResourceName: "flags", + }) + + require.NoError(t, err) + assert.Contains(t, result, "Successfully updated\n\n## my-flag") + assert.Contains(t, result, "- **Kind:** boolean") + }) + + t.Run("list produces markdown table", func(t *testing.T) { + input := `{ + "items": [ + { + "key": "my-flag", + "name": "My Flag", + "kind": "boolean", + "temporary": true, + "tags": ["beta"] + }, + { + "key": "dark-mode", + "name": "Dark Mode", + "kind": "boolean", + "temporary": false, + "tags": [] + } + ] + }` + + result, err := output.CmdOutput("list", "markdown", []byte(input), output.CmdOutputOpts{ + ResourceName: "flags", + }) + + require.NoError(t, err) + assert.Contains(t, result, "| KEY | NAME | KIND | TEMPORARY | TAGS |") + assert.Contains(t, result, "| --- | --- | --- | --- | --- |") + assert.Contains(t, result, "my-flag") + assert.Contains(t, result, "dark-mode") + }) + + t.Run("empty list returns no items found", func(t *testing.T) { + input := `{"items": []}` + + result, err := output.CmdOutput("list", "markdown", []byte(input), output.CmdOutputOpts{ + ResourceName: "flags", + }) + + require.NoError(t, err) + assert.Equal(t, "No items found", result) + }) + + t.Run("unknown resource singular uses generic heading", func(t *testing.T) { + input := `{"key": "test-key", "name": "test-name"}` + + result, err := output.CmdOutput("get", "markdown", []byte(input), output.CmdOutputOpts{ + ResourceName: "unknown-resource", + }) + + require.NoError(t, err) + assert.Equal(t, "## test-name (test-key)", result) + }) + + t.Run("unknown resource list uses bullet list", func(t *testing.T) { + input := `{ + "items": [ + {"key": "k1", "name": "n1"}, + {"key": "k2", "name": "n2"} + ] + }` + + result, err := output.CmdOutput("list", "markdown", []byte(input), output.CmdOutputOpts{ + ResourceName: "unknown-resource", + }) + + require.NoError(t, err) + assert.Contains(t, result, "- n1 (k1)") + assert.Contains(t, result, "- n2 (k2)") + }) + + t.Run("create list with resource name shows success and table", func(t *testing.T) { + input := `{ + "items": [ + {"key": "new-flag", "name": "New Flag", "kind": "boolean", "temporary": false, "tags": []} + ] + }` + + result, err := output.CmdOutput("create", "markdown", []byte(input), output.CmdOutputOpts{ + ResourceName: "flags", + }) + + require.NoError(t, err) + assert.Contains(t, result, "Successfully created") + assert.Contains(t, result, "| KEY |") + assert.Contains(t, result, "new-flag") + }) + + t.Run("list with pagination includes pagination suffix", func(t *testing.T) { + input := `{ + "_links": { + "self": { + "href": "/api/v2/flags/proj?limit=5&offset=0", + "type": "application/json" + } + }, + "items": [ + { + "key": "my-flag", + "name": "My Flag", + "kind": "boolean", + "temporary": true, + "tags": ["beta"] + } + ], + "totalCount": 100 + }` + + result, err := output.CmdOutput("list", "markdown", []byte(input), output.CmdOutputOpts{ + ResourceName: "flags", + }) + + require.NoError(t, err) + assert.Contains(t, result, "| KEY |") + assert.Contains(t, result, "my-flag") + assert.Contains(t, result, "Showing results 1 - 5 of 100.") + assert.Contains(t, result, "Use --offset 5 for additional results.") + }) + + t.Run("fields are ignored with stderr warning", func(t *testing.T) { + input := `{"key":"test-key","name":"test-name","extra":"extra-value"}` + + result, err := output.CmdOutput("get", "markdown", []byte(input), output.CmdOutputOpts{ + Fields: []string{"key"}, + ResourceName: "flags", + }) + + require.NoError(t, err) + assert.Contains(t, result, "## test-key") + }) +} + +func TestCmdOutputErrorMarkdown(t *testing.T) { + t.Run("reuses plaintext error format", func(t *testing.T) { + err := errors.NewError(`{"code":"conflict", "message":"an error"}`) + + result := output.CmdOutputError("markdown", err) + + assert.Equal(t, "an error (code: conflict)", result) + }) +} + func TestCmdOutputErrorNotAffectedByFields(t *testing.T) { t.Run("JSON error response always returns full structure", func(t *testing.T) { errJSON := `{"code":"not_found","message":"Not Found","statusCode":404,"suggestion":"Try ldcli flags list."}`