Skip to content
Merged
135 changes: 135 additions & 0 deletions cmd/audience_segments.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
package cmd

import (
"fmt"

"github.com/loops-so/cli/internal/config"
"github.com/loops-so/loops-go"
"github.com/spf13/cobra"
)

// formatSegmentFilter renders the filter on an audience segment. Unlike a
// campaign's audience-filter, a nil filter on a segment means the reserved
// "all contacts" segment — call that out explicitly.
func formatSegmentFilter(f *loops.AudienceFilter) string {
if f == nil {
return "(all contacts)"
}
return fmt.Sprintf("match=%s (%d conditions)", f.Match, len(f.Conditions))
}

func runAudienceSegmentsGet(cfg *config.Config, id string) (*loops.AudienceSegment, error) {
return newAPIClient(cfg).GetAudienceSegment(id)
}

func runAudienceSegmentsList(cfg *config.Config, params loops.PaginationParams) ([]loops.AudienceSegment, error) {
client := newAPIClient(cfg)
if params.Cursor != "" {
segments, _, err := client.ListAudienceSegments(params)
return segments, err
}
return loops.Paginate(func(cursor string) ([]loops.AudienceSegment, *loops.Pagination, error) {
return client.ListAudienceSegments(loops.PaginationParams{
PerPage: params.PerPage,
Cursor: cursor,
})
})
}

var audienceSegmentsCmd = &cobra.Command{
Use: "audience-segments",
Short: "Read audience segments",
}

var audienceSegmentsListCmd = &cobra.Command{
Use: "list",
Short: "List audience segments",
RunE: func(cmd *cobra.Command, args []string) error {
if err := validatePickFlags(cmd); err != nil {
return err
}

cfg, err := loadConfig()
if err != nil {
return err
}

segments, err := runAudienceSegmentsList(cfg, paginationParams(cmd))
if err != nil {
return err
}

if isJSONOutput() {
if segments == nil {
segments = []loops.AudienceSegment{}
}
return printJSON(cmd.OutOrStdout(), segments)
}

if len(segments) == 0 {
fmt.Fprintln(cmd.OutOrStdout(), "No audience segments found.")
return nil
}

headers := []string{"ID", "NAME", "FILTER", "UPDATED"}
rows := make([][]string, 0, len(segments))
for _, s := range segments {
rows = append(rows, []string{
s.ID,
s.Name,
formatSegmentFilter(s.Filter),
s.UpdatedAt,
})
}

if isPicking(cmd) {
return runPicker(headers, rows, []pickBinding{
copyColumnBinding("enter", "copy id", "segment ID", rows, 0, cmd.OutOrStdout()),
})
}

t := newStyledTable(cmd.OutOrStdout(), headers...)
for _, r := range rows {
t.Row(r...)
}
return t.Render()
},
}

var audienceSegmentsGetCmd = &cobra.Command{
Use: "get <id>",
Short: "Get an audience segment",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := loadConfig()
if err != nil {
return err
}

s, err := runAudienceSegmentsGet(cfg, args[0])
if err != nil {
return err
}

if isJSONOutput() {
return printJSON(cmd.OutOrStdout(), s)
}

t := newStyledTable(cmd.OutOrStdout(), "FIELD", "VALUE")
t.Row("segmentId", s.ID)
t.Row("name", s.Name)
t.Row("description", deref(s.Description))
t.Row("filter", formatSegmentFilter(s.Filter))
t.Row("createdAt", s.CreatedAt)
t.Row("updatedAt", s.UpdatedAt)
return t.Render()
},
}

func init() {
addPaginationFlags(audienceSegmentsListCmd)
addPickFlag(audienceSegmentsListCmd)
audienceSegmentsCmd.AddCommand(audienceSegmentsListCmd)
audienceSegmentsCmd.AddCommand(audienceSegmentsGetCmd)
rootCmd.AddCommand(audienceSegmentsCmd)
}
89 changes: 89 additions & 0 deletions cmd/audience_segments_get_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package cmd

import (
"net/http"
"testing"

"github.com/loops-so/loops-go"
)

func TestRunAudienceSegmentsGet(t *testing.T) {
t.Run("returns segment with nontrivial filter", func(t *testing.T) {
body := `{
"id": "seg_abc",
"name": "Active pro users",
"description": "Pro plan + opted in + active",
"createdAt": "2026-04-01T10:00:00Z",
"updatedAt": "2026-04-20T10:00:00Z",
"filter": {
"match": "all",
"conditions": [
{"type": "property", "key": "plan", "operator": "equals", "value": "pro"},
{"type": "optIn", "status": "accepted"},
{"type": "activity", "action": "opened", "negate": false, "target": "campaign", "id": "cmp_1"}
]
}
}`
serveJSON(t, http.StatusOK, body)

s, err := runAudienceSegmentsGet(cfg(t), "seg_abc")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if s.ID != "seg_abc" {
t.Errorf("ID = %q, want seg_abc", s.ID)
}
if deref(s.Description) != "Pro plan + opted in + active" {
t.Errorf("Description = %q", deref(s.Description))
}
if s.Filter == nil {
t.Fatal("Filter nil")
}
if s.Filter.Match != "all" {
t.Errorf("Match = %q", s.Filter.Match)
}
if len(s.Filter.Conditions) != 3 {
t.Fatalf("Conditions len = %d, want 3", len(s.Filter.Conditions))
}
if s.Filter.Conditions[0].Type != loops.AudienceConditionTypeProperty || s.Filter.Conditions[0].Property == nil {
t.Errorf("property condition not decoded: %+v", s.Filter.Conditions[0])
}
if s.Filter.Conditions[1].Type != loops.AudienceConditionTypeOptIn || s.Filter.Conditions[1].OptIn == nil {
t.Errorf("optIn condition not decoded: %+v", s.Filter.Conditions[1])
}
if s.Filter.Conditions[2].Type != loops.AudienceConditionTypeActivity || s.Filter.Conditions[2].Activity == nil {
t.Errorf("activity condition not decoded: %+v", s.Filter.Conditions[2])
}
})

t.Run("nil filter (all-contacts reserved segment)", func(t *testing.T) {
body := `{
"id": "seg_all",
"name": "All contacts",
"description": null,
"createdAt": "2026-04-01T10:00:00Z",
"updatedAt": "2026-04-01T10:00:00Z",
"filter": null
}`
serveJSON(t, http.StatusOK, body)

s, err := runAudienceSegmentsGet(cfg(t), "seg_all")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if s.Filter != nil {
t.Errorf("Filter = %+v, want nil", s.Filter)
}
if got := formatSegmentFilter(s.Filter); got != "(all contacts)" {
t.Errorf("formatSegmentFilter = %q, want (all contacts)", got)
}
})

t.Run("returns error on non-200 response", func(t *testing.T) {
serveJSON(t, http.StatusNotFound, `{"success":false,"message":"Audience segment not found"}`)
_, err := runAudienceSegmentsGet(cfg(t), "seg_missing")
if err == nil {
t.Fatal("expected error, got nil")
}
})
}
62 changes: 62 additions & 0 deletions cmd/audience_segments_list_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package cmd

import (
"net/http"
"testing"

"github.com/loops-so/loops-go"
)

func TestRunAudienceSegmentsList(t *testing.T) {
t.Run("returns segments", func(t *testing.T) {
body := `{
"pagination": {"nextCursor": ""},
"data": [
{
"id": "seg_1",
"name": "All contacts",
"description": null,
"createdAt": "2026-04-01T10:00:00Z",
"updatedAt": "2026-04-01T10:00:00Z",
"filter": null
},
{
"id": "seg_2",
"name": "Pro plan",
"description": "Paying customers",
"createdAt": "2026-04-02T10:00:00Z",
"updatedAt": "2026-04-20T10:00:00Z",
"filter": {
"match": "all",
"conditions": [
{"type": "property", "key": "plan", "operator": "equals", "value": "pro"}
]
}
}
]
}`
serveJSON(t, http.StatusOK, body)

segments, err := runAudienceSegmentsList(cfg(t), loops.PaginationParams{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(segments) != 2 {
t.Fatalf("len = %d, want 2", len(segments))
}
if segments[0].ID != "seg_1" || segments[0].Filter != nil {
t.Errorf("segments[0] = %+v", segments[0])
}
if segments[1].Filter == nil || len(segments[1].Filter.Conditions) != 1 {
t.Errorf("segments[1].Filter = %+v", segments[1].Filter)
}
})

t.Run("returns error on api failure", func(t *testing.T) {
serveJSON(t, http.StatusUnauthorized, `{"error":"unauthorized"}`)
_, err := runAudienceSegmentsList(cfg(t), loops.PaginationParams{})
if err == nil {
t.Fatal("expected error, got nil")
}
})
}
Loading