Skip to main content

tfparser_core/terragrunt/
resolver.rs

1//! Top-level Terragrunt resolver.
2//!
3//! [`TerragruntResolver::resolve`] is the Phase 6 entry point. Given a
4//! component directory and a [`TgContext`], it walks the `include` load
5//! chain, resolves Terragrunt-specific functions, applies the configured
6//! merge strategy, evaluates the resulting `locals`/`inputs`, and emits a
7//! [`TerragruntConfig`] suitable for the evaluator's `cascade_locals`
8//! and `repo_vars` (per [13-evaluator.md]).
9//!
10//! Per [14-terragrunt.md § 2-3].
11//!
12//! [14-terragrunt.md § 2-3]: ../../../specs/14-terragrunt.md
13//! [13-evaluator.md]: ../../../specs/13-evaluator.md
14
15// The resolver's hot-path functions are spec-driven and not amenable to
16// extraction without losing the locality the spec § 3 walks step by step.
17// Per CLAUDE.md § Code Style ("Unless absolutely necessary, function should
18// not be more than 150 lines"), 100–150-line spec-walks are acceptable; we
19// silence the clippy::too_many_lines pedantic lint module-wide rather than
20// invent intermediate types only the resolver would use.
21#![allow(clippy::too_many_lines)]
22
23use std::{
24    path::Path,
25    sync::{Arc, OnceLock},
26};
27
28use dashmap::DashMap;
29
30use crate::{
31    Diagnostic, LimitKind, Result, Severity,
32    diagnostic::Diagnostic as Diag,
33    eval::{CallCx, EvalLimits, FuncRegistry, FuncRegistryBuilder, HclFunc},
34    ir::{
35        AttributeMap, DependencyBlock, Expression, GenerateBlock, IncludePath, Map, StateBackend,
36        TerragruntConfig, Value,
37    },
38    loader::{HclEditLoader, LoaderLimits},
39    terragrunt::{
40        context::TgContext,
41        funcs::{
42            FindInParentFoldersFn, FindInParentFoldersFromFn, GetParentTerragruntDirFn,
43            GetRepoRootFn, GetTerragruntDirFn, PathRelativeFromIncludeFn, PathRelativeToIncludeFn,
44            TgState, TryFn,
45        },
46        merge::{MergeStrategy, merge_locals},
47        parsed::{self, ParsedTerragrunt},
48    },
49    util::paths::{self, SymlinkPolicy},
50};
51
52/// Trait every Terragrunt resolver implements. Phase 6 ships exactly one
53/// implementation: [`FsTerragruntResolver`].
54pub trait TerragruntResolver: Send + Sync + std::fmt::Debug {
55    /// Resolve the Terragrunt configuration at `component_dir` (the dir
56    /// containing the component's `terragrunt.hcl`).
57    ///
58    /// # Errors
59    ///
60    /// Returns [`crate::Error`] only on fatal I/O failures the resolver
61    /// cannot continue past (e.g. the component dir disappears mid-walk).
62    /// Per-include cycles, path-escapes, function errors are surfaced as
63    /// [`Diagnostic`]s on the returned [`TerragruntConfig`].
64    fn resolve(&self, component_dir: &Path, ctx: &TgContext) -> Result<TerragruntConfig>;
65}
66
67/// Default resolver — reads `terragrunt.hcl` files from the filesystem
68/// and threads them through the merge cascade.
69#[derive(Debug, Default)]
70#[non_exhaustive]
71pub struct FsTerragruntResolver;
72
73impl FsTerragruntResolver {
74    /// Construct a default resolver.
75    #[must_use]
76    pub const fn new() -> Self {
77        Self
78    }
79}
80
81/// Memoised parse-and-resolve result. Stored under canonical path in the
82/// per-call [`DashMap`] so any `read_terragrunt_config` call site that
83/// re-references the same file gets the cached body.
84#[derive(Clone, Debug)]
85pub(super) struct ResolvedTerragrunt {
86    pub locals: Map,
87    pub inputs: Map,
88}
89
90impl TerragruntResolver for FsTerragruntResolver {
91    fn resolve(&self, component_dir: &Path, ctx: &TgContext) -> Result<TerragruntConfig> {
92        let component_dir_arc: Arc<Path> = Arc::from(component_dir);
93
94        // Validate `component_dir` is inside the workspace root.
95        if paths::canonicalize_inside(component_dir, &ctx.workspace_root, SymlinkPolicy::Follow)
96            .is_err()
97        {
98            return Ok(TerragruntConfig::builder()
99                .component_dir(Arc::clone(&component_dir_arc))
100                .diagnostics(vec![Diag::new(
101                    Severity::Warn,
102                    "TG2001",
103                    format!(
104                        "component dir `{}` is outside workspace root `{}`",
105                        component_dir.display(),
106                        ctx.workspace_root.display()
107                    ),
108                )])
109                .build());
110        }
111
112        // Per-resolution shared state — the TG funcs close over an `Arc`
113        // of this so every function sees the live workspace root,
114        // component dir, and active include.
115        let tg_state = Arc::new(TgState::new(
116            Arc::clone(&ctx.workspace_root),
117            Arc::clone(&component_dir_arc),
118        ));
119        let memo: Arc<DashMap<Arc<Path>, Arc<ResolvedTerragrunt>>> = Arc::new(DashMap::new());
120        let stack: Arc<std::sync::Mutex<Vec<Arc<Path>>>> =
121            Arc::new(std::sync::Mutex::new(Vec::new()));
122        let inflight: Arc<DashMap<Arc<Path>, ()>> = Arc::new(DashMap::new());
123
124        let mut diagnostics: Vec<Diagnostic> = Vec::new();
125        let loader = HclEditLoader::new();
126        let loader_limits = LoaderLimits::default();
127        let eval_limits = EvalLimits::default();
128
129        // `ReadTerragruntConfigFn` recursively reduces parent locals — and
130        // those locals may themselves call `read_terragrunt_config`,
131        // `find_in_parent_folders`, etc. To dispatch correctly the function
132        // needs the *same* TG registry it lives in. We break the cycle via
133        // an `Arc<OnceLock<Arc<FuncRegistry>>>` populated immediately after
134        // the registry is built (F-021).
135        let registry_slot: Arc<OnceLock<Arc<FuncRegistry>>> = Arc::new(OnceLock::new());
136        let funcs = Arc::new(build_func_registry(
137            Arc::clone(&tg_state),
138            Arc::clone(&memo),
139            Arc::clone(&stack),
140            Arc::clone(&inflight),
141            Arc::clone(&registry_slot),
142            ctx,
143            loader,
144            loader_limits,
145            eval_limits,
146        ));
147        let _ = registry_slot.set(Arc::clone(&funcs));
148
149        let tg_hcl = component_dir_arc.join("terragrunt.hcl");
150        let Some(parsed_root) = read_and_project(
151            &tg_hcl,
152            &ctx.workspace_root,
153            loader,
154            &loader_limits,
155            &mut diagnostics,
156        ) else {
157            // No terragrunt.hcl in the component dir → return an empty
158            // config. The caller (orchestrator) decides whether to feed
159            // it to the evaluator.
160            return Ok(TerragruntConfig::builder()
161                .component_dir(Arc::clone(&component_dir_arc))
162                .diagnostics(diagnostics)
163                .build());
164        };
165
166        // Resolve the include chain, deepest-first list.
167        let mut visited: std::collections::HashSet<std::path::PathBuf> =
168            std::collections::HashSet::new();
169        let chain = resolve_include_chain(
170            &parsed_root,
171            &tg_hcl,
172            ctx,
173            &funcs,
174            &tg_state,
175            loader,
176            &loader_limits,
177            &eval_limits,
178            &mut visited,
179            0,
180            &mut diagnostics,
181        );
182
183        // Apply the merge cascade. Deepest-first means we walk the chain
184        // in order and merge each parent into the next child.
185        let merged = apply_cascade(&chain, &mut diagnostics);
186
187        // Evaluate the merged locals using a recursive worklist (locals
188        // can reference each other, e.g. `merged_vars = merge(env_vars.locals, ...)`).
189        let effective_locals = evaluate_locals(
190            &merged.locals,
191            &funcs,
192            &tg_state,
193            &ctx.workspace_root,
194            &ctx.env_var_mode,
195            &eval_limits,
196        );
197
198        // Inputs: take the merged inputs and reduce with effective locals in scope.
199        let inputs = if let Some(inputs_attrs) = &merged.inputs {
200            evaluate_inputs(
201                inputs_attrs,
202                &effective_locals,
203                &funcs,
204                &tg_state,
205                &ctx.workspace_root,
206                &ctx.env_var_mode,
207                &eval_limits,
208            )
209        } else {
210            Map::new()
211        };
212
213        // Reduce generate / dependency attribute expressions against the
214        // effective locals so heredoc contents with `${local.X}`
215        // interpolations collapse to a single Literal(Str) we can capture.
216        let reduced_generates = reduce_generates(
217            &merged.generates,
218            &effective_locals,
219            &funcs,
220            &ctx.workspace_root,
221            &ctx.env_var_mode,
222            &eval_limits,
223        );
224        let reduced_dependencies = reduce_dependencies(
225            &merged.dependencies,
226            &effective_locals,
227            &funcs,
228            &ctx.workspace_root,
229            &ctx.env_var_mode,
230            &eval_limits,
231        );
232
233        let generates = build_generates(&reduced_generates);
234        let dependencies = build_dependencies(&reduced_dependencies, &component_dir_arc);
235        let state_backend = extract_state_backend(&merged, loader, &loader_limits);
236
237        // Build the IncludePath list from the chain (deepest last per spec).
238        let includes: Vec<IncludePath> = chain
239            .iter()
240            .filter_map(|entry| {
241                entry.include_origin.as_ref().map(|origin| {
242                    IncludePath::builder()
243                        .path(Arc::clone(origin))
244                        .label(entry.include_label.clone())
245                        .span(entry.span.clone())
246                        .build()
247                })
248            })
249            .collect();
250
251        // Append any diagnostics produced by `read_terragrunt_config` calls
252        // that landed in the memo (they accumulate in the resolved entry,
253        // not in the surrounding scope).
254        Ok(TerragruntConfig::builder()
255            .component_dir(component_dir_arc)
256            .effective_locals(effective_locals)
257            .inputs(inputs)
258            .includes(includes)
259            .generates(generates)
260            .dependencies(dependencies)
261            .state_backend(state_backend)
262            .diagnostics(diagnostics)
263            .build())
264    }
265}
266
267/// One link in the include load chain — the parsed Terragrunt body plus
268/// metadata for the merge step.
269#[derive(Debug)]
270struct ChainEntry {
271    parsed: ParsedTerragrunt,
272    merge_strategy: MergeStrategy,
273    /// Absolute path of the include's source file (the parent), or `None`
274    /// for the child terragrunt.hcl at the chain's tail.
275    include_origin: Option<Arc<Path>>,
276    include_label: Option<Arc<str>>,
277    span: crate::ir::Span,
278}
279
280/// Resolve the include load chain starting from `child`.
281///
282/// The returned vector is in **deepest-first** order: the parent(s) come
283/// before the child. The child itself is the last entry.
284///
285/// `visited` is a *shared* set threaded through the recursion so cycles
286/// across multiple recursion levels are caught — `a includes b includes a`
287/// would otherwise overflow the stack because each recursive call would
288/// start with a fresh local set.
289#[allow(clippy::too_many_arguments)]
290fn resolve_include_chain(
291    child: &ParsedTerragrunt,
292    child_path: &Path,
293    ctx: &TgContext,
294    funcs: &Arc<FuncRegistry>,
295    tg_state: &Arc<TgState>,
296    loader: HclEditLoader,
297    loader_limits: &LoaderLimits,
298    eval_limits: &EvalLimits,
299    visited: &mut std::collections::HashSet<std::path::PathBuf>,
300    depth: u32,
301    diagnostics: &mut Vec<Diagnostic>,
302) -> Vec<ChainEntry> {
303    let mut chain: Vec<ChainEntry> = Vec::new();
304    if depth >= ctx.max_include_depth {
305        diagnostics.push(Diag::limit(
306            LimitKind::IncludeDepth,
307            "TG2002",
308            format!(
309                "terragrunt include depth cap ({}) exceeded",
310                ctx.max_include_depth
311            ),
312        ));
313        chain.push(ChainEntry {
314            parsed: child.clone(),
315            merge_strategy: MergeStrategy::DeepMapOnly,
316            include_origin: None,
317            include_label: None,
318            span: crate::ir::Span::synthetic(),
319        });
320        return chain;
321    }
322    if let Ok(canonical) =
323        paths::canonicalize_inside(child_path, &ctx.workspace_root, SymlinkPolicy::Follow)
324    {
325        visited.insert(canonical);
326    }
327
328    for include in &child.includes {
329        let Some(path_expr) = &include.path_expr else {
330            diagnostics.push(
331                Diag::new(
332                    Severity::Warn,
333                    "TG2003",
334                    format!("include `{}` missing `path = ...` attribute", include.label),
335                )
336                .with_span(include.span.clone()),
337            );
338            continue;
339        };
340        let Some(path_value) = reduce_to_string(
341            path_expr,
342            &Map::new(),
343            funcs,
344            tg_state,
345            &ctx.workspace_root,
346            &ctx.env_var_mode,
347            eval_limits,
348        ) else {
349            diagnostics.push(
350                Diag::new(
351                    Severity::Warn,
352                    "TG2004",
353                    format!(
354                        "include `{}` path expression did not reduce to a string",
355                        include.label
356                    ),
357                )
358                .with_span(include.span.clone()),
359            );
360            continue;
361        };
362        let candidate = Path::new(path_value.as_ref()).to_path_buf();
363        let canonical = match paths::canonicalize_inside(
364            &candidate,
365            &ctx.workspace_root,
366            SymlinkPolicy::Follow,
367        ) {
368            Ok(p) => p,
369            Err(err) => {
370                diagnostics.push(
371                    Diag::new(
372                        Severity::Warn,
373                        "TG2005",
374                        format!(
375                            "include `{}` path `{}` did not resolve under workspace root: {err}",
376                            include.label,
377                            candidate.display()
378                        ),
379                    )
380                    .with_span(include.span.clone()),
381                );
382                continue;
383            }
384        };
385        if !visited.insert(canonical.clone()) {
386            diagnostics.push(
387                Diag::new(
388                    Severity::Warn,
389                    "TG2006",
390                    format!(
391                        "terragrunt include cycle detected at `{}`",
392                        canonical.display()
393                    ),
394                )
395                .with_span(include.span.clone()),
396            );
397            continue;
398        }
399        let Some(parent_parsed) = read_and_project(
400            &canonical,
401            &ctx.workspace_root,
402            loader,
403            loader_limits,
404            diagnostics,
405        ) else {
406            continue;
407        };
408
409        // Update active include for the funcs that consult it.
410        if let Ok(mut g) = tg_state.active_include.lock() {
411            *g = Some(Arc::from(canonical.as_path()));
412        }
413
414        // Recurse on the parent's own includes before merging this one.
415        let mut sub = resolve_include_chain(
416            &parent_parsed,
417            &canonical,
418            ctx,
419            funcs,
420            tg_state,
421            loader,
422            loader_limits,
423            eval_limits,
424            visited,
425            depth + 1,
426            diagnostics,
427        );
428        chain.append(&mut sub);
429
430        let strategy = parse_merge_strategy(include.merge_strategy_expr.as_ref());
431        chain.push(ChainEntry {
432            parsed: parent_parsed,
433            merge_strategy: strategy,
434            include_origin: Some(Arc::from(canonical.as_path())),
435            include_label: Some(Arc::clone(&include.label)),
436            span: include.span.clone(),
437        });
438    }
439
440    // Append the child as the tail of the chain (deepest last).
441    chain.push(ChainEntry {
442        parsed: child.clone(),
443        merge_strategy: MergeStrategy::DeepMapOnly,
444        include_origin: None,
445        include_label: None,
446        span: child
447            .locals
448            .first()
449            .map_or_else(crate::ir::Span::synthetic, |l| l.span.clone()),
450    });
451
452    chain
453}
454
455/// Apply the merge cascade described by a load chain. Returns the final
456/// merged [`ParsedTerragrunt`].
457fn apply_cascade(chain: &[ChainEntry], _diagnostics: &mut Vec<Diagnostic>) -> ParsedTerragrunt {
458    if chain.is_empty() {
459        return ParsedTerragrunt::default();
460    }
461    // Start from the parent-most (chain[0]) and walk down.
462    let mut acc = ParsedTerragrunt::default();
463    for entry in chain {
464        // Merge locals per the entry's strategy.
465        let parent_locals = literal_map_for(&acc.locals);
466        let child_locals = literal_map_for(&entry.parsed.locals);
467        let merged_locals = merge_locals(&parent_locals, &child_locals, entry.merge_strategy);
468        // Accumulate non-literal locals across layers — both the parent's
469        // and the child's. The child's entries override the parent's by
470        // name. Without this, a parent layer's `merged_vars = merge(...)`
471        // (non-literal until the evaluator runs) gets dropped the moment
472        // the cascade moves on to a child layer (F-023 fix).
473        let prior_non_literals: Vec<crate::ir::Local> = acc
474            .locals
475            .iter()
476            .filter(|l| !matches!(l.value, Expression::Literal(_)))
477            .cloned()
478            .collect();
479        acc.locals =
480            map_to_locals_with_inherited(&merged_locals, &entry.parsed, &prior_non_literals);
481
482        // Inputs: only inherit the parent's inputs when the child does
483        // not declare one. (Terragrunt-canonical behaviour: a child
484        // override replaces the inputs block entirely.)
485        if entry.parsed.inputs.is_some() {
486            acc.inputs.clone_from(&entry.parsed.inputs);
487        }
488
489        // `generate` blocks: child entries override by label, additive otherwise.
490        for g in &entry.parsed.generates {
491            if let Some(slot) = acc.generates.iter_mut().find(|x| x.label == g.label) {
492                *slot = g.clone();
493            } else {
494                acc.generates.push(g.clone());
495            }
496        }
497        // `dependency` blocks: same shape as `generate` — by-name.
498        for d in &entry.parsed.dependencies {
499            if let Some(slot) = acc.dependencies.iter_mut().find(|x| x.name == d.name) {
500                *slot = d.clone();
501            } else {
502                acc.dependencies.push(d.clone());
503            }
504        }
505        // `terraform { ... }` blocks accumulate; the state-backend
506        // extractor picks the first one with `backend "s3" { ... }`.
507        acc.terraform.extend(entry.parsed.terraform.iter().cloned());
508        if entry.parsed.remote_state.is_some() {
509            acc.remote_state.clone_from(&entry.parsed.remote_state);
510        }
511        acc.diagnostics
512            .extend(entry.parsed.diagnostics.iter().cloned());
513    }
514    acc
515}
516
517/// Build a `Map` of `(name → Value)` from the [`Local`](crate::ir::Local) entries
518/// whose `value` is already a `Literal`. Used as the input to the
519/// `merge_locals` cascade — non-literal locals propagate via `evaluate_locals`
520/// below.
521fn literal_map_for(locals: &[crate::ir::Local]) -> Map {
522    locals
523        .iter()
524        .filter_map(|l| match &l.value {
525            Expression::Literal(v) => Some((Arc::clone(&l.name), v.clone())),
526            _ => None,
527        })
528        .collect()
529}
530
531/// Convert a merged `Map` back into the `Local` shape expected by
532/// downstream walkers. Non-literal locals from the source `parsed`
533/// (typically references to other locals or to `read_terragrunt_config`
534/// outputs) are retained verbatim so the subsequent evaluator pass can
535/// reduce them.
536///
537/// `inherited` carries non-literal locals from earlier cascade layers
538/// (parent → child); the child layer's non-literals override any
539/// inherited entry with the same name. This is the F-023 fix: without
540/// the inherited list, a parent's `merged_vars = merge(...)` would be
541/// dropped when the cascade moves on to the child.
542fn map_to_locals_with_inherited(
543    merged_map: &Map,
544    parsed: &ParsedTerragrunt,
545    inherited: &[crate::ir::Local],
546) -> Vec<crate::ir::Local> {
547    let mut out: Vec<crate::ir::Local> = Vec::new();
548    // 1) Start with inherited non-literals.
549    for l in inherited {
550        out.push(l.clone());
551    }
552    // 2) Add this layer's non-literals; later override earlier on name.
553    for l in parsed
554        .locals
555        .iter()
556        .filter(|l| !matches!(l.value, Expression::Literal(_)))
557    {
558        if let Some(slot) = out.iter_mut().find(|x| x.name == l.name) {
559            *slot = l.clone();
560        } else {
561            out.push(l.clone());
562        }
563    }
564    // 3) Append the merged literal map. Literals win over any non-literal sharing the same name (a
565    //    downstream layer that knows the value overrides an upstream layer that needed reduction).
566    for (k, v) in merged_map {
567        let local = crate::ir::Local::builder()
568            .name(Arc::clone(k))
569            .value(Expression::Literal(v.clone()))
570            .span(crate::ir::Span::synthetic())
571            .build();
572        if let Some(slot) = out.iter_mut().find(|x| x.name == *k) {
573            *slot = local;
574        } else {
575            out.push(local);
576        }
577    }
578    out
579}
580
581/// Iteratively reduce locals against themselves until a fixpoint is
582/// reached. Each pass reduces every local against the resolved-so-far
583/// map; partials stay as-is.
584fn evaluate_locals(
585    locals: &[crate::ir::Local],
586    funcs: &Arc<FuncRegistry>,
587    _tg_state: &Arc<TgState>,
588    workspace_root: &Path,
589    env_var_mode: &crate::eval::EnvVarMode,
590    eval_limits: &EvalLimits,
591) -> Map {
592    use crate::eval::reduce::{Scope, reduce_expression};
593    let mut resolved: Map = Map::new();
594    let mut pending: Vec<crate::ir::Local> = locals.to_vec();
595
596    // Bound iterations to defend against cyclic locals (the resolver
597    // does not run a full Tarjan; cycles surface as Unresolved leaves).
598    for _ in 0..16 {
599        let mut made_progress = false;
600        let mut next_pending: Vec<crate::ir::Local> = Vec::new();
601        for local in pending.drain(..) {
602            let scope = Scope::new(
603                Map::new(),
604                resolved.clone(),
605                workspace_root,
606                env_var_mode,
607                eval_limits,
608                funcs,
609                None,
610            );
611            let reduced = reduce_expression(&local.value, &scope);
612            if let Expression::Literal(v) = &reduced {
613                resolved.push((Arc::clone(&local.name), v.clone()));
614                made_progress = true;
615            } else {
616                next_pending.push(
617                    crate::ir::Local::builder()
618                        .name(Arc::clone(&local.name))
619                        .value(reduced)
620                        .span(local.span.clone())
621                        .build(),
622                );
623            }
624        }
625        pending = next_pending;
626        if !made_progress || pending.is_empty() {
627            break;
628        }
629    }
630    resolved
631}
632
633/// Reduce the merged `inputs = { ... }` against the effective locals.
634fn evaluate_inputs(
635    inputs_attrs: &AttributeMap,
636    effective_locals: &Map,
637    funcs: &Arc<FuncRegistry>,
638    _tg_state: &Arc<TgState>,
639    workspace_root: &Path,
640    env_var_mode: &crate::eval::EnvVarMode,
641    eval_limits: &EvalLimits,
642) -> Map {
643    use crate::eval::reduce::{Scope, reduce_expression};
644    let scope = Scope::new(
645        Map::new(),
646        effective_locals.clone(),
647        workspace_root,
648        env_var_mode,
649        eval_limits,
650        funcs,
651        None,
652    );
653    let mut out: Map = Vec::with_capacity(inputs_attrs.len());
654    for (name, expr) in inputs_attrs {
655        let reduced = reduce_expression(expr, &scope);
656        if let Expression::Literal(v) = reduced {
657            out.push((Arc::clone(name), v));
658        }
659    }
660    out
661}
662
663/// Reduce every attribute expression in a list of raw `generate` blocks
664/// against the effective locals. Used to collapse heredoc `contents` with
665/// `${local.X}` interpolations into a plain `Literal(Str)` so
666/// `build_generates` can capture them.
667fn reduce_generates(
668    raw: &[parsed::GenerateBlockRaw],
669    effective_locals: &Map,
670    funcs: &Arc<FuncRegistry>,
671    workspace_root: &Path,
672    env_var_mode: &crate::eval::EnvVarMode,
673    eval_limits: &EvalLimits,
674) -> Vec<parsed::GenerateBlockRaw> {
675    use crate::eval::reduce::{Scope, reduce_expression};
676    let scope = Scope::new(
677        Map::new(),
678        effective_locals.clone(),
679        workspace_root,
680        env_var_mode,
681        eval_limits,
682        funcs,
683        None,
684    );
685    raw.iter()
686        .map(|g| parsed::GenerateBlockRaw {
687            label: Arc::clone(&g.label),
688            attrs: g
689                .attrs
690                .iter()
691                .map(|(k, v)| (Arc::clone(k), reduce_expression(v, &scope)))
692                .collect(),
693            span: g.span.clone(),
694        })
695        .collect()
696}
697
698/// Reduce every attribute in a list of raw `dependency` blocks against the
699/// effective locals.
700fn reduce_dependencies(
701    raw: &[parsed::DependencyBlockRaw],
702    effective_locals: &Map,
703    funcs: &Arc<FuncRegistry>,
704    workspace_root: &Path,
705    env_var_mode: &crate::eval::EnvVarMode,
706    eval_limits: &EvalLimits,
707) -> Vec<parsed::DependencyBlockRaw> {
708    use crate::eval::reduce::{Scope, reduce_expression};
709    let scope = Scope::new(
710        Map::new(),
711        effective_locals.clone(),
712        workspace_root,
713        env_var_mode,
714        eval_limits,
715        funcs,
716        None,
717    );
718    raw.iter()
719        .map(|d| parsed::DependencyBlockRaw {
720            name: Arc::clone(&d.name),
721            attrs: d
722                .attrs
723                .iter()
724                .map(|(k, v)| (Arc::clone(k), reduce_expression(v, &scope)))
725                .collect(),
726            span: d.span.clone(),
727        })
728        .collect()
729}
730
731/// Build the `Vec<GenerateBlock>` to surface in [`TerragruntConfig`].
732///
733/// Best-effort: an attribute that did not collapse to a `Literal(Str)`
734/// (e.g. `contents` is a `TemplateConcat` carrying an unresolved
735/// `local.merged_vars.aws_account_id` interpolation) is rendered into a
736/// placeholder string so downstream consumers still get a row. Sentinel
737/// fragments in the rendered output (`${...}`) mark the residue.
738fn build_generates(raw: &[parsed::GenerateBlockRaw]) -> Vec<GenerateBlock> {
739    raw.iter()
740        .filter_map(|r| {
741            let mut path: Option<Arc<Path>> = None;
742            let mut if_exists: Option<Arc<str>> = None;
743            let mut contents: Option<Arc<str>> = None;
744            for (k, v) in &r.attrs {
745                match k.as_ref() {
746                    "path" => {
747                        path = render_expr_to_string(v).map(|s| Arc::from(Path::new(s.as_ref())));
748                    }
749                    "if_exists" => {
750                        if_exists = render_expr_to_string(v);
751                    }
752                    "contents" => {
753                        contents = render_expr_to_string(v);
754                    }
755                    _ => {}
756                }
757            }
758            match (path, if_exists, contents) {
759                (Some(p), Some(ie), Some(c)) => Some(
760                    GenerateBlock::builder()
761                        .label(Arc::clone(&r.label))
762                        .path(p)
763                        .if_exists(ie)
764                        .contents(c)
765                        .span(r.span.clone())
766                        .build(),
767                ),
768                _ => None,
769            }
770        })
771        .collect()
772}
773
774/// Render a `generate "backend"` heredoc body for re-parsing as HCL.
775///
776/// Unlike [`render_expr_to_string`] (which keeps `${unresolved}` markers so
777/// the rendered string is faithful for storage), this drops unresolved
778/// segments entirely. The reason is positional: a placeholder like
779/// `${local.backend_profile_block}` sitting at HCL body level — between
780/// attributes, not inside a string — re-parses as a syntax error and pulls
781/// down the surrounding `terraform { backend "s3" { ... } }` block with it.
782/// Dropping the unresolved parts leaves static fields (kind, bucket,
783/// region, etc.) recoverable; the few attributes that lived entirely
784/// inside the interpolation are lost, which is the best we can do without
785/// running a real Terragrunt evaluation.
786fn render_contents_for_reparse(expr: &Expression) -> Option<Arc<str>> {
787    match expr {
788        Expression::Literal(Value::Str(s)) => Some(Arc::clone(s)),
789        Expression::TemplateConcat(parts) => {
790            let mut out = String::new();
791            for part in parts {
792                match part {
793                    Expression::Literal(Value::Str(s)) => out.push_str(s),
794                    Expression::Literal(Value::Int(n)) => out.push_str(&n.to_string()),
795                    Expression::Literal(Value::Bool(b)) => out.push_str(&b.to_string()),
796                    // Unresolved or other non-literal parts: dropped on
797                    // purpose; see fn-level docs.
798                    _ => {}
799                }
800            }
801            Some(Arc::from(out))
802        }
803        _ => None,
804    }
805}
806
807/// Render an expression as best-effort string for `generate` attribute
808/// capture. Literals collapse exactly; `TemplateConcat` parts render with
809/// `${unresolved-source}` markers for any non-literal subexpression.
810fn render_expr_to_string(expr: &Expression) -> Option<Arc<str>> {
811    match expr {
812        Expression::Literal(Value::Str(s)) => Some(Arc::clone(s)),
813        Expression::Literal(Value::Int(n)) => Some(Arc::from(n.to_string())),
814        Expression::Literal(Value::Number(n)) => Some(Arc::from(n.to_string())),
815        Expression::Literal(Value::Bool(b)) => Some(Arc::from(b.to_string())),
816        Expression::TemplateConcat(parts) => {
817            let mut out = String::new();
818            for part in parts {
819                match part {
820                    Expression::Literal(Value::Str(s)) => out.push_str(s),
821                    Expression::Literal(Value::Int(n)) => out.push_str(&n.to_string()),
822                    Expression::Literal(Value::Bool(b)) => out.push_str(&b.to_string()),
823                    Expression::Unresolved(sym) => {
824                        out.push_str("${");
825                        out.push_str(&sym.source);
826                        out.push('}');
827                    }
828                    _ => return None,
829                }
830            }
831            Some(Arc::from(out))
832        }
833        _ => None,
834    }
835}
836
837/// Build the `Vec<DependencyBlock>` to surface in [`TerragruntConfig`].
838fn build_dependencies(
839    raw: &[parsed::DependencyBlockRaw],
840    component_dir: &Arc<Path>,
841) -> Vec<DependencyBlock> {
842    raw.iter()
843        .filter_map(|r| {
844            let mut config_path: Option<Arc<Path>> = None;
845            let mut mock_outputs: AttributeMap = Vec::new();
846            for (k, v) in &r.attrs {
847                match (k.as_ref(), v) {
848                    ("config_path", Expression::Literal(Value::Str(s))) => {
849                        let joined = component_dir.join(s.as_ref());
850                        config_path = Some(Arc::from(joined.as_path()));
851                    }
852                    ("mock_outputs", Expression::Object(entries)) => {
853                        mock_outputs = entries
854                            .iter()
855                            .filter_map(|(k, v)| match k {
856                                Expression::Literal(Value::Str(s)) => {
857                                    Some((Arc::clone(s), v.clone()))
858                                }
859                                _ => None,
860                            })
861                            .collect();
862                    }
863                    _ => {}
864                }
865            }
866            config_path.map(|cp| {
867                DependencyBlock::builder()
868                    .name(Arc::clone(&r.name))
869                    .config_path(cp)
870                    .mock_outputs(mock_outputs)
871                    .span(r.span.clone())
872                    .build()
873            })
874        })
875        .collect()
876}
877
878/// Extract the state backend either from a `terraform { backend "s3" { ... } }`
879/// block within a Terragrunt body, or from sub-parsing a
880/// `generate "backend"` block's `contents` string.
881fn extract_state_backend(
882    merged: &ParsedTerragrunt,
883    loader: HclEditLoader,
884    loader_limits: &LoaderLimits,
885) -> Option<StateBackend> {
886    // First try the `terraform { backend ... }` shape.
887    for tf_body in &merged.terraform {
888        if let Some(backend) = backend_from_terraform_body(tf_body) {
889            return Some(backend);
890        }
891    }
892    // Then sub-parse each `generate "backend"` block's contents.
893    for g in &merged.generates {
894        if g.label.as_ref() != "backend" {
895            continue;
896        }
897        let contents_str = g.attrs.iter().find_map(|(k, v)| {
898            if k.as_ref() == "contents" {
899                return render_contents_for_reparse(v);
900            }
901            None
902        });
903        let Some(contents) = contents_str else {
904            continue;
905        };
906        let synthetic_path: Arc<Path> = Arc::from(Path::new("generated_backend.tf"));
907        let parsed = loader.parse_bytes(contents.as_bytes(), &synthetic_path, loader_limits);
908        for block in &parsed.blocks {
909            if !matches!(block.kind, crate::ir::BlockKind::Terraform) {
910                continue;
911            }
912            if let Some(backend) = backend_from_terraform_body(&block.body) {
913                return Some(backend);
914            }
915        }
916    }
917    None
918}
919
920fn backend_from_terraform_body(body: &AttributeMap) -> Option<StateBackend> {
921    // `terraform { backend "s3" { ... } }` lowers under our loader as a
922    // single nested-block attribute keyed `"backend"`. The block labels
923    // live inside the resulting `Expression::Object` under the synthetic
924    // `__labels__` key (per spec defect S-006 / S-019). Pulling the first
925    // element of `__labels__` gives the backend kind; previously we
926    // hardcoded `"s3"` (F-022).
927    for (k, v) in body {
928        if k.as_ref() == "backend"
929            && let Expression::Object(entries) = v
930        {
931            let mut attrs: AttributeMap = Vec::new();
932            let mut kind: Arc<str> = Arc::from("s3");
933            for (kk, vv) in entries {
934                let Expression::Literal(Value::Str(name)) = kk else {
935                    continue;
936                };
937                if name.as_ref() == "__labels__"
938                    && let Expression::Literal(Value::List(labels)) = vv
939                    && let Some(Value::Str(label)) = labels.first()
940                {
941                    kind = Arc::clone(label);
942                    continue;
943                }
944                attrs.push((Arc::clone(name), vv.clone()));
945            }
946            return Some(
947                StateBackend::builder()
948                    .kind(kind)
949                    .attributes(attrs)
950                    .span(crate::ir::Span::synthetic())
951                    .build(),
952            );
953        }
954    }
955    None
956}
957
958fn parse_merge_strategy(expr: Option<&Expression>) -> MergeStrategy {
959    let Some(Expression::Literal(Value::Str(s))) = expr else {
960        return MergeStrategy::default();
961    };
962    MergeStrategy::parse(s.as_ref()).unwrap_or_default()
963}
964
965/// Read a Terragrunt file from `path` and project it. Returns `None`
966/// when the file does not exist or the parse failed (the diagnostics
967/// already captured the failure).
968fn read_and_project(
969    path: &Path,
970    workspace_root: &Path,
971    loader: HclEditLoader,
972    loader_limits: &LoaderLimits,
973    diagnostics: &mut Vec<Diagnostic>,
974) -> Option<ParsedTerragrunt> {
975    let canonical = paths::canonicalize_inside(path, workspace_root, SymlinkPolicy::Follow).ok()?;
976    let Ok(bytes) = std::fs::read(&canonical) else {
977        return None;
978    };
979    let canonical_arc: Arc<Path> = Arc::from(canonical.as_path());
980    let parsed_bytes = loader.parse_bytes(&bytes, &canonical_arc, loader_limits);
981    let parsed = parsed::project(&parsed_bytes, &canonical_arc);
982    diagnostics.extend(parsed.diagnostics.iter().cloned());
983    Some(parsed)
984}
985
986/// Evaluate an [`Expression`] to a `string` Value via the reducer. Used
987/// for include `path` expressions, generate path attributes, etc.
988#[allow(clippy::too_many_arguments)]
989fn reduce_to_string(
990    expr: &Expression,
991    extra_locals: &Map,
992    funcs: &Arc<FuncRegistry>,
993    _tg_state: &Arc<TgState>,
994    workspace_root: &Path,
995    env_var_mode: &crate::eval::EnvVarMode,
996    eval_limits: &EvalLimits,
997) -> Option<Arc<str>> {
998    use crate::eval::reduce::{Scope, reduce_expression};
999    let scope = Scope::new(
1000        Map::new(),
1001        extra_locals.clone(),
1002        workspace_root,
1003        env_var_mode,
1004        eval_limits,
1005        funcs,
1006        None,
1007    );
1008    match reduce_expression(expr, &scope) {
1009        Expression::Literal(Value::Str(s)) => Some(s),
1010        _ => None,
1011    }
1012}
1013
1014/// Build a function registry pre-loaded with the HCL/Terraform stdlib +
1015/// Terragrunt-specific functions. The TG funcs share an `Arc<TgState>`
1016/// via closure.
1017#[allow(clippy::too_many_arguments, clippy::needless_pass_by_value)]
1018fn build_func_registry(
1019    tg_state: Arc<TgState>,
1020    memo: Arc<DashMap<Arc<Path>, Arc<ResolvedTerragrunt>>>,
1021    stack: Arc<std::sync::Mutex<Vec<Arc<Path>>>>,
1022    inflight: Arc<DashMap<Arc<Path>, ()>>,
1023    registry_slot: Arc<OnceLock<Arc<FuncRegistry>>>,
1024    ctx: &TgContext,
1025    loader: HclEditLoader,
1026    loader_limits: LoaderLimits,
1027    eval_limits: EvalLimits,
1028) -> FuncRegistry {
1029    let mut b: FuncRegistryBuilder = FuncRegistry::default_with_stdlib().to_builder();
1030    b.register(
1031        "get_terragrunt_dir",
1032        Arc::new(GetTerragruntDirFn {
1033            state: Arc::clone(&tg_state),
1034        }) as Arc<dyn HclFunc>,
1035    );
1036    b.register(
1037        "get_repo_root",
1038        Arc::new(GetRepoRootFn {
1039            state: Arc::clone(&tg_state),
1040        }) as Arc<dyn HclFunc>,
1041    );
1042    b.register(
1043        "get_parent_terragrunt_dir",
1044        Arc::new(GetParentTerragruntDirFn {
1045            state: Arc::clone(&tg_state),
1046        }) as Arc<dyn HclFunc>,
1047    );
1048    b.register(
1049        "find_in_parent_folders",
1050        Arc::new(FindInParentFoldersFn {
1051            state: Arc::clone(&tg_state),
1052        }) as Arc<dyn HclFunc>,
1053    );
1054    b.register(
1055        "find_in_parent_folders_from",
1056        Arc::new(FindInParentFoldersFromFn {
1057            state: Arc::clone(&tg_state),
1058        }) as Arc<dyn HclFunc>,
1059    );
1060    b.register(
1061        "path_relative_to_include",
1062        Arc::new(PathRelativeToIncludeFn {
1063            state: Arc::clone(&tg_state),
1064        }) as Arc<dyn HclFunc>,
1065    );
1066    b.register(
1067        "path_relative_from_include",
1068        Arc::new(PathRelativeFromIncludeFn {
1069            state: Arc::clone(&tg_state),
1070        }) as Arc<dyn HclFunc>,
1071    );
1072    b.register("try", Arc::new(TryFn) as Arc<dyn HclFunc>);
1073    b.register(
1074        "read_terragrunt_config",
1075        Arc::new(ReadTerragruntConfigFn {
1076            state: Arc::clone(&tg_state),
1077            workspace_root: Arc::clone(&ctx.workspace_root),
1078            memo,
1079            stack,
1080            inflight,
1081            registry_slot,
1082            loader,
1083            loader_limits,
1084            eval_limits,
1085            env_var_mode: ctx.env_var_mode.clone(),
1086        }) as Arc<dyn HclFunc>,
1087    );
1088    b.build()
1089}
1090
1091/// `read_terragrunt_config(path, fallback?)` — parses the target
1092/// Terragrunt file, evaluates its locals/inputs, memoises the result by
1093/// canonical path, and returns a `Value::Map` shaped as
1094/// `{ locals = { ... }, inputs = { ... } }` so callers can do
1095/// `local.env_vars.locals.aws_region` style descents.
1096#[derive(Debug)]
1097struct ReadTerragruntConfigFn {
1098    state: Arc<TgState>,
1099    workspace_root: Arc<Path>,
1100    memo: Arc<DashMap<Arc<Path>, Arc<ResolvedTerragrunt>>>,
1101    stack: Arc<std::sync::Mutex<Vec<Arc<Path>>>>,
1102    inflight: Arc<DashMap<Arc<Path>, ()>>,
1103    /// Late-bound holder of the registry this function lives in. Populated
1104    /// right after the registry is constructed (F-021). When we recurse to
1105    /// reduce a parent's locals, we use the full TG registry so functions
1106    /// like `find_in_parent_folders` and `get_repo_root` remain dispatchable.
1107    registry_slot: Arc<OnceLock<Arc<FuncRegistry>>>,
1108    loader: HclEditLoader,
1109    loader_limits: LoaderLimits,
1110    eval_limits: EvalLimits,
1111    env_var_mode: crate::eval::EnvVarMode,
1112}
1113
1114impl HclFunc for ReadTerragruntConfigFn {
1115    fn call(
1116        &self,
1117        args: &[Value],
1118        _cx: &CallCx<'_>,
1119    ) -> std::result::Result<Value, crate::eval::FuncError> {
1120        let target_path: &str = match args.first() {
1121            Some(Value::Str(s)) => s.as_ref(),
1122            None => {
1123                return Err(crate::eval::FuncError::Arity {
1124                    name: Arc::from("read_terragrunt_config"),
1125                    expected: 1,
1126                    got: 0,
1127                });
1128            }
1129            Some(_) => {
1130                return Err(crate::eval::FuncError::Type {
1131                    name: Arc::from("read_terragrunt_config"),
1132                    index: 0,
1133                    expected: "string",
1134                    got: "non-string",
1135                });
1136            }
1137        };
1138
1139        let candidate = Path::new(target_path).to_path_buf();
1140        let canonical = match paths::canonicalize_inside(
1141            &candidate,
1142            &self.workspace_root,
1143            SymlinkPolicy::Follow,
1144        ) {
1145            Ok(p) => Arc::<Path>::from(p),
1146            Err(_) => {
1147                // Path escape or missing file → fallback.
1148                return Ok(args.get(1).cloned().unwrap_or(Value::Map(Vec::new())));
1149            }
1150        };
1151
1152        if let Some(entry) = self.memo.get(&canonical) {
1153            return Ok(read_result_to_value(&entry));
1154        }
1155
1156        // Cycle check via the per-resolution include stack.
1157        let cycle = match self.stack.lock() {
1158            Ok(g) => g.iter().any(|p| Arc::ptr_eq(p, &canonical)),
1159            Err(p) => p.into_inner().iter().any(|p| Arc::ptr_eq(p, &canonical)),
1160        };
1161        if cycle {
1162            return Err(crate::eval::FuncError::Other {
1163                name: Arc::from("read_terragrunt_config"),
1164                message: Arc::from(format!(
1165                    "terragrunt read cycle at `{}`",
1166                    canonical.display()
1167                )),
1168            });
1169        }
1170        // Single-flight: refuse to recurse if another in-flight read is on the same path.
1171        if self.inflight.insert(Arc::clone(&canonical), ()).is_some() {
1172            return Ok(args.get(1).cloned().unwrap_or(Value::Map(Vec::new())));
1173        }
1174
1175        if let Ok(mut g) = self.stack.lock() {
1176            g.push(Arc::clone(&canonical));
1177        }
1178
1179        let Ok(bytes) = std::fs::read(canonical.as_ref()) else {
1180            self.inflight.remove(&canonical);
1181            if let Ok(mut g) = self.stack.lock() {
1182                g.pop();
1183            }
1184            return Ok(args.get(1).cloned().unwrap_or(Value::Map(Vec::new())));
1185        };
1186        let parsed_bytes = self
1187            .loader
1188            .parse_bytes(&bytes, &canonical, &self.loader_limits);
1189        let parsed = parsed::project(&parsed_bytes, &canonical);
1190
1191        // Use the same TG registry this function lives in so transitive
1192        // `read_terragrunt_config` / `find_in_parent_folders` / `get_env`
1193        // calls inside the parent's locals dispatch correctly (F-021).
1194        // The OnceLock is populated right after the resolver builds the
1195        // registry; if it's somehow empty (shouldn't happen), fall back
1196        // to the stdlib-only registry to keep the call best-effort.
1197        let nested_funcs: Arc<FuncRegistry> = self
1198            .registry_slot
1199            .get()
1200            .map_or_else(|| Arc::new(FuncRegistry::default_with_stdlib()), Arc::clone);
1201
1202        let literal_map: Map = literal_map_for(&parsed.locals);
1203        let resolved = evaluate_locals(
1204            &parsed.locals,
1205            &nested_funcs,
1206            &self.state,
1207            &self.workspace_root,
1208            &self.env_var_mode,
1209            &self.eval_limits,
1210        );
1211        let merged_locals = merge_locals(&literal_map, &resolved, MergeStrategy::DeepMapOnly);
1212        let inputs = if let Some(input_attrs) = &parsed.inputs {
1213            evaluate_inputs(
1214                input_attrs,
1215                &merged_locals,
1216                &nested_funcs,
1217                &self.state,
1218                &self.workspace_root,
1219                &self.env_var_mode,
1220                &self.eval_limits,
1221            )
1222        } else {
1223            Map::new()
1224        };
1225
1226        let resolved_entry = Arc::new(ResolvedTerragrunt {
1227            locals: merged_locals,
1228            inputs,
1229        });
1230        self.memo
1231            .insert(Arc::clone(&canonical), Arc::clone(&resolved_entry));
1232
1233        if let Ok(mut g) = self.stack.lock() {
1234            g.pop();
1235        }
1236        self.inflight.remove(&canonical);
1237
1238        Ok(read_result_to_value(&resolved_entry))
1239    }
1240}
1241
1242/// Render a [`ResolvedTerragrunt`] as the `Value::Map` shape callers
1243/// expect:
1244/// `{ locals = { ... }, inputs = { ... } }`.
1245fn read_result_to_value(entry: &ResolvedTerragrunt) -> Value {
1246    let out: Map = vec![
1247        (Arc::from("locals"), Value::Map(entry.locals.clone())),
1248        (Arc::from("inputs"), Value::Map(entry.inputs.clone())),
1249    ];
1250    Value::Map(out)
1251}
1252
1253#[cfg(test)]
1254#[allow(
1255    clippy::unwrap_used,
1256    clippy::expect_used,
1257    clippy::indexing_slicing,
1258    clippy::panic
1259)]
1260mod tests {
1261    use super::*;
1262
1263    fn write_file(root: &Path, rel: &str, body: &str) -> std::path::PathBuf {
1264        let p = root.join(rel);
1265        if let Some(parent) = p.parent() {
1266            std::fs::create_dir_all(parent).unwrap();
1267        }
1268        std::fs::write(&p, body).unwrap();
1269        p
1270    }
1271
1272    #[test]
1273    fn test_resolves_component_with_root_include() {
1274        let tmp = tempfile::tempdir().unwrap();
1275        let root = std::fs::canonicalize(tmp.path()).unwrap();
1276        write_file(&root, "root.hcl", "locals { aws_region = \"us-east-2\" }\n");
1277        write_file(
1278            &root,
1279            "services/api/terragrunt.hcl",
1280            "include \"root\" {\n  path = find_in_parent_folders(\"root.hcl\")\n}\n",
1281        );
1282
1283        let ctx = TgContext::new(Arc::from(root.as_path()));
1284        let cfg = FsTerragruntResolver::new()
1285            .resolve(&root.join("services/api"), &ctx)
1286            .unwrap();
1287
1288        // The parent's `aws_region = "us-east-2"` should be visible.
1289        assert!(
1290            cfg.effective_locals
1291                .iter()
1292                .any(|(k, v)| &**k == "aws_region"
1293                    && matches!(v, Value::Str(s) if s.as_ref() == "us-east-2")),
1294            "diags={:?} locals={:?}",
1295            cfg.diagnostics,
1296            cfg.effective_locals
1297        );
1298        assert_eq!(cfg.includes.len(), 1);
1299    }
1300
1301    #[test]
1302    fn test_detects_include_cycle() {
1303        let tmp = tempfile::tempdir().unwrap();
1304        let root = std::fs::canonicalize(tmp.path()).unwrap();
1305        // a.hcl includes b.hcl; b.hcl includes a.hcl.
1306        write_file(
1307            &root,
1308            "a.hcl",
1309            "include \"b\" { path = find_in_parent_folders(\"b.hcl\") }\n",
1310        );
1311        write_file(
1312            &root,
1313            "b.hcl",
1314            "include \"a\" { path = find_in_parent_folders(\"a.hcl\") }\n",
1315        );
1316        write_file(
1317            &root,
1318            "x/terragrunt.hcl",
1319            "include \"a\" { path = find_in_parent_folders(\"a.hcl\") }\n",
1320        );
1321
1322        let ctx = TgContext::new(Arc::from(root.as_path()));
1323        let cfg = FsTerragruntResolver::new()
1324            .resolve(&root.join("x"), &ctx)
1325            .unwrap();
1326        assert!(
1327            cfg.diagnostics.iter().any(|d| &*d.code == "TG2006"),
1328            "expected TG2006 cycle diag; got {:?}",
1329            cfg.diagnostics
1330        );
1331    }
1332
1333    #[test]
1334    fn test_path_escape_in_find_in_parent_folders_falls_back() {
1335        let tmp = tempfile::tempdir().unwrap();
1336        let root = std::fs::canonicalize(tmp.path()).unwrap();
1337        // No file to find; fallback returns the literal string.
1338        write_file(
1339            &root,
1340            "x/terragrunt.hcl",
1341            "locals { x = find_in_parent_folders(\"missing.hcl\", \"fallback.hcl\") }\n",
1342        );
1343        let ctx = TgContext::new(Arc::from(root.as_path()));
1344        let cfg = FsTerragruntResolver::new()
1345            .resolve(&root.join("x"), &ctx)
1346            .unwrap();
1347        assert!(
1348            cfg.effective_locals
1349                .iter()
1350                .any(|(k, v)| &**k == "x"
1351                    && matches!(v, Value::Str(s) if s.as_ref() == "fallback.hcl")),
1352            "{:?}",
1353            cfg.effective_locals
1354        );
1355    }
1356
1357    #[test]
1358    fn test_captures_generate_block() {
1359        let tmp = tempfile::tempdir().unwrap();
1360        let root = std::fs::canonicalize(tmp.path()).unwrap();
1361        write_file(
1362            &root,
1363            "x/terragrunt.hcl",
1364            "generate \"backend\" {\n  path = \"backend.tf\"\n  if_exists = \
1365             \"overwrite_terragrunt\"\n  contents = \"terraform {}\"\n}\n",
1366        );
1367        let ctx = TgContext::new(Arc::from(root.as_path()));
1368        let cfg = FsTerragruntResolver::new()
1369            .resolve(&root.join("x"), &ctx)
1370            .unwrap();
1371        assert_eq!(cfg.generates.len(), 1);
1372        assert_eq!(&*cfg.generates[0].label, "backend");
1373    }
1374
1375    /// A `generate "backend"` heredoc whose `contents` body mixes static
1376    /// HCL with `${...}` interpolations (e.g. `${path_relative_to_include()}`
1377    /// inside the `key` attribute, plus a body-level `${local.x}` injection
1378    /// line) must still surface a usable [`StateBackend`]. Before the fix,
1379    /// `extract_state_backend` only accepted `Expression::Literal(Value::Str)`
1380    /// for the heredoc and silently bailed when it lowered to
1381    /// `TemplateConcat`, so every component that wired its backend through
1382    /// a templated `generate` block came out empty.
1383    #[test]
1384    fn test_should_extract_backend_from_templated_generate_contents() {
1385        let tmp = tempfile::tempdir().unwrap();
1386        let root = std::fs::canonicalize(tmp.path()).unwrap();
1387        write_file(
1388            &root,
1389            "root.hcl",
1390            "locals {\n  profile_block = \"\"\n}\ngenerate \"backend\" {\n  path = \
1391             \"backend.tf\"\n  if_exists = \"overwrite_terragrunt\"\n  contents = \
1392             <<EOF\nterraform {\n  backend \"s3\" {\n    bucket = \"my-tfstate\"\n    region = \
1393             \"us-west-2\"\n    key    = \
1394             \"acct/${path_relative_to_include()}.tfstate\"\n${local.profile_block}\n  \
1395             }\n}\nEOF\n}\n",
1396        );
1397        write_file(
1398            &root,
1399            "svc/terragrunt.hcl",
1400            "include \"root\" { path = find_in_parent_folders(\"root.hcl\") }\n",
1401        );
1402
1403        let ctx = TgContext::new(Arc::from(root.as_path()));
1404        let cfg = FsTerragruntResolver::new()
1405            .resolve(&root.join("svc"), &ctx)
1406            .unwrap();
1407        let backend = cfg
1408            .state_backend
1409            .as_ref()
1410            .expect("backend should be extracted from templated generate contents");
1411        assert_eq!(&*backend.kind, "s3");
1412        // The `region` is captured into the verbatim attribute map at
1413        // this stage; the provider-resolver pass later promotes it onto
1414        // `state_region` (see `provider::resolver`), so we assert at the
1415        // attribute-map level here.
1416        let attr_str = |name: &str| -> Option<Arc<str>> {
1417            backend
1418                .attributes
1419                .iter()
1420                .find(|(k, _)| &**k == name)
1421                .and_then(|(_, v)| match v {
1422                    Expression::Literal(Value::Str(s)) => Some(Arc::clone(s)),
1423                    _ => None,
1424                })
1425        };
1426        assert_eq!(attr_str("bucket").as_deref(), Some("my-tfstate"));
1427        assert_eq!(attr_str("region").as_deref(), Some("us-west-2"));
1428    }
1429
1430    #[test]
1431    fn test_captures_dependency_block() {
1432        let tmp = tempfile::tempdir().unwrap();
1433        let root = std::fs::canonicalize(tmp.path()).unwrap();
1434        write_file(
1435            &root,
1436            "x/terragrunt.hcl",
1437            "dependency \"vpc\" { config_path = \"../net\" }\n",
1438        );
1439        let ctx = TgContext::new(Arc::from(root.as_path()));
1440        let cfg = FsTerragruntResolver::new()
1441            .resolve(&root.join("x"), &ctx)
1442            .unwrap();
1443        assert_eq!(cfg.dependencies.len(), 1);
1444        assert_eq!(&*cfg.dependencies[0].name, "vpc");
1445    }
1446}