Skip to main content

tfparser_core/discovery/
classifier.rs

1//! Directory classifier.
2//!
3//! Implements the heuristics in [11-discovery.md § 3.2]. Decides whether a
4//! given directory is a component, a module, an environments dir, or
5//! something else, based on a **regex-grade scan** over the file bytes
6//! (anchored line-start patterns; not a real parser).
7//!
8//! Per [11-discovery.md § 3.3] and [70-security.md § 3.4]: we use the
9//! linear-time [`regex`] crate exclusively. The patterns sit behind a
10//! [`regex::RegexSet`] so a single pass over each file's bytes covers all
11//! probes.
12//!
13//! [11-discovery.md § 3.2]: ../../../specs/11-discovery.md
14//! [11-discovery.md § 3.3]: ../../../specs/11-discovery.md
15//! [70-security.md § 3.4]: ../../../specs/70-security.md
16
17use 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/// Why the classifier picked the kind it did. Surfaced in
28/// `Discovered.diagnostics` and in CLI verbose output for auditability.
29#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
30#[non_exhaustive]
31pub enum ClassificationReason {
32    /// `terragrunt.hcl` containing an `include` block.
33    TerragruntInclude,
34    /// `.tf` file declaring `terraform { backend "..." {} }`.
35    BackendBlock,
36    /// `.tf` file declaring at least one `resource`/`data` block, and the
37    /// dir does not match any `module_glob`.
38    HasResources,
39    /// Path matched the user-configured (or default) `module_glob`.
40    ModuleGlob,
41    /// `.tf` files declared `variable` blocks but no `resource`/`data`
42    /// blocks (a common library-module shape).
43    VariablesOnly,
44    /// The directory is named `environments` and lives directly under the
45    /// workspace root.
46    EnvironmentsDir,
47    /// The dir has only files / data / READMEs that we don't parse.
48    DataOnly,
49    /// Could not classify — emitted alongside [`DirKind::Other`].
50    Unknown,
51}
52
53// ----------------------------------------------------------------------------
54// Probe patterns — anchored line-start regexes per spec § 3.3.
55// ----------------------------------------------------------------------------
56
57const 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
71/// Lazily-built shared `RegexSet`. Building it once per process saves the
72/// (cheap but non-trivial) NFA construction on every discovered directory.
73fn probe_set() -> &'static RegexSet {
74    static PROBES: OnceLock<RegexSet> = OnceLock::new();
75    PROBES.get_or_init(|| {
76        // Building from a known-good set of constants — failure here means a
77        // code-level regression. We surface it as the empty set so the
78        // classifier degrades to "no match" rather than panicking.
79        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/// What [`probe_file`] found in a single file's contents.
92///
93/// Six independent boolean signals, one per probe pattern. Modelled as
94/// flags rather than a state-machine enum because all six can co-occur
95/// (a single `terragrunt.hcl` may carry `terraform`, `backend`, and
96/// `include` blocks at once).
97#[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
119/// Scan `bytes` once with the shared `RegexSet` and return the set of
120/// matching probes. Linear-time; bounded by the byte length per
121/// [70-security.md § 3.4](../../../specs/70-security.md).
122pub(super) fn probe_file(bytes: &[u8]) -> FileSignals {
123    let set = probe_set();
124    // `RegexSet::matches` works on &str. Try to interpret the bytes as UTF-8;
125    // if that fails (binary file masquerading as `.tf`), no signals — the
126    // loader will surface the parse error later.
127    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
141/// Decide a directory's kind from the aggregate of its file signals plus the
142/// contextual hints (whether the dir matches `module_glob`, whether it sits
143/// at the workspace root, whether it carries a `terragrunt.hcl` file).
144///
145/// Returns `(DirKind, ClassificationReason, ambiguous)` where `ambiguous` is
146/// `true` if the heuristics tripped multiple plausible classifications and
147/// the caller should emit an ambiguity diagnostic.
148pub(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    // Component checks (priority order per spec § 3.2):
167    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        // The spec resolves the (component vs module-glob) tie in favour of
179        // component; remember the ambiguity for the caller.
180        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        // A `terragrunt.hcl include` under `modules/` is unusual but the
383        // spec demands the component classification wins and an ambiguity
384        // diagnostic is emitted.
385        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}