diff --git a/internal/command/arguments/graph.go b/internal/command/arguments/graph.go new file mode 100644 index 0000000000..291ad2dfe0 --- /dev/null +++ b/internal/command/arguments/graph.go @@ -0,0 +1,63 @@ +// Copyright IBM Corp. 2014, 2026 +// SPDX-License-Identifier: BUSL-1.1 + +package arguments + +import "github.com/hashicorp/terraform/internal/tfdiags" + +// Graph represents the command-line arguments for the graph command. +type Graph struct { + // DrawCycles highlights any cycles in the graph with colored edges. + DrawCycles bool + + // GraphType is the type of operation graph to output (plan, + // plan-refresh-only, plan-destroy, or apply). Empty string means the + // default resource-dependency summary. + GraphType string + + // ModuleDepth is a deprecated option that was used in prior versions to + // control the depth of modules shown. + ModuleDepth int + + // Verbose enables verbose graph output. + Verbose bool + + // Plan is the path to a saved plan file to render as a graph. + Plan string +} + +// ParseGraph processes CLI arguments, returning a Graph value and errors. +// If errors are encountered, a Graph value is still returned representing +// the best effort interpretation of the arguments. +func ParseGraph(args []string) (*Graph, tfdiags.Diagnostics) { + var diags tfdiags.Diagnostics + graph := &Graph{ + ModuleDepth: -1, + } + + cmdFlags := defaultFlagSet("graph") + cmdFlags.BoolVar(&graph.DrawCycles, "draw-cycles", false, "draw-cycles") + cmdFlags.StringVar(&graph.GraphType, "type", "", "type") + cmdFlags.IntVar(&graph.ModuleDepth, "module-depth", -1, "module-depth") + cmdFlags.BoolVar(&graph.Verbose, "verbose", false, "verbose") + cmdFlags.StringVar(&graph.Plan, "plan", "", "plan") + + if err := cmdFlags.Parse(args); err != nil { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Failed to parse command-line flags", + err.Error(), + )) + } + + args = cmdFlags.Args() + if len(args) > 0 { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Too many command line arguments", + "Expected no positional arguments. Did you mean to use -chdir?", + )) + } + + return graph, diags +} diff --git a/internal/command/arguments/graph_test.go b/internal/command/arguments/graph_test.go new file mode 100644 index 0000000000..227707e039 --- /dev/null +++ b/internal/command/arguments/graph_test.go @@ -0,0 +1,147 @@ +// Copyright IBM Corp. 2014, 2026 +// SPDX-License-Identifier: BUSL-1.1 + +package arguments + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform/internal/tfdiags" +) + +func TestParseGraph_valid(t *testing.T) { + testCases := map[string]struct { + args []string + want *Graph + }{ + "defaults": { + nil, + &Graph{ + ModuleDepth: -1, + }, + }, + "plan type": { + []string{"-type=plan"}, + &Graph{ + GraphType: "plan", + ModuleDepth: -1, + }, + }, + "apply type": { + []string{"-type=apply"}, + &Graph{ + GraphType: "apply", + ModuleDepth: -1, + }, + }, + "draw-cycles": { + []string{"-draw-cycles", "-type=plan"}, + &Graph{ + DrawCycles: true, + GraphType: "plan", + ModuleDepth: -1, + }, + }, + "plan file": { + []string{"-plan=tfplan"}, + &Graph{ + Plan: "tfplan", + ModuleDepth: -1, + }, + }, + "verbose": { + []string{"-verbose"}, + &Graph{ + Verbose: true, + ModuleDepth: -1, + }, + }, + "module-depth": { + []string{"-module-depth=2"}, + &Graph{ + ModuleDepth: 2, + }, + }, + "all flags": { + []string{"-draw-cycles", "-type=plan-destroy", "-plan=tfplan", "-verbose", "-module-depth=3"}, + &Graph{ + DrawCycles: true, + GraphType: "plan-destroy", + Plan: "tfplan", + Verbose: true, + ModuleDepth: 3, + }, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + got, diags := ParseGraph(tc.args) + if len(diags) > 0 { + t.Fatalf("unexpected diags: %v", diags) + } + if diff := cmp.Diff(tc.want, got); diff != "" { + t.Fatalf("unexpected result\n%s", diff) + } + }) + } +} + +func TestParseGraph_invalid(t *testing.T) { + testCases := map[string]struct { + args []string + want *Graph + wantDiags tfdiags.Diagnostics + }{ + "unknown flag": { + []string{"-wat"}, + &Graph{ + ModuleDepth: -1, + }, + tfdiags.Diagnostics{ + tfdiags.Sourceless( + tfdiags.Error, + "Failed to parse command-line flags", + "flag provided but not defined: -wat", + ), + }, + }, + "positional argument": { + []string{"extra"}, + &Graph{ + ModuleDepth: -1, + }, + tfdiags.Diagnostics{ + tfdiags.Sourceless( + tfdiags.Error, + "Too many command line arguments", + "Expected no positional arguments. Did you mean to use -chdir?", + ), + }, + }, + "too many positional arguments": { + []string{"bad", "bad"}, + &Graph{ + ModuleDepth: -1, + }, + tfdiags.Diagnostics{ + tfdiags.Sourceless( + tfdiags.Error, + "Too many command line arguments", + "Expected no positional arguments. Did you mean to use -chdir?", + ), + }, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + got, gotDiags := ParseGraph(tc.args) + if diff := cmp.Diff(tc.want, got); diff != "" { + t.Fatalf("unexpected result\n%s", diff) + } + tfdiags.AssertDiagnosticsMatch(t, gotDiags, tc.wantDiags) + }) + } +} diff --git a/internal/command/graph.go b/internal/command/graph.go index ded3c1f70b..8e4b8efa57 100644 --- a/internal/command/graph.go +++ b/internal/command/graph.go @@ -24,27 +24,14 @@ type GraphCommand struct { Meta } -func (c *GraphCommand) Run(args []string) int { - var drawCycles bool - var graphTypeStr string - var moduleDepth int - var verbose bool - var planPath string - - args = c.Meta.process(args) - cmdFlags := c.Meta.defaultFlagSet("graph") - cmdFlags.BoolVar(&drawCycles, "draw-cycles", false, "draw-cycles") - cmdFlags.StringVar(&graphTypeStr, "type", "", "type") - cmdFlags.IntVar(&moduleDepth, "module-depth", -1, "module-depth") - cmdFlags.BoolVar(&verbose, "verbose", false, "verbose") - cmdFlags.StringVar(&planPath, "plan", "", "plan") - cmdFlags.Usage = func() { c.Ui.Error(c.Help()) } - if err := cmdFlags.Parse(args); err != nil { - c.Ui.Error(fmt.Sprintf("Error parsing command-line flags: %s\n", err.Error())) +func (c *GraphCommand) Run(rawArgs []string) int { + args, diags := arguments.ParseGraph(c.Meta.process(rawArgs)) + if diags.HasErrors() { + c.showDiagnostics(diags) return 1 } - configPath, err := ModulePath(cmdFlags.Args()) + configPath, err := ModulePath(nil) if err != nil { c.Ui.Error(err.Error()) return 1 @@ -58,16 +45,14 @@ func (c *GraphCommand) Run(args []string) int { // Try to load plan if path is specified var planFile *planfile.WrappedPlanFile - if planPath != "" { - planFile, err = c.PlanFile(planPath) + if args.Plan != "" { + planFile, err = c.PlanFile(args.Plan) if err != nil { c.Ui.Error(err.Error()) return 1 } } - var diags tfdiags.Diagnostics - // Load the backend b, backendDiags := c.backend(".", arguments.ViewHuman) diags = diags.Append(backendDiags) @@ -106,9 +91,9 @@ func (c *GraphCommand) Run(args []string) int { c.showDiagnostics(diags) return 1 } - lr.Core.SetGraphOpts(&terraform.ContextGraphOpts{SkipGraphValidation: drawCycles}) + lr.Core.SetGraphOpts(&terraform.ContextGraphOpts{SkipGraphValidation: args.DrawCycles}) - if graphTypeStr == "" { + if args.GraphType == "" { if planFile == nil { // Simple resource dependency mode: // This is based on the plan graph but we then further reduce it down @@ -125,13 +110,13 @@ func (c *GraphCommand) Run(args []string) int { g := fullG.ResourceGraph() return c.resourceOnlyGraph(g) } else { - graphTypeStr = "apply" + args.GraphType = "apply" } } var g *terraform.Graph var graphDiags tfdiags.Diagnostics - switch graphTypeStr { + switch args.GraphType { case "plan": g, graphDiags = lr.Core.PlanGraphForUI(lr.Config, lr.InputState, plans.NormalMode) case "plan-refresh-only": @@ -162,7 +147,7 @@ func (c *GraphCommand) Run(args []string) int { graphDiags = graphDiags.Append(tfdiags.Sourceless( tfdiags.Error, "Graph type no longer available", - fmt.Sprintf("The graph type %q is no longer available. Use -type=plan instead to get a similar result.", graphTypeStr), + fmt.Sprintf("The graph type %q is no longer available. Use -type=plan instead to get a similar result.", args.GraphType), )) default: graphDiags = graphDiags.Append(tfdiags.Sourceless( @@ -178,9 +163,9 @@ func (c *GraphCommand) Run(args []string) int { } graphStr, err := terraform.GraphDot(g, &dag.DotOpts{ - DrawCycles: drawCycles, - MaxDepth: moduleDepth, - Verbose: verbose, + DrawCycles: args.DrawCycles, + MaxDepth: args.ModuleDepth, + Verbose: args.Verbose, }) if err != nil { c.Ui.Error(fmt.Sprintf("Error converting graph: %s", err))