Skip to main content

tfparser_core/ir/
component.rs

1//! Component IR: the apply-able unit of a Terraform / Terragrunt workspace.
2
3use std::{path::Path, sync::Arc};
4
5use serde::{Deserialize, Serialize};
6use typed_builder::TypedBuilder;
7
8use crate::ir::{
9    ComponentId, Expression, ModuleCall, ProviderBlock, Resource, SourceFile, Span, StateBackend,
10    TerragruntConfig,
11};
12
13/// Whether a [`Component`] is an apply-able root or a reusable module body.
14///
15/// Set by discovery / module resolution. See [11-discovery.md § 3.2] for the
16/// classification heuristics.
17///
18/// [11-discovery.md § 3.2]: ../../specs/11-discovery.md
19#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
20#[serde(rename_all = "kebab-case")]
21#[non_exhaustive]
22pub enum ComponentKind {
23    /// An apply-able root directory: contains `terragrunt.hcl`, or
24    /// `terraform { backend ... }`, or `resource` blocks at top level.
25    Component,
26    /// A reusable module body — referenced via `module "x" { source =
27    /// "..." }` from a component or another module.
28    Module,
29}
30
31/// A `variable "name" {}` declaration inside a component.
32///
33/// `Debug` redacts `default` when `sensitive == true`, per
34/// [10-data-model.md § 2.5 (I-IR-8)](../../specs/10-data-model.md).
35#[derive(Clone, PartialEq, Serialize, Deserialize, TypedBuilder)]
36#[non_exhaustive]
37#[serde(rename_all = "camelCase")]
38#[builder(field_defaults(setter(into)))]
39pub struct Variable {
40    /// Variable name.
41    pub name: Arc<str>,
42    /// Optional `description = "..."`.
43    #[builder(default)]
44    pub description: Option<Arc<str>>,
45    /// Optional `type = ...` expression (kept as-is; we do not parse the
46    /// HCL type-constructor mini-language in Phase 1).
47    #[builder(default)]
48    pub type_expr: Option<Expression>,
49    /// Optional `default = ...` value. Pre-evaluation this is an
50    /// [`Expression`]; post-evaluation it may be reduced to a
51    /// [`Value`](crate::ir::Value).
52    #[builder(default)]
53    pub default: Option<Expression>,
54    /// Whether the variable was declared `sensitive = true`.
55    #[builder(default = false)]
56    pub sensitive: bool,
57    /// Span of the `variable` keyword.
58    pub span: Span,
59}
60
61impl std::fmt::Debug for Variable {
62    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63        let mut s = f.debug_struct("Variable");
64        s.field("name", &self.name)
65            .field("description", &self.description)
66            .field("type_expr", &self.type_expr.as_ref().map(|_| "<expr>"))
67            .field("sensitive", &self.sensitive);
68        if self.sensitive {
69            s.field("default", &self.default.as_ref().map(|_| "<redacted>"));
70        } else {
71            s.field("default", &self.default);
72        }
73        s.field("span", &self.span).finish()
74    }
75}
76
77/// A `locals { ... }` entry.
78#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, TypedBuilder)]
79#[non_exhaustive]
80#[serde(rename_all = "camelCase")]
81#[builder(field_defaults(setter(into)))]
82pub struct Local {
83    /// Local name (left of `=`).
84    pub name: Arc<str>,
85    /// Right-hand side expression.
86    pub value: Expression,
87    /// Span of the assignment.
88    pub span: Span,
89}
90
91/// An `output "name" {}` declaration.
92///
93/// `Debug` redacts `value` when `sensitive == true`, per
94/// [10-data-model.md § 2.5 (I-IR-8)](../../specs/10-data-model.md).
95#[derive(Clone, PartialEq, Serialize, Deserialize, TypedBuilder)]
96#[non_exhaustive]
97#[serde(rename_all = "camelCase")]
98#[builder(field_defaults(setter(into)))]
99pub struct Output {
100    /// Output name.
101    pub name: Arc<str>,
102    /// `value = ...` expression.
103    pub value: Expression,
104    /// Optional `description = "..."`.
105    #[builder(default)]
106    pub description: Option<Arc<str>>,
107    /// Whether the output was declared `sensitive = true`.
108    #[builder(default = false)]
109    pub sensitive: bool,
110    /// Span of the `output` keyword.
111    pub span: Span,
112}
113
114impl std::fmt::Debug for Output {
115    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
116        let mut s = f.debug_struct("Output");
117        s.field("name", &self.name)
118            .field("description", &self.description)
119            .field("sensitive", &self.sensitive);
120        if self.sensitive {
121            s.field("value", &"<redacted>");
122        } else {
123            s.field("value", &self.value);
124        }
125        s.field("span", &self.span).finish()
126    }
127}
128
129/// An apply-able component (Terraform "root module").
130///
131/// Field order matches [10-data-model.md § 2.1]. Build via
132/// [`ComponentBuilder`].
133///
134/// [10-data-model.md § 2.1]: ../../specs/10-data-model.md
135#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, TypedBuilder)]
136#[non_exhaustive]
137#[serde(rename_all = "camelCase")]
138#[builder(field_defaults(setter(into)))]
139pub struct Component {
140    /// Stable-within-a-run id.
141    pub id: ComponentId,
142
143    /// Path of the component dir, relative to [`crate::ir::Workspace::root`].
144    #[serde(with = "crate::ir::path_serde::arc_path")]
145    pub path: Arc<Path>,
146
147    /// Whether this is an apply-able root or a reusable module body.
148    pub kind: ComponentKind,
149
150    /// Source files belonging to this component (`*.tf`, `*.tfvars`,
151    /// `terragrunt.hcl`, …).
152    #[builder(default)]
153    pub files: Vec<SourceFile>,
154
155    /// `variable "..." {}` blocks declared in the component.
156    #[builder(default)]
157    pub variables: Vec<Variable>,
158
159    /// `locals { ... }` entries (flattened across multiple `locals` blocks).
160    #[builder(default)]
161    pub locals: Vec<Local>,
162
163    /// `provider "..." {}` declarations.
164    #[builder(default)]
165    pub providers: Vec<ProviderBlock>,
166
167    /// `resource` and `data` blocks, post-evaluation. Module-expanded
168    /// resources from child modules are appended here at the graph phase.
169    #[builder(default)]
170    pub resources: Vec<Resource>,
171
172    /// `module "name" { ... }` call sites. Per
173    /// [10-data-model.md § 2.1](../../specs/10-data-model.md), the field is
174    /// named `modules` even though [`Workspace::modules`](crate::ir::Workspace::modules)
175    /// also exists — they are different types (`ModuleCall` here vs `Module`
176    /// there) and the local name matches the HCL keyword.
177    #[builder(default)]
178    pub modules: Vec<ModuleCall>,
179
180    /// `output "..." {}` blocks.
181    #[builder(default)]
182    pub outputs: Vec<Output>,
183
184    /// Terragrunt-driven configuration, if any.
185    #[builder(default)]
186    pub terragrunt: Option<TerragruntConfig>,
187
188    /// Resolved state backend from `terraform { backend ... }` or
189    /// `generate "backend"`.
190    #[builder(default)]
191    pub state_backend: Option<StateBackend>,
192}
193
194#[cfg(test)]
195#[allow(
196    clippy::unwrap_used,
197    clippy::expect_used,
198    clippy::panic,
199    clippy::indexing_slicing
200)]
201mod tests {
202    use std::path::PathBuf;
203
204    use super::*;
205    use crate::ir::{ComponentId, Value};
206
207    #[test]
208    fn test_should_build_minimal_component() {
209        let c = Component::builder()
210            .id(ComponentId::from_index(0))
211            .path(Arc::<Path>::from(PathBuf::from("services/api-gateway")))
212            .kind(ComponentKind::Component)
213            .build();
214        assert_eq!(c.kind, ComponentKind::Component);
215        assert!(c.files.is_empty());
216        assert!(c.resources.is_empty());
217        assert!(c.terragrunt.is_none());
218    }
219
220    #[test]
221    fn test_should_redact_sensitive_variable_default_in_debug() {
222        let v = Variable::builder()
223            .name(Arc::<str>::from("db_password"))
224            .default(Some(Expression::Literal(Value::Str(Arc::<str>::from(
225                "very-secret",
226            )))))
227            .sensitive(true)
228            .span(Span::synthetic())
229            .build();
230        let debug = format!("{v:?}");
231        assert!(!debug.contains("very-secret"), "{debug}");
232        assert!(debug.contains("redacted"), "{debug}");
233    }
234
235    #[test]
236    fn test_should_not_redact_non_sensitive_variable_default() {
237        let v = Variable::builder()
238            .name(Arc::<str>::from("region"))
239            .default(Some(Expression::Literal(Value::Str(Arc::<str>::from(
240                "us-west-2",
241            )))))
242            .span(Span::synthetic())
243            .build();
244        let debug = format!("{v:?}");
245        assert!(debug.contains("us-west-2"), "{debug}");
246    }
247
248    #[test]
249    fn test_should_redact_sensitive_output_value_in_debug() {
250        let o = Output::builder()
251            .name(Arc::<str>::from("db_endpoint"))
252            .value(Expression::Literal(Value::Str(Arc::<str>::from(
253                "very-secret-endpoint",
254            ))))
255            .sensitive(true)
256            .span(Span::synthetic())
257            .build();
258        let debug = format!("{o:?}");
259        assert!(!debug.contains("very-secret-endpoint"), "{debug}");
260        assert!(debug.contains("redacted"), "{debug}");
261    }
262
263    #[test]
264    fn test_should_round_trip_local_via_serde() {
265        let l = Local::builder()
266            .name(Arc::<str>::from("name_prefix"))
267            .value(Expression::Literal(Value::Str(Arc::<str>::from(
268                "northwind",
269            ))))
270            .span(Span::synthetic())
271            .build();
272        let json = serde_json::to_string(&l).unwrap();
273        let back: Local = serde_json::from_str(&json).unwrap();
274        assert_eq!(l, back);
275    }
276}