diff --git a/internal/cmd/graph/user/list/list.go b/internal/cmd/graph/user/list/list.go index 73145f9f..4f1a141e 100644 --- a/internal/cmd/graph/user/list/list.go +++ b/internal/cmd/graph/user/list/list.go @@ -86,7 +86,7 @@ func runCmd(ctx util.CmdContext, opts *usersListOptions) error { io.StartProgressIndicator() defer io.StopProgressIndicator() - var scope *util.Scope + var scope *util.Path switch { case opts.projectName != "" && opts.organizationName != "": scope, err = util.ParseProjectScope(ctx, fmt.Sprintf("%s/%s", opts.organizationName, opts.projectName)) @@ -97,7 +97,7 @@ func runCmd(ctx util.CmdContext, opts *usersListOptions) error { if parseErr != nil { return parseErr } - scope = &util.Scope{Organization: org} + scope = &util.Path{Organization: org} } if err != nil { return util.FlagErrorWrap(err) diff --git a/internal/cmd/pipelines/variablegroup/create/create.go b/internal/cmd/pipelines/variablegroup/create/create.go index 18d089db..73b8fd7e 100644 --- a/internal/cmd/pipelines/variablegroup/create/create.go +++ b/internal/cmd/pipelines/variablegroup/create/create.go @@ -126,7 +126,7 @@ func run(cmdCtx util.CmdContext, opts *options) error { return util.FlagErrorWrap(err) } - groupName := strings.TrimSpace(target.Target) + groupName := strings.TrimSpace(target.Targets[0]) if groupName == "" { return util.FlagErrorf("variable group name cannot be empty") } @@ -168,7 +168,7 @@ func run(cmdCtx util.CmdContext, opts *options) error { var providerData any if keyVaultRequested { - providerData, err = buildKeyVaultProviderData(cmdCtx, target.Scope, opts) + providerData, err = buildKeyVaultProviderData(cmdCtx, *target, opts) if err != nil { return util.FlagErrorWrap(err) } @@ -329,7 +329,7 @@ func buildKeyVaultVariables(opts *options) (*map[string]any, error) { func buildKeyVaultProviderData( cmdCtx util.CmdContext, - scope util.Scope, + scope util.Path, opts *options, ) (*taskagent.AzureKeyVaultVariableGroupProviderData, error) { if opts.keyVaultServiceEndpoint == "" { diff --git a/internal/cmd/pipelines/variablegroup/delete/delete.go b/internal/cmd/pipelines/variablegroup/delete/delete.go index fc17e2e7..5a7bf8e0 100644 --- a/internal/cmd/pipelines/variablegroup/delete/delete.go +++ b/internal/cmd/pipelines/variablegroup/delete/delete.go @@ -92,7 +92,7 @@ func run(cmdCtx util.CmdContext, opts *options) error { return fmt.Errorf("failed to create task agent client: %w", err) } - group, err := shared.ResolveVariableGroup(cmdCtx, taskClient, scope.Project, scope.Target) + group, err := shared.ResolveVariableGroup(cmdCtx, taskClient, scope.Project, scope.Targets[0]) if err != nil { return err } @@ -101,7 +101,7 @@ func run(cmdCtx util.CmdContext, opts *options) error { return fmt.Errorf("resolved variable group is missing an ID") } groupID := *group.Id - groupName := types.GetValue(group.Name, scope.Target) + groupName := types.GetValue(group.Name, scope.Targets[0]) projectIndex := buildProjectIndex(group) projectIDs, err := selectProjectIDs(projectIndex, scope.Project, opts.projectReferences, opts.all) @@ -115,7 +115,7 @@ func run(cmdCtx util.CmdContext, opts *options) error { zap.L().Debug("resolved variable group", zap.String("organization", scope.Organization), zap.String("project", scope.Project), - zap.String("input", scope.Target), + zap.String("input", scope.Targets[0]), zap.Int("groupId", groupID), zap.String("name", groupName), ) diff --git a/internal/cmd/pipelines/variablegroup/show/show.go b/internal/cmd/pipelines/variablegroup/show/show.go index 7462cfa2..382d81cf 100644 --- a/internal/cmd/pipelines/variablegroup/show/show.go +++ b/internal/cmd/pipelines/variablegroup/show/show.go @@ -121,17 +121,17 @@ func run(cmdCtx util.CmdContext, o *opts) error { logger := zap.L().With( zap.String("organization", scope.Organization), zap.String("project", scope.Project), - zap.String("variableGroup", scope.Target), + zap.String("variableGroup", scope.Targets[0]), ) logger.Debug("resolving variable group identifier") - group, err := shared.ResolveVariableGroup(cmdCtx, taskClient, scope.Project, scope.Target) + group, err := shared.ResolveVariableGroup(cmdCtx, taskClient, scope.Project, scope.Targets[0]) if err != nil { return err } if group == nil || group.Id == nil { - return fmt.Errorf("variable group %q not found", scope.Target) + return fmt.Errorf("variable group %q not found", scope.Targets[0]) } permissionsClient, err := cmdCtx.ClientFactory().PipelinePermissions(cmdCtx.Context(), scope.Organization) diff --git a/internal/cmd/pipelines/variablegroup/update/update.go b/internal/cmd/pipelines/variablegroup/update/update.go index 5024c477..1441aa9b 100644 --- a/internal/cmd/pipelines/variablegroup/update/update.go +++ b/internal/cmd/pipelines/variablegroup/update/update.go @@ -136,12 +136,12 @@ func run(cmdCtx util.CmdContext, o *opts) error { } // resolve variable group - group, err := shared.ResolveVariableGroup(cmdCtx, taskClient, scope.Project, scope.Target) + group, err := shared.ResolveVariableGroup(cmdCtx, taskClient, scope.Project, scope.Targets[0]) if err != nil { return err } if group == nil || group.Id == nil { - return fmt.Errorf("variable group %q not found", scope.Target) + return fmt.Errorf("variable group %q not found", scope.Targets[0]) } var updatedGroup *taskagent.VariableGroup diff --git a/internal/cmd/pipelines/variablegroup/variable/create/create.go b/internal/cmd/pipelines/variablegroup/variable/create/create.go index b1e89e5c..3f42644e 100644 --- a/internal/cmd/pipelines/variablegroup/variable/create/create.go +++ b/internal/cmd/pipelines/variablegroup/variable/create/create.go @@ -73,12 +73,12 @@ func run(cmdCtx util.CmdContext, opts *opts) error { return fmt.Errorf("failed to create task agent client: %w", err) } - group, err := shared.ResolveVariableGroup(cmdCtx, taskClient, scope.Project, scope.Target) + group, err := shared.ResolveVariableGroup(cmdCtx, taskClient, scope.Project, scope.Targets[0]) if err != nil { return err } if group == nil { - return fmt.Errorf("variable group %q not found", scope.Target) + return fmt.Errorf("variable group %q not found", scope.Targets[0]) } if group.Type != nil && strings.EqualFold(types.GetValue(group.Type, ""), "AzureKeyVault") { return util.FlagErrorf("cannot add variables to an Azure Key Vault-backed variable group") @@ -88,7 +88,7 @@ func run(cmdCtx util.CmdContext, opts *opts) error { if group.Variables != nil { for k := range *group.Variables { if strings.EqualFold(k, opts.name) { - return util.FlagErrorf("variable %q already exists in group %q", opts.name, types.GetValue(group.Name, scope.Target)) + return util.FlagErrorf("variable %q already exists in group %q", opts.name, types.GetValue(group.Name, scope.Targets[0])) } } } diff --git a/internal/cmd/pipelines/variablegroup/variable/delete/delete.go b/internal/cmd/pipelines/variablegroup/variable/delete/delete.go index 212bd93a..dfcb47b4 100644 --- a/internal/cmd/pipelines/variablegroup/variable/delete/delete.go +++ b/internal/cmd/pipelines/variablegroup/variable/delete/delete.go @@ -65,19 +65,19 @@ func run(cmdCtx util.CmdContext, opts *opts) error { return fmt.Errorf("failed to create task agent client: %w", err) } - group, err := shared.ResolveVariableGroup(cmdCtx, taskClient, scope.Project, scope.Target) + group, err := shared.ResolveVariableGroup(cmdCtx, taskClient, scope.Project, scope.Targets[0]) if err != nil { return err } if group == nil { - return fmt.Errorf("variable group %q not found", scope.Target) + return fmt.Errorf("variable group %q not found", scope.Targets[0]) } if group.Id == nil { return fmt.Errorf("resolved variable group is missing an ID") } groupID := *group.Id - groupName := types.GetValue(group.Name, scope.Target) + groupName := types.GetValue(group.Name, scope.Targets[0]) // Find variable key case-insensitively var foundKey string diff --git a/internal/cmd/pipelines/variablegroup/variable/list/list.go b/internal/cmd/pipelines/variablegroup/variable/list/list.go index b7650f44..d2813867 100644 --- a/internal/cmd/pipelines/variablegroup/variable/list/list.go +++ b/internal/cmd/pipelines/variablegroup/variable/list/list.go @@ -68,7 +68,7 @@ func run(ctx util.CmdContext, opts *opts) error { } // Determine if groupIdentifier is ID or Name - groupID, err := strconv.Atoi(scope.Target) + groupID, err := strconv.Atoi(scope.Targets[0]) if err == nil && groupID < 0 { return util.FlagErrorf("Invalid group id %d", groupID) } else if err != nil { @@ -92,10 +92,10 @@ func run(ctx util.CmdContext, opts *opts) error { }, }) } else { - zap.L().Debug("Fetching variable group by name", zap.String("groupName", scope.Target)) + zap.L().Debug("Fetching variable group by name", zap.String("groupName", scope.Targets[0])) g, err = client.GetVariableGroups(ctx.Context(), taskagent.GetVariableGroupsArgs{ Project: &scope.Project, - GroupName: &scope.Target, + GroupName: &scope.Targets[0], }) } if err != nil { diff --git a/internal/cmd/pipelines/variablegroup/variable/update/update.go b/internal/cmd/pipelines/variablegroup/variable/update/update.go index 21788a1a..49e821c0 100644 --- a/internal/cmd/pipelines/variablegroup/variable/update/update.go +++ b/internal/cmd/pipelines/variablegroup/variable/update/update.go @@ -86,12 +86,12 @@ func run(cmdCtx util.CmdContext, cmd *cobra.Command, opts *opts) error { return fmt.Errorf("failed to create task agent client: %w", err) } - group, err := shared.ResolveVariableGroup(cmdCtx, taskClient, scope.Project, scope.Target) + group, err := shared.ResolveVariableGroup(cmdCtx, taskClient, scope.Project, scope.Targets[0]) if err != nil { return err } if group == nil { - return util.FlagErrorf("variable group %q not found", scope.Target) + return util.FlagErrorf("variable group %q not found", scope.Targets[0]) } // Load --from-json if provided @@ -174,7 +174,7 @@ func run(cmdCtx util.CmdContext, cmd *cobra.Command, opts *opts) error { } } if origKey == "" { - return util.FlagErrorf("variable %q not found in group %q", opts.name, types.GetValue(group.Name, scope.Target)) + return util.FlagErrorf("variable %q not found in group %q", opts.name, types.GetValue(group.Name, scope.Targets[0])) } // Detect if this is an Azure Key Vault-backed group @@ -229,7 +229,7 @@ func run(cmdCtx util.CmdContext, cmd *cobra.Command, opts *opts) error { // check collision for k := range newVars { if strings.EqualFold(k, *fromPayload.NewName) && !strings.EqualFold(k, origKey) { - return util.FlagErrorf("variable %q already exists in group %q", *fromPayload.NewName, types.GetValue(group.Name, scope.Target)) + return util.FlagErrorf("variable %q already exists in group %q", *fromPayload.NewName, types.GetValue(group.Name, scope.Targets[0])) } } finalKey = *fromPayload.NewName @@ -237,7 +237,7 @@ func run(cmdCtx util.CmdContext, cmd *cobra.Command, opts *opts) error { // check collision for k := range newVars { if strings.EqualFold(k, opts.newName) && !strings.EqualFold(k, origKey) { - return util.FlagErrorf("variable %q already exists in group %q", opts.newName, types.GetValue(group.Name, scope.Target)) + return util.FlagErrorf("variable %q already exists in group %q", opts.newName, types.GetValue(group.Name, scope.Targets[0])) } } finalKey = opts.newName @@ -321,7 +321,7 @@ func run(cmdCtx util.CmdContext, cmd *cobra.Command, opts *opts) error { if err != nil { return err } - ok, err := prompter.Confirm(fmt.Sprintf("Clear value of variable '%s' in group '%s'?", opts.name, types.GetValue(group.Name, scope.Target)), false) + ok, err := prompter.Confirm(fmt.Sprintf("Clear value of variable '%s' in group '%s'?", opts.name, types.GetValue(group.Name, scope.Targets[0])), false) ios.StartProgressIndicator() if err != nil { return err diff --git a/internal/cmd/security/group/delete/delete.go b/internal/cmd/security/group/delete/delete.go index e6646544..1ce6516e 100644 --- a/internal/cmd/security/group/delete/delete.go +++ b/internal/cmd/security/group/delete/delete.go @@ -60,8 +60,8 @@ func run(ctx util.CmdContext, opts *deleteOpts) error { return err } - zap.L().Debug("Resolving group for deletion", zap.String("organization", target.Organization), zap.String("project", target.Project), zap.String("group", target.Target)) - targetGroup, err := shared.FindGroupByName(ctx, target.Organization, target.Project, target.Target, opts.descriptor) + zap.L().Debug("Resolving group for deletion", zap.String("organization", target.Organization), zap.String("project", target.Project), zap.String("group", target.Targets[0])) + targetGroup, err := shared.FindGroupByName(ctx, target.Organization, target.Project, target.Targets[0], opts.descriptor) if err != nil { return err } @@ -78,7 +78,7 @@ func run(ctx util.CmdContext, opts *deleteOpts) error { if err != nil { return err } - confirmed, err := p.Confirm(fmt.Sprintf("Delete security group %q?", target.Target), false) + confirmed, err := p.Confirm(fmt.Sprintf("Delete security group %q?", target.Targets[0]), false) if err != nil { return err } @@ -95,6 +95,6 @@ func run(ctx util.CmdContext, opts *deleteOpts) error { return fmt.Errorf("failed to delete group: %w", err) } - fmt.Fprintf(ios.Out, "Deleted security group %q.\n", target.Target) + fmt.Fprintf(ios.Out, "Deleted security group %q.\n", target.Targets[0]) return nil } diff --git a/internal/cmd/security/group/membership/add/add.go b/internal/cmd/security/group/membership/add/add.go index 49d40d33..b84cda9e 100644 --- a/internal/cmd/security/group/membership/add/add.go +++ b/internal/cmd/security/group/membership/add/add.go @@ -105,10 +105,10 @@ func runAdd(ctx util.CmdContext, o *opts) error { zap.L().Debug("resolving group for membership add", zap.String("organization", organization), zap.String("project", project), - zap.String("group", target.Target), + zap.String("group", target.Targets[0]), ) - group, err := shared.FindGroupByName(ctx, organization, project, target.Target, "") + group, err := shared.FindGroupByName(ctx, organization, project, target.Targets[0], "") if err != nil { return err } @@ -156,7 +156,7 @@ func runAdd(ctx util.CmdContext, o *opts) error { if err == nil { results = append(results, addResult{ GroupDescriptor: types.GetValue(group.Descriptor, ""), - GroupDisplayName: types.GetValue(group.DisplayName, target.Target), + GroupDisplayName: types.GetValue(group.DisplayName, target.Targets[0]), MemberDescriptor: memberDescriptor, MemberDisplayName: types.GetValue(memberSubject.DisplayName, ""), MemberSubjectKind: types.GetValue(memberSubject.SubjectKind, ""), @@ -188,7 +188,7 @@ func runAdd(ctx util.CmdContext, o *opts) error { if errors.As(err, &addErr) && addErr != nil && addErr.StatusCode != nil && *addErr.StatusCode == http.StatusConflict { results = append(results, addResult{ GroupDescriptor: types.GetValue(group.Descriptor, ""), - GroupDisplayName: types.GetValue(group.DisplayName, target.Target), + GroupDisplayName: types.GetValue(group.DisplayName, target.Targets[0]), MemberDescriptor: memberDescriptor, MemberDisplayName: types.GetValue(memberSubject.DisplayName, ""), MemberSubjectKind: types.GetValue(memberSubject.SubjectKind, ""), @@ -204,7 +204,7 @@ func runAdd(ctx util.CmdContext, o *opts) error { results = append(results, addResult{ GroupDescriptor: types.GetValue(group.Descriptor, ""), - GroupDisplayName: types.GetValue(group.DisplayName, target.Target), + GroupDisplayName: types.GetValue(group.DisplayName, target.Targets[0]), MemberDescriptor: types.GetValue(membership.MemberDescriptor, memberDescriptor), MemberDisplayName: types.GetValue(memberSubject.DisplayName, ""), MemberSubjectKind: types.GetValue(memberSubject.SubjectKind, ""), diff --git a/internal/cmd/security/group/membership/list/list.go b/internal/cmd/security/group/membership/list/list.go index b2c2a90d..96889b29 100644 --- a/internal/cmd/security/group/membership/list/list.go +++ b/internal/cmd/security/group/membership/list/list.go @@ -63,7 +63,7 @@ func runList(ctx util.CmdContext, o *opts) error { } organization := target.Organization project := target.Project - group := target.Target + group := target.Targets[0] graphClient, err := ctx.ClientFactory().Graph(ctx.Context(), organization) if err != nil { diff --git a/internal/cmd/security/group/membership/remove/remove.go b/internal/cmd/security/group/membership/remove/remove.go index bf8e536b..45e9c9b6 100644 --- a/internal/cmd/security/group/membership/remove/remove.go +++ b/internal/cmd/security/group/membership/remove/remove.go @@ -102,10 +102,10 @@ func runRemove(ctx util.CmdContext, o *opts) error { zap.L().Debug("resolving group for membership removal", zap.String("organization", organization), zap.String("project", project), - zap.String("group", target.Target), + zap.String("group", target.Targets[0]), ) - group, err := shared.FindGroupByName(ctx, organization, project, target.Target, "") + group, err := shared.FindGroupByName(ctx, organization, project, target.Targets[0], "") if err != nil { return err } @@ -206,9 +206,9 @@ func runRemove(ctx util.CmdContext, o *opts) error { break } } - prompt = fmt.Sprintf("Remove %q from group %q?", name, target.Target) + prompt = fmt.Sprintf("Remove %q from group %q?", name, target.Targets[0]) } else { - prompt = fmt.Sprintf("Remove %d members from group %q?", removable, target.Target) + prompt = fmt.Sprintf("Remove %d members from group %q?", removable, target.Targets[0]) } confirmed, err := p.Confirm(prompt, false) @@ -262,7 +262,7 @@ func runRemove(ctx util.CmdContext, o *opts) error { results = append(results, removeResult{ GroupDescriptor: types.GetValue(group.Descriptor, ""), - GroupDisplayName: types.GetValue(group.DisplayName, target.Target), + GroupDisplayName: types.GetValue(group.DisplayName, target.Targets[0]), MemberDescriptor: c.descriptor, MemberDisplayName: c.displayName, MemberSubjectKind: types.GetValue(c.subject.SubjectKind, ""), diff --git a/internal/cmd/security/group/show/show.go b/internal/cmd/security/group/show/show.go index c99ec6f7..20d94cda 100644 --- a/internal/cmd/security/group/show/show.go +++ b/internal/cmd/security/group/show/show.go @@ -76,9 +76,9 @@ func runCommand(ctx util.CmdContext, o *opts) error { return err } - zap.L().Sugar().Debugw("Resolved target for show command", "organization", target.Organization, "project", target.Project, "group", target.Target) + zap.L().Sugar().Debugw("Resolved target for show command", "organization", target.Organization, "project", target.Project, "group", target.Targets[0]) - groupDetailsResult, err := shared.FindGroupByName(ctx, target.Organization, target.Project, target.Target, "") + groupDetailsResult, err := shared.FindGroupByName(ctx, target.Organization, target.Project, target.Targets[0], "") if err != nil { return err } diff --git a/internal/cmd/security/group/update/update.go b/internal/cmd/security/group/update/update.go index a397207b..addb284f 100644 --- a/internal/cmd/security/group/update/update.go +++ b/internal/cmd/security/group/update/update.go @@ -91,7 +91,7 @@ func run(ctx util.CmdContext, o *opts) error { return err } - group, err := shared.FindGroupByName(ctx, target.Organization, target.Project, target.Target, o.descriptor) + group, err := shared.FindGroupByName(ctx, target.Organization, target.Project, target.Targets[0], o.descriptor) if err != nil { return err } diff --git a/internal/cmd/security/permission/namespace/show/show.go b/internal/cmd/security/permission/namespace/show/show.go index c2094500..e4f223a2 100644 --- a/internal/cmd/security/permission/namespace/show/show.go +++ b/internal/cmd/security/permission/namespace/show/show.go @@ -175,7 +175,7 @@ func runCommand(ctx util.CmdContext, o *opts) error { return actionPrinter.Render() } -func parseNamespaceTarget(ctx util.CmdContext, input string) (*util.Scope, string, error) { +func parseNamespaceTarget(ctx util.CmdContext, input string) (*util.Path, string, error) { trimmed := strings.TrimSpace(input) if trimmed == "" { return nil, "", util.FlagErrorf("namespace identifier is required") @@ -198,7 +198,7 @@ func parseNamespaceTarget(ctx util.CmdContext, input string) (*util.Scope, strin if err != nil { return nil, "", err } - return &util.Scope{Organization: organization}, trimmed, nil + return &util.Path{Organization: organization}, trimmed, nil } func namespaceMatches(ns security.SecurityNamespaceDescription, identifier string) bool { diff --git a/internal/cmd/security/permission/shared/target.go b/internal/cmd/security/permission/shared/target.go index e585b812..a484575a 100644 --- a/internal/cmd/security/permission/shared/target.go +++ b/internal/cmd/security/permission/shared/target.go @@ -8,7 +8,7 @@ import ( ) type SubjectTarget struct { - util.Scope + util.Path Subject string } @@ -26,7 +26,7 @@ func ParseSubjectTarget(ctx util.CmdContext, input string) (*SubjectTarget, erro return nil, err } return &SubjectTarget{ - Scope: *scope, + Path: *scope, }, nil } @@ -47,7 +47,7 @@ func ParseSubjectTarget(ctx util.CmdContext, input string) (*SubjectTarget, erro return nil, err } return &SubjectTarget{ - Scope: *scope, + Path: *scope, Subject: "", }, nil case 2: @@ -60,7 +60,7 @@ func ParseSubjectTarget(ctx util.CmdContext, input string) (*SubjectTarget, erro return nil, err } return &SubjectTarget{ - Scope: *scope, + Path: *scope, Subject: subject, }, nil case 3: @@ -78,7 +78,7 @@ func ParseSubjectTarget(ctx util.CmdContext, input string) (*SubjectTarget, erro return nil, err } return &SubjectTarget{ - Scope: *scope, + Path: *scope, Subject: subject, }, nil default: diff --git a/internal/cmd/serviceendpoint/delete/delete.go b/internal/cmd/serviceendpoint/delete/delete.go index 515b11b4..b95f3a30 100644 --- a/internal/cmd/serviceendpoint/delete/delete.go +++ b/internal/cmd/serviceendpoint/delete/delete.go @@ -85,9 +85,9 @@ func runDelete(ctx util.CmdContext, opts *deleteOptions) error { return util.FlagErrorWrap(err) } - var additionalScopes []*util.Scope + var additionalScopes []*util.Path if len(opts.additionalProjects) > 0 { - additionalScopes = make([]*util.Scope, 0, len(opts.additionalProjects)) + additionalScopes = make([]*util.Path, 0, len(opts.additionalProjects)) for _, value := range opts.additionalProjects { additionalScope, parseErr := util.ParseProjectScope(ctx, value) if parseErr != nil { @@ -101,7 +101,7 @@ func runDelete(ctx util.CmdContext, opts *deleteOptions) error { } if !opts.yes { - message := fmt.Sprintf("Delete service endpoint %q from project %s/%s?", scope.Target, scope.Organization, scope.Project) + message := fmt.Sprintf("Delete service endpoint %q from project %s/%s?", scope.Targets[0], scope.Organization, scope.Project) var extra []string if opts.deep { extra = append(extra, "This will also delete the backing Azure AD application when supported.") @@ -130,19 +130,19 @@ func runDelete(ctx util.CmdContext, opts *deleteOptions) error { return fmt.Errorf("failed to create service endpoint client: %w", err) } - endpoint, err := shared.FindServiceEndpoint(ctx, serviceEndpointClient, scope.Project, scope.Target) + endpoint, err := shared.FindServiceEndpoint(ctx, serviceEndpointClient, scope.Project, scope.Targets[0]) if err != nil { if errors.Is(err, shared.ErrEndpointNotFound) { ios.StopProgressIndicator() cs := ios.ColorScheme() - fmt.Fprintf(ios.Out, "%s Service endpoint %q was not found in %s/%s.\n", cs.WarningIcon(), scope.Target, scope.Organization, scope.Project) + fmt.Fprintf(ios.Out, "%s Service endpoint %q was not found in %s/%s.\n", cs.WarningIcon(), scope.Targets[0], scope.Organization, scope.Project) return nil } return err } if endpoint == nil || endpoint.Id == nil { - return fmt.Errorf("resolved service endpoint %q is missing an identifier", scope.Target) + return fmt.Errorf("resolved service endpoint %q is missing an identifier", scope.Targets[0]) } coreClient, err := ctx.ClientFactory().Core(ctx.Context(), scope.Organization) @@ -163,7 +163,7 @@ func runDelete(ctx util.CmdContext, opts *deleteOptions) error { zap.L().Debug("Deleting service endpoint", zap.String("organization", scope.Organization), zap.String("project", scope.Project), - zap.String("identifier", scope.Target), + zap.String("identifier", scope.Targets[0]), zap.Bool("deep", opts.deep), zap.Strings("projectIds", projectIDs), ) @@ -183,13 +183,13 @@ func runDelete(ctx util.CmdContext, opts *deleteOptions) error { ios.StopProgressIndicator() cs := ios.ColorScheme() - name := strings.TrimSpace(types.GetValue(endpoint.Name, scope.Target)) + name := strings.TrimSpace(types.GetValue(endpoint.Name, scope.Targets[0])) fmt.Fprintf(ios.Out, "%s Deleted service endpoint %q (%s) from %d project(s).\n", cs.SuccessIcon(), name, endpoint.Id.String(), len(projectTargets)) return nil } -func buildProjectTargets(ctx util.CmdContext, coreClient core.Client, primaryScope *util.Target, endpoint *serviceendpoint.ServiceEndpoint, additionalScopes []*util.Scope) ([]projectTarget, error) { +func buildProjectTargets(ctx util.CmdContext, coreClient core.Client, primaryScope *util.Path, endpoint *serviceendpoint.ServiceEndpoint, additionalScopes []*util.Path) ([]projectTarget, error) { idSet := make(map[string]struct{}) var targets []projectTarget diff --git a/internal/cmd/serviceendpoint/export/export.go b/internal/cmd/serviceendpoint/export/export.go index f9bef8d2..19be5567 100644 --- a/internal/cmd/serviceendpoint/export/export.go +++ b/internal/cmd/serviceendpoint/export/export.go @@ -90,20 +90,20 @@ func runExport(ctx util.CmdContext, opts *exportOptions) error { return fmt.Errorf("failed to create service endpoint client: %w", err) } - endpoint, err := shared.FindServiceEndpoint(ctx, client, scope.Project, scope.Target) + endpoint, err := shared.FindServiceEndpoint(ctx, client, scope.Project, scope.Targets[0]) if err != nil { if errors.Is(err, shared.ErrEndpointNotFound) { - return fmt.Errorf("service endpoint %q was not found in %s/%s", scope.Target, scope.Organization, scope.Project) + return fmt.Errorf("service endpoint %q was not found in %s/%s", scope.Targets[0], scope.Organization, scope.Project) } return err } if endpoint == nil { - return fmt.Errorf("service endpoint %q could not be resolved", scope.Target) + return fmt.Errorf("service endpoint %q could not be resolved", scope.Targets[0]) } payload := &exportedServiceEndpoint{ - Name: strings.TrimSpace(types.GetValue(endpoint.Name, scope.Target)), + Name: strings.TrimSpace(types.GetValue(endpoint.Name, scope.Targets[0])), Type: strings.TrimSpace(types.GetValue(endpoint.Type, "")), URL: strings.TrimSpace(types.GetValue(endpoint.Url, "")), IsShared: endpoint.IsShared, @@ -145,7 +145,7 @@ func runExport(ctx util.CmdContext, opts *exportOptions) error { zap.L().Debug("Exporting service endpoint", zap.String("organization", scope.Organization), zap.String("project", scope.Project), - zap.String("identifier", scope.Target), + zap.String("identifier", scope.Targets[0]), zap.Bool("withSecrets", opts.withSecrets), zap.String("destination", func(path string) string { if strings.TrimSpace(path) == "" { diff --git a/internal/cmd/serviceendpoint/shared/projects.go b/internal/cmd/serviceendpoint/shared/projects.go index 5b865e51..a8374478 100644 --- a/internal/cmd/serviceendpoint/shared/projects.go +++ b/internal/cmd/serviceendpoint/shared/projects.go @@ -13,7 +13,7 @@ import ( // ResolveProjectReference fetches the project metadata required to attach service endpoints to a project. // It returns a ProjectReference that includes the stable storage key (ID) and display name. -func ResolveProjectReference(ctx util.CmdContext, scope *util.Scope) (*serviceendpoint.ProjectReference, error) { +func ResolveProjectReference(ctx util.CmdContext, scope *util.Path) (*serviceendpoint.ProjectReference, error) { if scope == nil { return nil, fmt.Errorf("scope is required") } diff --git a/internal/cmd/serviceendpoint/shared/runner_update.go b/internal/cmd/serviceendpoint/shared/runner_update.go index 2c70171d..08d86921 100644 --- a/internal/cmd/serviceendpoint/shared/runner_update.go +++ b/internal/cmd/serviceendpoint/shared/runner_update.go @@ -36,12 +36,12 @@ func RunTypedUpdate(cmd *cobra.Command, args []string, cfg EndpointTypeConfigure } // 2. Find existing endpoint - endpoint, err := FindServiceEndpoint(cmdCtx, client, scope.Project, scope.Target) + endpoint, err := FindServiceEndpoint(cmdCtx, client, scope.Project, scope.Targets[0]) if err != nil { if errors.Is(err, ErrEndpointNotFound) { ios.StopProgressIndicator() cs := ios.ColorScheme() - fmt.Fprintf(ios.Out, "%s Service endpoint %q was not found in %s/%s.\n", cs.WarningIcon(), scope.Target, scope.Organization, scope.Project) + fmt.Fprintf(ios.Out, "%s Service endpoint %q was not found in %s/%s.\n", cs.WarningIcon(), scope.Targets[0], scope.Organization, scope.Project) return nil } return err @@ -94,7 +94,7 @@ func RunTypedUpdate(cmd *cobra.Command, args []string, cfg EndpointTypeConfigure // 9. Pipeline permissions if cmd.Flags().Changed("grant-permission-to-all-pipelines") { - projectRef, err := ResolveProjectReference(cmdCtx, &scope.Scope) + projectRef, err := ResolveProjectReference(cmdCtx, scope) if err != nil { return err } diff --git a/internal/cmd/serviceendpoint/show/show.go b/internal/cmd/serviceendpoint/show/show.go index 2061d22a..f940a307 100644 --- a/internal/cmd/serviceendpoint/show/show.go +++ b/internal/cmd/serviceendpoint/show/show.go @@ -65,19 +65,19 @@ func runShow(ctx util.CmdContext, opts *showOptions) error { return fmt.Errorf("failed to create service endpoint client: %w", err) } - endpoint, err := shared.FindServiceEndpoint(ctx, client, scope.Project, scope.Target) + endpoint, err := shared.FindServiceEndpoint(ctx, client, scope.Project, scope.Targets[0]) if err != nil { if errors.Is(err, shared.ErrEndpointNotFound) { ios.StopProgressIndicator() cs := ios.ColorScheme() - fmt.Fprintf(ios.Out, "%s Service endpoint %q was not found in %s/%s.\n", cs.WarningIcon(), scope.Target, scope.Organization, scope.Project) + fmt.Fprintf(ios.Out, "%s Service endpoint %q was not found in %s/%s.\n", cs.WarningIcon(), scope.Targets[0], scope.Organization, scope.Project) return nil } return err } if endpoint == nil || endpoint.Id == nil { - return fmt.Errorf("resolved service endpoint %q is missing an identifier", scope.Target) + return fmt.Errorf("resolved service endpoint %q is missing an identifier", scope.Targets[0]) } ios.StopProgressIndicator() diff --git a/internal/cmd/serviceendpoint/update/update.go b/internal/cmd/serviceendpoint/update/update.go index b3b0e806..0f502d5b 100644 --- a/internal/cmd/serviceendpoint/update/update.go +++ b/internal/cmd/serviceendpoint/update/update.go @@ -100,18 +100,18 @@ func run(ctx util.CmdContext, o *opts) error { return fmt.Errorf("failed to create service endpoint client: %w", err) } - endpoint, err := shared.FindServiceEndpoint(ctx, client, scope.Project, scope.Target) + endpoint, err := shared.FindServiceEndpoint(ctx, client, scope.Project, scope.Targets[0]) if err != nil { if errors.Is(err, shared.ErrEndpointNotFound) { ios.StopProgressIndicator() cs := ios.ColorScheme() - fmt.Fprintf(ios.Out, "%s Service endpoint %q was not found in %s/%s.\n", cs.WarningIcon(), scope.Target, scope.Organization, scope.Project) + fmt.Fprintf(ios.Out, "%s Service endpoint %q was not found in %s/%s.\n", cs.WarningIcon(), scope.Targets[0], scope.Organization, scope.Project) return nil } return err } if endpoint == nil || endpoint.Id == nil { - return fmt.Errorf("resolved service endpoint %q is missing an identifier", scope.Target) + return fmt.Errorf("resolved service endpoint %q is missing an identifier", scope.Targets[0]) } var cachedProjectRef *serviceendpoint.ProjectReference @@ -119,7 +119,7 @@ func run(ctx util.CmdContext, o *opts) error { if cachedProjectRef != nil { return cachedProjectRef, nil } - ref, err := shared.ResolveProjectReference(ctx, &scope.Scope) + ref, err := shared.ResolveProjectReference(ctx, scope) if err != nil { return nil, err } @@ -193,7 +193,7 @@ func run(ctx util.CmdContext, o *opts) error { fields := []zap.Field{ zap.String("organization", scope.Organization), zap.String("project", scope.Project), - zap.String("identifier", scope.Target), + zap.String("identifier", scope.Targets[0]), zap.String("endpointId", types.GetValue(toUpdate.Id, uuid.Nil).String()), } diff --git a/internal/cmd/util/scope.go b/internal/cmd/util/scope.go index 2e95a2bb..90f73823 100644 --- a/internal/cmd/util/scope.go +++ b/internal/cmd/util/scope.go @@ -9,21 +9,86 @@ import ( "github.com/tmeckel/azdo-cli/internal/types" ) -// Scope describes the organization/project resolution for commands that accept optional project scope. -type Scope struct { +// Path represents a parsed user-input path of the form +// [ORGANIZATION[/PROJECT]]/TARGET[/SUBTARGET[/...]]. +// Organization is always populated after a successful Parse. +type Path struct { Organization string Project string + Targets []string } -// ParseScope resolves the organization and optional project from an input argument of the form -// "[ORGANIZATION[/PROJECT]]". When the input is empty, the default organization from the user configuration -// is returned. The function trims whitespace around individual segments and ensures the resulting values are -// non-empty when provided. -func ParseScope(ctx CmdContext, scope string) (*Scope, error) { - result := &Scope{} +// ParseOptions configures how a raw user input is split into a Path. +type ParseOptions struct { + AllowImplicitOrg bool + RequireProject bool + MinTargets int + MaxTargets int +} + +// Parse splits a raw user input into a Path according to opts. +func Parse(ctx CmdContext, raw string, opts ParseOptions) (*Path, error) { + trimmed := strings.TrimSpace(raw) + var parts []string + if trimmed != "" { + parts = strings.Split(trimmed, "/") + for i := range parts { + parts[i] = strings.TrimSpace(parts[i]) + if parts[i] == "" { + return nil, fmt.Errorf("input %q contains empty segment", raw) + } + } + } + + n := len(parts) + + minOrg := 0 + if !opts.AllowImplicitOrg { + minOrg = 1 + } + minProject := 0 + if opts.RequireProject { + minProject = 1 + } + minPrefix := minOrg + minProject + minTotal := minPrefix + opts.MinTargets + + maxPrefix := 2 + maxTargets := opts.MaxTargets + if maxTargets == 0 { + maxTargets = 999 + } + maxTotal := maxPrefix + maxTargets + + if n < minTotal || n > maxTotal { + return nil, fmt.Errorf("invalid input %q: expected %d-%d segments, got %d", raw, minTotal, maxTotal, n) + } + + p := &Path{} + if opts.MinTargets > 0 { + p.Targets = make([]string, opts.MinTargets) + if n >= opts.MinTargets { + copy(p.Targets, parts[n-opts.MinTargets:]) + } + } + + switch extra := n - opts.MinTargets; extra { + case 0: + case 1: + if opts.RequireProject { + p.Project = parts[0] + } else { + p.Organization = parts[0] + } + case 2: + p.Organization = parts[0] + p.Project = parts[1] + } - trimmed := strings.TrimSpace(scope) - if trimmed == "" { + if p.Organization == "" { + if ctx == nil { + return nil, fmt.Errorf("no organization specified and no default organization configured") + } cfg, err := ctx.Config() if err != nil { return nil, err @@ -32,37 +97,32 @@ func ParseScope(ctx CmdContext, scope string) (*Scope, error) { if err != nil { return nil, fmt.Errorf("no organization specified and no default organization configured: %w", err) } - result.Organization = org - return result, nil - } - - parts := strings.Split(trimmed, "/") - switch len(parts) { - case 1: - org := strings.TrimSpace(parts[0]) + org = strings.TrimSpace(org) if org == "" { - return nil, fmt.Errorf("invalid scope format: %s", scope) - } - result.Organization = org - case 2: - org := strings.TrimSpace(parts[0]) - project := strings.TrimSpace(parts[1]) - if org == "" || project == "" { - return nil, fmt.Errorf("invalid scope format: %s", scope) + return nil, fmt.Errorf("no organization specified and no default organization configured") } - result.Organization = org - result.Project = project - default: - return nil, fmt.Errorf("invalid scope format: %s", scope) + p.Organization = org } - return result, nil + return p, nil +} + +// ParseScope resolves the organization and optional project from an input argument of the form +// "[ORGANIZATION[/PROJECT]]". When the input is empty, the default organization from the user configuration +// is returned. The function trims whitespace around individual segments and ensures the resulting values are +// non-empty when provided. +func ParseScope(ctx CmdContext, scope string) (*Path, error) { + return Parse(ctx, scope, ParseOptions{ + AllowImplicitOrg: true, + }) } // ParseOrganizationArg resolves the organization from an input argument of the form "[ORGANIZATION]". // When the input is empty, the default organization from the user configuration is returned. func ParseOrganizationArg(ctx CmdContext, arg string) (string, error) { - scope, err := ParseScope(ctx, arg) + scope, err := Parse(ctx, arg, ParseOptions{ + AllowImplicitOrg: true, + }) if err != nil { return "", err } @@ -75,36 +135,43 @@ func ParseOrganizationArg(ctx CmdContext, arg string) (string, error) { // ParseProjectScope parses arguments in the form [ORGANIZATION/]PROJECT. When the organization // segment is omitted the default organization from the user's configuration is used. The function // trims whitespace around individual segments and ensures the resulting values are non-empty. -func ParseProjectScope(ctx CmdContext, arg string) (*Scope, error) { - result := &Scope{} - parts := strings.Split(strings.TrimSpace(arg), "/") - switch len(parts) { - case 1: - result.Project = strings.TrimSpace(parts[0]) - if result.Project == "" { - return nil, fmt.Errorf("project argument cannot be empty") - } - cfg, err := ctx.Config() - if err != nil { - return nil, fmt.Errorf("failed to read configuration: %w", err) - } - org, err := cfg.Authentication().GetDefaultOrganization() - org = strings.TrimSpace(org) - if err != nil || org == "" { - return nil, fmt.Errorf("no organization specified and no default organization configured") - } - result.Organization = org - return result, nil - case 2: - result.Organization = strings.TrimSpace(parts[0]) - result.Project = strings.TrimSpace(parts[1]) - if result.Organization == "" || result.Project == "" { - return nil, fmt.Errorf("invalid project argument %q; expected format ORGANIZATION/PROJECT", arg) - } - return result, nil - default: - return nil, fmt.Errorf("invalid project argument %q; expected format ORGANIZATION/PROJECT", arg) - } +func ParseProjectScope(ctx CmdContext, arg string) (*Path, error) { + return Parse(ctx, arg, ParseOptions{ + AllowImplicitOrg: true, + RequireProject: true, + }) +} + +// ParseTarget validates and parses a target argument of form ORGANIZATION/TARGET or ORGANIZATION/PROJECT/TARGET. +func ParseTarget(target string) (*Path, error) { + return Parse(nil, target, ParseOptions{ + AllowImplicitOrg: false, + MinTargets: 1, + MaxTargets: 1, + }) +} + +// ParseTargetWithDefaultOrganization resolves a group-oriented target that allows an implicit organization by +// falling back to the configured default. Accepted formats are [ORGANIZATION/]GROUP and +// [ORGANIZATION/]PROJECT/GROUP (used by security membership commands where the middle segment is optional). +func ParseTargetWithDefaultOrganization(ctx CmdContext, target string) (*Path, error) { + return Parse(ctx, target, ParseOptions{ + AllowImplicitOrg: true, + MinTargets: 1, + MaxTargets: 1, + }) +} + +// ParseProjectTargetWithDefaultOrganization resolves targets that must include a project segment. It accepts +// arguments in the form [ORGANIZATION/]PROJECT/TARGET, falling back to the user's default organization when the +// organization segment is omitted. +func ParseProjectTargetWithDefaultOrganization(ctx CmdContext, target string) (*Path, error) { + return Parse(ctx, target, ParseOptions{ + AllowImplicitOrg: true, + RequireProject: true, + MinTargets: 1, + MaxTargets: 1, + }) } // ResolveScopeDescriptor fetches the descriptor representing the project scope when a project is supplied. @@ -153,113 +220,3 @@ func ResolveScopeDescriptor(ctx CmdContext, organization, project string) (*stri return descriptor.Value, projectID, nil } - -type Target struct { - Scope - Target string -} - -// ParseTarget validates and parses a target argument of form ORGANIZATION/TARGET or ORGANIZATION/PROJECT/TARGET. -func ParseTarget(target string) (*Target, error) { - return parseTarget(nil, target, targetParseOptions{ - allowImplicitOrg: false, - requireProject: false, - }) -} - -// ParseTargetWithDefaultOrganization resolves a group-oriented target that allows an implicit organization by -// falling back to the configured default. Accepted formats are [ORGANIZATION/]GROUP and -// [ORGANIZATION/]PROJECT/GROUP (used by security membership commands where the middle segment is optional). -func ParseTargetWithDefaultOrganization(ctx CmdContext, target string) (*Target, error) { - return parseTarget(ctx, target, targetParseOptions{ - allowImplicitOrg: true, - requireProject: false, - }) -} - -// ParseProjectTargetWithDefaultOrganization resolves targets that must include a project segment. It accepts -// arguments in the form [ORGANIZATION/]PROJECT/TARGET, falling back to the user's default organization when the -// organization segment is omitted. -func ParseProjectTargetWithDefaultOrganization(ctx CmdContext, target string) (*Target, error) { - return parseTarget(ctx, target, targetParseOptions{ - allowImplicitOrg: true, - requireProject: true, - }) -} - -type targetParseOptions struct { - allowImplicitOrg bool - requireProject bool -} - -func parseTarget(ctx CmdContext, raw string, opts targetParseOptions) (*Target, error) { - trimmed := strings.TrimSpace(raw) - if trimmed == "" { - return nil, fmt.Errorf("target must not be empty") - } - - parts := strings.Split(trimmed, "/") - for i := range parts { - parts[i] = strings.TrimSpace(parts[i]) - } - - var org, project, targetValue string - switch len(parts) { - case 1: - if !opts.allowImplicitOrg || opts.requireProject { - return nil, fmt.Errorf("invalid target format: %s", raw) - } - targetValue = parts[0] - case 2: - if opts.requireProject { - project = parts[0] - targetValue = parts[1] - } else { - org = parts[0] - targetValue = parts[1] - } - case 3: - org = parts[0] - project = parts[1] - targetValue = parts[2] - default: - return nil, fmt.Errorf("invalid target format: %s", raw) - } - - if targetValue == "" { - return nil, fmt.Errorf("invalid target format: %s", raw) - } - if opts.requireProject && project == "" { - return nil, fmt.Errorf("invalid target format: %s", raw) - } - - if org == "" { - if !opts.allowImplicitOrg { - return nil, fmt.Errorf("invalid target format: %s", raw) - } - if ctx == nil { - return nil, fmt.Errorf("no organization specified and no default organization configured") - } - cfg, err := ctx.Config() - if err != nil { - return nil, err - } - authCfg := cfg.Authentication() - org, err = authCfg.GetDefaultOrganization() - if err != nil { - return nil, fmt.Errorf("no organization specified and no default organization configured: %w", err) - } - org = strings.TrimSpace(org) - if org == "" { - return nil, fmt.Errorf("no organization specified and no default organization configured") - } - } - - return &Target{ - Scope: Scope{ - Organization: org, - Project: project, - }, - Target: targetValue, - }, nil -} diff --git a/internal/cmd/util/scope_test.go b/internal/cmd/util/scope_test.go index cfb13062..28885fa3 100644 --- a/internal/cmd/util/scope_test.go +++ b/internal/cmd/util/scope_test.go @@ -154,7 +154,7 @@ func TestParseTarget(t *testing.T) { require.NoError(t, err) assert.Equal(t, "org", result.Organization) assert.Empty(t, result.Project) - assert.Equal(t, "group", result.Target) + assert.Equal(t, "group", result.Targets[0]) }) t.Run("organization project group", func(t *testing.T) { @@ -162,7 +162,7 @@ func TestParseTarget(t *testing.T) { require.NoError(t, err) assert.Equal(t, "org", result.Organization) assert.Equal(t, "project", result.Project) - assert.Equal(t, "group", result.Target) + assert.Equal(t, "group", result.Targets[0]) }) t.Run("invalid format", func(t *testing.T) { @@ -187,7 +187,7 @@ func TestParseTargetWithDefaultOrganization(t *testing.T) { result, err := util.ParseTargetWithDefaultOrganization(mockCtx, "group") require.NoError(t, err) assert.Equal(t, "default-org", result.Organization) - assert.Equal(t, "group", result.Target) + assert.Equal(t, "group", result.Targets[0]) }) t.Run("explicit organization", func(t *testing.T) { @@ -197,7 +197,7 @@ func TestParseTargetWithDefaultOrganization(t *testing.T) { result, err := util.ParseTargetWithDefaultOrganization(mocks.NewMockCmdContext(ctrl), "org/group") require.NoError(t, err) assert.Equal(t, "org", result.Organization) - assert.Equal(t, "group", result.Target) + assert.Equal(t, "group", result.Targets[0]) }) t.Run("organization project group", func(t *testing.T) { @@ -208,7 +208,7 @@ func TestParseTargetWithDefaultOrganization(t *testing.T) { require.NoError(t, err) assert.Equal(t, "org", result.Organization) assert.Equal(t, "project", result.Project) - assert.Equal(t, "group", result.Target) + assert.Equal(t, "group", result.Targets[0]) }) t.Run("missing default organization", func(t *testing.T) { @@ -254,7 +254,7 @@ func TestParseProjectTargetWithDefaultOrganization(t *testing.T) { require.NotNil(t, result) assert.Equal(t, "default-org", result.Organization) assert.Equal(t, "project", result.Project) - assert.Equal(t, "target", result.Target) + assert.Equal(t, "target", result.Targets[0]) }) t.Run("explicit organization", func(t *testing.T) { @@ -265,7 +265,7 @@ func TestParseProjectTargetWithDefaultOrganization(t *testing.T) { require.NoError(t, err) assert.Equal(t, "org", result.Organization) assert.Equal(t, "project", result.Project) - assert.Equal(t, "target", result.Target) + assert.Equal(t, "target", result.Targets[0]) }) t.Run("missing default organization", func(t *testing.T) { @@ -301,6 +301,121 @@ func TestParseProjectTargetWithDefaultOrganization(t *testing.T) { }) } +func TestParse(t *testing.T) { + tests := []struct { + name string + raw string + opts util.ParseOptions + want *util.Path + wantErr string + }{ + { + name: "empty input with implicit org", + raw: "", + opts: util.ParseOptions{AllowImplicitOrg: true}, + want: &util.Path{Organization: "default-org"}, + }, + { + name: "empty input without implicit org", + raw: "", + opts: util.ParseOptions{AllowImplicitOrg: false}, + wantErr: "expected 1-", + }, + { + name: "single segment with implicit org", + raw: "myorg", + opts: util.ParseOptions{AllowImplicitOrg: true}, + want: &util.Path{Organization: "myorg"}, + }, + { + name: "two segments with implicit org", + raw: "myorg/myproject", + opts: util.ParseOptions{AllowImplicitOrg: true}, + want: &util.Path{Organization: "myorg", Project: "myproject"}, + }, + { + name: "single segment without implicit org", + raw: "myorg", + opts: util.ParseOptions{AllowImplicitOrg: false}, + want: &util.Path{Organization: "myorg"}, + }, + { + name: "explicit org with target (no project)", + raw: "org/group", + opts: util.ParseOptions{AllowImplicitOrg: false, MinTargets: 1, MaxTargets: 1}, + want: &util.Path{Organization: "org", Targets: []string{"group"}}, + }, + { + name: "explicit org and project with target", + raw: "org/project/group", + opts: util.ParseOptions{AllowImplicitOrg: false, MinTargets: 1, MaxTargets: 1}, + want: &util.Path{Organization: "org", Project: "project", Targets: []string{"group"}}, + }, + { + name: "project target with implicit org", + raw: "project/target", + opts: util.ParseOptions{AllowImplicitOrg: true, RequireProject: true, MinTargets: 1, MaxTargets: 1}, + want: &util.Path{Organization: "default-org", Project: "project", Targets: []string{"target"}}, + }, + { + name: "target only with implicit org", + raw: "target", + opts: util.ParseOptions{AllowImplicitOrg: true, MinTargets: 1, MaxTargets: 1}, + want: &util.Path{Organization: "default-org", Targets: []string{"target"}}, + }, + { + name: "empty segment", + raw: "org/", + opts: util.ParseOptions{AllowImplicitOrg: true}, + wantErr: "contains empty segment", + }, + { + name: "whitespace only input", + raw: " ", + opts: util.ParseOptions{AllowImplicitOrg: false}, + wantErr: "expected 1-", + }, + { + name: "whitespace segment", + raw: "org/ /project", + opts: util.ParseOptions{AllowImplicitOrg: true}, + wantErr: "contains empty segment", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var ctx util.CmdContext + if tt.opts.AllowImplicitOrg || tt.raw == "" { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockCtx := mocks.NewMockCmdContext(ctrl) + mockConfig := mocks.NewMockConfig(ctrl) + mockAuth := mocks.NewMockAuthConfig(ctrl) + + mockCtx.EXPECT().Config().Return(mockConfig, nil).AnyTimes() + mockConfig.EXPECT().Authentication().Return(mockAuth).AnyTimes() + mockAuth.EXPECT().GetDefaultOrganization().Return("default-org", nil).AnyTimes() + + ctx = mockCtx + } + + got, err := util.Parse(ctx, tt.raw, tt.opts) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, tt.want.Organization, got.Organization) + assert.Equal(t, tt.want.Project, got.Project) + assert.Equal(t, tt.want.Targets, got.Targets) + }) + } +} + func TestResolveScopeDescriptor_NoProject(t *testing.T) { ctrl := gomock.NewController(t) t.Cleanup(ctrl.Finish) diff --git a/internal/tableprinter/table.go b/internal/tableprinter/table.go index 6a758f50..fe4bc48d 100644 --- a/internal/tableprinter/table.go +++ b/internal/tableprinter/table.go @@ -132,7 +132,9 @@ func (t *ttyTablePrinter) Render() error { truncVal = field.truncateFunc(colWidths[col], field.text) } if field.paddingFunc != nil { - truncVal = field.paddingFunc(colWidths[col], truncVal) + if col < numCols-1 { + truncVal = field.paddingFunc(colWidths[col], truncVal) + } } else if col < numCols-1 { truncVal = text.PadRight(colWidths[col], truncVal) } diff --git a/internal/tableprinter/table_test.go b/internal/tableprinter/table_test.go index b6ca20b1..e856ca01 100644 --- a/internal/tableprinter/table_test.go +++ b/internal/tableprinter/table_test.go @@ -128,7 +128,7 @@ func Test_ttyTablePrinter_WithPadding(t *testing.T) { } expected := heredoc.Doc(` - A B C + A B C hello beautiful people `) if buf.String() != expected {