Skip to main content

tfparser_core/
pipeline.rs

1//! Top-level pipeline trait + default implementation.
2//!
3//! **Most consumers want [`crate::Parser`] instead** — it wraps
4//! [`DefaultPipeline`] + [`crate::exporter::ParquetExporter`] behind one
5//! builder. The trait here is the seam that lets tests swap a stub in.
6//!
7//! [`Pipeline::run`] is the single entry point downstream crates (the CLI,
8//! the future server) call. Phase 1 defined the trait skeleton; Phase 9
9//! lands the [`DefaultPipeline`] implementation wiring discovery → loader
10//! → projection → terragrunt → evaluator → graph → provider into a single
11//! [`Workspace`].
12//!
13//! The exporter is intentionally **not** part of the pipeline: callers may
14//! consume the IR directly (the future server) or hand it to the
15//! [`crate::exporter::ParquetExporter`] (today's CLI). Phase 9 keeps that
16//! boundary so a downstream test can drive the same pipeline without
17//! touching disk for output.
18//!
19//! Per [61-crates-and-features.md § 3.1] and [91-impl-plan.md § 8/§ 11].
20//!
21//! [61-crates-and-features.md § 3.1]: ../../specs/61-crates-and-features.md
22//! [91-impl-plan.md § 8/§ 11]: ../../specs/91-impl-plan.md
23
24use std::{collections::BTreeSet, path::Path, sync::Arc};
25
26use serde::{Deserialize, Serialize};
27use typed_builder::TypedBuilder;
28
29use crate::{
30    Result,
31    diagnostic::Diagnostic,
32    discovery::{DiscoveredDir, Discoverer, DiscoveryOptions, FsDiscoverer},
33    eval::{
34        EnvVarMode, EvalContext, EvalLimits, EvaluatedComponent, Evaluator, FuncRegistry,
35        HclEvaluator,
36    },
37    graph::{DefaultGraphBuilder, GraphBuilder, GraphContext, ModuleRegistry},
38    ir::{ComponentId, Map, Value, Workspace},
39    loader::{HclEditLoader, LoadContext, Loader, LoaderLimits, RawComponent, SourceMap},
40    projection::project_component,
41    provider::{DefaultProviderResolver, ProfileMap, ProviderContext, ProviderResolver},
42    terragrunt::{FsTerragruntResolver, TerragruntResolver, TgContext},
43};
44
45/// Options for [`Pipeline::run`].
46///
47/// Build via [`PipelineOptionsBuilder`]; defaults are conservative
48/// (resource limits at spec defaults, env mode strict-with-empty-allowlist).
49#[derive(Clone, Debug, Serialize, Deserialize, TypedBuilder)]
50#[non_exhaustive]
51#[serde(rename_all = "camelCase")]
52#[builder(field_defaults(setter(into)))]
53pub struct PipelineOptions {
54    /// Workspace root passed by the caller.
55    #[serde(with = "crate::ir::path_serde::arc_path")]
56    pub root: Arc<Path>,
57
58    /// Optional environment name to pin (`var.environment`).
59    #[builder(default)]
60    pub environment: Option<Arc<str>>,
61
62    /// How the evaluator treats `get_env(...)`.
63    #[builder(default)]
64    pub env_var_mode: EnvVarMode,
65
66    /// Allowlist of `TF_VAR_*` / generic env vars visible to `get_env` /
67    /// Terragrunt funcs. Defaults to empty.
68    #[serde(default)]
69    #[builder(default)]
70    pub allowed_env: BTreeSet<Arc<str>>,
71
72    /// Repo-level `var.*` bindings (CLI `--var k=v` / `.tfvars`). Stored as
73    /// a sorted vec for stable iteration; see [`crate::ir::Map`].
74    #[serde(default)]
75    #[builder(default)]
76    pub repo_vars: Map,
77
78    /// Maximum walk depth (discovery). Default: 16.
79    #[builder(default = 16)]
80    pub max_walk_depth: u32,
81
82    /// Maximum total files in the workspace. Default: `200_000`.
83    #[builder(default = 200_000)]
84    pub max_total_files: u64,
85
86    /// Maximum size per file. Default: 4 MiB.
87    #[builder(default = 4 * 1024 * 1024)]
88    pub max_file_bytes: u64,
89
90    /// Maximum Terragrunt include depth. Default: 32.
91    #[builder(default = 32)]
92    pub max_include_depth: u32,
93
94    /// Whether the discoverer follows symlinks. Default: `false`.
95    #[builder(default = false)]
96    pub follow_symlinks: bool,
97}
98
99impl PipelineOptions {
100    /// Convenience constructor pinning the workspace root with all other
101    /// fields at spec defaults.
102    #[must_use]
103    pub fn new(root: impl Into<Arc<Path>>) -> Self {
104        Self::builder().root(root.into()).build()
105    }
106}
107
108/// Top-level pipeline trait.
109///
110/// Phase 9 lands [`DefaultPipeline`] wiring discovery → loader → projection
111/// → terragrunt → evaluator → graph → provider. Tests may swap in stubs
112/// implementing this trait directly.
113pub trait Pipeline: Send + Sync {
114    /// Parse the workspace at `options.root` and return its IR.
115    ///
116    /// # Errors
117    ///
118    /// Returns a [`crate::Error`] if the workspace cannot be parsed at all
119    /// (root missing, fatal I/O error, resource limit exceeded). Non-fatal
120    /// problems are reported via `Workspace::diagnostics`.
121    fn run(&self, options: &PipelineOptions) -> Result<Workspace>;
122}
123
124/// Default pipeline implementation per spec 91 § 12.
125///
126/// Stateless aside from the `profile_map` it carries for the provider
127/// resolver. Construct via [`DefaultPipeline::new`] then chain
128/// [`DefaultPipeline::with_profile_map`] / [`DefaultPipeline::with_default_region`]
129/// / [`DefaultPipeline::strict`] as needed.
130#[derive(Clone, Debug)]
131#[non_exhaustive]
132pub struct DefaultPipeline {
133    /// Provider-profile map shared with [`crate::provider::DefaultProviderResolver`].
134    /// `None` means "no AWS profile mapping configured" — the resolver
135    /// still runs and uses Terragrunt-cascade / `default_region` only.
136    ///
137    /// Held as `Arc<ProfileMap>` (not `SharedProfileMap = ArcSwap<...>`) so
138    /// the pipeline is cheap-Clone. Operators who want hot-reload semantics
139    /// can keep their own `ArcSwap<ProfileMap>` and rebuild the pipeline on
140    /// rotation; the resolver does not own the swappable handle.
141    pub profile_map: Option<Arc<ProfileMap>>,
142    /// Default AWS region applied when neither provider blocks nor
143    /// Terragrunt cascade supply one. Mirrors `ProviderContext::default_region`.
144    pub default_region: Option<crate::ir::Region>,
145    /// When `true`, the provider resolver returns `StrictUnresolved`
146    /// when any referenced profile is missing from `profile_map`.
147    pub strict_providers: bool,
148}
149
150impl Default for DefaultPipeline {
151    fn default() -> Self {
152        Self::new()
153    }
154}
155
156impl DefaultPipeline {
157    /// Construct a pipeline with no profile map and lenient provider mode.
158    #[must_use]
159    pub const fn new() -> Self {
160        Self {
161            profile_map: None,
162            default_region: None,
163            strict_providers: false,
164        }
165    }
166
167    /// Pin a profile map. Returns `self` for chaining.
168    #[must_use]
169    pub fn with_profile_map(mut self, map: Arc<ProfileMap>) -> Self {
170        self.profile_map = Some(map);
171        self
172    }
173
174    /// Pin a default region. Returns `self` for chaining.
175    #[must_use]
176    pub fn with_default_region(mut self, region: crate::ir::Region) -> Self {
177        self.default_region = Some(region);
178        self
179    }
180
181    /// Enable strict provider-mapping enforcement. Returns `self` for chaining.
182    #[must_use]
183    pub const fn strict(mut self) -> Self {
184        self.strict_providers = true;
185        self
186    }
187}
188
189impl Pipeline for DefaultPipeline {
190    #[tracing::instrument(
191        level = "info",
192        name = "pipeline.run",
193        skip(self, opts),
194        fields(root = %opts.root.display())
195    )]
196    // The pipeline is a linear seven-step flow; splitting the steps into
197    // separate functions threads ~15 borrows of `canonical_root`,
198    // `diagnostics`, `funcs`, and the eval/projection helpers through the
199    // call graph for no readability gain. Phase 9 keeps the inline shape;
200    // the seven `// ---- N)` comments anchor where future work would
201    // extract subroutines if a Phase 10 reorg ever requires it.
202    #[allow(clippy::too_many_lines)]
203    fn run(&self, opts: &PipelineOptions) -> Result<Workspace> {
204        let canonical_root: Arc<Path> = canonicalize_root(opts.root.as_ref())?;
205
206        // ---- 1) Discovery ----------------------------------------------
207        let discovery_opts = DiscoveryOptions::builder()
208            .max_depth(opts.max_walk_depth)
209            .max_total_files(opts.max_total_files)
210            .max_file_size_bytes(opts.max_file_bytes)
211            .follow_symlinks(opts.follow_symlinks)
212            .build();
213        let discovered = FsDiscoverer.discover(canonical_root.as_ref(), &discovery_opts)?;
214
215        // ---- 2) Loader (modules + components) --------------------------
216        let sources = SourceMap::new();
217        // LoaderLimits' max_file_bytes is u32; clamp to that range so we
218        // do not silently overflow on hostile fixtures.
219        let loader_file_cap: u32 = u32::try_from(opts.max_file_bytes).unwrap_or(u32::MAX);
220        let limits = LoaderLimits::builder()
221            .max_file_bytes(loader_file_cap)
222            .build();
223        let load_ctx = LoadContext::new(&discovered.root, &sources, &limits);
224
225        let mut diagnostics: Vec<Diagnostic> = Vec::new();
226        diagnostics.extend(discovered.diagnostics.iter().cloned());
227
228        let mut next_component_id: usize = 0;
229        let mut next_index = || {
230            let id = ComponentId::from_index(next_component_id);
231            next_component_id += 1;
232            id
233        };
234
235        // Build module RawComponents first so the registry is populated
236        // before any caller tries to expand them.
237        let mut module_raws: Vec<(DiscoveredDir, RawComponent, ComponentId)> = Vec::new();
238        for dir in &discovered.modules {
239            let raw = HclEditLoader.load(dir, &load_ctx)?;
240            diagnostics.extend(raw.diagnostics.iter().cloned());
241            module_raws.push((dir.clone(), raw, next_index()));
242        }
243        let mut component_raws: Vec<(DiscoveredDir, RawComponent, ComponentId)> = Vec::new();
244        for dir in &discovered.components {
245            let raw = HclEditLoader.load(dir, &load_ctx)?;
246            diagnostics.extend(raw.diagnostics.iter().cloned());
247            component_raws.push((dir.clone(), raw, next_index()));
248        }
249
250        // ---- 3) Projection: RawComponent → Component -------------------
251        let module_components: Vec<(DiscoveredDir, crate::ir::Component)> = module_raws
252            .iter()
253            .map(|(dir, raw, id)| {
254                let mut diag = Vec::new();
255                let component = project_component(raw, *id, &mut diag);
256                diagnostics.extend(diag);
257                (dir.clone(), component)
258            })
259            .collect();
260        let mut component_components: Vec<(DiscoveredDir, crate::ir::Component)> = component_raws
261            .iter()
262            .map(|(dir, raw, id)| {
263                let mut diag = Vec::new();
264                let component = project_component(raw, *id, &mut diag);
265                diagnostics.extend(diag);
266                (dir.clone(), component)
267            })
268            .collect();
269
270        // ---- 4) Terragrunt resolve per component -----------------------
271        let tg_resolver = FsTerragruntResolver::new();
272        let mut tg_ctx = TgContext::new(Arc::clone(&canonical_root));
273        tg_ctx.environment.clone_from(&opts.environment);
274        tg_ctx.env_var_mode.clone_from(&opts.env_var_mode);
275        tg_ctx.allowed_env = Arc::new(opts.allowed_env.clone());
276        tg_ctx.max_include_depth = opts.max_include_depth;
277
278        for (dir, component) in &mut component_components {
279            let abs_component_dir = canonical_root.join(dir.path.as_ref());
280            if !is_terragrunt_component(&abs_component_dir) {
281                continue;
282            }
283            match tg_resolver.resolve(&abs_component_dir, &tg_ctx) {
284                Ok(cfg) => {
285                    diagnostics.extend(cfg.diagnostics.iter().cloned());
286                    component.terragrunt = Some(cfg);
287                }
288                Err(err) => {
289                    diagnostics.push(Diagnostic::new(
290                        crate::Severity::Warn,
291                        "TG2001",
292                        format!(
293                            "terragrunt resolve failed for {}: {err}",
294                            abs_component_dir.display()
295                        ),
296                    ));
297                }
298            }
299        }
300
301        // ---- 5) Evaluator (modules first, then components) -------------
302        let evaluator = HclEvaluator::new();
303        let funcs: Arc<FuncRegistry> = Arc::new(FuncRegistry::default_with_stdlib());
304        let limits = EvalLimits::default();
305
306        let mut registry = ModuleRegistry::new();
307        let mut module_evals: Vec<EvaluatedComponent> = Vec::with_capacity(module_components.len());
308        for (dir, component) in &module_components {
309            let eval_ctx = EvalContext::new(
310                Arc::clone(&canonical_root),
311                opts.environment.clone(),
312                opts.env_var_mode.clone(),
313                opts.repo_vars.clone(),
314                Map::new(), // modules don't get Terragrunt cascade locals
315                Arc::clone(&funcs),
316                limits,
317            );
318            let evald = evaluator.evaluate(component, &eval_ctx)?;
319            diagnostics.extend(evald.diagnostics.iter().cloned());
320            let canonical: Arc<Path> = Arc::from(canonical_root.join(dir.path.as_ref()));
321            registry.insert_local(canonical, evald.clone());
322            module_evals.push(evald);
323        }
324
325        let mut component_evals: Vec<EvaluatedComponent> =
326            Vec::with_capacity(component_components.len());
327        for (_, component) in &component_components {
328            // Merge cascade locals from Terragrunt with caller-supplied repo_vars.
329            let cascade_locals: Map = component
330                .terragrunt
331                .as_ref()
332                .map(|c| c.effective_locals.clone())
333                .unwrap_or_default();
334
335            // Compose repo_vars: Terragrunt inputs feed `var.*` (Terragrunt
336            // semantics — inputs propagate to the underlying module/component
337            // as Terraform variables) layered under the caller's overrides.
338            let mut repo_vars: Map = component
339                .terragrunt
340                .as_ref()
341                .map(|c| c.inputs.clone())
342                .unwrap_or_default();
343            for (k, v) in &opts.repo_vars {
344                override_or_push(&mut repo_vars, k.as_ref(), v.clone());
345            }
346
347            let eval_ctx = EvalContext::new(
348                Arc::clone(&canonical_root),
349                opts.environment.clone(),
350                opts.env_var_mode.clone(),
351                repo_vars,
352                cascade_locals,
353                Arc::clone(&funcs),
354                limits,
355            );
356            let evald = evaluator.evaluate(component, &eval_ctx)?;
357            diagnostics.extend(evald.diagnostics.iter().cloned());
358            component_evals.push(evald);
359        }
360
361        // ---- 6) Graph build (module expansion + secondary tables) -----
362        let graph_ctx = GraphContext::new(Arc::clone(&canonical_root));
363        let mut combined: Vec<EvaluatedComponent> =
364            Vec::with_capacity(module_evals.len() + component_evals.len());
365        combined.extend(module_evals);
366        combined.extend(component_evals);
367        let mut ws = DefaultGraphBuilder::new().build(combined, &registry, &graph_ctx)?;
368
369        // Pipe accumulated discovery + load + projection diagnostics onto
370        // the workspace (the graph builder only appends its own).
371        ws.diagnostics.extend(diagnostics);
372
373        // ---- 7) Provider resolver --------------------------------------
374        let profile_map = self
375            .profile_map
376            .clone()
377            .unwrap_or_else(crate::provider::empty_profile_map);
378        let mut provider_ctx = ProviderContext::new(profile_map);
379        provider_ctx.default_region.clone_from(&self.default_region);
380        provider_ctx.strict = self.strict_providers;
381        DefaultProviderResolver::new().resolve(&mut ws, &provider_ctx)?;
382
383        // Preserve original discovery shape (envs_dir + root_hcl) for
384        // downstream consumers that need it. Phase 9 surfaces them via the
385        // workspace shape; not yet wired through the IR (would require a
386        // new `Workspace.discovery` field). Kept here as a `_` to acknowledge.
387        let _ = (&discovered.envs_dir, &discovered.root_hcl);
388
389        Ok(ws)
390    }
391}
392
393fn canonicalize_root(root: &Path) -> Result<Arc<Path>> {
394    let canonical = root.canonicalize().map_err(|source| crate::Error::Io {
395        path: root.to_path_buf(),
396        source,
397    })?;
398    Ok(Arc::from(canonical))
399}
400
401/// A component dir is "Terragrunt-shaped" iff it contains a `terragrunt.hcl`.
402fn is_terragrunt_component(abs_dir: &Path) -> bool {
403    abs_dir.join("terragrunt.hcl").is_file()
404}
405
406fn override_or_push(map: &mut Map, key: &str, value: Value) {
407    if let Some(slot) = map.iter_mut().find(|(k, _)| k.as_ref() == key) {
408        slot.1 = value;
409    } else {
410        map.push((Arc::from(key), value));
411    }
412}
413
414#[cfg(test)]
415#[allow(clippy::unwrap_used)]
416mod tests {
417    use std::path::PathBuf;
418
419    use super::*;
420
421    #[test]
422    fn test_should_build_pipeline_options_with_defaults() {
423        let opts = PipelineOptions::new(Arc::<Path>::from(PathBuf::from("/tmp/repo")));
424        assert_eq!(opts.max_walk_depth, 16);
425        assert_eq!(opts.max_total_files, 200_000);
426        assert!(matches!(opts.env_var_mode, EnvVarMode::Strict { .. }));
427        assert!(!opts.follow_symlinks);
428    }
429
430    #[test]
431    fn test_should_override_specific_options() {
432        let opts = PipelineOptions::builder()
433            .root(Arc::<Path>::from(PathBuf::from("/tmp/repo")))
434            .environment(Some(Arc::<str>::from("staging")))
435            .max_walk_depth(8_u32)
436            .follow_symlinks(true)
437            .build();
438        assert_eq!(opts.max_walk_depth, 8);
439        assert!(opts.follow_symlinks);
440        assert_eq!(opts.environment.as_deref(), Some("staging"));
441    }
442
443    #[test]
444    fn test_should_serde_round_trip_options() {
445        // PipelineOptions intentionally does not derive `Eq` because
446        // `repo_vars` carries `Value` (which contains `f64`). Round-trip
447        // through JSON and check structural invariants instead.
448        let opts = PipelineOptions::builder()
449            .root(Arc::<Path>::from(PathBuf::from("/tmp/repo")))
450            .max_walk_depth(8_u32)
451            .build();
452        let json = serde_json::to_string(&opts).unwrap();
453        let back: PipelineOptions = serde_json::from_str(&json).unwrap();
454        assert_eq!(back.root, opts.root);
455        assert_eq!(back.max_walk_depth, opts.max_walk_depth);
456        assert!(back.repo_vars.is_empty());
457    }
458
459    #[test]
460    fn test_default_pipeline_smoke_run_on_single_component_fixture() {
461        let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
462        let workspace_root = manifest.ancestors().nth(2).unwrap();
463        let fixture = workspace_root.join("fixtures").join("single-component");
464        if !fixture.exists() {
465            return; // skip when fixture is missing (e.g. running from a partial checkout)
466        }
467        let opts = PipelineOptions::new(Arc::<Path>::from(fixture));
468        let ws = DefaultPipeline::new().run(&opts).unwrap();
469        assert!(
470            !ws.components.is_empty(),
471            "expected at least one component in single-component fixture"
472        );
473    }
474
475    #[test]
476    fn test_pipeline_is_send_sync_object_safe() {
477        fn assert_send_sync<T: Send + Sync>() {}
478        assert_send_sync::<DefaultPipeline>();
479        assert_send_sync::<Box<dyn Pipeline>>();
480    }
481}