From b7c63bdf7c58220b70f7ef2728ee86772887d4e2 Mon Sep 17 00:00:00 2001 From: Mike Sawka Date: Tue, 25 Aug 2026 22:46:41 -0700 Subject: [PATCH] new id part default fn for resolving parent ids based on environment (pwd, configs etc) --- modules/code/insight_test.go | 2 + modules/gitops/gitops_test.go | 2 + pkg/cmdctx/cmdctx.go | 6 +++ pkg/registry/buildctx.go | 72 ++++++++++++++++++++++++++++----- pkg/registry/checks.go | 5 +++ pkg/registry/completion.go | 46 +++++++++++++++++---- pkg/registry/fields_test.go | 1 + pkg/registry/moduleregistrar.go | 10 +++++ pkg/registry/registry.go | 15 +++++++ pkg/spec/spec.go | 5 +++ 10 files changed, 146 insertions(+), 18 deletions(-) diff --git a/modules/code/insight_test.go b/modules/code/insight_test.go index 4b4e768..03ce682 100644 --- a/modules/code/insight_test.go +++ b/modules/code/insight_test.go @@ -28,6 +28,7 @@ func (noopResolver) ResolveTextFormatter(id string) cmdctx.TextFormatterFn func (noopResolver) ResolveBodyFn(id string) cmdctx.CreateBodyFn { return nil } func (noopResolver) ResolveQueryParamsFn(id string) cmdctx.QueryParamsFn { return nil } func (noopResolver) ResolveFlagResolveFn(id string) cmdctx.FlagResolveFn { return nil } +func (noopResolver) ResolveIdPartDefaultFn(id string) cmdctx.IdPartDefaultFn { return nil } func (noopResolver) ResolveFetchFn(id string) (cmdctx.FetchFn, error) { return nil, nil } func (noopResolver) ResolveListTransformFn(id string) cmdctx.ListTransformFn { return nil } func (noopResolver) ResolveEndpointValidator(id string) cmdctx.EndpointValidatorFn { return nil } @@ -375,4 +376,5 @@ func (s *moduleInitSpy) RegisterFetchFn(string, cmdctx.FetchFn) func (s *moduleInitSpy) RegisterListTransformFn(string, cmdctx.ListTransformFn) {} func (s *moduleInitSpy) RegisterFlagCompletionFn(string, registry.FlagCompletionFn) {} func (s *moduleInitSpy) RegisterFlagResolveFn(string, cmdctx.FlagResolveFn) {} +func (s *moduleInitSpy) RegisterIdPartDefaultFn(string, cmdctx.IdPartDefaultFn) {} func (s *moduleInitSpy) RegisterEndpointValidatorFn(string, cmdctx.EndpointValidatorFn) {} diff --git a/modules/gitops/gitops_test.go b/modules/gitops/gitops_test.go index db7863b..89030d5 100644 --- a/modules/gitops/gitops_test.go +++ b/modules/gitops/gitops_test.go @@ -29,6 +29,7 @@ func (noopResolver) ResolveTextFormatter(id string) cmdctx.TextFormatterFn func (noopResolver) ResolveBodyFn(id string) cmdctx.CreateBodyFn { return nil } func (noopResolver) ResolveQueryParamsFn(id string) cmdctx.QueryParamsFn { return nil } func (noopResolver) ResolveFlagResolveFn(id string) cmdctx.FlagResolveFn { return nil } +func (noopResolver) ResolveIdPartDefaultFn(id string) cmdctx.IdPartDefaultFn { return nil } func (noopResolver) ResolveFetchFn(id string) (cmdctx.FetchFn, error) { return nil, nil } func (noopResolver) ResolveListTransformFn(id string) cmdctx.ListTransformFn { return nil } func (noopResolver) ResolveEndpointValidator(id string) cmdctx.EndpointValidatorFn { return nil } @@ -382,4 +383,5 @@ func (s *moduleInitSpy) RegisterFetchFn(string, cmdctx.FetchFn) func (s *moduleInitSpy) RegisterListTransformFn(string, cmdctx.ListTransformFn) {} func (s *moduleInitSpy) RegisterFlagCompletionFn(string, registry.FlagCompletionFn) {} func (s *moduleInitSpy) RegisterFlagResolveFn(string, cmdctx.FlagResolveFn) {} +func (s *moduleInitSpy) RegisterIdPartDefaultFn(string, cmdctx.IdPartDefaultFn) {} func (s *moduleInitSpy) RegisterEndpointValidatorFn(string, cmdctx.EndpointValidatorFn) {} diff --git a/pkg/cmdctx/cmdctx.go b/pkg/cmdctx/cmdctx.go index 93228b4..c4a8ac4 100644 --- a/pkg/cmdctx/cmdctx.go +++ b/pkg/cmdctx/cmdctx.go @@ -106,6 +106,11 @@ type RawBody struct { // flag value as an empty string. Returning an error aborts the command. type FlagResolveFn func(ctx *Ctx, raw string) (string, error) +// IdPartDefaultFn supplies a value for a missing/sentinel id_part, derived from +// ctx and the environment (e.g. the current directory's git remote) rather than +// from user input — see spec.IdPartDefaultFn and spec.IdPartSentinel. +type IdPartDefaultFn func(ctx *Ctx) (string, error) + // Resolver looks up registered handler functions by their fully-qualified ID. // The registry implements this; commands receive it via Ctx.Resolver. type Resolver interface { @@ -113,6 +118,7 @@ type Resolver interface { ResolveBodyFn(id string) CreateBodyFn ResolveQueryParamsFn(id string) QueryParamsFn ResolveFlagResolveFn(id string) FlagResolveFn + ResolveIdPartDefaultFn(id string) IdPartDefaultFn ResolveFetchFn(id string) (FetchFn, error) ResolveListTransformFn(id string) ListTransformFn ResolveEndpointValidator(id string) EndpointValidatorFn diff --git a/pkg/registry/buildctx.go b/pkg/registry/buildctx.go index f02bd70..1dd0c60 100644 --- a/pkg/registry/buildctx.go +++ b/pkg/registry/buildctx.go @@ -228,7 +228,7 @@ func buildCtx(cmd *cobra.Command, cs *spec.CommandSpec, args []string, r *Regist ctx.Level = levelFlag } } - if err := validateIdParts(cs, vspec, ctx); err != nil { + if err := r.validateIdParts(cs, vspec, ctx); err != nil { return nil, err } if !cs.NoAuth { @@ -346,9 +346,10 @@ func resolveFlagValues(ctx *cmdctx.Ctx, cs *spec.CommandSpec) error { return nil } -func validateIdParts(cs *spec.CommandSpec, vspec VerbSpec, ctx *cmdctx.Ctx) error { +func (r *Registry) validateIdParts(cs *spec.CommandSpec, vspec VerbSpec, ctx *cmdctx.Ctx) error { val, label := ctx.Id, cs.IdLabel - if vspec.AllowsParentId { + isParent := vspec.AllowsParentId + if isParent { val = ctx.ParentId if cs.ParentIdLabel != "" { label = "<" + cs.ParentIdLabel + ">" @@ -357,18 +358,69 @@ func validateIdParts(cs *spec.CommandSpec, vspec VerbSpec, ctx *cmdctx.Ctx) erro if label == "" { label = "" } + setVal := func(v string) { + if isParent { + ctx.ParentId = v + } else { + ctx.Id = v + } + } + + // Whole id/parentid (id_parts <= 1): the default fn fires on omission or the + // explicit sentinel. "" only reaches here for commands that already treat a + // missing id/parentid as legal — RequiresId commands error before this point. + if cs.IdPartDefaultFn != "" && cs.IdParts <= 1 && (val == "" || val == spec.IdPartSentinel) { + resolved, err := r.resolveIdPartDefault(cs, ctx) + if err != nil { + return fmt.Errorf("%s: %w", label, err) + } + val = resolved + setVal(val) + } + if val == "" || cs.IdAllowSlash { return nil } - allowed := max(cs.IdParts-1, 0) - if got := strings.Count(val, "/"); got > allowed { - if cs.IdParts > 1 { - return fmt.Errorf("expected %s with exactly %d parts separated by '/', got %q", label, cs.IdParts, val) + + if cs.IdParts <= 1 { + if strings.Contains(val, "/") { + return fmt.Errorf("%s %s: %s must not contain '/' (got %q)", cs.Verb, cs.Noun, label, val) } - return fmt.Errorf("%s %s: %s must not contain '/' (got %q)", cs.Verb, cs.Noun, label, val) + return nil } - if cs.IdParts > 1 { - ctx.IdParts = strings.SplitN(val, "/", cs.IdParts) + + // Split id (id_parts > 1): the leading part defaults on an explicit sentinel + // ("./42") or on a bare, slash-free value ("42" instead of "/42"). + allowed := cs.IdParts - 1 + got := strings.Count(val, "/") + switch { + case got == allowed-1 && cs.IdPartDefaultFn != "": + resolved, err := r.resolveIdPartDefault(cs, ctx) + if err != nil { + return fmt.Errorf("%s: %w", label, err) + } + ctx.IdParts = append([]string{resolved}, strings.Split(val, "/")...) + case got == allowed: + parts := strings.SplitN(val, "/", cs.IdParts) + if cs.IdPartDefaultFn != "" && parts[0] == spec.IdPartSentinel { + resolved, err := r.resolveIdPartDefault(cs, ctx) + if err != nil { + return fmt.Errorf("%s: %w", label, err) + } + parts[0] = resolved + } + ctx.IdParts = parts + default: + return fmt.Errorf("expected %s with exactly %d parts separated by '/', got %q", label, cs.IdParts, val) } return nil } + +// resolveIdPartDefault invokes the command's registered id_part_default_fn. +func (r *Registry) resolveIdPartDefault(cs *spec.CommandSpec, ctx *cmdctx.Ctx) (string, error) { + fn := r.ResolveIdPartDefaultFn(cs.IdPartDefaultFn) + if fn == nil { + return "", fmt.Errorf("id_part_default_fn %q not registered", cs.IdPartDefaultFn) + } + return fn(ctx) +} diff --git a/pkg/registry/checks.go b/pkg/registry/checks.go index 567e536..903b5a7 100644 --- a/pkg/registry/checks.go +++ b/pkg/registry/checks.go @@ -119,6 +119,11 @@ func (r *Registry) checkFunctionsSpec(cs *spec.CommandSpec) []string { errs = append(errs, fmt.Sprintf("command %q: follow_fn %q not registered", cs.Command, cs.FollowFn)) } } + if cs.IdPartDefaultFn != "" { + if _, ok := r.idPartDefaultFns[cs.IdPartDefaultFn]; !ok { + errs = append(errs, fmt.Sprintf("command %q: id_part_default_fn %q not registered", cs.Command, cs.IdPartDefaultFn)) + } + } for _, f := range cs.Flags { if f.CompletionFn != "" { if _, ok := r.flagCompletionFns[f.CompletionFn]; !ok { diff --git a/pkg/registry/completion.go b/pkg/registry/completion.go index 4930a72..e9cfe89 100644 --- a/pkg/registry/completion.go +++ b/pkg/registry/completion.go @@ -189,12 +189,42 @@ func (r *Registry) wireSeqCompletion(cmd *cobra.Command, cs *spec.CommandSpec) { hlog.Debug("completion: seq beyond last step", "stepIdx", stepIdx, "numSteps", len(steps)) return nil, cobra.ShellCompDirectiveNoFileComp } - step := steps[stepIdx] - prefix := strings.Join(parts[:stepIdx], "/") - if prefix != "" { - prefix += "/" + queryParentId := strings.Join(parts[:stepIdx], "/") + queryStepIdx := stepIdx + outputPrefix := queryParentId + if outputPrefix != "" { + outputPrefix += "/" } - hlog.Debug("completion: seq step", "stepIdx", stepIdx, "stepNoun", step.CompletionNoun, "prefix", prefix) + + // A leading id_part can be defaulted from context (id_part_default_fn): + // either it's missing entirely (stepIdx==0, nothing typed for it yet) or + // the user typed the explicit sentinel "." for it. Either way the query + // needs a real value; outputPrefix is left untouched — it always echoes + // back exactly what the user already typed. + if cs.IdPartDefaultFn != "" { + resolveOnce := func() (string, bool) { + dctx, err := r.buildCompletionCtx(cmd, cs.Verb, cs.Noun, "") + if err != nil { + return "", false + } + resolved, err := r.resolveIdPartDefault(cs, dctx) + return resolved, err == nil + } + switch { + case stepIdx == 0 && len(steps) > 1: + if resolved, ok := resolveOnce(); ok { + queryStepIdx = 1 + queryParentId = resolved + } + case len(parts) > 0 && parts[0] == spec.IdPartSentinel: + if resolved, ok := resolveOnce(); ok { + queryParentId = resolved + } + } + } + + step := steps[queryStepIdx] + hlog.Debug("completion: seq step", "stepIdx", queryStepIdx, "stepNoun", step.CompletionNoun, "parentId", queryParentId) var completions []string if len(step.StaticValues) > 0 { @@ -208,7 +238,7 @@ func (r *Registry) wireSeqCompletion(cmd *cobra.Command, cs *spec.CommandSpec) { ep := listSpec.Endpoint cspec := ep.Completion - ctx, err := r.buildCompletionCtx(cmd, VerbList, step.CompletionNoun, strings.Join(parts[:stepIdx], "/")) + ctx, err := r.buildCompletionCtx(cmd, VerbList, step.CompletionNoun, queryParentId) if err != nil { hlog.Debug("completion error: seq buildCtx", "stepNoun", step.CompletionNoun, "err", err) return nil, cobra.ShellCompDirectiveError @@ -228,7 +258,7 @@ func (r *Registry) wireSeqCompletion(cmd *cobra.Command, cs *spec.CommandSpec) { } hlog.Debug("completion: seq result", "stepNoun", step.CompletionNoun, "items", len(completions), "completions", len(completions)) - isLastStep := stepIdx == len(steps)-1 + isLastStep := queryStepIdx == len(steps)-1 directive := cobra.ShellCompDirectiveNoFileComp if step.KeepOrder { directive |= cobra.ShellCompDirectiveKeepOrder @@ -248,7 +278,7 @@ func (r *Registry) wireSeqCompletion(cmd *cobra.Command, cs *spec.CommandSpec) { c += "/" } } - completions[i] = prefix + c + completions[i] = outputPrefix + c } return completions, directive } diff --git a/pkg/registry/fields_test.go b/pkg/registry/fields_test.go index 5c9e985..bd98102 100644 --- a/pkg/registry/fields_test.go +++ b/pkg/registry/fields_test.go @@ -100,6 +100,7 @@ func (tr *testResolver) ResolveQueryParamsFn(id string) cmdctx.QueryParamsFn func (tr *testResolver) ResolveFetchFn(id string) (cmdctx.FetchFn, error) { return nil, nil } func (tr *testResolver) ResolveListTransformFn(id string) cmdctx.ListTransformFn { return nil } func (tr *testResolver) ResolveFlagResolveFn(id string) cmdctx.FlagResolveFn { return nil } +func (tr *testResolver) ResolveIdPartDefaultFn(id string) cmdctx.IdPartDefaultFn { return nil } func (tr *testResolver) ResolveEndpointValidator(id string) cmdctx.EndpointValidatorFn { return nil } diff --git a/pkg/registry/moduleregistrar.go b/pkg/registry/moduleregistrar.go index 07918cc..34dcd8f 100644 --- a/pkg/registry/moduleregistrar.go +++ b/pkg/registry/moduleregistrar.go @@ -34,6 +34,7 @@ type ModuleRegistrar interface { RegisterListTransformFn(shortID string, fn cmdctx.ListTransformFn) RegisterFlagCompletionFn(shortID string, fn FlagCompletionFn) RegisterFlagResolveFn(shortID string, fn cmdctx.FlagResolveFn) + RegisterIdPartDefaultFn(shortID string, fn cmdctx.IdPartDefaultFn) RegisterEndpointValidatorFn(shortID string, fn cmdctx.EndpointValidatorFn) } @@ -89,6 +90,9 @@ func (m *moduleRegistrar) Register(cs *spec.CommandSpec) error { if cs.FollowFn != "" { cs.FollowFn = m.qualify(cs.FollowFn, cmd+" follow_fn", true) } + if cs.IdPartDefaultFn != "" { + cs.IdPartDefaultFn = m.qualify(cs.IdPartDefaultFn, cmd+" id_part_default_fn", true) + } if cs.Endpoint != nil && cs.Endpoint.FetchFn != "" { cs.Endpoint.FetchFn = m.qualify(cs.Endpoint.FetchFn, cmd+" fetch_fn", true) } @@ -174,6 +178,12 @@ func (m *moduleRegistrar) RegisterFlagResolveFn(shortID string, fn cmdctx.FlagRe } } +func (m *moduleRegistrar) RegisterIdPartDefaultFn(shortID string, fn cmdctx.IdPartDefaultFn) { + if q := m.qualify(shortID, fmt.Sprintf("id_part_default_fn %q", shortID), false); q != "" { + m.reg.RegisterIdPartDefaultFn(q, fn) + } +} + func (m *moduleRegistrar) RegisterEndpointValidatorFn(shortID string, fn cmdctx.EndpointValidatorFn) { if q := m.qualify(shortID, fmt.Sprintf("endpoint_validator_fn %q", shortID), false); q != "" { m.reg.RegisterEndpointValidatorFn(q, fn) diff --git a/pkg/registry/registry.go b/pkg/registry/registry.go index 3caf258..2131014 100644 --- a/pkg/registry/registry.go +++ b/pkg/registry/registry.go @@ -64,6 +64,7 @@ type Registry struct { listTransformFns map[string]cmdctx.ListTransformFn flagCompletionFns map[string]FlagCompletionFn flagResolveFns map[string]cmdctx.FlagResolveFn + idPartDefaultFns map[string]cmdctx.IdPartDefaultFn endpointValidatorFns map[string]cmdctx.EndpointValidatorFn initErrs []string } @@ -84,6 +85,7 @@ func New() *Registry { listTransformFns: map[string]cmdctx.ListTransformFn{}, flagCompletionFns: map[string]FlagCompletionFn{}, flagResolveFns: map[string]cmdctx.FlagResolveFn{}, + idPartDefaultFns: map[string]cmdctx.IdPartDefaultFn{}, endpointValidatorFns: map[string]cmdctx.EndpointValidatorFn{}, } r.registerCoreFormatters() @@ -398,6 +400,19 @@ func (r *Registry) RegisterFlagResolveFn(id string, fn cmdctx.FlagResolveFn) { r.flagResolveFns[id] = fn } +// ResolveIdPartDefaultFn implements cmdctx.Resolver. +func (r *Registry) ResolveIdPartDefaultFn(id string) cmdctx.IdPartDefaultFn { + return r.idPartDefaultFns[id] +} + +// RegisterIdPartDefaultFn registers a fully-qualified id_part_default_fn ID. +func (r *Registry) RegisterIdPartDefaultFn(id string, fn cmdctx.IdPartDefaultFn) { + if _, ok := r.idPartDefaultFns[id]; ok { + panic(fmt.Sprintf("registry: duplicate id_part_default_fn %q", id)) + } + r.idPartDefaultFns[id] = fn +} + // RegisterBodyFn registers a fully-qualified body constructor ID. func (r *Registry) RegisterBodyFn(id string, fn cmdctx.CreateBodyFn) { if _, ok := r.bodyFns[id]; ok { diff --git a/pkg/spec/spec.go b/pkg/spec/spec.go index 6f1e61d..5536f44 100644 --- a/pkg/spec/spec.go +++ b/pkg/spec/spec.go @@ -88,6 +88,10 @@ const ( PagingStrategyOffsetLimit = "offset_limit" // API accepts offset (items to skip) + limit; response has totalCount ) +// IdPartSentinel ("." — shell-style "current") opts a fully-specified id/parentid +// into id_part_default_fn, e.g. "." alone or "./42" for a 2-part id. +const IdPartSentinel = "." + // Valid presence values for MigrateFlag. const ( MigratePresenceRequired = "required" // flag is registered and must be provided @@ -560,6 +564,7 @@ type CommandSpec struct { ArgsLabel string `yaml:"args_label,omitempty"` // appended to Usage after the id label (e.g. ""); only used when has_args is true IdParts int `yaml:"id_parts,omitempty"` // when > 1, id must contain exactly (id_parts-1) "/" separators; parts available as {ctx:id_part:0}, {ctx:id_part:1}, ... IdAllowSlash bool `yaml:"id_allow_slash,omitempty"` // skip the slash-count validation on id (use when the id format has variable segments) + IdPartDefaultFn string `yaml:"id_part_default_fn,omitempty"` // registered IdPartDefaultFn name; supplies the leading id_part (or the whole id/parentid, for id_parts<=1) when it is omitted or given as IdPartSentinel ("."). See IdPartSentinel. RequiresParentId bool `yaml:"requires_parentid,omitempty"` // list commands only: makes the [parentid] arg mandatory ParentIdLabel string `yaml:"parentid_label,omitempty"` // overrides "[parentid]" in the Usage line for list commands (e.g. "") Hidden bool `yaml:"hidden,omitempty"`