Skip to main content

tfparser_core/ir/
module.rs

1//! Module call sites + the module bodies they resolve to.
2
3use std::{path::Path, sync::Arc};
4
5use serde::{Deserialize, Serialize};
6use typed_builder::TypedBuilder;
7
8use crate::ir::{Address, AttributeMap, Component, Expression, ModuleId, ProviderRef, Span};
9
10/// Where a `module "x" { source = "..." }` call points.
11///
12/// Only [`ModuleSource::Local`] is resolvable source-only by the parser;
13/// other variants are captured so the dependency-graph phase can emit a
14/// row in `modules.parquet` even when the body is not walked.
15#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
16#[non_exhaustive]
17#[serde(rename_all = "camelCase", tag = "kind", content = "value")]
18pub enum ModuleSource {
19    /// Local path source (`./...` or `../...`).
20    Local(Arc<str>),
21
22    /// Terraform registry address (e.g. `terraform-aws-modules/eks/aws`).
23    Registry(Arc<str>),
24
25    /// Git source (`git::https://...`).
26    Git(Arc<str>),
27
28    /// Anything else — captured verbatim.
29    External(Arc<str>),
30}
31
32impl ModuleSource {
33    /// Classify a raw `source = "..."` value.
34    #[must_use]
35    pub fn classify(raw: &str) -> Self {
36        let raw_arc: Arc<str> = Arc::from(raw);
37        if raw.starts_with("./") || raw.starts_with("../") {
38            Self::Local(raw_arc)
39        } else if raw.starts_with("git::")
40            || raw.starts_with("git@")
41            || raw.contains(".git")
42            || raw.starts_with("github.com/")
43        {
44            Self::Git(raw_arc)
45        } else if raw.contains('/') && !raw.starts_with('/') && raw.matches('/').count() >= 2 {
46            // `<namespace>/<name>/<provider>` shape.
47            Self::Registry(raw_arc)
48        } else {
49            Self::External(raw_arc)
50        }
51    }
52}
53
54/// A `module "name" { source = "..."; ... }` call site.
55///
56/// Field order matches [10-data-model.md § 2.2].
57#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, TypedBuilder)]
58#[non_exhaustive]
59#[serde(rename_all = "camelCase")]
60#[builder(field_defaults(setter(into)))]
61pub struct ModuleCall {
62    /// Full TF address of the call (e.g. `module.pacer_db`).
63    pub address: Address,
64
65    /// Verbatim source string from the HCL.
66    pub source_raw: Arc<str>,
67
68    /// Classified source.
69    pub source: ModuleSource,
70
71    /// Set by the graph phase once the source has been resolved to a
72    /// [`Module`] in the registry.
73    #[builder(default)]
74    pub resolved: Option<ModuleId>,
75
76    /// `providers = { aws = aws.main }` rewrites the call site applies.
77    #[builder(default)]
78    pub providers: Vec<(Arc<str>, ProviderRef)>,
79
80    /// Input expressions passed into the module.
81    #[builder(default)]
82    pub inputs: AttributeMap,
83
84    /// `count = ...` expression.
85    #[builder(default)]
86    pub count_expr: Option<Expression>,
87
88    /// `for_each = ...` expression.
89    #[builder(default)]
90    pub for_each_expr: Option<Expression>,
91
92    /// Span of the opening `module` keyword.
93    pub span: Span,
94}
95
96/// A reusable module body, addressed by [`ModuleId`].
97///
98/// Modules are referenced via [`ModuleCall::source`]. The graph phase
99/// walks local modules and populates [`Module::component`].
100#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, TypedBuilder)]
101#[non_exhaustive]
102#[serde(rename_all = "camelCase")]
103#[builder(field_defaults(setter(into)))]
104pub struct Module {
105    /// Stable-within-a-run id.
106    pub id: ModuleId,
107
108    /// Classified source.
109    pub source: ModuleSource,
110
111    /// Canonical directory of the module (only set for [`ModuleSource::Local`]).
112    #[builder(default)]
113    #[serde(with = "crate::ir::path_serde::arc_path_opt")]
114    pub canonical_path: Option<Arc<Path>>,
115
116    /// The module body, parsed as a [`Component`] with kind
117    /// [`crate::ir::ComponentKind::Module`].
118    pub component: Component,
119}
120
121#[cfg(test)]
122#[allow(clippy::unwrap_used)]
123mod tests {
124    use super::*;
125
126    #[test]
127    fn test_should_classify_local_source() {
128        assert!(matches!(
129            ModuleSource::classify("./foo"),
130            ModuleSource::Local(_)
131        ));
132        assert!(matches!(
133            ModuleSource::classify("../../modules/rds"),
134            ModuleSource::Local(_)
135        ));
136    }
137
138    #[test]
139    fn test_should_classify_git_source() {
140        for s in [
141            "git::https://github.com/x/y.git",
142            "github.com/x/y",
143            "git@github.com:x/y.git",
144        ] {
145            assert!(
146                matches!(ModuleSource::classify(s), ModuleSource::Git(_)),
147                "expected Git classification for {s}"
148            );
149        }
150    }
151
152    #[test]
153    fn test_should_classify_registry_source() {
154        assert!(matches!(
155            ModuleSource::classify("terraform-aws-modules/eks/aws"),
156            ModuleSource::Registry(_)
157        ));
158    }
159
160    #[test]
161    fn test_should_round_trip_module_source_via_serde() {
162        let s = ModuleSource::Local(Arc::<str>::from("./foo"));
163        let json = serde_json::to_string(&s).unwrap();
164        let back: ModuleSource = serde_json::from_str(&json).unwrap();
165        assert_eq!(s, back);
166    }
167
168    #[test]
169    fn test_should_classify_absolute_unix_path_as_external() {
170        // `/abs/path` is not a local-relative source and not a registry
171        // address. We capture it as External; the resolver can refuse to
172        // walk it.
173        assert!(matches!(
174            ModuleSource::classify("/abs/path"),
175            ModuleSource::External(_)
176        ));
177    }
178
179    #[test]
180    fn test_should_classify_bare_name_as_external() {
181        assert!(matches!(
182            ModuleSource::classify("just_a_name"),
183            ModuleSource::External(_)
184        ));
185    }
186
187    #[test]
188    fn test_should_classify_single_slash_as_external_not_registry() {
189        // `foo/bar` has only one slash and is not a 3-segment registry
190        // address; treat as External.
191        assert!(matches!(
192            ModuleSource::classify("foo/bar"),
193            ModuleSource::External(_)
194        ));
195    }
196}