use std::{collections::HashSet, path::Path, sync::Arc};
use crate::{
Result,
diagnostic::Diagnostic,
eval::EvaluatedComponent,
graph::{
expand::{ExpansionState, expand_resource, flatten_modules},
registry::ModuleRegistry,
},
ir::{Component, Resource, Workspace},
};
#[derive(Clone, Debug)]
#[non_exhaustive]
pub struct GraphContext {
pub workspace_root: Arc<Path>,
pub max_module_depth: u32,
pub infer_dependencies: bool,
pub max_expansion_per_resource: u32,
}
impl GraphContext {
#[must_use]
pub fn new(workspace_root: Arc<Path>) -> Self {
Self {
workspace_root,
max_module_depth: 8,
infer_dependencies: true,
max_expansion_per_resource: 1024,
}
}
}
pub trait GraphBuilder: Send + Sync + std::fmt::Debug {
fn build(
&self,
components: Vec<EvaluatedComponent>,
registry: &ModuleRegistry,
ctx: &GraphContext,
) -> Result<Workspace>;
}
#[derive(Debug, Default)]
#[non_exhaustive]
pub struct DefaultGraphBuilder;
impl DefaultGraphBuilder {
#[must_use]
pub const fn new() -> Self {
Self
}
}
impl GraphBuilder for DefaultGraphBuilder {
fn build(
&self,
components: Vec<EvaluatedComponent>,
registry: &ModuleRegistry,
ctx: &GraphContext,
) -> Result<Workspace> {
let mut diagnostics: Vec<Diagnostic> = Vec::new();
let mut out_components: Vec<Component> = Vec::with_capacity(components.len());
for evaluated in &components {
if !matches!(evaluated.raw.kind, crate::ir::ComponentKind::Component) {
continue;
}
let mut state = ExpansionState::new(
ctx.workspace_root.as_ref(),
ctx.max_module_depth,
ctx.max_expansion_per_resource,
registry,
);
let module_resources: Vec<Resource> = flatten_modules(evaluated, &mut state);
diagnostics.append(&mut state.diagnostics);
let mut combined: Vec<Resource> =
Vec::with_capacity(evaluated.resources.len() + module_resources.len());
for r in &evaluated.resources {
let expanded =
expand_resource(r.clone(), ctx.max_expansion_per_resource, &mut diagnostics);
combined.extend(expanded);
}
for r in module_resources {
let expanded = expand_resource(r, ctx.max_expansion_per_resource, &mut diagnostics);
combined.extend(expanded);
}
let mut seen: HashSet<String> = HashSet::with_capacity(combined.len());
let mut deduped: Vec<Resource> = Vec::with_capacity(combined.len());
for r in combined {
let key = r.address.as_str().to_string();
if seen.insert(key.clone()) {
deduped.push(r);
} else {
diagnostics.push(crate::diagnostic::Diagnostic::new(
crate::Severity::Warn,
"TF1506",
format!("address collision after expansion: {key}"),
));
}
}
diagnostics.extend(evaluated.diagnostics.iter().cloned());
let raw = evaluated.raw.as_ref();
out_components.push(
Component::builder()
.id(raw.id)
.path(Arc::clone(&raw.path))
.kind(raw.kind)
.files(raw.files.clone())
.variables(evaluated.variables.clone())
.locals(evaluated.locals.clone())
.providers(evaluated.providers.clone())
.resources(deduped)
.modules(evaluated.modules.clone())
.outputs(evaluated.outputs.clone())
.terragrunt(raw.terragrunt.clone())
.state_backend(raw.state_backend.clone())
.build(),
);
}
out_components.sort_by(|a, b| a.path.cmp(&b.path));
let modules = build_workspace_modules(&components);
let mut ws = Workspace::builder()
.root(Arc::clone(&ctx.workspace_root))
.components(out_components)
.modules(modules)
.diagnostics(diagnostics)
.build();
if ctx.infer_dependencies {
super::edges::collect_edges_in_place(&mut ws);
}
Ok(ws)
}
}
fn build_workspace_modules(components: &[EvaluatedComponent]) -> Vec<crate::ir::Module> {
use crate::ir::{Module, ModuleId, ModuleSource};
let mut modules: Vec<Module> = Vec::new();
for (i, e) in components.iter().enumerate() {
let raw = e.raw.as_ref();
if !matches!(raw.kind, crate::ir::ComponentKind::Module) {
continue;
}
let id = ModuleId::from_index(i);
modules.push(
Module::builder()
.id(id)
.source(ModuleSource::Local(Arc::from(
raw.path.to_string_lossy().as_ref(),
)))
.component(raw.clone())
.build(),
);
}
modules
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::indexing_slicing, clippy::panic)]
mod tests {
use std::path::{Path, PathBuf};
use super::*;
use crate::{
eval::EvaluatedComponent,
ir::{
Address, AttributeMap, Component, ComponentId, ComponentKind, Expression, ModuleCall,
ModuleSource, Resource, ResourceKind, Span, SymbolKind, Symbolic, Value,
},
};
fn span() -> Span {
Span::synthetic()
}
fn eval(c: &Component) -> EvaluatedComponent {
EvaluatedComponent::from_component(c.clone())
}
#[test]
fn test_builder_passes_through_top_level_resources() {
let r = Resource::builder()
.address(Address::new("aws_iam_role.r").unwrap())
.kind(ResourceKind::Managed)
.type_(Arc::<str>::from("aws_iam_role"))
.name(Arc::<str>::from("r"))
.span(span())
.build();
let c = Component::builder()
.id(ComponentId::from_index(0))
.path(Arc::<Path>::from(PathBuf::from("svc")))
.kind(ComponentKind::Component)
.resources(vec![r])
.build();
let registry = ModuleRegistry::new();
let ctx = GraphContext::new(Arc::<Path>::from(PathBuf::from("/tmp/repo")));
let workspace = DefaultGraphBuilder
.build(vec![eval(&c)], ®istry, &ctx)
.unwrap();
assert_eq!(workspace.components.len(), 1);
assert_eq!(workspace.components[0].resources.len(), 1);
}
#[test]
fn test_builder_skips_module_kind_components() {
let c = Component::builder()
.id(ComponentId::from_index(0))
.path(Arc::<Path>::from(PathBuf::from("modules/x")))
.kind(ComponentKind::Module)
.build();
let registry = ModuleRegistry::new();
let ctx = GraphContext::new(Arc::<Path>::from(PathBuf::from("/tmp/repo")));
let workspace = DefaultGraphBuilder
.build(vec![eval(&c)], ®istry, &ctx)
.unwrap();
assert!(workspace.components.is_empty());
assert_eq!(workspace.modules.len(), 1);
}
#[test]
fn test_count_literal_expands_top_level_resources() {
let r = Resource::builder()
.address(Address::new("aws_iam_role.r").unwrap())
.kind(ResourceKind::Managed)
.type_(Arc::<str>::from("aws_iam_role"))
.name(Arc::<str>::from("r"))
.count_expr(Some(Expression::Literal(Value::Int(3))))
.span(span())
.build();
let c = Component::builder()
.id(ComponentId::from_index(0))
.path(Arc::<Path>::from(PathBuf::from("svc")))
.kind(ComponentKind::Component)
.resources(vec![r])
.build();
let registry = ModuleRegistry::new();
let ctx = GraphContext::new(Arc::<Path>::from(PathBuf::from("/tmp/repo")));
let workspace = DefaultGraphBuilder
.build(vec![eval(&c)], ®istry, &ctx)
.unwrap();
let addrs: Vec<&str> = workspace.components[0]
.resources
.iter()
.map(|r| r.address.as_str())
.collect();
assert_eq!(
addrs,
vec![
"aws_iam_role.r[0]",
"aws_iam_role.r[1]",
"aws_iam_role.r[2]",
]
);
}
#[test]
fn test_count_unresolved_keeps_one_template_row() {
let r = Resource::builder()
.address(Address::new("aws_iam_role.r").unwrap())
.kind(ResourceKind::Managed)
.type_(Arc::<str>::from("aws_iam_role"))
.name(Arc::<str>::from("r"))
.count_expr(Some(Expression::Unresolved(
Symbolic::builder()
.kind(SymbolKind::Var)
.source(Arc::<str>::from("var.foo"))
.span(span())
.build(),
)))
.span(span())
.build();
let c = Component::builder()
.id(ComponentId::from_index(0))
.path(Arc::<Path>::from(PathBuf::from("svc")))
.kind(ComponentKind::Component)
.resources(vec![r])
.build();
let registry = ModuleRegistry::new();
let ctx = GraphContext::new(Arc::<Path>::from(PathBuf::from("/tmp/repo")));
let workspace = DefaultGraphBuilder
.build(vec![eval(&c)], ®istry, &ctx)
.unwrap();
let resources = &workspace.components[0].resources;
assert_eq!(resources.len(), 1);
assert_eq!(resources[0].address.as_str(), "aws_iam_role.r");
assert!(resources[0].count_expr.is_some());
}
#[test]
fn test_workspace_components_sorted_by_path() {
let c1 = Component::builder()
.id(ComponentId::from_index(0))
.path(Arc::<Path>::from(PathBuf::from("z")))
.kind(ComponentKind::Component)
.build();
let c2 = Component::builder()
.id(ComponentId::from_index(1))
.path(Arc::<Path>::from(PathBuf::from("a")))
.kind(ComponentKind::Component)
.build();
let registry = ModuleRegistry::new();
let ctx = GraphContext::new(Arc::<Path>::from(PathBuf::from("/tmp/repo")));
let workspace = DefaultGraphBuilder
.build(vec![eval(&c1), eval(&c2)], ®istry, &ctx)
.unwrap();
assert_eq!(workspace.components[0].path.as_ref(), Path::new("a"));
assert_eq!(workspace.components[1].path.as_ref(), Path::new("z"));
}
fn make_module_call(call_name: &str, source_rel: &str, inputs: AttributeMap) -> ModuleCall {
let raw: Arc<str> = Arc::from(source_rel);
ModuleCall::builder()
.address(Address::new(format!("module.{call_name}")).unwrap())
.source_raw(Arc::clone(&raw))
.source(ModuleSource::classify(&raw))
.inputs(inputs)
.span(span())
.build()
}
fn module_body(addr: &str) -> EvaluatedComponent {
let r = Resource::builder()
.address(Address::new(addr).unwrap())
.kind(ResourceKind::Managed)
.type_(Arc::<str>::from("aws_s3_bucket"))
.name(Arc::<str>::from("this"))
.attributes(vec![(
Arc::from("name"),
Expression::Unresolved(
Symbolic::builder()
.kind(SymbolKind::Var)
.source(Arc::<str>::from("var.name"))
.span(span())
.build(),
),
)])
.span(span())
.build();
let c = Component::builder()
.id(ComponentId::from_index(0))
.path(Arc::<Path>::from(PathBuf::from("modules/s3")))
.kind(ComponentKind::Module)
.resources(vec![r])
.build();
eval(&c)
}
fn module_calling_self(canonical: &Arc<Path>) -> EvaluatedComponent {
let mc = ModuleCall::builder()
.address(Address::new("module.self_ref").unwrap())
.source_raw(Arc::<str>::from("."))
.source(ModuleSource::Local(Arc::from(".")))
.span(span())
.build();
let c = Component::builder()
.id(ComponentId::from_index(0))
.path(Arc::clone(canonical))
.kind(ComponentKind::Module)
.modules(vec![mc])
.build();
eval(&c)
}
#[test]
fn test_should_detect_module_self_cycle_and_emit_diagnostic() {
let tmp = tempfile::tempdir().unwrap();
let root: Arc<Path> = Arc::from(std::fs::canonicalize(tmp.path()).unwrap());
std::fs::create_dir_all(root.join("mod")).unwrap();
let mod_path: Arc<Path> = Arc::from(root.join("mod"));
let mut registry = ModuleRegistry::new();
let module_eval = module_calling_self(&mod_path);
registry.insert_local(Arc::clone(&mod_path), module_eval);
let caller_call = ModuleCall::builder()
.address(Address::new("module.outer").unwrap())
.source_raw(Arc::<str>::from("./mod"))
.source(ModuleSource::Local(Arc::from("./mod")))
.span(span())
.build();
let caller = Component::builder()
.id(ComponentId::from_index(1))
.path(Arc::<Path>::from(PathBuf::from("")))
.kind(ComponentKind::Component)
.modules(vec![caller_call])
.build();
let ctx = GraphContext::new(root);
let workspace = DefaultGraphBuilder
.build(vec![eval(&caller)], ®istry, &ctx)
.unwrap();
assert!(
workspace.diagnostics.iter().any(|d| &*d.code == "TF1504"),
"{:?}",
workspace.diagnostics
);
}
#[test]
fn test_should_enforce_max_module_depth_cap() {
let tmp = tempfile::tempdir().unwrap();
let root: Arc<Path> = Arc::from(std::fs::canonicalize(tmp.path()).unwrap());
for p in ["a", "b", "c"] {
std::fs::create_dir_all(root.join(p)).unwrap();
}
let mut registry = ModuleRegistry::new();
let c_body = Component::builder()
.id(ComponentId::from_index(0))
.path(Arc::<Path>::from(root.join("c")))
.kind(ComponentKind::Module)
.build();
registry.insert_local(Arc::from(root.join("c")), eval(&c_body));
let b_to_c = ModuleCall::builder()
.address(Address::new("module.c").unwrap())
.source_raw(Arc::<str>::from("../c"))
.source(ModuleSource::Local(Arc::from("../c")))
.span(span())
.build();
let b_body = Component::builder()
.id(ComponentId::from_index(0))
.path(Arc::<Path>::from(root.join("b")))
.kind(ComponentKind::Module)
.modules(vec![b_to_c])
.build();
registry.insert_local(Arc::from(root.join("b")), eval(&b_body));
let a_to_b = ModuleCall::builder()
.address(Address::new("module.b").unwrap())
.source_raw(Arc::<str>::from("../b"))
.source(ModuleSource::Local(Arc::from("../b")))
.span(span())
.build();
let a_body = Component::builder()
.id(ComponentId::from_index(0))
.path(Arc::<Path>::from(root.join("a")))
.kind(ComponentKind::Module)
.modules(vec![a_to_b])
.build();
registry.insert_local(Arc::from(root.join("a")), eval(&a_body));
let caller_call = ModuleCall::builder()
.address(Address::new("module.a").unwrap())
.source_raw(Arc::<str>::from("./a"))
.source(ModuleSource::Local(Arc::from("./a")))
.span(span())
.build();
let caller = Component::builder()
.id(ComponentId::from_index(1))
.path(Arc::<Path>::from(PathBuf::from("")))
.kind(ComponentKind::Component)
.modules(vec![caller_call])
.build();
let mut ctx = GraphContext::new(root);
ctx.max_module_depth = 2; let workspace = DefaultGraphBuilder
.build(vec![eval(&caller)], ®istry, &ctx)
.unwrap();
assert!(
workspace.diagnostics.iter().any(|d| &*d.code == "TF1501"),
"{:?}",
workspace.diagnostics
);
}
#[test]
fn test_should_flatten_module_into_parent_with_address_prefix() {
let tmp = tempfile::tempdir().unwrap();
let workspace_root: Arc<Path> = Arc::from(std::fs::canonicalize(tmp.path()).unwrap());
std::fs::create_dir_all(workspace_root.join("modules/s3")).unwrap();
std::fs::create_dir_all(workspace_root.join("services/api-gateway")).unwrap();
let mod_path: Arc<Path> = Arc::from(workspace_root.join("modules/s3"));
let mut registry = ModuleRegistry::new();
registry.insert_local(Arc::clone(&mod_path), module_body("aws_s3_bucket.this"));
let mc = make_module_call(
"edge_logs",
"../../modules/s3",
vec![(
Arc::from("name"),
Expression::Literal(Value::Str(Arc::from("hello"))),
)],
);
let c = Component::builder()
.id(ComponentId::from_index(1))
.path(Arc::<Path>::from(PathBuf::from("services/api-gateway")))
.kind(ComponentKind::Component)
.modules(vec![mc])
.build();
let ctx = GraphContext::new(workspace_root);
let workspace = DefaultGraphBuilder
.build(vec![eval(&c)], ®istry, &ctx)
.unwrap();
let resources = &workspace.components[0].resources;
assert_eq!(
resources.len(),
1,
"resources={resources:?}\ndiagnostics={:?}",
workspace.diagnostics
);
assert_eq!(
resources[0].address.as_str(),
"module.edge_logs.aws_s3_bucket.this"
);
let (_, name_expr) = resources[0]
.attributes
.iter()
.find(|(k, _)| k.as_ref() == "name")
.unwrap();
assert_eq!(
name_expr,
&Expression::Literal(Value::Str(Arc::from("hello")))
);
}
}