diff --git a/go.mod b/go.mod index 37cf930b..4a5cf754 100644 --- a/go.mod +++ b/go.mod @@ -51,7 +51,7 @@ require ( sigs.k8s.io/yaml v1.6.0 unikraft.com/cloud/sdk v0.3.1-0.20260730110715-92b5bae5afbe unikraft.com/x/colors v0.0.0-20260710212606-b68ba398853d - unikraft.com/x/filters v0.0.0-20260416164455-ec39ae908f3f + unikraft.com/x/filters v0.0.0-20260804153219-d1b47a40e047 unikraft.com/x/fingerprint v0.0.0-20260126094137-ab6e717e5679 unikraft.com/x/guesstermwidth v0.0.0-20260708141520-0cbb0a92056b unikraft.com/x/iata v0.0.0-20260713183529-fd34645687a0 diff --git a/go.sum b/go.sum index cad171a8..ea886459 100644 --- a/go.sum +++ b/go.sum @@ -488,8 +488,8 @@ unikraft.com/cloud/sdk v0.3.1-0.20260730110715-92b5bae5afbe h1:oupb6urtot/mVqphO unikraft.com/cloud/sdk v0.3.1-0.20260730110715-92b5bae5afbe/go.mod h1:m/TX4opCAcR4OFFP/X8TWAjtzzW0Z6FxuVYznw9UW2Q= unikraft.com/x/colors v0.0.0-20260710212606-b68ba398853d h1:xNtt0WCZRm/WIr3F48AnYc7m41W6TqgvsqprDbXi4/A= unikraft.com/x/colors v0.0.0-20260710212606-b68ba398853d/go.mod h1:4jHfTebx2ff1Gd5MbS/xMrGFKRQTuwvTdzmAzaJWlSM= -unikraft.com/x/filters v0.0.0-20260416164455-ec39ae908f3f h1:v6pitpzsBnOjyzDIW0/YAEHHSI6cNOw4QOwQV0uD+dc= -unikraft.com/x/filters v0.0.0-20260416164455-ec39ae908f3f/go.mod h1:m4Qdsw8FQThJcu8g+XTEdcmpN+blf/jBuNRI81juz1M= +unikraft.com/x/filters v0.0.0-20260804153219-d1b47a40e047 h1:ZeMtVwVhGtiEIwnHXbzp4NAtiZp2pCAhEro0qPN+Vgs= +unikraft.com/x/filters v0.0.0-20260804153219-d1b47a40e047/go.mod h1:deba9Bu2u3ibYXGbsO1QRxFPuXHQXoiuY6eFmwTbuLU= unikraft.com/x/fingerprint v0.0.0-20260126094137-ab6e717e5679 h1:zdvJjNkjsriS8RM46FcdgcRoCh4EYM66PGjqVgi/ups= unikraft.com/x/fingerprint v0.0.0-20260126094137-ab6e717e5679/go.mod h1:FP7uOxux/W5PKqSRQsR4tyjNuLq4Cfio7mc5QVH1kW8= unikraft.com/x/guesstermwidth v0.0.0-20260708141520-0cbb0a92056b h1:/VC/nXdu6k0G+a9Lkd9P/sz1r1mrlh0rocNtr1M84V8= diff --git a/internal/resource/cmd/cmd.go b/internal/resource/cmd/cmd.go index 928f938f..987c2a49 100644 --- a/internal/resource/cmd/cmd.go +++ b/internal/resource/cmd/cmd.go @@ -14,13 +14,10 @@ import ( "fmt" "io" "os" - "reflect" "slices" - "strconv" "strings" "time" - "github.com/charmbracelet/x/ansi" "unikraft.com/x/filters" "unikraft.com/x/kingkong" "unikraft.com/x/log" @@ -29,7 +26,6 @@ import ( "unikraft.com/cli/internal/multimetro" "unikraft.com/cli/internal/resource" "unikraft.com/cli/internal/resource/patch" - "unikraft.com/cli/internal/resource/value" "unikraft.com/cli/internal/tui/watcher" xkong "unikraft.com/cli/internal/x/kong" "unikraft.com/cloud/sdk/platform/group" @@ -412,84 +408,6 @@ func filterResources(ctx context.Context, resources []resource.Resource, filter return filtered, rerr } -// newFieldAdaptor creates a filters.Adaptor that can traverse resource fields. -// It handles both structured fields (with subfields) and slice values (like []string tags). -func newFieldAdaptor(fields []resource.Field) filters.AdapterFunc { - return func(key []string) (string, []string, bool) { - matched := resource.GetFieldByPath(fields, key) - if len(matched) == 0 { - // GetFieldByPath may not find a match if we're looking up an index - // in a slice value (e.g., ["tags", "0"]). Try to handle this case. - if len(key) >= 2 { - parentMatched := resource.GetFieldByPath(fields, key[:len(key)-1]) - if len(parentMatched) == 1 { - if slice, ok := getSliceValue(parentMatched[0].Value); ok { - idx, err := strconv.Atoi(key[len(key)-1]) - if err == nil && idx >= 0 && idx < len(slice) { - return slice[idx], nil, true - } - } - } - } - return "", nil, false - } - if len(matched) == 1 { - field := matched[0] - // If the field has subfields, return their names as entries - // This enables wildcard filtering (e.g., nested.*.value) - if len(field.Subfields) > 0 { - entries := make([]string, len(field.Subfields)) - for i, sub := range field.Subfields { - entries[i] = sub.Name - } - return "", entries, true - } - // Check if the field's value is a slice (e.g., []string tags) - // If so, return indices as entries for wildcard support - if slice, ok := getSliceValue(field.Value); ok { - entries := make([]string, len(slice)) - for i := range slice { - entries[i] = strconv.Itoa(i) - } - return "", entries, true - } - // HACK: strip escape sequences from rendered output - out, _ := field.Render(value.RenderOpts{}) - return ansi.Strip(out), nil, true - } - // >1 fields = ambiguous match, return entries for wildcard support - entries := make([]string, len(matched)) - for i, field := range matched { - entries[i] = field.Name - } - return "", entries, true - } -} - -// getSliceValue extracts a []string from a field value if possible. -// It handles both []string and other slice types by converting to strings. -func getSliceValue(value any) ([]string, bool) { - if value == nil { - return nil, false - } - rv := reflect.ValueOf(value) - if rv.Kind() != reflect.Slice { - return nil, false - } - result := make([]string, rv.Len()) - for i := range rv.Len() { - elem := rv.Index(i) - if s, ok := elem.Interface().(string); ok { - result[i] = s - } else if s, ok := elem.Interface().(fmt.Stringer); ok { - result[i] = s.String() - } else { - result[i] = fmt.Sprintf("%v", elem.Interface()) - } - } - return result, true -} - type ResourceRemoveCmd[R resource.DeletableResource] struct { Targets []string `arg:"" name:"target" completion-predictor:"resource-key-${name}" help:"Target ${names} to remove."` diff --git a/internal/resource/cmd/cmd_test.go b/internal/resource/cmd/cmd_test.go index 025e2af9..607831b1 100644 --- a/internal/resource/cmd/cmd_test.go +++ b/internal/resource/cmd/cmd_test.go @@ -42,13 +42,16 @@ func setupTestEnv() *resourcet.TestEnv { Hidden: "hidden-test1", Invisible: "invisible-test1", Settings: resourcet.TestSettings{ - Foo: 42, - Bar: "hello", + Foo: 42, + Bar: "hello", + Score: 10.0, + Flag: true, }, Authors: []resourcet.TestAuthor{ {Name: "Alice", Email: "alice@example.com"}, {Name: "Bob", Email: "bob@example.com"}, }, + Tags: []string{"prod", "web"}, }) env.Add(resourcet.TestResource{ ID: "id-test2", @@ -58,13 +61,15 @@ func setupTestEnv() *resourcet.TestEnv { Hidden: "hidden-test2", Invisible: "invisible-test2", Settings: resourcet.TestSettings{ - Foo: 7, - Bar: "world", + Foo: 7, + Bar: "world", + Score: 1.2, }, Authors: []resourcet.TestAuthor{ {Name: "Charlie", Email: "charlie@example.com"}, {Name: "Dana", Email: "dana@example.com"}, }, + Tags: []string{"staging"}, }) return env } @@ -2066,3 +2071,147 @@ func TestDeleteBulk(t *testing.T) { assert.Contains(t, env.Store, "test2") }) } + +func TestFilterComparisonOperators(t *testing.T) { + env := setupTestEnv() + ctx := resourcet.WithTestEnv(context.Background(), env) + sandbox := &resource.Sandbox{} + + runFilter := func(t *testing.T, filter string) (string, error) { + t.Helper() + var out bytes.Buffer + cmd := &ResourceListCmd[resourcet.TestResource]{ + Filter: []string{filter}, + FormatOpts: FormatOpts{ + Output: Printer{Type: PrinterTypeQuiet}, + }, + } + err := cmd.Run(ctx, testStdio(&out), sandbox) + return out.String(), err + } + + t.Run("equal", func(t *testing.T) { + output, err := runFilter(t, "settings.foo==7") + require.NoError(t, err) + assert.Contains(t, output, "test2") + assert.NotContains(t, output, "test1") + }) + + t.Run("greater", func(t *testing.T) { + output, err := runFilter(t, "settings.foo>7") + require.NoError(t, err) + assert.Contains(t, output, "test1") + assert.NotContains(t, output, "test2") + }) + + t.Run("greater_equal", func(t *testing.T) { + output, err := runFilter(t, "settings.foo>=7") + require.NoError(t, err) + assert.Contains(t, output, "test1") + assert.Contains(t, output, "test2") + }) + + t.Run("less", func(t *testing.T) { + output, err := runFilter(t, "settings.foo<42") + require.NoError(t, err) + assert.NotContains(t, output, "test1") + assert.Contains(t, output, "test2") + }) + + t.Run("less_equal", func(t *testing.T) { + output, err := runFilter(t, "settings.foo<=7") + require.NoError(t, err) + assert.NotContains(t, output, "test1") + assert.Contains(t, output, "test2") + }) + + t.Run("float_field_vs_int_literal", func(t *testing.T) { + output, err := runFilter(t, "settings.score<5") + require.NoError(t, err) + assert.Contains(t, output, "test2") + assert.NotContains(t, output, "test1") + }) + + t.Run("float_field_greater_equal", func(t *testing.T) { + output, err := runFilter(t, "settings.score>=10") + require.NoError(t, err) + assert.Contains(t, output, "test1") + assert.NotContains(t, output, "test2") + }) + + t.Run("invalid_literal_no_match_no_error", func(t *testing.T) { + output, err := runFilter(t, "settings.foo>notanumber") + require.NoError(t, err) + assert.Empty(t, strings.TrimSpace(output)) + }) + + t.Run("string_field_ordering_no_op", func(t *testing.T) { + output, err := runFilter(t, "state>pending") + require.NoError(t, err) + assert.Empty(t, strings.TrimSpace(output)) + }) + + t.Run("string_field_ordering_disallowed_even_with_differing_values", func(t *testing.T) { + for _, filter := range []string{ + "settings.bar>hello", + "settings.bar>=hello", + "settings.barfalse", + "settings.flag>=false", + "settings.flag7") + require.NoError(t, err) + assert.Contains(t, output, "test1") + assert.NotContains(t, output, "test2") + }) +} diff --git a/internal/resource/cmd/filter.go b/internal/resource/cmd/filter.go new file mode 100644 index 00000000..58d1d197 --- /dev/null +++ b/internal/resource/cmd/filter.go @@ -0,0 +1,167 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (c) 2025, Unikraft GmbH and The Unikraft CLI Authors. +// Licensed under the BSD-3-Clause License (the "License"). +// You may not use this file except in compliance with the License. + +package cmd + +import ( + "fmt" + "reflect" + "strconv" + "time" + + "github.com/charmbracelet/x/ansi" + + "unikraft.com/cli/internal/resource" + "unikraft.com/cli/internal/resource/value" + "unikraft.com/x/filters" +) + +type fieldAdaptor struct { + children []resource.Field + field *resource.Field + entries []string +} + +func newFieldAdaptor(fields []resource.Field) filters.Adaptor { + return &fieldAdaptor{children: fields} +} + +func (a *fieldAdaptor) Select(key []string) (filters.Adaptor, bool) { + if len(key) == 0 && a.field != nil { + return a, true + } + + matched := resource.GetFieldByPath(a.children, key) + if len(matched) == 0 { + if len(key) >= 1 { + parentKey := key[:len(key)-1] + var parent *resource.Field + if len(parentKey) == 0 { + parent = a.field + } else if pm := resource.GetFieldByPath(a.children, parentKey); len(pm) == 1 { + parent = &pm[0] + } + if parent != nil { + if slice, sok := getSliceValue(parent.Value); sok { + idx, err := strconv.Atoi(key[len(key)-1]) + if err == nil && idx >= 0 && idx < len(slice) { + f := resource.Field{Name: key[len(key)-1], Value: slice[idx]} + return &fieldAdaptor{field: &f}, true + } + } + } + } + return nil, false + } + + if len(matched) == 1 { + f := matched[0] + if len(f.Subfields) > 0 { + names := make([]string, len(f.Subfields)) + for i, sub := range f.Subfields { + names[i] = sub.Name + } + return &fieldAdaptor{children: f.Subfields, field: &f, entries: names}, true + } + if slice, sok := getSliceValue(f.Value); sok { + names := make([]string, len(slice)) + for i := range slice { + names[i] = strconv.Itoa(i) + } + return &fieldAdaptor{field: &f, entries: names}, true + } + return &fieldAdaptor{field: &f}, true + } + + names := make([]string, len(matched)) + for i, f := range matched { + names[i] = f.Name + } + return &fieldAdaptor{entries: names}, true +} + +func (a *fieldAdaptor) String() string { + if a.field == nil { + return "" + } + out, _ := a.field.Render(value.RenderOpts{}) + return ansi.Strip(out) +} + +func (a *fieldAdaptor) Value() any { + if a.field == nil { + return nil + } + return a.field.Value +} + +func (a *fieldAdaptor) Entries() []string { + return a.entries +} + +func (a *fieldAdaptor) compareValue(other string) (int, bool) { + if a.field == nil || a.field.Value == nil { + return 0, false + } + parsed, err := value.ParseNew([]string{other}, a.field.Value) + if err != nil { + return 0, false + } + return value.Compare(a.field.Value, parsed), true +} + +func (a *fieldAdaptor) Equals(other string) (bool, bool) { + result, ok := a.compareValue(other) + return result == 0, ok +} + +func (a *fieldAdaptor) Compare(other string) (int, bool) { + if a.field == nil || !isOrderable(a.field.Value) { + return 0, false + } + return a.compareValue(other) +} + +func isOrderable(v any) bool { + if v == nil { + return false + } + rv := reflect.ValueOf(v) + for rv.Kind() == reflect.Pointer { + if rv.IsNil() { + return false + } + rv = rv.Elem() + } + switch rv.Kind() { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, + reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, + reflect.Float32, reflect.Float64: + return true + } + return rv.Type().ConvertibleTo(reflect.TypeFor[time.Time]()) +} + +func getSliceValue(value any) ([]string, bool) { + if value == nil { + return nil, false + } + rv := reflect.ValueOf(value) + if rv.Kind() != reflect.Slice { + return nil, false + } + result := make([]string, rv.Len()) + for i := range rv.Len() { + elem := rv.Index(i) + if s, ok := elem.Interface().(string); ok { + result[i] = s + } else if s, ok := elem.Interface().(fmt.Stringer); ok { + result[i] = s.String() + } else { + result[i] = fmt.Sprintf("%v", elem.Interface()) + } + } + return result, true +} diff --git a/internal/resource/testing/resource.go b/internal/resource/testing/resource.go index 3e225a7e..37eae2ca 100644 --- a/internal/resource/testing/resource.go +++ b/internal/resource/testing/resource.go @@ -65,6 +65,7 @@ type TestResource struct { Lazy string // Computed via callback when requested Settings TestSettings Authors []TestAuthor + Tags []string } var ( @@ -76,8 +77,10 @@ var ( ) type TestSettings struct { - Foo int - Bar string + Foo int + Bar string + Score float64 + Flag bool } type TestAuthor struct { @@ -210,6 +213,16 @@ func (t TestResource) Fields(ctx context.Context) ([]resource.Field, error) { Set: t.Settings.Bar, // Use actual value }, }, + { + Name: "score", + Value: t.Settings.Score, + Verbosity: resource.FieldVerbosityLong, + }, + { + Name: "flag", + Value: t.Settings.Flag, + Verbosity: resource.FieldVerbosityLong, + }, }, }, { @@ -250,6 +263,11 @@ func (t TestResource) Fields(ctx context.Context) ([]resource.Field, error) { return fields }(), }, + { + Name: "tags", + Value: t.Tags, + Verbosity: resource.FieldVerbosityLong, + }, }, nil }