1use 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#[derive(Clone, Debug)]
60#[non_exhaustive]
61pub struct Parser {
62 pub pipeline_options: PipelineOptions,
64 pub profile_map: Option<Arc<ProfileMap>>,
66 pub default_region: Option<Region>,
69 pub strict_providers: bool,
72}
73
74impl Parser {
75 #[must_use]
77 pub fn builder() -> ParserBuilder {
78 ParserBuilder::default()
79 }
80
81 pub fn parse(&self) -> Result<Workspace> {
89 self.build_pipeline().run(&self.pipeline_options)
90 }
91
92 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#[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 #[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 #[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 #[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 #[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 #[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 #[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 #[must_use]
206 pub fn repo_vars(mut self, vars: Map) -> Self {
207 self.repo_vars = vars;
208 self
209 }
210
211 #[must_use]
213 pub fn profile_map(mut self, map: Arc<ProfileMap>) -> Self {
214 self.profile_map = Some(map);
215 self
216 }
217
218 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 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 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 #[must_use]
258 pub const fn strict_providers(mut self, strict: bool) -> Self {
259 self.strict_providers = strict;
260 self
261 }
262
263 #[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 #[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 #[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 #[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 #[must_use]
293 pub const fn follow_symlinks(mut self, follow: bool) -> Self {
294 self.follow_symlinks = Some(follow);
295 self
296 }
297
298 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
348pub 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") .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!(®ion.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}