Skip to main content

tfparser_core/
parser.rs

1//! High-level [`Parser`] facade — the recommended consumption surface.
2//!
3//! The pipeline, evaluator, terragrunt resolver, provider resolver, graph
4//! builder and exporter compose into a long argument list of typed structs.
5//! For library consumers that's flexible but noisy; [`Parser`] bundles the
6//! common shape behind one ergonomic builder and two execution methods.
7//!
8//! ```no_run
9//! # fn main() -> tfparser_core::Result<()> {
10//! // One-shot: parse a workspace with all defaults.
11//! let workspace = tfparser_core::parse("./my-tf-repo")?;
12//! println!("{} components", workspace.components.len());
13//! # Ok(()) }
14//! ```
15//!
16//! ```no_run
17//! # fn main() -> tfparser_core::Result<()> {
18//! use tfparser_core::{Parser, EnvVarMode};
19//!
20//! // Builder for full control. Every option is optional except
21//! // `workspace_root`; the rest defer to spec defaults.
22//! let workspace = Parser::builder()
23//!     .workspace_root("./my-tf-repo")
24//!     .environment("production")
25//!     .default_region("us-west-2")?
26//!     .env_var_mode(EnvVarMode::Passthrough)
27//!     .allow_env("TF_VAR_environment")
28//!     .var("region", "us-east-1")
29//!     .strict_providers(true)
30//!     .build()?
31//!     .parse()?;
32//! # Ok(()) }
33//! ```
34//!
35//! Add `.parse_and_export(&opts)` to write the four canonical Parquet tables
36//! in the same call.
37
38use std::{
39    collections::BTreeSet,
40    path::{Path, PathBuf},
41    sync::Arc,
42};
43
44use crate::{
45    Error, Result,
46    eval::EnvVarMode,
47    exporter::{ExportOptions, ExportReport, Exporter, ParquetExporter},
48    ir::{Map, Region, Value, Workspace},
49    pipeline::{DefaultPipeline, Pipeline, PipelineOptions},
50    provider::{ProfileMap, load_aws_config, load_yaml_profile_map},
51};
52
53/// High-level wrapper around [`DefaultPipeline`] + [`ParquetExporter`].
54///
55/// Configure via [`Parser::builder`]; run via [`Parser::parse`] (workspace
56/// only) or [`Parser::parse_and_export`] (workspace plus Parquet output).
57///
58/// The struct is cheap to clone (every field is an `Arc` or `Copy`).
59#[derive(Clone, Debug)]
60#[non_exhaustive]
61pub struct Parser {
62    /// Pipeline-level configuration (workspace root, limits, env mode, vars).
63    pub pipeline_options: PipelineOptions,
64    /// Optional AWS profile-map driving the provider resolver.
65    pub profile_map: Option<Arc<ProfileMap>>,
66    /// Default region used when neither provider blocks nor Terragrunt
67    /// cascade supply one.
68    pub default_region: Option<Region>,
69    /// Whether the provider resolver fails when a referenced profile is
70    /// missing from `profile_map`.
71    pub strict_providers: bool,
72}
73
74impl Parser {
75    /// Start a builder. `workspace_root(...)` is the only required field.
76    #[must_use]
77    pub fn builder() -> ParserBuilder {
78        ParserBuilder::default()
79    }
80
81    /// Run the parsing pipeline and return the in-memory [`Workspace`].
82    ///
83    /// # Errors
84    ///
85    /// Propagates any fatal error from discovery / loader / evaluator /
86    /// graph / provider stages. Non-fatal issues are surfaced via
87    /// `Workspace::diagnostics`.
88    pub fn parse(&self) -> Result<Workspace> {
89        self.build_pipeline().run(&self.pipeline_options)
90    }
91
92    /// Run the pipeline then write the four canonical Parquet tables.
93    ///
94    /// Equivalent to `parse()` + `ParquetExporter::new().export(&ws, opts)`,
95    /// folded into a single call that hands back both the in-memory
96    /// [`Workspace`] and the exporter's [`ExportReport`].
97    ///
98    /// # Errors
99    ///
100    /// Returns [`Error::Export`] when the writer fails; otherwise as
101    /// [`Self::parse`].
102    pub fn parse_and_export(&self, opts: &ExportOptions) -> Result<(Workspace, ExportReport)> {
103        let ws = self.parse()?;
104        let report = ParquetExporter::new()
105            .export(&ws, opts)
106            .map_err(Error::from)?;
107        Ok((ws, report))
108    }
109
110    fn build_pipeline(&self) -> DefaultPipeline {
111        let mut p = DefaultPipeline::new();
112        if let Some(map) = &self.profile_map {
113            p = p.with_profile_map(Arc::clone(map));
114        }
115        if let Some(region) = &self.default_region {
116            p = p.with_default_region(region.clone());
117        }
118        if self.strict_providers {
119            p = p.strict();
120        }
121        p
122    }
123}
124
125/// Builder for [`Parser`]. Every method consumes and returns `self` so
126/// configuration reads top-to-bottom.
127#[derive(Clone, Debug, Default)]
128#[non_exhaustive]
129pub struct ParserBuilder {
130    workspace_root: Option<PathBuf>,
131    environment: Option<Arc<str>>,
132    env_var_mode: Option<EnvVarMode>,
133    allowed_env: BTreeSet<Arc<str>>,
134    repo_vars: Map,
135    max_walk_depth: Option<u32>,
136    max_total_files: Option<u64>,
137    max_file_bytes: Option<u64>,
138    max_include_depth: Option<u32>,
139    follow_symlinks: Option<bool>,
140    profile_map: Option<Arc<ProfileMap>>,
141    default_region: Option<Region>,
142    strict_providers: bool,
143}
144
145impl ParserBuilder {
146    /// **Required.** Workspace root (the directory that contains the
147    /// Terraform / Terragrunt code).
148    #[must_use]
149    pub fn workspace_root(mut self, root: impl AsRef<Path>) -> Self {
150        self.workspace_root = Some(root.as_ref().to_path_buf());
151        self
152    }
153
154    /// Pin `terraform.workspace` / Terragrunt cascade choice. Optional;
155    /// when unset the resolver leaves `var.environment` unresolved.
156    #[must_use]
157    pub fn environment(mut self, name: impl Into<Arc<str>>) -> Self {
158        self.environment = Some(name.into());
159        self
160    }
161
162    /// How `get_env(...)` / Terragrunt funcs read the process env. Default:
163    /// strict with an empty allowlist (no env vars are visible to user code).
164    #[must_use]
165    pub fn env_var_mode(mut self, mode: EnvVarMode) -> Self {
166        self.env_var_mode = Some(mode);
167        self
168    }
169
170    /// Allow a single env var name through `get_env(...)`. Repeatable.
171    #[must_use]
172    pub fn allow_env(mut self, name: impl Into<Arc<str>>) -> Self {
173        self.allowed_env.insert(name.into());
174        self
175    }
176
177    /// Allowlist multiple env var names in one call.
178    #[must_use]
179    pub fn allow_env_many<I, S>(mut self, names: I) -> Self
180    where
181        I: IntoIterator<Item = S>,
182        S: Into<Arc<str>>,
183    {
184        for n in names {
185            self.allowed_env.insert(n.into());
186        }
187        self
188    }
189
190    /// Repo-level `var.<key> = value` binding. Repeatable. Values are
191    /// stored as `Value::Str`; for richer types build the [`Map`] manually
192    /// via [`Self::repo_vars`].
193    #[must_use]
194    pub fn var(mut self, key: impl Into<Arc<str>>, value: impl Into<Arc<str>>) -> Self {
195        let key = key.into();
196        let val = Value::Str(value.into());
197        match self.repo_vars.iter_mut().find(|(k, _)| *k == key) {
198            Some(slot) => slot.1 = val,
199            None => self.repo_vars.push((key, val)),
200        }
201        self
202    }
203
204    /// Replace the repo-level variable map. See also [`Self::var`].
205    #[must_use]
206    pub fn repo_vars(mut self, vars: Map) -> Self {
207        self.repo_vars = vars;
208        self
209    }
210
211    /// Pin an explicit [`ProfileMap`] for the provider resolver.
212    #[must_use]
213    pub fn profile_map(mut self, map: Arc<ProfileMap>) -> Self {
214        self.profile_map = Some(map);
215        self
216    }
217
218    /// Load the profile map from a YAML file (spec 16 § 3.2).
219    ///
220    /// # Errors
221    ///
222    /// Returns [`Error::Provider`] when the file is missing, malformed, or
223    /// violates the validator rules (account-id pattern, region length,
224    /// etc.).
225    pub fn load_profile_map_yaml(mut self, path: impl AsRef<Path>) -> Result<Self> {
226        let map = load_yaml_profile_map(path.as_ref()).map_err(Error::from)?;
227        self.profile_map = Some(map);
228        Ok(self)
229    }
230
231    /// Load the profile map from an `~/.aws/config`-shaped INI file
232    /// (spec 16 § 3.1).
233    ///
234    /// # Errors
235    ///
236    /// Returns [`Error::Provider`] when the file is missing or malformed.
237    pub fn load_aws_config(mut self, path: impl AsRef<Path>) -> Result<Self> {
238        let map = load_aws_config(path.as_ref()).map_err(Error::from)?;
239        self.profile_map = Some(map);
240        Ok(self)
241    }
242
243    /// Default AWS region applied when neither provider blocks nor the
244    /// Terragrunt cascade supply one.
245    ///
246    /// # Errors
247    ///
248    /// Returns [`Error::Validation`] when `region` fails the
249    /// [`Region`] validator (charset / length).
250    pub fn default_region(mut self, region: impl AsRef<str>) -> Result<Self> {
251        self.default_region = Some(Region::new(region.as_ref()).map_err(Error::from)?);
252        Ok(self)
253    }
254
255    /// If `true`, the provider resolver returns `StrictUnresolved` when a
256    /// referenced profile is missing from the profile map. Default: `false`.
257    #[must_use]
258    pub const fn strict_providers(mut self, strict: bool) -> Self {
259        self.strict_providers = strict;
260        self
261    }
262
263    /// Maximum walk depth (discovery). Default: 16.
264    #[must_use]
265    pub const fn max_walk_depth(mut self, depth: u32) -> Self {
266        self.max_walk_depth = Some(depth);
267        self
268    }
269
270    /// Maximum total files in the workspace. Default: 200 000.
271    #[must_use]
272    pub const fn max_total_files(mut self, n: u64) -> Self {
273        self.max_total_files = Some(n);
274        self
275    }
276
277    /// Maximum size per file. Default: 4 MiB.
278    #[must_use]
279    pub const fn max_file_bytes(mut self, n: u64) -> Self {
280        self.max_file_bytes = Some(n);
281        self
282    }
283
284    /// Maximum Terragrunt include depth. Default: 32.
285    #[must_use]
286    pub const fn max_include_depth(mut self, n: u32) -> Self {
287        self.max_include_depth = Some(n);
288        self
289    }
290
291    /// Whether the discoverer follows symlinks. Default: `false`.
292    #[must_use]
293    pub const fn follow_symlinks(mut self, follow: bool) -> Self {
294        self.follow_symlinks = Some(follow);
295        self
296    }
297
298    /// Finalise into a [`Parser`].
299    ///
300    /// # Errors
301    ///
302    /// Returns [`Error::Validation`] when `workspace_root` was not set.
303    pub fn build(self) -> Result<Parser> {
304        let Some(root) = self.workspace_root else {
305            return Err(Error::Validation(crate::ValidationError::MissingField(
306                "workspace_root",
307            )));
308        };
309        let mut opts = PipelineOptions::builder()
310            .root(Arc::<Path>::from(root.as_path()))
311            .build();
312        if let Some(env) = self.environment {
313            opts.environment = Some(env);
314        }
315        if let Some(mode) = self.env_var_mode {
316            opts.env_var_mode = mode;
317        }
318        if !self.allowed_env.is_empty() {
319            opts.allowed_env = self.allowed_env;
320        }
321        if !self.repo_vars.is_empty() {
322            opts.repo_vars = self.repo_vars;
323        }
324        if let Some(d) = self.max_walk_depth {
325            opts.max_walk_depth = d;
326        }
327        if let Some(n) = self.max_total_files {
328            opts.max_total_files = n;
329        }
330        if let Some(n) = self.max_file_bytes {
331            opts.max_file_bytes = n;
332        }
333        if let Some(n) = self.max_include_depth {
334            opts.max_include_depth = n;
335        }
336        if let Some(f) = self.follow_symlinks {
337            opts.follow_symlinks = f;
338        }
339        Ok(Parser {
340            pipeline_options: opts,
341            profile_map: self.profile_map,
342            default_region: self.default_region,
343            strict_providers: self.strict_providers,
344        })
345    }
346}
347
348/// Parse a workspace with all defaults. Convenience over
349/// [`Parser::builder`] when you don't need to tune anything.
350///
351/// ```no_run
352/// # fn main() -> tfparser_core::Result<()> {
353/// let workspace = tfparser_core::parse("./my-tf-repo")?;
354/// # let _ = workspace;
355/// # Ok(()) }
356/// ```
357///
358/// # Errors
359///
360/// Same as [`Parser::parse`].
361pub fn parse(root: impl AsRef<Path>) -> Result<Workspace> {
362    Parser::builder().workspace_root(root).build()?.parse()
363}
364
365#[cfg(test)]
366#[allow(clippy::unwrap_used, clippy::expect_used)]
367mod tests {
368    use super::*;
369
370    #[test]
371    fn test_should_require_workspace_root() {
372        let err = Parser::builder().build().unwrap_err();
373        assert!(
374            matches!(
375                err,
376                Error::Validation(crate::ValidationError::MissingField(_))
377            ),
378            "expected MissingField, got {err:?}"
379        );
380    }
381
382    #[test]
383    fn test_should_carry_builder_options_into_pipeline_options() {
384        let parser = Parser::builder()
385            .workspace_root("/tmp/repo")
386            .environment("staging")
387            .var("region", "us-east-1")
388            .var("region", "us-west-2") // overrides
389            .allow_env("TF_VAR_environment")
390            .max_walk_depth(8_u32)
391            .strict_providers(true)
392            .build()
393            .unwrap();
394        assert_eq!(parser.pipeline_options.max_walk_depth, 8);
395        assert_eq!(
396            parser.pipeline_options.environment.as_deref(),
397            Some("staging")
398        );
399        assert!(parser.strict_providers);
400        let region = parser
401            .pipeline_options
402            .repo_vars
403            .iter()
404            .find(|(k, _)| k.as_ref() == "region")
405            .expect("region var present");
406        assert!(matches!(&region.1, Value::Str(s) if s.as_ref() == "us-west-2"));
407        assert_eq!(parser.pipeline_options.allowed_env.len(), 1);
408    }
409
410    #[test]
411    fn test_should_validate_default_region() {
412        let err = Parser::builder()
413            .workspace_root("/tmp/repo")
414            .default_region("INVALID region!")
415            .unwrap_err();
416        assert!(matches!(err, Error::Validation(_)), "got {err:?}");
417    }
418}