Skip to content

Commit 15a2899

Browse files
wannteclaude
andcommitted
Add prefix matching to databricks bundle open
Allow users to type a prefix instead of a full resource key. If the prefix uniquely identifies a resource it is auto-resolved; if multiple resources match, an interactive prompt is shown (or an error with candidates in non-interactive mode). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 19b4057 commit 15a2899

4 files changed

Lines changed: 252 additions & 1 deletion

File tree

bundle/resources/lookup.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package resources
22

33
import (
44
"fmt"
5+
"strings"
56

67
"github.com/databricks/cli/bundle/config/resources"
78

@@ -98,3 +99,15 @@ func Lookup(b *bundle.Bundle, key string, filters ...Filter) (Reference, error)
9899
panic("unreachable")
99100
}
100101
}
102+
103+
// LookupByPrefix returns all resources whose key starts with the given prefix.
104+
func LookupByPrefix(b *bundle.Bundle, prefix string, filters ...Filter) []Reference {
105+
keyOnly, _ := References(b, filters...)
106+
var matches []Reference
107+
for k, refs := range keyOnly {
108+
if strings.HasPrefix(k, prefix) {
109+
matches = append(matches, refs...)
110+
}
111+
}
112+
return matches
113+
}

bundle/resources/lookup_test.go

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package resources
22

33
import (
4+
"sort"
45
"testing"
56

67
"github.com/databricks/cli/bundle"
@@ -128,3 +129,109 @@ func TestLookup_NominalWithFilters(t *testing.T) {
128129
require.Error(t, err)
129130
assert.ErrorContains(t, err, `resource with key "bar" not found`)
130131
}
132+
133+
func TestLookupByPrefix_NoMatches(t *testing.T) {
134+
b := &bundle.Bundle{
135+
Config: config.Root{
136+
Resources: config.Resources{
137+
Jobs: map[string]*resources.Job{
138+
"foo": {},
139+
"bar": {},
140+
},
141+
},
142+
},
143+
}
144+
145+
matches := LookupByPrefix(b, "qux")
146+
assert.Empty(t, matches)
147+
}
148+
149+
func TestLookupByPrefix_SingleMatch(t *testing.T) {
150+
b := &bundle.Bundle{
151+
Config: config.Root{
152+
Resources: config.Resources{
153+
Jobs: map[string]*resources.Job{
154+
"foo_job": {
155+
JobSettings: jobs.JobSettings{Name: "Foo job"},
156+
},
157+
"bar_job": {},
158+
},
159+
},
160+
},
161+
}
162+
163+
matches := LookupByPrefix(b, "foo")
164+
require.Len(t, matches, 1)
165+
assert.Equal(t, "foo_job", matches[0].Key)
166+
assert.Equal(t, "Foo job", matches[0].Resource.GetName())
167+
}
168+
169+
func TestLookupByPrefix_MultipleMatches(t *testing.T) {
170+
b := &bundle.Bundle{
171+
Config: config.Root{
172+
Resources: config.Resources{
173+
Jobs: map[string]*resources.Job{
174+
"my_job_1": {},
175+
"my_job_2": {},
176+
"other": {},
177+
},
178+
},
179+
},
180+
}
181+
182+
matches := LookupByPrefix(b, "my_")
183+
require.Len(t, matches, 2)
184+
185+
keys := []string{matches[0].Key, matches[1].Key}
186+
sort.Strings(keys)
187+
assert.Equal(t, []string{"my_job_1", "my_job_2"}, keys)
188+
}
189+
190+
func TestLookupByPrefix_WithFilters(t *testing.T) {
191+
b := &bundle.Bundle{
192+
Config: config.Root{
193+
Resources: config.Resources{
194+
Jobs: map[string]*resources.Job{
195+
"my_job": {},
196+
},
197+
Pipelines: map[string]*resources.Pipeline{
198+
"my_pipeline": {},
199+
},
200+
},
201+
},
202+
}
203+
204+
includeJobs := func(ref Reference) bool {
205+
_, ok := ref.Resource.(*resources.Job)
206+
return ok
207+
}
208+
209+
matches := LookupByPrefix(b, "my_", includeJobs)
210+
require.Len(t, matches, 1)
211+
assert.Equal(t, "my_job", matches[0].Key)
212+
}
213+
214+
func TestLookupByPrefix_ExactPrefixMatchesAll(t *testing.T) {
215+
b := &bundle.Bundle{
216+
Config: config.Root{
217+
Resources: config.Resources{
218+
Jobs: map[string]*resources.Job{
219+
"foo": {},
220+
"foobar": {},
221+
"foobaz": {},
222+
"another": {},
223+
},
224+
},
225+
},
226+
}
227+
228+
matches := LookupByPrefix(b, "foo")
229+
require.Len(t, matches, 3)
230+
231+
keys := make([]string, len(matches))
232+
for i, m := range matches {
233+
keys[i] = m.Key
234+
}
235+
sort.Strings(keys)
236+
assert.Equal(t, []string{"foo", "foobar", "foobaz"}, keys)
237+
}

cmd/bundle/open.go

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,40 @@ func resolveOpenArgument(ctx context.Context, b *bundle.Bundle, args []string) (
4545
return "", errors.New("expected a KEY of the resource to open")
4646
}
4747

48-
return args[0], nil
48+
arg := args[0]
49+
50+
// Check for an exact match first.
51+
completions := resources.Completions(b)
52+
if _, ok := completions[arg]; ok {
53+
return arg, nil
54+
}
55+
56+
// Check for prefix matches.
57+
matches := resources.LookupByPrefix(b, arg)
58+
switch {
59+
case len(matches) == 1:
60+
return matches[0].Key, nil
61+
case len(matches) > 1:
62+
if cmdio.IsPromptSupported(ctx) {
63+
// Show a filtered prompt with only matching resources.
64+
inv := make(map[string]string)
65+
for _, ref := range matches {
66+
title := fmt.Sprintf("%s: %s", ref.Description.SingularTitle, ref.Resource.GetName())
67+
inv[title] = ref.Key
68+
}
69+
return cmdio.Select(ctx, inv, "Resource to open")
70+
}
71+
72+
// Non-interactive: return error listing candidates.
73+
keys := make([]string, 0, len(matches))
74+
for _, ref := range matches {
75+
keys = append(keys, ref.Key)
76+
}
77+
return "", fmt.Errorf("multiple resources match prefix %q: %v", arg, keys)
78+
}
79+
80+
// No matches; return the arg as-is and let Lookup handle the "not found" error.
81+
return arg, nil
4982
}
5083

5184
func newOpenCommand() *cobra.Command {

cmd/bundle/open_test.go

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
package bundle
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/databricks/cli/bundle"
8+
"github.com/databricks/cli/bundle/config"
9+
"github.com/databricks/cli/bundle/config/resources"
10+
"github.com/databricks/cli/libs/cmdio"
11+
"github.com/databricks/databricks-sdk-go/service/jobs"
12+
"github.com/stretchr/testify/assert"
13+
"github.com/stretchr/testify/require"
14+
)
15+
16+
func TestResolveOpenArgument_NoArgs_NonInteractive(t *testing.T) {
17+
ctx := cmdio.MockDiscard(context.Background())
18+
b := &bundle.Bundle{}
19+
20+
_, err := resolveOpenArgument(ctx, b, nil)
21+
require.Error(t, err)
22+
assert.ErrorContains(t, err, "expected a KEY of the resource to open")
23+
}
24+
25+
func TestResolveOpenArgument_ExactMatch(t *testing.T) {
26+
ctx := cmdio.MockDiscard(context.Background())
27+
b := &bundle.Bundle{
28+
Config: config.Root{
29+
Resources: config.Resources{
30+
Jobs: map[string]*resources.Job{
31+
"my_job": {JobSettings: jobs.JobSettings{Name: "My Job"}},
32+
"my_job_2": {JobSettings: jobs.JobSettings{Name: "My Job 2"}},
33+
},
34+
},
35+
},
36+
}
37+
38+
key, err := resolveOpenArgument(ctx, b, []string{"my_job"})
39+
require.NoError(t, err)
40+
assert.Equal(t, "my_job", key)
41+
}
42+
43+
func TestResolveOpenArgument_PrefixSingleMatch(t *testing.T) {
44+
ctx := cmdio.MockDiscard(context.Background())
45+
b := &bundle.Bundle{
46+
Config: config.Root{
47+
Resources: config.Resources{
48+
Jobs: map[string]*resources.Job{
49+
"foo_job": {JobSettings: jobs.JobSettings{Name: "Foo Job"}},
50+
"bar_job": {JobSettings: jobs.JobSettings{Name: "Bar Job"}},
51+
},
52+
},
53+
},
54+
}
55+
56+
key, err := resolveOpenArgument(ctx, b, []string{"foo"})
57+
require.NoError(t, err)
58+
assert.Equal(t, "foo_job", key)
59+
}
60+
61+
func TestResolveOpenArgument_PrefixMultipleMatches_NonInteractive(t *testing.T) {
62+
ctx := cmdio.MockDiscard(context.Background())
63+
b := &bundle.Bundle{
64+
Config: config.Root{
65+
Resources: config.Resources{
66+
Jobs: map[string]*resources.Job{
67+
"my_job_1": {JobSettings: jobs.JobSettings{Name: "My Job 1"}},
68+
"my_job_2": {JobSettings: jobs.JobSettings{Name: "My Job 2"}},
69+
"other": {JobSettings: jobs.JobSettings{Name: "Other"}},
70+
},
71+
},
72+
},
73+
}
74+
75+
_, err := resolveOpenArgument(ctx, b, []string{"my_"})
76+
require.Error(t, err)
77+
assert.ErrorContains(t, err, "multiple resources match prefix")
78+
assert.ErrorContains(t, err, "my_job_1")
79+
assert.ErrorContains(t, err, "my_job_2")
80+
}
81+
82+
func TestResolveOpenArgument_PrefixNoMatch(t *testing.T) {
83+
ctx := cmdio.MockDiscard(context.Background())
84+
b := &bundle.Bundle{
85+
Config: config.Root{
86+
Resources: config.Resources{
87+
Jobs: map[string]*resources.Job{
88+
"foo": {JobSettings: jobs.JobSettings{Name: "Foo"}},
89+
},
90+
},
91+
},
92+
}
93+
94+
// No prefix match; returns the arg as-is.
95+
key, err := resolveOpenArgument(ctx, b, []string{"zzz"})
96+
require.NoError(t, err)
97+
assert.Equal(t, "zzz", key)
98+
}

0 commit comments

Comments
 (0)