Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
40 changes: 35 additions & 5 deletions cmd/c3x/estimate.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ func newEstimateCmd() *cobra.Command {
inlineDemo bool
currency string
showSkipped bool
showDelta bool
)

cmd := &cobra.Command{
Expand Down Expand Up @@ -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)
},
}

Expand Down Expand Up @@ -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)")
Expand All @@ -160,6 +163,7 @@ func runEstimate(
saveBaseline string,
budget float64,
showSkipped bool,
showDelta bool,
) error {
varMap, err := parseVarFlags(rawVars)
if err != nil {
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -333,19 +337,45 @@ 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)
}
_, _ = cmd.OutOrStdout().Write([]byte(out))
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{}
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions internal/calculator/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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
}

Expand Down
5 changes: 5 additions & 0 deletions internal/domain/cost.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions internal/domain/estimate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
18 changes: 18 additions & 0 deletions internal/domain/resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
94 changes: 89 additions & 5 deletions internal/parser/plan/plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Appending pure-delete resources into the same out slice the calculator prices means they land in PROJECT TOTAL — which regresses the #52 semantics: estimate plan.json without --show-delta now counts resources that won't exist post-apply.

Repro with a plan of {create t3.small, no-op t3.micro, delete t3.medium}: PROJECT TOTAL prints $53.14, but the post-apply fleet is $22.77 (the two survivors). --show-delta shows the same inflated $53.14.

Deletes shouldn't contribute to the post-apply total, and shouldn't appear at all in the non-delta view. Suggest surfacing them to the delta renderer via a separate field (or excluding PlanActionDelete from the priced total) so the - marker and the DELTA line keep working while PROJECT TOTAL stays the post-apply fleet.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — you're right, appending deletes into out broke the #52 semantics. Fixed in 63b6d0f:

  • NewEstimate now skips PlanActionDelete when summing ProjectTotal
  • Standard renderer hides deleted resources entirely (they only surface in --show-delta)
  • Action markers (+/~/-) scoped to delta mode only — normal view has no prefixes

Verified with your repro scenario: estimate plan.json shows only the two survivors, --show-delta shows all three with correct markers and a separate DELTA line.

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
Expand All @@ -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
}
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -210,6 +281,7 @@ type resourceChange struct {

type change struct {
Actions []string `json:"actions"`
Before any `json:"before"`
After any `json:"after"`
}

Expand All @@ -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
Expand All @@ -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
}
Loading
Loading