1use std::{path::Path, sync::OnceLock};
18
19use regex::RegexSet;
20
21use super::{
22 options::DiscoveryOptions,
23 types::{DirKind, DiscoveredFile},
24};
25use crate::ir::FileExt;
26
27#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
30#[non_exhaustive]
31pub enum ClassificationReason {
32 TerragruntInclude,
34 BackendBlock,
36 HasResources,
39 ModuleGlob,
41 VariablesOnly,
44 EnvironmentsDir,
47 DataOnly,
49 Unknown,
51}
52
53const PATTERN_TERRAFORM_BLOCK: &str = r"(?m)^\s*terraform\s*\{";
58const PATTERN_BACKEND_BLOCK: &str = r#"(?m)^\s*backend\s+"[^"]+"\s*\{"#;
59const PATTERN_RESOURCE_BLOCK: &str = r#"(?m)^\s*resource\s+"[^"]+"\s+"[^"]+"\s*\{"#;
60const PATTERN_DATA_BLOCK: &str = r#"(?m)^\s*data\s+"[^"]+"\s+"[^"]+"\s*\{"#;
61const PATTERN_VARIABLE_BLOCK: &str = r#"(?m)^\s*variable\s+"[^"]+"\s*\{"#;
62const PATTERN_INCLUDE_BLOCK: &str = r#"(?m)^\s*include(\s+"[^"]*")?\s*\{"#;
63
64const IDX_TERRAFORM: usize = 0;
65const IDX_BACKEND: usize = 1;
66const IDX_RESOURCE: usize = 2;
67const IDX_DATA: usize = 3;
68const IDX_VARIABLE: usize = 4;
69const IDX_INCLUDE: usize = 5;
70
71fn probe_set() -> &'static RegexSet {
74 static PROBES: OnceLock<RegexSet> = OnceLock::new();
75 PROBES.get_or_init(|| {
76 RegexSet::new([
80 PATTERN_TERRAFORM_BLOCK,
81 PATTERN_BACKEND_BLOCK,
82 PATTERN_RESOURCE_BLOCK,
83 PATTERN_DATA_BLOCK,
84 PATTERN_VARIABLE_BLOCK,
85 PATTERN_INCLUDE_BLOCK,
86 ])
87 .unwrap_or_else(|_| RegexSet::empty())
88 })
89}
90
91#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
98#[allow(clippy::struct_excessive_bools)]
99pub(super) struct FileSignals {
100 pub terraform_block: bool,
101 pub backend_block: bool,
102 pub resource_block: bool,
103 pub data_block: bool,
104 pub variable_block: bool,
105 pub include_block: bool,
106}
107
108impl FileSignals {
109 pub(super) fn merge(&mut self, other: Self) {
110 self.terraform_block |= other.terraform_block;
111 self.backend_block |= other.backend_block;
112 self.resource_block |= other.resource_block;
113 self.data_block |= other.data_block;
114 self.variable_block |= other.variable_block;
115 self.include_block |= other.include_block;
116 }
117}
118
119pub(super) fn probe_file(bytes: &[u8]) -> FileSignals {
123 let set = probe_set();
124 let Ok(s) = std::str::from_utf8(bytes) else {
128 return FileSignals::default();
129 };
130 let matches = set.matches(s);
131 FileSignals {
132 terraform_block: matches.matched(IDX_TERRAFORM),
133 backend_block: matches.matched(IDX_BACKEND),
134 resource_block: matches.matched(IDX_RESOURCE),
135 data_block: matches.matched(IDX_DATA),
136 variable_block: matches.matched(IDX_VARIABLE),
137 include_block: matches.matched(IDX_INCLUDE),
138 }
139}
140
141pub(super) fn classify(
149 rel_path: &Path,
150 signals: FileSignals,
151 files: &[DiscoveredFile],
152 opts: &DiscoveryOptions,
153 is_workspace_child_named_environments: bool,
154) -> (DirKind, ClassificationReason, bool) {
155 if is_workspace_child_named_environments {
156 return (
157 DirKind::Environments,
158 ClassificationReason::EnvironmentsDir,
159 false,
160 );
161 }
162
163 let module_glob_match = opts.module_globs.is_match(rel_path);
164 let has_terragrunt_hcl = files.iter().any(|f| f.ext == FileExt::TerragruntHcl);
165
166 let (component_reason, component_picked) = if has_terragrunt_hcl && signals.include_block {
168 (Some(ClassificationReason::TerragruntInclude), true)
169 } else if signals.backend_block && signals.terraform_block {
170 (Some(ClassificationReason::BackendBlock), true)
171 } else if (signals.resource_block || signals.data_block) && !module_glob_match {
172 (Some(ClassificationReason::HasResources), true)
173 } else {
174 (None, false)
175 };
176
177 if component_picked {
178 let ambiguous = component_picked && module_glob_match;
181 return (
182 DirKind::Component,
183 component_reason.unwrap_or(ClassificationReason::Unknown),
184 ambiguous,
185 );
186 }
187
188 if module_glob_match {
189 return (DirKind::Module, ClassificationReason::ModuleGlob, false);
190 }
191
192 let only_variables = signals.variable_block
193 && !signals.resource_block
194 && !signals.data_block
195 && !signals.backend_block
196 && !has_terragrunt_hcl;
197 if only_variables {
198 return (DirKind::Module, ClassificationReason::VariablesOnly, false);
199 }
200
201 let any_hcl = files.iter().any(|f| f.ext.is_hcl());
202 if !any_hcl && !files.is_empty() {
203 return (DirKind::Files, ClassificationReason::DataOnly, false);
204 }
205
206 (DirKind::Other, ClassificationReason::Unknown, false)
207}
208
209#[cfg(test)]
210#[allow(clippy::unwrap_used, clippy::indexing_slicing, clippy::expect_used)]
211mod tests {
212 use std::sync::Arc;
213
214 use super::*;
215
216 fn file(rel: &str, ext: FileExt) -> DiscoveredFile {
217 DiscoveredFile {
218 path: Arc::from(Path::new(rel)),
219 ext,
220 size: 0,
221 }
222 }
223
224 #[test]
225 fn test_probe_detects_resource_block() {
226 let s = b"\nresource \"aws_iam_role\" \"r\" {\n}\n";
227 let sig = probe_file(s);
228 assert!(sig.resource_block);
229 assert!(!sig.backend_block);
230 }
231
232 #[test]
233 fn test_probe_detects_terraform_with_backend() {
234 let s = b"terraform {\n backend \"s3\" {\n bucket = \"x\"\n }\n}\n";
235 let sig = probe_file(s);
236 assert!(sig.terraform_block);
237 assert!(sig.backend_block);
238 }
239
240 #[test]
241 fn test_probe_detects_variable_block() {
242 let s = b"variable \"name\" {\n type = string\n}\n";
243 let sig = probe_file(s);
244 assert!(sig.variable_block);
245 assert!(!sig.resource_block);
246 }
247
248 #[test]
249 fn test_probe_detects_terragrunt_include_with_label() {
250 let s = b"include \"root\" {\n path = find_in_parent_folders()\n}\n";
251 let sig = probe_file(s);
252 assert!(sig.include_block);
253 }
254
255 #[test]
256 fn test_probe_detects_bare_include() {
257 let s = b"include {\n path = find_in_parent_folders()\n}\n";
258 let sig = probe_file(s);
259 assert!(sig.include_block);
260 }
261
262 #[test]
263 fn test_probe_returns_no_signals_for_invalid_utf8() {
264 let s = &[0xFFu8, 0xFE, 0xFD];
265 let sig = probe_file(s);
266 assert!(!sig.resource_block);
267 }
268
269 #[test]
270 fn test_classify_terragrunt_dir_with_include_as_component() {
271 let opts = DiscoveryOptions::defaults();
272 let files = vec![file("terragrunt.hcl", FileExt::TerragruntHcl)];
273 let signals = FileSignals {
274 include_block: true,
275 ..Default::default()
276 };
277 let (kind, reason, ambiguous) =
278 classify(Path::new("services/api"), signals, &files, &opts, false);
279 assert_eq!(kind, DirKind::Component);
280 assert_eq!(reason, ClassificationReason::TerragruntInclude);
281 assert!(!ambiguous);
282 }
283
284 #[test]
285 fn test_classify_backend_dir_as_component() {
286 let opts = DiscoveryOptions::defaults();
287 let files = vec![file("main.tf", FileExt::Tf)];
288 let signals = FileSignals {
289 terraform_block: true,
290 backend_block: true,
291 ..Default::default()
292 };
293 let (kind, reason, _) = classify(Path::new("backend-only"), signals, &files, &opts, false);
294 assert_eq!(kind, DirKind::Component);
295 assert_eq!(reason, ClassificationReason::BackendBlock);
296 }
297
298 #[test]
299 fn test_classify_resources_only_as_component() {
300 let opts = DiscoveryOptions::defaults();
301 let files = vec![file("main.tf", FileExt::Tf)];
302 let signals = FileSignals {
303 resource_block: true,
304 ..Default::default()
305 };
306 let (kind, _, _) = classify(Path::new("svc"), signals, &files, &opts, false);
307 assert_eq!(kind, DirKind::Component);
308 }
309
310 #[test]
311 fn test_classify_module_glob_match_as_module() {
312 let opts = DiscoveryOptions::defaults();
313 let files = vec![file("main.tf", FileExt::Tf)];
314 let signals = FileSignals {
315 variable_block: true,
316 ..Default::default()
317 };
318 let (kind, reason, _) =
319 classify(Path::new("modules/iam-role"), signals, &files, &opts, false);
320 assert_eq!(kind, DirKind::Module);
321 assert_eq!(reason, ClassificationReason::ModuleGlob);
322 }
323
324 #[test]
325 fn test_classify_variables_only_as_module() {
326 let opts = DiscoveryOptions::defaults();
327 let files = vec![file("variables.tf", FileExt::Tf)];
328 let signals = FileSignals {
329 variable_block: true,
330 ..Default::default()
331 };
332 let (kind, reason, _) = classify(Path::new("library"), signals, &files, &opts, false);
333 assert_eq!(kind, DirKind::Module);
334 assert_eq!(reason, ClassificationReason::VariablesOnly);
335 }
336
337 #[test]
338 fn test_classify_environments_dir_at_root() {
339 let opts = DiscoveryOptions::defaults();
340 let (kind, reason, _) = classify(
341 Path::new("environments"),
342 FileSignals::default(),
343 &[],
344 &opts,
345 true,
346 );
347 assert_eq!(kind, DirKind::Environments);
348 assert_eq!(reason, ClassificationReason::EnvironmentsDir);
349 }
350
351 #[test]
352 fn test_classify_data_only_dir_as_files() {
353 let opts = DiscoveryOptions::defaults();
354 let files = vec![file("policy.json", FileExt::Json)];
355 let (kind, reason, _) = classify(
356 Path::new("svc/policies"),
357 FileSignals::default(),
358 &files,
359 &opts,
360 false,
361 );
362 assert_eq!(kind, DirKind::Files);
363 assert_eq!(reason, ClassificationReason::DataOnly);
364 }
365
366 #[test]
367 fn test_classify_unknown_dir_as_other() {
368 let opts = DiscoveryOptions::defaults();
369 let (kind, reason, _) = classify(
370 Path::new("empty"),
371 FileSignals::default(),
372 &[],
373 &opts,
374 false,
375 );
376 assert_eq!(kind, DirKind::Other);
377 assert_eq!(reason, ClassificationReason::Unknown);
378 }
379
380 #[test]
381 fn test_classify_component_inside_modules_glob_marks_ambiguous() {
382 let opts = DiscoveryOptions::defaults();
386 let files = vec![file("terragrunt.hcl", FileExt::TerragruntHcl)];
387 let signals = FileSignals {
388 include_block: true,
389 ..Default::default()
390 };
391 let (kind, _, ambiguous) = classify(
392 Path::new("modules/odd-component"),
393 signals,
394 &files,
395 &opts,
396 false,
397 );
398 assert_eq!(kind, DirKind::Component);
399 assert!(ambiguous);
400 }
401}