use std::{
collections::BTreeSet,
path::{Path, PathBuf},
sync::Arc,
};
use crate::{
Error, Result,
eval::EnvVarMode,
exporter::{ExportOptions, ExportReport, Exporter, ParquetExporter},
ir::{Map, Region, Value, Workspace},
pipeline::{DefaultPipeline, Pipeline, PipelineOptions},
provider::{ProfileMap, load_aws_config, load_yaml_profile_map},
};
#[derive(Clone, Debug)]
#[non_exhaustive]
pub struct Parser {
pub pipeline_options: PipelineOptions,
pub profile_map: Option<Arc<ProfileMap>>,
pub default_region: Option<Region>,
pub strict_providers: bool,
}
impl Parser {
#[must_use]
pub fn builder() -> ParserBuilder {
ParserBuilder::default()
}
pub fn parse(&self) -> Result<Workspace> {
self.build_pipeline().run(&self.pipeline_options)
}
pub fn parse_and_export(&self, opts: &ExportOptions) -> Result<(Workspace, ExportReport)> {
let ws = self.parse()?;
let report = ParquetExporter::new()
.export(&ws, opts)
.map_err(Error::from)?;
Ok((ws, report))
}
fn build_pipeline(&self) -> DefaultPipeline {
let mut p = DefaultPipeline::new();
if let Some(map) = &self.profile_map {
p = p.with_profile_map(Arc::clone(map));
}
if let Some(region) = &self.default_region {
p = p.with_default_region(region.clone());
}
if self.strict_providers {
p = p.strict();
}
p
}
}
#[derive(Clone, Debug, Default)]
#[non_exhaustive]
pub struct ParserBuilder {
workspace_root: Option<PathBuf>,
environment: Option<Arc<str>>,
env_var_mode: Option<EnvVarMode>,
allowed_env: BTreeSet<Arc<str>>,
repo_vars: Map,
max_walk_depth: Option<u32>,
max_total_files: Option<u64>,
max_file_bytes: Option<u64>,
max_include_depth: Option<u32>,
follow_symlinks: Option<bool>,
profile_map: Option<Arc<ProfileMap>>,
default_region: Option<Region>,
strict_providers: bool,
}
impl ParserBuilder {
#[must_use]
pub fn workspace_root(mut self, root: impl AsRef<Path>) -> Self {
self.workspace_root = Some(root.as_ref().to_path_buf());
self
}
#[must_use]
pub fn environment(mut self, name: impl Into<Arc<str>>) -> Self {
self.environment = Some(name.into());
self
}
#[must_use]
pub fn env_var_mode(mut self, mode: EnvVarMode) -> Self {
self.env_var_mode = Some(mode);
self
}
#[must_use]
pub fn allow_env(mut self, name: impl Into<Arc<str>>) -> Self {
self.allowed_env.insert(name.into());
self
}
#[must_use]
pub fn allow_env_many<I, S>(mut self, names: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<Arc<str>>,
{
for n in names {
self.allowed_env.insert(n.into());
}
self
}
#[must_use]
pub fn var(mut self, key: impl Into<Arc<str>>, value: impl Into<Arc<str>>) -> Self {
let key = key.into();
let val = Value::Str(value.into());
match self.repo_vars.iter_mut().find(|(k, _)| *k == key) {
Some(slot) => slot.1 = val,
None => self.repo_vars.push((key, val)),
}
self
}
#[must_use]
pub fn repo_vars(mut self, vars: Map) -> Self {
self.repo_vars = vars;
self
}
#[must_use]
pub fn profile_map(mut self, map: Arc<ProfileMap>) -> Self {
self.profile_map = Some(map);
self
}
pub fn load_profile_map_yaml(mut self, path: impl AsRef<Path>) -> Result<Self> {
let map = load_yaml_profile_map(path.as_ref()).map_err(Error::from)?;
self.profile_map = Some(map);
Ok(self)
}
pub fn load_aws_config(mut self, path: impl AsRef<Path>) -> Result<Self> {
let map = load_aws_config(path.as_ref()).map_err(Error::from)?;
self.profile_map = Some(map);
Ok(self)
}
pub fn default_region(mut self, region: impl AsRef<str>) -> Result<Self> {
self.default_region = Some(Region::new(region.as_ref()).map_err(Error::from)?);
Ok(self)
}
#[must_use]
pub const fn strict_providers(mut self, strict: bool) -> Self {
self.strict_providers = strict;
self
}
#[must_use]
pub const fn max_walk_depth(mut self, depth: u32) -> Self {
self.max_walk_depth = Some(depth);
self
}
#[must_use]
pub const fn max_total_files(mut self, n: u64) -> Self {
self.max_total_files = Some(n);
self
}
#[must_use]
pub const fn max_file_bytes(mut self, n: u64) -> Self {
self.max_file_bytes = Some(n);
self
}
#[must_use]
pub const fn max_include_depth(mut self, n: u32) -> Self {
self.max_include_depth = Some(n);
self
}
#[must_use]
pub const fn follow_symlinks(mut self, follow: bool) -> Self {
self.follow_symlinks = Some(follow);
self
}
pub fn build(self) -> Result<Parser> {
let Some(root) = self.workspace_root else {
return Err(Error::Validation(crate::ValidationError::MissingField(
"workspace_root",
)));
};
let mut opts = PipelineOptions::builder()
.root(Arc::<Path>::from(root.as_path()))
.build();
if let Some(env) = self.environment {
opts.environment = Some(env);
}
if let Some(mode) = self.env_var_mode {
opts.env_var_mode = mode;
}
if !self.allowed_env.is_empty() {
opts.allowed_env = self.allowed_env;
}
if !self.repo_vars.is_empty() {
opts.repo_vars = self.repo_vars;
}
if let Some(d) = self.max_walk_depth {
opts.max_walk_depth = d;
}
if let Some(n) = self.max_total_files {
opts.max_total_files = n;
}
if let Some(n) = self.max_file_bytes {
opts.max_file_bytes = n;
}
if let Some(n) = self.max_include_depth {
opts.max_include_depth = n;
}
if let Some(f) = self.follow_symlinks {
opts.follow_symlinks = f;
}
Ok(Parser {
pipeline_options: opts,
profile_map: self.profile_map,
default_region: self.default_region,
strict_providers: self.strict_providers,
})
}
}
pub fn parse(root: impl AsRef<Path>) -> Result<Workspace> {
Parser::builder().workspace_root(root).build()?.parse()
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn test_should_require_workspace_root() {
let err = Parser::builder().build().unwrap_err();
assert!(
matches!(
err,
Error::Validation(crate::ValidationError::MissingField(_))
),
"expected MissingField, got {err:?}"
);
}
#[test]
fn test_should_carry_builder_options_into_pipeline_options() {
let parser = Parser::builder()
.workspace_root("/tmp/repo")
.environment("staging")
.var("region", "us-east-1")
.var("region", "us-west-2") .allow_env("TF_VAR_environment")
.max_walk_depth(8_u32)
.strict_providers(true)
.build()
.unwrap();
assert_eq!(parser.pipeline_options.max_walk_depth, 8);
assert_eq!(
parser.pipeline_options.environment.as_deref(),
Some("staging")
);
assert!(parser.strict_providers);
let region = parser
.pipeline_options
.repo_vars
.iter()
.find(|(k, _)| k.as_ref() == "region")
.expect("region var present");
assert!(matches!(®ion.1, Value::Str(s) if s.as_ref() == "us-west-2"));
assert_eq!(parser.pipeline_options.allowed_env.len(), 1);
}
#[test]
fn test_should_validate_default_region() {
let err = Parser::builder()
.workspace_root("/tmp/repo")
.default_region("INVALID region!")
.unwrap_err();
assert!(matches!(err, Error::Validation(_)), "got {err:?}");
}
}