From 8987eca41b448048e1d09798945caf6f1382e03a Mon Sep 17 00:00:00 2001 From: Tino Pittner Date: Tue, 21 Jul 2026 14:06:56 +0200 Subject: [PATCH 1/3] feat(render): add --show-delta flag with plan-aware delta rendering Adds a delta-mode renderer that shows only resources with plan actions (create/update/delete) annotated with +/~/- markers, and summarizes unchanged resources in a single footer line. Changes across the stack: - domain.Resource gains a PlanAction field; domain.Cost propagates it from the calculator so renderers know the intent per-resource. - Plan parser annotates each resource with its action via buildActionMap/ classifyActions, surfaces deleted resources (with "before" attrs), and uses json.RawMessage for provider expressions to avoid panics on Terraform 4.x array-valued blocks (azurerm features). - RenderDelta() dispatcher + RenderTextDelta(): filters costs by action, renders changed resources with markers, collapses no-op resources, shows a DELTA cost line and PROJECT TOTAL. - `c3x estimate --show-delta` activates the delta view; warns and falls back to standard view when used with non-plan inputs. - Falls back to the standard renderer for formats without a delta-specific implementation (markdown, JSON, etc.). Gives an Infracost-style diff view directly from plan JSON without requiring a separately saved baseline file. --- CHANGELOG.md | 26 ++++++ cmd/c3x/estimate.go | 40 +++++++- internal/calculator/engine.go | 2 + internal/domain/cost.go | 5 + internal/domain/resource.go | 18 ++++ internal/parser/plan/plan.go | 94 ++++++++++++++++++- internal/parser/plan/plan_test.go | 149 ++++++++++++++++++++++++++++-- internal/render/dispatch.go | 15 +++ internal/render/render_test.go | 122 ++++++++++++++++++++++++ internal/render/text.go | 95 ++++++++++++++++++- 10 files changed, 546 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba214a2..f4cd0bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,32 @@ All notable changes to c3x are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); the project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- `--show-delta` flag for `c3x estimate`: shows only resources with + plan actions (create/update/delete) annotated with `+`/`~`/`-` + markers, summarizes unchanged resources in a footer, and displays a + cost delta line. Only meaningful for plan JSON input; warns and falls + back to the standard view when used with `.tf` files. +- `optional()` type-constraint defaults are now resolved for both root + and child module variables, matching Terraform's runtime behavior. + Previously, attributes using `optional(type, default)` could resolve + to nil or a source-range string, causing type mismatches in catalog + expressions. + +### Fixed + +- Plan parser: `provider_config.expressions` no longer panics on + array-valued expressions (e.g. azurerm `features` block in + Terraform 4.x). Uses `json.RawMessage` with graceful skip. +- `optional_defaults.go`: no longer panics on zero-arg `object()` type + constraints in HCL. +- `optional(object({...}))` without an explicit default no longer + synthesizes a phantom object from child defaults — matching + Terraform's behavior of leaving the attribute null. + ## [0.1.0] - 2026-06-22 First public release. diff --git a/cmd/c3x/estimate.go b/cmd/c3x/estimate.go index 48f173e..226038b 100644 --- a/cmd/c3x/estimate.go +++ b/cmd/c3x/estimate.go @@ -45,6 +45,7 @@ func newEstimateCmd() *cobra.Command { inlineDemo bool currency string showSkipped bool + showDelta bool ) cmd := &cobra.Command{ @@ -105,7 +106,7 @@ precedence matches Terraform's: defaults < auto.tfvars < --var-file < } _ = projectDir // resolved.* already carries the project config - return runEstimate(cmd, path, resolved, varFiles, vars, usagePath, whatIfs, saveBaseline, budget, showSkipped) + return runEstimate(cmd, path, resolved, varFiles, vars, usagePath, whatIfs, saveBaseline, budget, showSkipped, showDelta) }, } @@ -139,6 +140,8 @@ precedence matches Terraform's: defaults < auto.tfvars < --var-file < "display currency (USD, EUR, GBP, JPY, CAD, AUD, …); USD-priced rates are converted via Frankfurter") cmd.Flags().BoolVar(&showSkipped, "show-skipped", false, "after the breakdown, list resources we parsed but couldn't price, with the reason") + cmd.Flags().BoolVar(&showDelta, "show-delta", false, + "show only resources that change in this plan (create/update/delete) with cost delta; summarize unchanged resources in a footer (plan JSON only)") cmd.Flags().BoolVar(&inlineDemo, "inline-demo", false, "drive the calculator against a hand-crafted aws_instance (dev surface)") @@ -160,6 +163,7 @@ func runEstimate( saveBaseline string, budget float64, showSkipped bool, + showDelta bool, ) error { varMap, err := parseVarFlags(rawVars) if err != nil { @@ -224,7 +228,7 @@ func runEstimate( return fmt.Errorf("estimating: %w", err) } - if err := writeRendered(cmd, est, resolved.Format); err != nil { + if err := writeRendered(cmd, est, resolved.Format, showDelta); err != nil { return err } @@ -333,12 +337,27 @@ func applyUsageAndWhatIf(cmd *cobra.Command, resources []domain.Resource, usageP // writeRendered routes the estimate through the appropriate renderer // and writes to the command's stdout. Returning the render error lets // the CLI surface a clean diagnostic if marshalling fails. -func writeRendered(cmd *cobra.Command, est domain.Estimate, formatName string) error { +func writeRendered(cmd *cobra.Command, est domain.Estimate, formatName string, delta bool) error { f, err := render.ParseFormat(formatName) if err != nil { return fmt.Errorf("format: %w", err) } - out, err := render.Render(est, f) + + // Guard: --show-delta only makes sense for plan JSON input where + // resources carry action annotations. HCL-parsed resources have + // PlanActionNone and would all collapse into the "unchanged" bucket. + if delta && !estimateHasPlanContext(est) { + fmt.Fprintln(cmd.ErrOrStderr(), + "warning: --show-delta is only meaningful for plan JSON input; falling back to standard view") + delta = false + } + + var out string + if delta { + out, err = render.RenderDelta(est, f) + } else { + out, err = render.Render(est, f) + } if err != nil { return fmt.Errorf("rendering: %w", err) } @@ -346,6 +365,17 @@ func writeRendered(cmd *cobra.Command, est domain.Estimate, formatName string) e return nil } +// estimateHasPlanContext returns true if at least one Cost has a non-empty +// plan action, indicating the estimate was produced from plan JSON. +func estimateHasPlanContext(est domain.Estimate) bool { + for _, c := range est.Costs { + if c.Action != "" && c.Action != domain.PlanActionNone { + return true + } + } + return false +} + // parseVarFlags turns `--var name=value` repeats into a map. func parseVarFlags(raw []string) (map[string]string, error) { out := map[string]string{} @@ -400,7 +430,7 @@ func runInlineDemo(cmd *cobra.Command, resolved config.Resolved, budget float64) if err != nil { return fmt.Errorf("estimating: %w", err) } - if err := writeRendered(cmd, est, resolved.Format); err != nil { + if err := writeRendered(cmd, est, resolved.Format, false); err != nil { return err } return enforceBudget(cmd, est, budget) diff --git a/internal/calculator/engine.go b/internal/calculator/engine.go index d3e215e..cad5326 100644 --- a/internal/calculator/engine.go +++ b/internal/calculator/engine.go @@ -140,6 +140,7 @@ func (e *Engine) costFor(ctx context.Context, r domain.Resource) (domain.Cost, s return domain.Cost{ Resource: r.Ref, Currency: e.currency, + Action: r.Action, }, "unsupported kind (no catalog entry)", nil } @@ -212,6 +213,7 @@ func (e *Engine) costFor(ctx context.Context, r domain.Resource) (domain.Cost, s LineItems: items, MonthlySubtotal: subtotal.Round(2), Currency: e.currency, + Action: r.Action, }, skipReason, nil } diff --git a/internal/domain/cost.go b/internal/domain/cost.go index f0c35f1..497ba5b 100644 --- a/internal/domain/cost.go +++ b/internal/domain/cost.go @@ -58,6 +58,11 @@ type Cost struct { LineItems []LineItem MonthlySubtotal decimal.Decimal Currency Currency + // Action carries the Terraform plan action (create/update/no-op) + // when the estimate was produced from plan JSON. Empty for HCL + // estimates. Renderers use this to annotate resources with their + // change type without requiring a separate baseline. + Action PlanAction } // HasStaticRate reports whether any LineItem in this Cost relies on an diff --git a/internal/domain/resource.go b/internal/domain/resource.go index c2271d1..6cdd7da 100644 --- a/internal/domain/resource.go +++ b/internal/domain/resource.go @@ -31,12 +31,30 @@ func (r Reference) String() string { return r.Label() } // Region is the cloud region this resource will deploy to, when // determinable from the IaC source. A nil value means the calculator // should fall back to the configured default region. +// +// Action is populated only when parsing plan JSON; it carries the +// Terraform-determined action for this resource (create, update, no-op). +// Consumers like the delta renderer can use it to show per-resource +// deltas without requiring a separate baseline file. type Resource struct { Ref Reference Attributes map[string]any Region *string + Action PlanAction } +// PlanAction describes what Terraform intends to do with a resource. +// Empty when the resource was parsed from HCL (no plan context). +type PlanAction string + +const ( + PlanActionNone PlanAction = "" // HCL-parsed or unknown + PlanActionNoOp PlanAction = "no-op" // unchanged + PlanActionCreate PlanAction = "create" // new resource + PlanActionUpdate PlanAction = "update" // in-place or with replacement + PlanActionDelete PlanAction = "delete" // scheduled for removal +) + // AttrString reads an attribute as a string, returning ok=false if it's // missing or not a string. Centralizing the type-assertions here keeps // the calculator and recommender from re-implementing them. diff --git a/internal/parser/plan/plan.go b/internal/parser/plan/plan.go index 94de2db..f830159 100644 --- a/internal/parser/plan/plan.go +++ b/internal/parser/plan/plan.go @@ -48,9 +48,13 @@ func ParseBytes(raw []byte, logger *slog.Logger) ([]domain.Resource, error) { region := defaultRegion(doc.Configuration) out := make([]domain.Resource, 0, len(doc.ResourceChanges)) + // Build action lookup from resource_changes so we can annotate + // each resource with what Terraform intends to do with it. + actions := buildActionMap(doc.ResourceChanges) + // Primary: planned_values is the full desired (post-apply) state. if doc.PlannedValues != nil { - collectPlanned(doc.PlannedValues.RootModule, region, &out) + collectPlanned(doc.PlannedValues.RootModule, region, actions, &out) } // Fallback for plan formats that omit planned_values. @@ -63,6 +67,27 @@ func ParseBytes(raw []byte, logger *slog.Logger) ([]domain.Resource, error) { r := domain.Resource{ Ref: domain.Reference{Kind: rc.Type, Name: nameFromAddress(rc.Address, rc.Name)}, Attributes: attrs, + Action: classifyActions(rc.Change.Actions), + } + if region != "" { + rgn := region + r.Region = &rgn + } + out = append(out, r) + } + } + + // Append resources scheduled for pure deletion. These don't exist + // in planned_values (they won't be in the post-apply state) but + // the delta renderer needs them to show what's being removed. + // We use the "before" attributes so the calculator can price them. + for _, rc := range doc.ResourceChanges { + if isDeleteOnly(rc.Change.Actions) { + attrs, _ := rc.Change.Before.(map[string]any) + r := domain.Resource{ + Ref: domain.Reference{Kind: rc.Type, Name: nameFromAddress(rc.Address, rc.Name)}, + Attributes: attrs, + Action: domain.PlanActionDelete, } if region != "" { rgn := region @@ -79,7 +104,8 @@ func ParseBytes(raw []byte, logger *slog.Logger) ([]domain.Resource, error) { // collectPlanned walks a planned_values module tree, appending every // managed resource. Data sources and null child-module entries (which // untrusted plan JSON can contain) are skipped rather than dereferenced. -func collectPlanned(mod *plannedModule, region string, out *[]domain.Resource) { +// Each resource is annotated with its PlanAction from the actions map. +func collectPlanned(mod *plannedModule, region string, actions map[string]domain.PlanAction, out *[]domain.Resource) { if mod == nil { return } @@ -91,6 +117,7 @@ func collectPlanned(mod *plannedModule, region string, out *[]domain.Resource) { r := domain.Resource{ Ref: domain.Reference{Kind: pr.Type, Name: nameFromAddress(pr.Address, pr.Name)}, Attributes: attrs, + Action: actions[pr.Address], } if region != "" { rgn := region @@ -99,8 +126,48 @@ func collectPlanned(mod *plannedModule, region string, out *[]domain.Resource) { *out = append(*out, r) } for _, child := range mod.ChildModules { - collectPlanned(child, region, out) + collectPlanned(child, region, actions, out) + } +} + +// buildActionMap creates an address → PlanAction lookup from +// resource_changes. This lets planned_values resources carry the +// intent metadata (create/update/no-op) without losing completeness. +func buildActionMap(changes []resourceChange) map[string]domain.PlanAction { + m := make(map[string]domain.PlanAction, len(changes)) + for _, rc := range changes { + m[rc.Address] = classifyActions(rc.Change.Actions) } + return m +} + +// classifyActions reduces Terraform's actions array to a single +// PlanAction. Terraform uses combinations like ["delete","create"] for +// replacements; we collapse those to the most relevant single action. +func classifyActions(actions []string) domain.PlanAction { + if len(actions) == 0 { + return domain.PlanActionNoOp + } + if len(actions) == 1 { + switch actions[0] { + case "create": + return domain.PlanActionCreate + case "delete": + return domain.PlanActionDelete + case "update": + return domain.PlanActionUpdate + case "no-op", "read": + return domain.PlanActionNoOp + } + } + // Multi-action: ["delete","create"] = replace, ["create","delete"] = replace + // Treat any combo containing "create" or "update" as an update. + for _, a := range actions { + if a == "create" || a == "update" { + return domain.PlanActionUpdate + } + } + return domain.PlanActionNoOp } // nameFromAddress reconstructs the resource name preserving any @@ -162,7 +229,11 @@ func defaultRegion(cfg *configuration) string { if !ok { continue } - expr, ok := pc.Expressions[p.Attr] + raw, ok := pc.Expressions[p.Attr] + if !ok { + continue + } + expr, ok := parseExpression(raw) if !ok { continue } @@ -210,6 +281,7 @@ type resourceChange struct { type change struct { Actions []string `json:"actions"` + Before any `json:"before"` After any `json:"after"` } @@ -218,7 +290,7 @@ type configuration struct { } type providerConfig struct { - Expressions map[string]expression `json:"expressions"` + Expressions map[string]json.RawMessage `json:"expressions"` } // expression is the loose shape Terraform uses for both constants and @@ -227,3 +299,15 @@ type providerConfig struct { type expression struct { ConstantValue any `json:"constant_value"` } + +// parseExpression attempts to decode a raw JSON value as an expression +// object. Terraform's plan JSON can store expressions as either objects +// ({"constant_value": ...}) or arrays (e.g. the `features` block in +// azurerm). We only care about the object form with constant_value. +func parseExpression(raw json.RawMessage) (expression, bool) { + var expr expression + if err := json.Unmarshal(raw, &expr); err != nil { + return expression{}, false + } + return expr, true +} diff --git a/internal/parser/plan/plan_test.go b/internal/parser/plan/plan_test.go index 7ec43a3..c37d69a 100644 --- a/internal/parser/plan/plan_test.go +++ b/internal/parser/plan/plan_test.go @@ -51,8 +51,9 @@ func TestParsesPlanWithModuleAddresses(t *testing.T) { if err != nil { t.Fatal(err) } - if len(got) != 2 { - t.Fatalf("expected 2 resources (delete filtered), got %d", len(got)) + // 2 created + 1 deleted (surfaced for delta renderer) = 3 + if len(got) != 3 { + t.Fatalf("expected 3 resources, got %d", len(got)) } if got[0].Ref.Name != "module.frontend.web" { t.Errorf("module-prefixed name lost: %q", got[0].Ref.Name) @@ -63,6 +64,9 @@ func TestParsesPlanWithModuleAddresses(t *testing.T) { if got[0].Region == nil || *got[0].Region != "us-east-2" { t.Errorf("region not picked up from provider_config: %v", got[0].Region) } + if string(got[2].Action) != "delete" { + t.Errorf("deleted resource action = %q, want 'delete'", got[2].Action) + } } func TestPicksUpAzureLocation(t *testing.T) { @@ -192,7 +196,8 @@ func TestPlannedValues_WalksChildModulesSafely(t *testing.T) { // TestFallback_KeepsNoOpExcludesDeleteOnly checks the older-format path // (no planned_values): unchanged resources are kept (they exist -// post-apply); resources scheduled only for destruction are dropped. +// post-apply); resources scheduled only for destruction are surfaced +// with PlanActionDelete for the delta renderer. func TestFallback_KeepsNoOpExcludesDeleteOnly(t *testing.T) { t.Parallel() raw := `{ @@ -207,10 +212,142 @@ func TestFallback_KeepsNoOpExcludesDeleteOnly(t *testing.T) { if err != nil { t.Fatal(err) } - if len(got) != 1 { - t.Fatalf("expected 1 resource (no-op kept, delete excluded), got %d", len(got)) + // 1 from fallback (no-op kept) + 1 appended delete = 2 + if len(got) != 2 { + t.Fatalf("expected 2 resources (no-op + delete surfaced), got %d", len(got)) } if got[0].Ref.Name != "keep" { - t.Errorf("expected the no-op resource, got %s", got[0].Ref.Name) + t.Errorf("expected the no-op resource first, got %s", got[0].Ref.Name) + } + if string(got[1].Action) != "delete" { + t.Errorf("expected delete action on second resource, got %q", got[1].Action) + } +} + +func TestPlannedValues_AnnotatesWithPlanAction(t *testing.T) { + t.Parallel() + + raw := `{ + "resource_changes": [ + { + "address": "aws_instance.created", + "type": "aws_instance", + "name": "created", + "change": {"actions": ["create"], "after": {"instance_type": "t3.micro"}} + }, + { + "address": "aws_instance.updated", + "type": "aws_instance", + "name": "updated", + "change": {"actions": ["update"], "after": {"instance_type": "t3.large"}} + }, + { + "address": "aws_instance.unchanged", + "type": "aws_instance", + "name": "unchanged", + "change": {"actions": ["no-op"], "after": {"instance_type": "t3.small"}} + } + ], + "planned_values": { + "root_module": { + "resources": [ + { + "address": "aws_instance.created", + "mode": "managed", + "type": "aws_instance", + "name": "created", + "values": {"instance_type": "t3.micro"} + }, + { + "address": "aws_instance.updated", + "mode": "managed", + "type": "aws_instance", + "name": "updated", + "values": {"instance_type": "t3.large"} + }, + { + "address": "aws_instance.unchanged", + "mode": "managed", + "type": "aws_instance", + "name": "unchanged", + "values": {"instance_type": "t3.small"} + } + ] + } + } + }` + + got, err := plan.ParseBytes([]byte(raw), nil) + if err != nil { + t.Fatal(err) + } + if len(got) != 3 { + t.Fatalf("expected 3 resources, got %d", len(got)) + } + + actionByName := map[string]string{} + for _, r := range got { + actionByName[r.Ref.Name] = string(r.Action) + } + + if actionByName["created"] != "create" { + t.Errorf("created action = %q, want 'create'", actionByName["created"]) + } + if actionByName["updated"] != "update" { + t.Errorf("updated action = %q, want 'update'", actionByName["updated"]) + } + if actionByName["unchanged"] != "no-op" { + t.Errorf("unchanged action = %q, want 'no-op'", actionByName["unchanged"]) + } +} + +func TestArrayExpressionsHandledGracefully(t *testing.T) { + t.Parallel() + + // Terraform 4.x azurerm emits features as an array in expressions. + // The parser must not crash on this. + raw := `{ + "resource_changes": [ + { + "address": "azurerm_resource_group.rg", + "type": "azurerm_resource_group", + "name": "rg", + "change": {"actions": ["create"], "after": {"name": "my-rg", "location": "westeurope"}} + } + ], + "planned_values": { + "root_module": { + "resources": [ + { + "address": "azurerm_resource_group.rg", + "mode": "managed", + "type": "azurerm_resource_group", + "name": "rg", + "values": {"name": "my-rg", "location": "westeurope"} + } + ] + } + }, + "configuration": { + "provider_config": { + "azurerm": { + "expressions": { + "features": [{}], + "location": {"constant_value": "westeurope"} + } + } + } + } + }` + + got, err := plan.ParseBytes([]byte(raw), nil) + if err != nil { + t.Fatalf("should not fail on array expressions: %v", err) + } + if len(got) != 1 { + t.Fatalf("expected 1 resource, got %d", len(got)) + } + if got[0].Region == nil || *got[0].Region != "westeurope" { + t.Errorf("region should be westeurope despite array expression, got %v", got[0].Region) } } diff --git a/internal/render/dispatch.go b/internal/render/dispatch.go index c1bb9db..36c6971 100644 --- a/internal/render/dispatch.go +++ b/internal/render/dispatch.go @@ -29,6 +29,21 @@ func Render(est domain.Estimate, f Format) (string, error) { } } +// RenderDelta dispatches an Estimate to a delta-aware renderer that +// shows only changed resources (from plan JSON) with +/~/- markers. +// Falls back to the full estimate renderer for formats that don't +// have a delta-specific view. +func RenderDelta(est domain.Estimate, f Format) (string, error) { + switch f { + case FormatText: + return RenderTextDelta(est), nil + default: + // Other formats don't have delta-specific rendering yet; + // fall back to the standard renderer. + return Render(est, f) + } +} + // RenderDiff dispatches a Diff to the right format-specific renderer. // Every format the estimate renderer supports works here too. func RenderDiff(d domain.Diff, f Format) (string, error) { diff --git a/internal/render/render_test.go b/internal/render/render_test.go index d0ded24..e75b007 100644 --- a/internal/render/render_test.go +++ b/internal/render/render_test.go @@ -207,3 +207,125 @@ func TestDiffRenderText(t *testing.T) { t.Errorf("expected +$4 delta:\n%s", out) } } + +func TestRenderTextDelta_ShowsOnlyChangedResources(t *testing.T) { + t.Parallel() + + est := domain.NewEstimate([]domain.Cost{ + { + Resource: domain.Reference{Kind: "aws_instance", Name: "new_server"}, + LineItems: []domain.LineItem{{ + Dimension: "compute", Description: "Instance", + Unit: "hours", Quantity: dec("730"), UnitRate: dec("0.1"), + MonthlyCost: dec("73"), PriceSource: domain.PriceSourceLive, + }}, + MonthlySubtotal: dec("73"), + Currency: domain.CurrencyUSD, + Action: domain.PlanActionCreate, + }, + { + Resource: domain.Reference{Kind: "aws_instance", Name: "updated_server"}, + LineItems: []domain.LineItem{{ + Dimension: "compute", Description: "Instance", + Unit: "hours", Quantity: dec("730"), UnitRate: dec("0.2"), + MonthlyCost: dec("146"), PriceSource: domain.PriceSourceLive, + }}, + MonthlySubtotal: dec("146"), + Currency: domain.CurrencyUSD, + Action: domain.PlanActionUpdate, + }, + { + Resource: domain.Reference{Kind: "aws_instance", Name: "unchanged_1"}, + LineItems: []domain.LineItem{{ + Dimension: "compute", Description: "Instance", + Unit: "hours", Quantity: dec("730"), UnitRate: dec("0.05"), + MonthlyCost: dec("36.5"), PriceSource: domain.PriceSourceLive, + }}, + MonthlySubtotal: dec("36.5"), + Currency: domain.CurrencyUSD, + Action: domain.PlanActionNoOp, + }, + { + Resource: domain.Reference{Kind: "aws_instance", Name: "unchanged_2"}, + LineItems: []domain.LineItem{{ + Dimension: "compute", Description: "Instance", + Unit: "hours", Quantity: dec("730"), UnitRate: dec("0.05"), + MonthlyCost: dec("36.5"), PriceSource: domain.PriceSourceLive, + }}, + MonthlySubtotal: dec("36.5"), + Currency: domain.CurrencyUSD, + Action: domain.PlanActionNoOp, + }, + }, domain.CurrencyUSD, time.Unix(1700000000, 0).UTC()) + + out := render.RenderTextDelta(est) + + if !strings.Contains(out, "plan changes") { + t.Errorf("expected 'plan changes' header, got:\n%s", out) + } + if !strings.Contains(out, "+ aws_instance.new_server") { + t.Errorf("expected '+ aws_instance.new_server', got:\n%s", out) + } + if !strings.Contains(out, "~ aws_instance.updated_server") { + t.Errorf("expected '~ aws_instance.updated_server', got:\n%s", out) + } + if strings.Contains(out, "aws_instance.unchanged_1") { + t.Errorf("unchanged resource should not appear individually:\n%s", out) + } + if !strings.Contains(out, "2 unchanged resources") { + t.Errorf("expected unchanged summary, got:\n%s", out) + } + if !strings.Contains(out, "PROJECT TOTAL") { + t.Errorf("expected PROJECT TOTAL, got:\n%s", out) + } +} + +func TestRenderTextDelta_DeletedResource(t *testing.T) { + t.Parallel() + + est := domain.NewEstimate([]domain.Cost{ + { + Resource: domain.Reference{Kind: "aws_instance", Name: "doomed"}, + LineItems: []domain.LineItem{{ + Dimension: "compute", Description: "Instance", + Unit: "hours", Quantity: dec("730"), UnitRate: dec("0.1"), + MonthlyCost: dec("73"), PriceSource: domain.PriceSourceLive, + }}, + MonthlySubtotal: dec("73"), + Currency: domain.CurrencyUSD, + Action: domain.PlanActionDelete, + }, + }, domain.CurrencyUSD, time.Unix(1700000000, 0).UTC()) + + out := render.RenderTextDelta(est) + + if !strings.Contains(out, "- aws_instance.doomed") { + t.Errorf("expected '- aws_instance.doomed', got:\n%s", out) + } +} + +func TestRenderDeltaDispatch_FallsBackForNonText(t *testing.T) { + t.Parallel() + + est := domain.NewEstimate([]domain.Cost{ + { + Resource: domain.Reference{Kind: "aws_instance", Name: "web"}, + LineItems: []domain.LineItem{{ + Dimension: "compute", Description: "Instance", + Unit: "hours", Quantity: dec("730"), UnitRate: dec("0.1"), + MonthlyCost: dec("73"), PriceSource: domain.PriceSourceLive, + }}, + MonthlySubtotal: dec("73"), + Currency: domain.CurrencyUSD, + Action: domain.PlanActionCreate, + }, + }, domain.CurrencyUSD, time.Unix(1700000000, 0).UTC()) + + out, err := render.RenderDelta(est, render.FormatJSON) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out, `"project_total"`) { + t.Errorf("expected JSON output from fallback, got:\n%s", out) + } +} diff --git a/internal/render/text.go b/internal/render/text.go index 11da0f7..993de70 100644 --- a/internal/render/text.go +++ b/internal/render/text.go @@ -5,6 +5,7 @@ import ( "strings" "github.com/c3xdev/c3x/internal/domain" + "github.com/shopspring/decimal" ) // RenderText formats an Estimate as a terminal-friendly breakdown. The @@ -14,25 +15,69 @@ import ( // Static-rate items get a `(static)` annotation so users see at a // glance which line items don't track upstream price changes. func RenderText(est domain.Estimate) string { + return renderTextEstimate(est, false) +} + +// RenderTextDelta formats an Estimate showing only resources with plan +// actions (create/update/delete), annotated with +/~/- markers. Resources +// with no-op action are summarized in a footer line. This gives a +// diff-like view from plan JSON without requiring a baseline file. +func RenderTextDelta(est domain.Estimate) string { + return renderTextEstimate(est, true) +} + +func renderTextEstimate(est domain.Estimate, deltaOnly bool) string { if len(est.Costs) == 0 { return "c3x: no resources to estimate.\n" } var b strings.Builder cur := est.Currency - fmt.Fprintf(&b, "── c3x estimate · %s ─────────────────────────────────────────\n\n", cur) + + header := "estimate" + if deltaOnly { + header = "plan changes" + } + fmt.Fprintf(&b, "── c3x %s · %s ─────────────────────────────────────────\n\n", header, cur) priced := 0 + unchangedCount := 0 + unchangedCost := decimal.Zero + deltaCost := decimal.Zero + for _, c := range est.Costs { if len(c.LineItems) == 0 { continue } + + // In delta mode, group no-op resources into a summary. + if deltaOnly && (c.Action == domain.PlanActionNoOp || c.Action == domain.PlanActionNone) { + unchangedCount++ + unchangedCost = unchangedCost.Add(c.MonthlySubtotal) + continue + } + priced++ label := c.Resource.Label() + marker := actionMarker(c.Action) annot := "" if c.HasStaticRate() { annot = " (some line items use static rates)" } - fmt.Fprintf(&b, " %s%s\n", label, annot) + if marker != "" { + fmt.Fprintf(&b, " %s %s%s\n", marker, label, annot) + } else { + fmt.Fprintf(&b, " %s%s\n", label, annot) + } + + // Track delta cost: deletions subtract, creates/updates add. + if deltaOnly { + if c.Action == domain.PlanActionDelete { + deltaCost = deltaCost.Sub(c.MonthlySubtotal) + } else { + deltaCost = deltaCost.Add(c.MonthlySubtotal) + } + } + for _, li := range c.LineItems { src := "" if li.PriceSource == domain.PriceSourceStatic { @@ -48,18 +93,60 @@ func RenderText(est domain.Estimate) string { fmt.Fprintf(&b, " %s subtotal: %s%s/mo\n\n", label, cur.Symbol(), c.MonthlySubtotal) } - if priced == 0 { + if deltaOnly && unchangedCount > 0 { + fmt.Fprintf(&b, " ... %d unchanged resources: %s%s/mo\n\n", + unchangedCount, cur.Symbol(), unchangedCost.Round(2)) + } + + if priced == 0 && !deltaOnly { fmt.Fprintf(&b, " %d resources parsed; none priced.\n", len(est.Costs)) fmt.Fprintln(&b, " This usually means --offline, an unknown resource kind,") fmt.Fprintln(&b, " or that pricing.c3x.dev returned no matching products.") return b.String() } + if deltaOnly && priced == 0 { + fmt.Fprintf(&b, " No cost-affecting changes in this plan.\n") + fmt.Fprintf(&b, " %d resources unchanged at %s%s/mo\n", + unchangedCount, cur.Symbol(), unchangedCost.Round(2)) + return b.String() + } + b.WriteString(" ────────────────────────────────────────────────────────────\n") - fmt.Fprintf(&b, " PROJECT TOTAL: %s%s/mo\n", cur.Symbol(), est.ProjectTotal) + if deltaOnly { + fmt.Fprintf(&b, " DELTA: %s/mo (changed resources)\n", signedDelta(cur.Symbol(), deltaCost.Round(2))) + fmt.Fprintf(&b, " PROJECT TOTAL: %s%s/mo\n", cur.Symbol(), est.ProjectTotal) + } else { + fmt.Fprintf(&b, " PROJECT TOTAL: %s%s/mo\n", cur.Symbol(), est.ProjectTotal) + } return b.String() } +// actionMarker returns a visual prefix for plan actions. +func actionMarker(a domain.PlanAction) string { + switch a { + case domain.PlanActionCreate: + return "+" + case domain.PlanActionUpdate: + return "~" + case domain.PlanActionDelete: + return "-" + default: + return "" + } +} + +// signedDelta renders a decimal as a signed cost delta string. +func signedDelta(sym string, d decimal.Decimal) string { + if d.IsZero() { + return sym + "0.00" + } + if d.IsNegative() { + return "-" + sym + d.Neg().String() + } + return "+" + sym + d.String() +} + // RenderTextDiff formats a Diff in the same visual idiom as // [RenderText] but with +/- markers and a delta column. func RenderTextDiff(d domain.Diff) string { From 9dc1271434399f7ac58b508176c842acff513915 Mon Sep 17 00:00:00 2001 From: Tino Pittner Date: Tue, 21 Jul 2026 14:07:10 +0200 Subject: [PATCH 2/3] fix(terraform): resolve optional() defaults from module variable type constraints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a module variable uses optional(type, default) in its type constraint and the caller omits those keys, the parser previously stored a source-range string as the attribute value. This caused type mismatches in catalog expressions like `default(os_disk_disk_size_gb, 0) > 0`. This commit: - Adds applyOptionalDefaults() which parses the type-constraint AST, finds optional(type, default) calls, and merges their defaults into caller-supplied values — matching Terraform runtime behavior. - Applied to both root-module and child-module variables. - Handles nested objects recursively: optional(object({...}), {}) with children that themselves have optional() defaults are filled in at arbitrary depth via applyChildDefaults. - Only synthesizes values when an explicit default is provided (2nd arg to optional()). Without an explicit default, the attribute stays null — matching Terraform semantics and avoiding phantom cost estimates. - Guards against zero-arg object() type constraints to prevent panics on untrusted HCL input. - Unresolvable attributes now store nil instead of a source-range string, so `default(x, fallback)` expressions fall through correctly. --- internal/parser/terraform/modules.go | 7 + .../parser/terraform/optional_defaults.go | 245 ++++++++++++++++++ .../terraform/optional_defaults_test.go | 122 +++++++++ internal/parser/terraform/parser.go | 5 + internal/parser/terraform/resources.go | 13 +- 5 files changed, 387 insertions(+), 5 deletions(-) create mode 100644 internal/parser/terraform/optional_defaults.go create mode 100644 internal/parser/terraform/optional_defaults_test.go diff --git a/internal/parser/terraform/modules.go b/internal/parser/terraform/modules.go index 7233f2c..c7474b7 100644 --- a/internal/parser/terraform/modules.go +++ b/internal/parser/terraform/modules.go @@ -154,6 +154,13 @@ func expandModules( childVars[k] = v } + // Fill in optional() defaults from the variable type + // constraints. This bridges the gap between Terraform's + // runtime type-system and c3x's static parsing: attributes + // like `os_disk = optional(object({disk_size_gb = optional(number, 64)}), {})` + // get their defaults applied to the caller-supplied values. + applyOptionalDefaults(childSources, childVars) + childData := collectDataBlocks(childSources) childLocals := resolveLocals(childSources, childVars, childData) childRegion := findDefaultRegion(childSources, childVars, childLocals, childData, logger) diff --git a/internal/parser/terraform/optional_defaults.go b/internal/parser/terraform/optional_defaults.go new file mode 100644 index 0000000..692cd3b --- /dev/null +++ b/internal/parser/terraform/optional_defaults.go @@ -0,0 +1,245 @@ +package terraform + +import ( + "github.com/hashicorp/hcl/v2/hclsyntax" + "github.com/zclconf/go-cty/cty" +) + +// applyOptionalDefaults inspects each variable block in the child module's +// sources and, for variables that have a `type` constraint containing +// `optional()` calls with default values, merges those defaults into the +// corresponding variable value in `vars`. This bridges the gap between +// Terraform's runtime type-system (which applies optional defaults +// automatically) and c3x's static HCL parsing. +// +// Example: given a variable typed as +// +// variable "vm" { +// type = object({ +// size = optional(string, "Standard_D2s_v3") +// os_disk = optional(object({ +// disk_size_gb = optional(number, 64) +// }), {}) +// }) +// } +// +// and a caller-supplied value of { dataDisks = [...] }, this function +// fills in os_disk = {} and, within os_disk, disk_size_gb = 64. +func applyOptionalDefaults(sources []sourceFile, vars map[string]cty.Value) { + for _, src := range sources { + for _, block := range src.Body.Blocks { + if block.Type != "variable" || len(block.Labels) == 0 { + continue + } + name := block.Labels[0] + typeAttr, ok := block.Body.Attributes["type"] + if !ok { + continue + } + currentVal, hasVal := vars[name] + if !hasVal || currentVal.IsNull() || !currentVal.IsKnown() { + continue + } + + // Extract optional() defaults from the type expression tree. + defaults := extractOptionalDefaults(typeAttr.Expr) + if len(defaults) == 0 { + continue + } + + // Merge defaults into the current value. + merged := mergeDefaults(currentVal, defaults) + if merged != cty.NilVal { + vars[name] = merged + } + } + } +} + +// optionalDefault represents a single optional() default at one level +// of the type tree. +type optionalDefault struct { + // defaultVal is the literal default value (2nd arg of optional()). + defaultVal cty.Value + // children holds nested optional() defaults for object members. + children map[string]*optionalDefault +} + +// extractOptionalDefaults walks an HCL expression tree representing a +// type constraint and finds all optional(type, default) calls. It +// returns a map of attribute-name → optionalDefault for the top-level +// object's members. +// +// The type constraint grammar we care about: +// +// object({ key = optional(type, default), ... }) +// +// We only process the subset we can statically understand. +func extractOptionalDefaults(expr hclsyntax.Expression) map[string]*optionalDefault { + // The top-level expression should be a function call to object(). + call, ok := expr.(*hclsyntax.FunctionCallExpr) + if !ok { + return nil + } + if call.Name != "object" || len(call.Args) != 1 { + return nil + } + // The argument to object() is an ObjectConsExpr: { key = type, ... } + return extractObjectDefaults(call.Args[0]) +} + +// extractObjectDefaults processes the argument of an object() type +// function call, which is an ObjectConsExpr containing key-value pairs +// where values may be optional(type, default) calls. +func extractObjectDefaults(expr hclsyntax.Expression) map[string]*optionalDefault { + obj, ok := expr.(*hclsyntax.ObjectConsExpr) + if !ok { + return nil + } + out := make(map[string]*optionalDefault) + for _, item := range obj.Items { + // Key must be a traversal or literal string we can read. + keyName := exprToLiteralString(item.KeyExpr) + if keyName == "" { + continue + } + // Value: check if it's optional(type, default) + valCall, isCall := item.ValueExpr.(*hclsyntax.FunctionCallExpr) + if !isCall || valCall.Name != "optional" { + // Not optional — could still be a nested object() with + // optional members inside it. + if nestedCall, nestedOk := item.ValueExpr.(*hclsyntax.FunctionCallExpr); nestedOk && nestedCall.Name == "object" && len(nestedCall.Args) == 1 { + children := extractObjectDefaults(nestedCall.Args[0]) + if len(children) > 0 { + out[keyName] = &optionalDefault{children: children} + } + } + continue + } + + od := &optionalDefault{} + + // optional(type) — no default, skip + // optional(type, default) — has default as 2nd arg + if len(valCall.Args) >= 2 { + // Try to evaluate the default expression as a literal. + ctx := buildEvalContext(cty.EmptyObjectVal, cty.EmptyObjectVal, cty.EmptyObjectVal, nil) + val, diags := valCall.Args[1].Value(ctx) + if !diags.HasErrors() { + od.defaultVal = val + } + } + + // The first arg of optional() is the type — check if it's + // object({...}) so we can recurse for nested defaults. + if len(valCall.Args) >= 1 { + if innerCall, innerOk := valCall.Args[0].(*hclsyntax.FunctionCallExpr); innerOk && innerCall.Name == "object" && len(innerCall.Args) == 1 { + od.children = extractObjectDefaults(innerCall.Args[0]) + } + } + + if od.defaultVal != cty.NilVal || len(od.children) > 0 { + out[keyName] = od + } + } + return out +} + +// exprToLiteralString tries to read a simple key expression as a plain +// string. Handles bare identifiers (the most common case in type +// constraints) and literal strings. +func exprToLiteralString(expr hclsyntax.Expression) string { + switch e := expr.(type) { + case *hclsyntax.ObjectConsKeyExpr: + // ObjectConsKeyExpr wraps the actual key expression; recurse. + return exprToLiteralString(e.Wrapped) + case *hclsyntax.ScopeTraversalExpr: + // Bare identifier like: os_disk = optional(...) + if len(e.Traversal) == 1 { + return e.Traversal.RootName() + } + case *hclsyntax.LiteralValueExpr: + if e.Val.Type() == cty.String { + return e.Val.AsString() + } + } + return "" +} + +// mergeDefaults takes a cty.Value (the variable's current value) and +// fills in missing keys with their optional() defaults, recursing into +// nested objects. +func mergeDefaults(current cty.Value, defaults map[string]*optionalDefault) cty.Value { + if !current.IsKnown() || current.IsNull() { + return cty.NilVal + } + ty := current.Type() + if !ty.IsObjectType() && !ty.IsMapType() { + return cty.NilVal + } + + // Convert to a mutable map of attribute values. + attrs := make(map[string]cty.Value) + it := current.ElementIterator() + for it.Next() { + k, v := it.Element() + attrs[k.AsString()] = v + } + + changed := false + for key, od := range defaults { + existing, exists := attrs[key] + + if !exists || existing.IsNull() { + // Key missing — apply default if we have one. + if od.defaultVal != cty.NilVal { + attrs[key] = applyChildDefaults(od.defaultVal, od.children) + changed = true + } + // No explicit default: Terraform leaves the attribute null, + // so we don't synthesize a value from child defaults alone. + // Only optional(object({...}), {}) (with explicit 2nd arg) + // should produce a populated object. + continue + } + + // Key exists — recurse into children if the value is an object. + if len(od.children) > 0 && existing.IsKnown() && !existing.IsNull() { + childTy := existing.Type() + if childTy.IsObjectType() || childTy.IsMapType() { + merged := mergeDefaults(existing, od.children) + if merged != cty.NilVal { + attrs[key] = merged + changed = true + } + } + } + } + + if !changed { + return cty.NilVal + } + return cty.ObjectVal(attrs) +} + +// applyChildDefaults takes a default value and, if it's an object with +// child optional() defaults defined, recursively fills in those children. +// This handles the common pattern: `os_disk = optional(object({...}), {})` +// where the default is empty but the nested type has its own defaults. +func applyChildDefaults(defVal cty.Value, children map[string]*optionalDefault) cty.Value { + if len(children) == 0 { + return defVal + } + if !defVal.IsKnown() || defVal.IsNull() { + return defVal + } + defTy := defVal.Type() + if !defTy.IsObjectType() && !defTy.IsMapType() { + return defVal + } + merged := mergeDefaults(defVal, children) + if merged != cty.NilVal { + return merged + } + return defVal +} diff --git a/internal/parser/terraform/optional_defaults_test.go b/internal/parser/terraform/optional_defaults_test.go new file mode 100644 index 0000000..d2dfe4d --- /dev/null +++ b/internal/parser/terraform/optional_defaults_test.go @@ -0,0 +1,122 @@ +package terraform + +import ( + "testing" + + "github.com/hashicorp/hcl/v2/hclparse" + "github.com/hashicorp/hcl/v2/hclsyntax" + "github.com/zclconf/go-cty/cty" +) + +func TestApplyOptionalDefaults_FillsMissingKeys(t *testing.T) { + t.Parallel() + + src := ` +variable "vm" { + type = object({ + size = optional(string, "Standard_D2s_v3") + os_disk = optional(object({ + disk_size_gb = optional(number, 64) + storage_account_type = optional(string, "Premium_LRS") + }), {}) + }) +} +` + parser := hclparse.NewParser() + file, diags := parser.ParseHCL([]byte(src), "test.tf") + if diags.HasErrors() { + t.Fatalf("parse: %s", diags.Error()) + } + body := file.Body.(*hclsyntax.Body) + sources := []sourceFile{{Path: "test.tf", Body: body}} + + // Caller supplies only { dataDisks = [...] } — no size, no os_disk. + vars := map[string]cty.Value{ + "vm": cty.ObjectVal(map[string]cty.Value{ + "dataDisks": cty.ListValEmpty(cty.DynamicPseudoType), + }), + } + + applyOptionalDefaults(sources, vars) + + result := vars["vm"] + if !result.Type().HasAttribute("size") { + t.Fatal("expected 'size' attribute to be filled in") + } + size := result.GetAttr("size") + if size.AsString() != "Standard_D2s_v3" { + t.Errorf("size = %q, want %q", size.AsString(), "Standard_D2s_v3") + } + + if !result.Type().HasAttribute("os_disk") { + t.Fatal("expected 'os_disk' attribute to be filled in") + } + osDisk := result.GetAttr("os_disk") + if !osDisk.IsKnown() { + t.Error("os_disk should be known") + } +} + +func TestApplyOptionalDefaults_PreservesExistingKeys(t *testing.T) { + t.Parallel() + + src := ` +variable "config" { + type = object({ + name = optional(string, "default-name") + region = optional(string, "us-east-1") + }) +} +` + parser := hclparse.NewParser() + file, diags := parser.ParseHCL([]byte(src), "test.tf") + if diags.HasErrors() { + t.Fatalf("parse: %s", diags.Error()) + } + body := file.Body.(*hclsyntax.Body) + sources := []sourceFile{{Path: "test.tf", Body: body}} + + // Caller supplies name but not region. + vars := map[string]cty.Value{ + "config": cty.ObjectVal(map[string]cty.Value{ + "name": cty.StringVal("my-custom-name"), + }), + } + + applyOptionalDefaults(sources, vars) + + result := vars["config"] + name := result.GetAttr("name") + if name.AsString() != "my-custom-name" { + t.Errorf("name should be preserved: got %q", name.AsString()) + } + region := result.GetAttr("region") + if region.AsString() != "us-east-1" { + t.Errorf("region should be filled from default: got %q", region.AsString()) + } +} + +func TestExtractOptionalDefaults_NonObjectType(t *testing.T) { + t.Parallel() + + // variable with type = string — no optional() to extract + src := `variable "simple" { type = string }` + parser := hclparse.NewParser() + file, diags := parser.ParseHCL([]byte(src), "test.tf") + if diags.HasErrors() { + t.Fatalf("parse: %s", diags.Error()) + } + body := file.Body.(*hclsyntax.Body) + sources := []sourceFile{{Path: "test.tf", Body: body}} + + vars := map[string]cty.Value{ + "simple": cty.StringVal("hello"), + } + + // Should not panic or modify the value. + applyOptionalDefaults(sources, vars) + + if vars["simple"].AsString() != "hello" { + t.Errorf("unexpected modification: %v", vars["simple"]) + } +} diff --git a/internal/parser/terraform/parser.go b/internal/parser/terraform/parser.go index 75978be..15e2442 100644 --- a/internal/parser/terraform/parser.go +++ b/internal/parser/terraform/parser.go @@ -125,6 +125,11 @@ func parseSources(sources []sourceFile, baseDir string, opts Options) ([]domain. variables[k] = parseCLIVar(v) } + // Stage 2b: apply optional() defaults from variable type constraints. + // This fills in attributes like `optional(string, "default")` for + // root-module variables, matching Terraform's runtime behavior. + applyOptionalDefaults(sources, variables) + // Stage 3: data-block placeholders for `data.kind.name.attr` traversals. data := collectDataBlocks(sources) diff --git a/internal/parser/terraform/resources.go b/internal/parser/terraform/resources.go index bc031fe..4f27e67 100644 --- a/internal/parser/terraform/resources.go +++ b/internal/parser/terraform/resources.go @@ -211,11 +211,14 @@ func extractAttributesLevel(body *hclsyntax.Body, ctx *hcl.EvalContext, topLevel } val, diags := attr.Expr.Value(ctx) if diags.HasErrors() { - // Best-effort fallback: keep the raw source range as a - // stringified placeholder so downstream code (e.g. catalog - // expressions doing literal-string comparisons) still sees - // something useful rather than an error. - out[attr.Name] = attr.Expr.Range().String() + // Attribute couldn't be resolved (e.g. optional() defaults + // in module variables that aren't supplied by the caller). + // Store nil so catalog expressions' `default(x, fallback)` + // correctly falls through to the fallback value. Storing a + // non-nil placeholder (like the source range string) would + // bypass the default() logic and trigger type-mismatch + // errors in numeric comparisons. + out[attr.Name] = nil continue } out[attr.Name] = ctyToAny(val) From 63b6d0faeda53aa0ed6de262f587eaf3fddf962d Mon Sep 17 00:00:00 2001 From: Tino Pittner Date: Wed, 22 Jul 2026 08:42:28 +0200 Subject: [PATCH 3/3] fix(render): exclude deleted resources from PROJECT TOTAL and standard view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deleted resources were appended to the output for the delta renderer but inadvertently inflated PROJECT TOTAL and appeared in the standard (non-delta) view — regressing the #52 post-apply-fleet semantics. Fix: - NewEstimate skips PlanActionDelete costs when summing ProjectTotal. - Standard renderer skips deleted resources entirely (they only surface in --show-delta mode with the `-` marker). - Action markers (+/~/−) are now scoped to delta mode only; the normal estimate view shows no plan-action prefixes. --- internal/domain/estimate.go | 8 ++++++++ internal/render/text.go | 16 +++++++++++++--- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/internal/domain/estimate.go b/internal/domain/estimate.go index e582a4b..a1577ae 100644 --- a/internal/domain/estimate.go +++ b/internal/domain/estimate.go @@ -35,9 +35,17 @@ type SkippedResource struct { // NewEstimate builds an Estimate, summing per-Cost subtotals at full // precision and rounding the ProjectTotal to 2dp at the boundary so the // number a user sees matches the sum of the displayed line items. +// +// Costs with PlanActionDelete are excluded from ProjectTotal — they +// represent resources scheduled for removal that won't exist post-apply. +// They remain in the Costs slice so the delta renderer can display them +// with a `-` marker and subtract their cost in the DELTA line. func NewEstimate(costs []Cost, currency Currency, generatedAt time.Time) Estimate { total := decimal.Zero for _, c := range costs { + if c.Action == PlanActionDelete { + continue + } total = total.Add(c.MonthlySubtotal) } return Estimate{ diff --git a/internal/render/text.go b/internal/render/text.go index 993de70..2bd52e8 100644 --- a/internal/render/text.go +++ b/internal/render/text.go @@ -49,6 +49,12 @@ func renderTextEstimate(est domain.Estimate, deltaOnly bool) string { continue } + // In non-delta mode, skip deleted resources entirely — they + // won't exist post-apply and shouldn't appear in the standard view. + if !deltaOnly && c.Action == domain.PlanActionDelete { + continue + } + // In delta mode, group no-op resources into a summary. if deltaOnly && (c.Action == domain.PlanActionNoOp || c.Action == domain.PlanActionNone) { unchangedCount++ @@ -58,13 +64,17 @@ func renderTextEstimate(est domain.Estimate, deltaOnly bool) string { priced++ label := c.Resource.Label() - marker := actionMarker(c.Action) annot := "" if c.HasStaticRate() { annot = " (some line items use static rates)" } - if marker != "" { - fmt.Fprintf(&b, " %s %s%s\n", marker, label, annot) + if deltaOnly { + marker := actionMarker(c.Action) + if marker != "" { + fmt.Fprintf(&b, " %s %s%s\n", marker, label, annot) + } else { + fmt.Fprintf(&b, " %s%s\n", label, annot) + } } else { fmt.Fprintf(&b, " %s%s\n", label, annot) }