1use std::{collections::HashSet, path::Path, sync::Arc};
13
14use crate::{
15 Result,
16 diagnostic::Diagnostic,
17 eval::EvaluatedComponent,
18 graph::{
19 expand::{ExpansionState, expand_resource, flatten_modules},
20 registry::ModuleRegistry,
21 },
22 ir::{Component, Resource, Workspace},
23};
24
25#[derive(Clone, Debug)]
30#[non_exhaustive]
31pub struct GraphContext {
32 pub workspace_root: Arc<Path>,
35 pub max_module_depth: u32,
37 pub infer_dependencies: bool,
39 pub max_expansion_per_resource: u32,
43}
44
45impl GraphContext {
46 #[must_use]
49 pub fn new(workspace_root: Arc<Path>) -> Self {
50 Self {
51 workspace_root,
52 max_module_depth: 8,
53 infer_dependencies: true,
54 max_expansion_per_resource: 1024,
55 }
56 }
57}
58
59pub trait GraphBuilder: Send + Sync + std::fmt::Debug {
63 fn build(
73 &self,
74 components: Vec<EvaluatedComponent>,
75 registry: &ModuleRegistry,
76 ctx: &GraphContext,
77 ) -> Result<Workspace>;
78}
79
80#[derive(Debug, Default)]
82#[non_exhaustive]
83pub struct DefaultGraphBuilder;
84
85impl DefaultGraphBuilder {
86 #[must_use]
88 pub const fn new() -> Self {
89 Self
90 }
91}
92
93impl GraphBuilder for DefaultGraphBuilder {
94 fn build(
95 &self,
96 components: Vec<EvaluatedComponent>,
97 registry: &ModuleRegistry,
98 ctx: &GraphContext,
99 ) -> Result<Workspace> {
100 let mut diagnostics: Vec<Diagnostic> = Vec::new();
101 let mut out_components: Vec<Component> = Vec::with_capacity(components.len());
102
103 for evaluated in &components {
107 if !matches!(evaluated.raw.kind, crate::ir::ComponentKind::Component) {
108 continue;
109 }
110 let mut state = ExpansionState::new(
111 ctx.workspace_root.as_ref(),
112 ctx.max_module_depth,
113 ctx.max_expansion_per_resource,
114 registry,
115 );
116 let module_resources: Vec<Resource> = flatten_modules(evaluated, &mut state);
117 diagnostics.append(&mut state.diagnostics);
118
119 let mut combined: Vec<Resource> =
122 Vec::with_capacity(evaluated.resources.len() + module_resources.len());
123 for r in &evaluated.resources {
124 let expanded =
125 expand_resource(r.clone(), ctx.max_expansion_per_resource, &mut diagnostics);
126 combined.extend(expanded);
127 }
128 for r in module_resources {
129 let expanded = expand_resource(r, ctx.max_expansion_per_resource, &mut diagnostics);
130 combined.extend(expanded);
131 }
132
133 let mut seen: HashSet<String> = HashSet::with_capacity(combined.len());
140 let mut deduped: Vec<Resource> = Vec::with_capacity(combined.len());
141 for r in combined {
142 let key = r.address.as_str().to_string();
143 if seen.insert(key.clone()) {
144 deduped.push(r);
145 } else {
146 diagnostics.push(crate::diagnostic::Diagnostic::new(
147 crate::Severity::Warn,
148 "TF1506",
149 format!("address collision after expansion: {key}"),
150 ));
151 }
152 }
153
154 diagnostics.extend(evaluated.diagnostics.iter().cloned());
158
159 let raw = evaluated.raw.as_ref();
160 out_components.push(
161 Component::builder()
162 .id(raw.id)
163 .path(Arc::clone(&raw.path))
164 .kind(raw.kind)
165 .files(raw.files.clone())
166 .variables(evaluated.variables.clone())
167 .locals(evaluated.locals.clone())
168 .providers(evaluated.providers.clone())
169 .resources(deduped)
170 .modules(evaluated.modules.clone())
171 .outputs(evaluated.outputs.clone())
172 .terragrunt(raw.terragrunt.clone())
173 .state_backend(raw.state_backend.clone())
174 .build(),
175 );
176 }
177
178 out_components.sort_by(|a, b| a.path.cmp(&b.path));
180
181 let modules = build_workspace_modules(&components);
186
187 let mut ws = Workspace::builder()
188 .root(Arc::clone(&ctx.workspace_root))
189 .components(out_components)
190 .modules(modules)
191 .diagnostics(diagnostics)
192 .build();
193
194 if ctx.infer_dependencies {
197 super::edges::collect_edges_in_place(&mut ws);
198 }
199
200 Ok(ws)
201 }
202}
203
204fn build_workspace_modules(components: &[EvaluatedComponent]) -> Vec<crate::ir::Module> {
205 use crate::ir::{Module, ModuleId, ModuleSource};
206
207 let mut modules: Vec<Module> = Vec::new();
208 for (i, e) in components.iter().enumerate() {
209 let raw = e.raw.as_ref();
210 if !matches!(raw.kind, crate::ir::ComponentKind::Module) {
211 continue;
212 }
213 let id = ModuleId::from_index(i);
214 modules.push(
215 Module::builder()
216 .id(id)
217 .source(ModuleSource::Local(Arc::from(
218 raw.path.to_string_lossy().as_ref(),
219 )))
220 .component(raw.clone())
221 .build(),
222 );
223 }
224 modules
225}
226
227#[cfg(test)]
228#[allow(clippy::unwrap_used, clippy::indexing_slicing, clippy::panic)]
229mod tests {
230 use std::path::{Path, PathBuf};
231
232 use super::*;
233 use crate::{
234 eval::EvaluatedComponent,
235 ir::{
236 Address, AttributeMap, Component, ComponentId, ComponentKind, Expression, ModuleCall,
237 ModuleSource, Resource, ResourceKind, Span, SymbolKind, Symbolic, Value,
238 },
239 };
240
241 fn span() -> Span {
242 Span::synthetic()
243 }
244
245 fn eval(c: &Component) -> EvaluatedComponent {
246 EvaluatedComponent::from_component(c.clone())
247 }
248
249 #[test]
250 fn test_builder_passes_through_top_level_resources() {
251 let r = Resource::builder()
252 .address(Address::new("aws_iam_role.r").unwrap())
253 .kind(ResourceKind::Managed)
254 .type_(Arc::<str>::from("aws_iam_role"))
255 .name(Arc::<str>::from("r"))
256 .span(span())
257 .build();
258 let c = Component::builder()
259 .id(ComponentId::from_index(0))
260 .path(Arc::<Path>::from(PathBuf::from("svc")))
261 .kind(ComponentKind::Component)
262 .resources(vec![r])
263 .build();
264 let registry = ModuleRegistry::new();
265 let ctx = GraphContext::new(Arc::<Path>::from(PathBuf::from("/tmp/repo")));
266 let workspace = DefaultGraphBuilder
267 .build(vec![eval(&c)], ®istry, &ctx)
268 .unwrap();
269 assert_eq!(workspace.components.len(), 1);
270 assert_eq!(workspace.components[0].resources.len(), 1);
271 }
272
273 #[test]
274 fn test_builder_skips_module_kind_components() {
275 let c = Component::builder()
276 .id(ComponentId::from_index(0))
277 .path(Arc::<Path>::from(PathBuf::from("modules/x")))
278 .kind(ComponentKind::Module)
279 .build();
280 let registry = ModuleRegistry::new();
281 let ctx = GraphContext::new(Arc::<Path>::from(PathBuf::from("/tmp/repo")));
282 let workspace = DefaultGraphBuilder
283 .build(vec![eval(&c)], ®istry, &ctx)
284 .unwrap();
285 assert!(workspace.components.is_empty());
286 assert_eq!(workspace.modules.len(), 1);
288 }
289
290 #[test]
291 fn test_count_literal_expands_top_level_resources() {
292 let r = Resource::builder()
293 .address(Address::new("aws_iam_role.r").unwrap())
294 .kind(ResourceKind::Managed)
295 .type_(Arc::<str>::from("aws_iam_role"))
296 .name(Arc::<str>::from("r"))
297 .count_expr(Some(Expression::Literal(Value::Int(3))))
298 .span(span())
299 .build();
300 let c = Component::builder()
301 .id(ComponentId::from_index(0))
302 .path(Arc::<Path>::from(PathBuf::from("svc")))
303 .kind(ComponentKind::Component)
304 .resources(vec![r])
305 .build();
306 let registry = ModuleRegistry::new();
307 let ctx = GraphContext::new(Arc::<Path>::from(PathBuf::from("/tmp/repo")));
308 let workspace = DefaultGraphBuilder
309 .build(vec![eval(&c)], ®istry, &ctx)
310 .unwrap();
311 let addrs: Vec<&str> = workspace.components[0]
312 .resources
313 .iter()
314 .map(|r| r.address.as_str())
315 .collect();
316 assert_eq!(
317 addrs,
318 vec![
319 "aws_iam_role.r[0]",
320 "aws_iam_role.r[1]",
321 "aws_iam_role.r[2]",
322 ]
323 );
324 }
325
326 #[test]
327 fn test_count_unresolved_keeps_one_template_row() {
328 let r = Resource::builder()
329 .address(Address::new("aws_iam_role.r").unwrap())
330 .kind(ResourceKind::Managed)
331 .type_(Arc::<str>::from("aws_iam_role"))
332 .name(Arc::<str>::from("r"))
333 .count_expr(Some(Expression::Unresolved(
334 Symbolic::builder()
335 .kind(SymbolKind::Var)
336 .source(Arc::<str>::from("var.foo"))
337 .span(span())
338 .build(),
339 )))
340 .span(span())
341 .build();
342 let c = Component::builder()
343 .id(ComponentId::from_index(0))
344 .path(Arc::<Path>::from(PathBuf::from("svc")))
345 .kind(ComponentKind::Component)
346 .resources(vec![r])
347 .build();
348 let registry = ModuleRegistry::new();
349 let ctx = GraphContext::new(Arc::<Path>::from(PathBuf::from("/tmp/repo")));
350 let workspace = DefaultGraphBuilder
351 .build(vec![eval(&c)], ®istry, &ctx)
352 .unwrap();
353 let resources = &workspace.components[0].resources;
354 assert_eq!(resources.len(), 1);
355 assert_eq!(resources[0].address.as_str(), "aws_iam_role.r");
356 assert!(resources[0].count_expr.is_some());
357 }
358
359 #[test]
360 fn test_workspace_components_sorted_by_path() {
361 let c1 = Component::builder()
362 .id(ComponentId::from_index(0))
363 .path(Arc::<Path>::from(PathBuf::from("z")))
364 .kind(ComponentKind::Component)
365 .build();
366 let c2 = Component::builder()
367 .id(ComponentId::from_index(1))
368 .path(Arc::<Path>::from(PathBuf::from("a")))
369 .kind(ComponentKind::Component)
370 .build();
371 let registry = ModuleRegistry::new();
372 let ctx = GraphContext::new(Arc::<Path>::from(PathBuf::from("/tmp/repo")));
373 let workspace = DefaultGraphBuilder
374 .build(vec![eval(&c1), eval(&c2)], ®istry, &ctx)
375 .unwrap();
376 assert_eq!(workspace.components[0].path.as_ref(), Path::new("a"));
377 assert_eq!(workspace.components[1].path.as_ref(), Path::new("z"));
378 }
379
380 fn make_module_call(call_name: &str, source_rel: &str, inputs: AttributeMap) -> ModuleCall {
381 let raw: Arc<str> = Arc::from(source_rel);
382 ModuleCall::builder()
383 .address(Address::new(format!("module.{call_name}")).unwrap())
384 .source_raw(Arc::clone(&raw))
385 .source(ModuleSource::classify(&raw))
386 .inputs(inputs)
387 .span(span())
388 .build()
389 }
390
391 fn module_body(addr: &str) -> EvaluatedComponent {
392 let r = Resource::builder()
393 .address(Address::new(addr).unwrap())
394 .kind(ResourceKind::Managed)
395 .type_(Arc::<str>::from("aws_s3_bucket"))
396 .name(Arc::<str>::from("this"))
397 .attributes(vec![(
398 Arc::from("name"),
399 Expression::Unresolved(
400 Symbolic::builder()
401 .kind(SymbolKind::Var)
402 .source(Arc::<str>::from("var.name"))
403 .span(span())
404 .build(),
405 ),
406 )])
407 .span(span())
408 .build();
409 let c = Component::builder()
410 .id(ComponentId::from_index(0))
411 .path(Arc::<Path>::from(PathBuf::from("modules/s3")))
412 .kind(ComponentKind::Module)
413 .resources(vec![r])
414 .build();
415 eval(&c)
416 }
417
418 fn module_calling_self(canonical: &Arc<Path>) -> EvaluatedComponent {
419 let mc = ModuleCall::builder()
420 .address(Address::new("module.self_ref").unwrap())
421 .source_raw(Arc::<str>::from("."))
422 .source(ModuleSource::Local(Arc::from(".")))
423 .span(span())
424 .build();
425 let c = Component::builder()
426 .id(ComponentId::from_index(0))
427 .path(Arc::clone(canonical))
428 .kind(ComponentKind::Module)
429 .modules(vec![mc])
430 .build();
431 eval(&c)
432 }
433
434 #[test]
435 fn test_should_detect_module_self_cycle_and_emit_diagnostic() {
436 let tmp = tempfile::tempdir().unwrap();
437 let root: Arc<Path> = Arc::from(std::fs::canonicalize(tmp.path()).unwrap());
438 std::fs::create_dir_all(root.join("mod")).unwrap();
439 let mod_path: Arc<Path> = Arc::from(root.join("mod"));
440
441 let mut registry = ModuleRegistry::new();
442 let module_eval = module_calling_self(&mod_path);
444 registry.insert_local(Arc::clone(&mod_path), module_eval);
445
446 let caller_call = ModuleCall::builder()
448 .address(Address::new("module.outer").unwrap())
449 .source_raw(Arc::<str>::from("./mod"))
450 .source(ModuleSource::Local(Arc::from("./mod")))
451 .span(span())
452 .build();
453 let caller = Component::builder()
454 .id(ComponentId::from_index(1))
455 .path(Arc::<Path>::from(PathBuf::from("")))
456 .kind(ComponentKind::Component)
457 .modules(vec![caller_call])
458 .build();
459
460 let ctx = GraphContext::new(root);
461 let workspace = DefaultGraphBuilder
462 .build(vec![eval(&caller)], ®istry, &ctx)
463 .unwrap();
464 assert!(
467 workspace.diagnostics.iter().any(|d| &*d.code == "TF1504"),
468 "{:?}",
469 workspace.diagnostics
470 );
471 }
472
473 #[test]
474 fn test_should_enforce_max_module_depth_cap() {
475 let tmp = tempfile::tempdir().unwrap();
478 let root: Arc<Path> = Arc::from(std::fs::canonicalize(tmp.path()).unwrap());
479 for p in ["a", "b", "c"] {
480 std::fs::create_dir_all(root.join(p)).unwrap();
481 }
482 let mut registry = ModuleRegistry::new();
483 let c_body = Component::builder()
485 .id(ComponentId::from_index(0))
486 .path(Arc::<Path>::from(root.join("c")))
487 .kind(ComponentKind::Module)
488 .build();
489 registry.insert_local(Arc::from(root.join("c")), eval(&c_body));
490 let b_to_c = ModuleCall::builder()
492 .address(Address::new("module.c").unwrap())
493 .source_raw(Arc::<str>::from("../c"))
494 .source(ModuleSource::Local(Arc::from("../c")))
495 .span(span())
496 .build();
497 let b_body = Component::builder()
498 .id(ComponentId::from_index(0))
499 .path(Arc::<Path>::from(root.join("b")))
500 .kind(ComponentKind::Module)
501 .modules(vec![b_to_c])
502 .build();
503 registry.insert_local(Arc::from(root.join("b")), eval(&b_body));
504 let a_to_b = ModuleCall::builder()
506 .address(Address::new("module.b").unwrap())
507 .source_raw(Arc::<str>::from("../b"))
508 .source(ModuleSource::Local(Arc::from("../b")))
509 .span(span())
510 .build();
511 let a_body = Component::builder()
512 .id(ComponentId::from_index(0))
513 .path(Arc::<Path>::from(root.join("a")))
514 .kind(ComponentKind::Module)
515 .modules(vec![a_to_b])
516 .build();
517 registry.insert_local(Arc::from(root.join("a")), eval(&a_body));
518
519 let caller_call = ModuleCall::builder()
520 .address(Address::new("module.a").unwrap())
521 .source_raw(Arc::<str>::from("./a"))
522 .source(ModuleSource::Local(Arc::from("./a")))
523 .span(span())
524 .build();
525 let caller = Component::builder()
526 .id(ComponentId::from_index(1))
527 .path(Arc::<Path>::from(PathBuf::from("")))
528 .kind(ComponentKind::Component)
529 .modules(vec![caller_call])
530 .build();
531
532 let mut ctx = GraphContext::new(root);
533 ctx.max_module_depth = 2; let workspace = DefaultGraphBuilder
535 .build(vec![eval(&caller)], ®istry, &ctx)
536 .unwrap();
537 assert!(
539 workspace.diagnostics.iter().any(|d| &*d.code == "TF1501"),
540 "{:?}",
541 workspace.diagnostics
542 );
543 }
544
545 #[test]
546 fn test_should_flatten_module_into_parent_with_address_prefix() {
547 let tmp = tempfile::tempdir().unwrap();
548 let workspace_root: Arc<Path> = Arc::from(std::fs::canonicalize(tmp.path()).unwrap());
551 std::fs::create_dir_all(workspace_root.join("modules/s3")).unwrap();
554 std::fs::create_dir_all(workspace_root.join("services/api-gateway")).unwrap();
555
556 let mod_path: Arc<Path> = Arc::from(workspace_root.join("modules/s3"));
559 let mut registry = ModuleRegistry::new();
560 registry.insert_local(Arc::clone(&mod_path), module_body("aws_s3_bucket.this"));
561
562 let mc = make_module_call(
563 "edge_logs",
564 "../../modules/s3",
565 vec![(
566 Arc::from("name"),
567 Expression::Literal(Value::Str(Arc::from("hello"))),
568 )],
569 );
570 let c = Component::builder()
571 .id(ComponentId::from_index(1))
572 .path(Arc::<Path>::from(PathBuf::from("services/api-gateway")))
573 .kind(ComponentKind::Component)
574 .modules(vec![mc])
575 .build();
576 let ctx = GraphContext::new(workspace_root);
577 let workspace = DefaultGraphBuilder
578 .build(vec![eval(&c)], ®istry, &ctx)
579 .unwrap();
580 let resources = &workspace.components[0].resources;
581 assert_eq!(
582 resources.len(),
583 1,
584 "resources={resources:?}\ndiagnostics={:?}",
585 workspace.diagnostics
586 );
587 assert_eq!(
588 resources[0].address.as_str(),
589 "module.edge_logs.aws_s3_bucket.this"
590 );
591 let (_, name_expr) = resources[0]
593 .attributes
594 .iter()
595 .find(|(k, _)| k.as_ref() == "name")
596 .unwrap();
597 assert_eq!(
598 name_expr,
599 &Expression::Literal(Value::Str(Arc::from("hello")))
600 );
601 }
602}