Skip to content
Merged
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
91 changes: 78 additions & 13 deletions internal/commands/pro_report_platform.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,35 +32,58 @@ failed, and pending device counts. Requires platform gateway auth.`,
ctx := cmd.Context()
c := cliCtx.PlatformSDKClient

bps, err := blueprints.New(c).ListBlueprints(ctx, nil, "")
bp := blueprints.New(c)

bps, err := bp.ListBlueprints(ctx, nil, "")
if err != nil {
return err
}

rows := make([]map[string]any, 0, len(bps))
for _, bp := range bps {
for _, b := range bps {
state := ""
if bp.DeploymentState != nil {
state = bp.DeploymentState.State
if b.DeploymentState != nil {
state = b.DeploymentState.State
}

// Every row carries every key, because a table's columns are
// the keys of its *first* row (see the "A table's columns"
// convention in CLAUDE.md). Leaving a count off a
// NOT_DEPLOYED row cost the whole report its SUCCEEDED /
// FAILED / PENDING columns whenever a NOT_DEPLOYED blueprint
// happened to sort first (issue #356). nil means "not
// applicable to this row" and is rendered as a placeholder
// for table/csv/plain and dropped for json/yaml, so the
// structured output keeps the shape it has always had.
row := map[string]any{
"name": bp.Name,
"state": state,
"name": b.Name,
"state": state,
"scope": nil,
"steps": nil,
"succeeded": nil,
"failed": nil,
"pending": nil,
}

detail, err := blueprints.New(c).GetBlueprint(ctx, bp.ID)
if err == nil {
detail, err := bp.GetBlueprint(ctx, b.ID)
switch {
case err != nil:
// Reported rather than swallowed: without this the row
// silently claimed no scope and no steps.
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Warning: failed to fetch blueprint %q: %v\n", b.Name, err)
default:
row["scope"] = 0
if detail.Scope != nil {
row["scope"] = len(detail.Scope.DeviceGroups)
} else {
row["scope"] = 0
}
row["steps"] = len(detail.Steps)
}

if state == "DEPLOYED" {
report, err := blueprints.New(c).GetBlueprintReport(ctx, bp.ID)
if err == nil {
report, err := bp.GetBlueprintReport(ctx, b.ID)
if err != nil {
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Warning: failed to fetch deployment report for %q: %v\n", b.Name, err)
} else {
row["succeeded"] = report.Succeeded
row["failed"] = report.Failed
row["pending"] = report.Pending
Expand All @@ -75,11 +98,53 @@ failed, and pending device counts. Requires platform gateway auth.`,
return nil
}

return printRows(cliCtx, rows)
return printRows(cliCtx, blueprintStatusRowsForFormat(rows, outputFmt))
},
}
}

// notApplicable is what a column-bearing row shows for a value that does not
// apply to it. An em dash rather than 0, because 0 succeeded devices and no
// deployment to have succeeded on are different facts.
const notApplicable = "\u2014"

// structuredRowFormats are the formats that marshal a row rather than render
// it into columns. They keep an absent value absent — which is the shape they
// have always emitted, and the only honest one, since a consumer of these
// reads a number where a column reader reads a cell. -o xml and -o raw are
// deliberately not here: neither has a case in the formatter's switch, so both
// render a table and both need the column.
var structuredRowFormats = map[string]bool{
"json": true,
"json-multi": true,
"yaml": true,
"ndjson": true,
}

// blueprintStatusRowsForFormat resolves the nil placeholders in the canonical
// rows for the requested format: a structured format drops the key, and every
// column-rendering format substitutes notApplicable so the key survives as a
// column whatever the first row happens to be.
func blueprintStatusRowsForFormat(rows []map[string]any, format string) []map[string]any {
structured := structuredRowFormats[format]
out := make([]map[string]any, 0, len(rows))
for _, row := range rows {
resolved := make(map[string]any, len(row))
for k, v := range row {
switch {
case v != nil:
resolved[k] = v
case structured:
// key omitted
default:
resolved[k] = notApplicable
}
}
out = append(out, resolved)
}
return out
}

// ── Compliance Rules Report ────────────────────────────────────────────────

func newReportComplianceRulesCmd(cliCtx *registry.CLIContext) *cobra.Command {
Expand Down
193 changes: 193 additions & 0 deletions internal/commands/pro_report_platform_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
// Copyright 2026, Jamf Software LLC

package commands

import (
"context"
"encoding/json"
"io"
"net/http"
"strings"
"testing"

"github.com/Jamf-Concepts/jamf-cli/internal/output"
"github.com/Jamf-Concepts/jamf-cli/internal/registry"
)

// blueprintStatusCanonicalRows is the shape newReportBlueprintStatusCmd builds
// before handing it to blueprintStatusRowsForFormat: a NOT_DEPLOYED blueprint
// first, so it is the row whose keys a table would take its columns from, and
// a DEPLOYED one carrying counts second.
func blueprintStatusCanonicalRows() []map[string]any {
return []map[string]any{
{
"name": "Restrictions - Example", "state": "NOT_DEPLOYED",
"scope": 0, "steps": 1,
"succeeded": nil, "failed": nil, "pending": nil,
},
{
"name": "App Settings - Example", "state": "DEPLOYED",
"scope": 1, "steps": 2,
"succeeded": 12, "failed": 0, "pending": 2,
},
}
}

// TestBlueprintStatusKeepsItsCountColumnsWhenTheFirstRowIsNotDeployed is issue
// #356. A table's columns are the keys of its *first* row, so a NOT_DEPLOYED
// blueprint sorting first took SUCCEEDED / FAILED / PENDING off the report
// entirely — while -o json showed the counts on the rows below it, which is
// what made the report look like a rendering bug rather than a missing column.
func TestBlueprintStatusKeepsItsCountColumnsWhenTheFirstRowIsNotDeployed(t *testing.T) {
for _, format := range []string{"table", "csv", "plain"} {
t.Run(format, func(t *testing.T) {
rows := blueprintStatusRowsForFormat(blueprintStatusCanonicalRows(), format)

var out strings.Builder
f := output.New(format, true, false)
f.SetWriter(&out)
if err := f.Print(rows); err != nil {
t.Fatalf("print: %v", err)
}

body := out.String()
if format == "plain" {
// plain emits no header, so the column set is only visible as
// the field count, which has to be the same on both rows.
for i, line := range strings.Split(strings.TrimRight(body, "\n"), "\n") {
if n := len(strings.Split(line, "\t")); n != 7 {
t.Errorf("-o plain row %d has %d fields, want 7:\n%s", i, n, body)
}
}
} else {
for _, col := range []string{"succeeded", "failed", "pending"} {
// The header is upper-cased in table mode and lower-cased
// in csv, so match case-insensitively rather than per
// format.
if !strings.Contains(strings.ToLower(body), col) {
t.Errorf("-o %s dropped the %s column:\n%s", format, col, body)
}
}
}
// The count that exists must still be rendered, so a fix that
// blanks every row's counts does not pass.
if !strings.Contains(body, "12") {
t.Errorf("-o %s lost the DEPLOYED row's succeeded count:\n%s", format, body)
}
// And a NOT_DEPLOYED row must not claim zero successes.
if !strings.Contains(body, notApplicable) {
t.Errorf("-o %s rendered no not-applicable placeholder, so a NOT_DEPLOYED row reads as 0 succeeded:\n%s", format, body)
}
})
}
}

// TestBlueprintStatusStructuredOutputOmitsInapplicableCounts pins the other
// half: json and yaml keep the shape they have always emitted, where a
// NOT_DEPLOYED blueprint simply carries no count keys. Substituting the
// placeholder there would put an em dash where a consumer expects a number.
func TestBlueprintStatusStructuredOutputOmitsInapplicableCounts(t *testing.T) {
for _, format := range []string{"json", "json-multi", "yaml", "ndjson"} {
t.Run(format, func(t *testing.T) {
rows := blueprintStatusRowsForFormat(blueprintStatusCanonicalRows(), format)

if _, ok := rows[0]["succeeded"]; ok {
t.Errorf("-o %s gave the NOT_DEPLOYED row a succeeded key: %v", format, rows[0])
}
if got := rows[1]["succeeded"]; got != 12 {
t.Errorf("-o %s: DEPLOYED row succeeded = %v, want 12", format, got)
}
for _, row := range rows {
for k, v := range row {
if v == notApplicable {
t.Errorf("-o %s put the placeholder in %s: %v", format, k, row)
}
}
}
})
}
}

// TestBlueprintStatusRowsForFormatDoesNotMutateItsInput guards the ordering the
// command depends on: it builds the canonical rows once and the resolver is the
// only thing that decides how a nil reads, so a resolver writing back into the
// caller's maps would make the format decision sticky.
func TestBlueprintStatusRowsForFormatDoesNotMutateItsInput(t *testing.T) {
rows := blueprintStatusCanonicalRows()
before, err := json.Marshal(rows)
if err != nil {
t.Fatalf("marshal: %v", err)
}

blueprintStatusRowsForFormat(rows, "table")

after, err := json.Marshal(rows)
if err != nil {
t.Fatalf("marshal: %v", err)
}
if string(before) != string(after) {
t.Errorf("input mutated:\n before %s\n after %s", before, after)
}
}

// TestReportBlueprintStatusRendersEveryColumnFromTheWire drives the command
// itself over the wire shape issue #356 reported: a NOT_DEPLOYED blueprint
// listed first, a DEPLOYED one after it. The resolver tests above take the
// canonical rows as given, so only this one fails if the command stops
// carrying a key on the row that has no count.
func TestReportBlueprintStatusRendersEveryColumnFromTheWire(t *testing.T) {
restoreOutputFlags(t)
outputFmt = "table"

sdk, mux := newTestPlatformSDK(t)

mux.HandleFunc("/blueprints/v1/blueprints", func(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, map[string]any{
"totalCount": 2,
"results": []map[string]any{
{"id": "bp-1", "name": "Restrictions - Example", "deploymentState": map[string]any{"state": "NOT_DEPLOYED"}},
{"id": "bp-2", "name": "App Settings - Example", "deploymentState": map[string]any{"state": "DEPLOYED"}},
},
})
})
mux.HandleFunc("/blueprints/v1/blueprints/bp-1", func(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, map[string]any{"id": "bp-1", "state": "NOT_DEPLOYED", "steps": []map[string]any{{}}})
})
mux.HandleFunc("/blueprints/v1/blueprints/bp-2", func(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, map[string]any{
"id": "bp-2",
"state": "DEPLOYED",
"scope": map[string]any{"deviceGroups": []map[string]any{{"id": "dg-1"}}},
"steps": []map[string]any{{}, {}},
})
})
mux.HandleFunc("/blueprints/v1/blueprints/bp-2/report", func(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, map[string]any{"succeeded": 12, "failed": 0, "pending": 2})
})

var out strings.Builder
f := output.New("table", true, false)
f.SetWriter(&out)

cmd := newReportBlueprintStatusCmd(&registry.CLIContext{
PlatformSDKClient: sdk,
Output: &cliOutput{f},
})
cmd.SetContext(context.Background())
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)

if err := cmd.RunE(cmd, nil); err != nil {
t.Fatalf("blueprint-status: %v", err)
}

body := out.String()
for _, col := range []string{"SUCCEEDED", "FAILED", "PENDING", "SCOPE", "STEPS"} {
if !strings.Contains(body, col) {
t.Errorf("the %s column is missing even though a DEPLOYED blueprint carries it:\n%s", col, body)
}
}
if !strings.Contains(body, "12") {
t.Errorf("the DEPLOYED row lost its succeeded count:\n%s", body)
}
}