use std::{path::Path, sync::OnceLock};
use regex::RegexSet;
use super::{
options::DiscoveryOptions,
types::{DirKind, DiscoveredFile},
};
use crate::ir::FileExt;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum ClassificationReason {
TerragruntInclude,
BackendBlock,
HasResources,
ModuleGlob,
VariablesOnly,
EnvironmentsDir,
DataOnly,
Unknown,
}
const PATTERN_TERRAFORM_BLOCK: &str = r"(?m)^\s*terraform\s*\{";
const PATTERN_BACKEND_BLOCK: &str = r#"(?m)^\s*backend\s+"[^"]+"\s*\{"#;
const PATTERN_RESOURCE_BLOCK: &str = r#"(?m)^\s*resource\s+"[^"]+"\s+"[^"]+"\s*\{"#;
const PATTERN_DATA_BLOCK: &str = r#"(?m)^\s*data\s+"[^"]+"\s+"[^"]+"\s*\{"#;
const PATTERN_VARIABLE_BLOCK: &str = r#"(?m)^\s*variable\s+"[^"]+"\s*\{"#;
const PATTERN_INCLUDE_BLOCK: &str = r#"(?m)^\s*include(\s+"[^"]*")?\s*\{"#;
const IDX_TERRAFORM: usize = 0;
const IDX_BACKEND: usize = 1;
const IDX_RESOURCE: usize = 2;
const IDX_DATA: usize = 3;
const IDX_VARIABLE: usize = 4;
const IDX_INCLUDE: usize = 5;
fn probe_set() -> &'static RegexSet {
static PROBES: OnceLock<RegexSet> = OnceLock::new();
PROBES.get_or_init(|| {
RegexSet::new([
PATTERN_TERRAFORM_BLOCK,
PATTERN_BACKEND_BLOCK,
PATTERN_RESOURCE_BLOCK,
PATTERN_DATA_BLOCK,
PATTERN_VARIABLE_BLOCK,
PATTERN_INCLUDE_BLOCK,
])
.unwrap_or_else(|_| RegexSet::empty())
})
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
#[allow(clippy::struct_excessive_bools)]
pub(super) struct FileSignals {
pub terraform_block: bool,
pub backend_block: bool,
pub resource_block: bool,
pub data_block: bool,
pub variable_block: bool,
pub include_block: bool,
}
impl FileSignals {
pub(super) fn merge(&mut self, other: Self) {
self.terraform_block |= other.terraform_block;
self.backend_block |= other.backend_block;
self.resource_block |= other.resource_block;
self.data_block |= other.data_block;
self.variable_block |= other.variable_block;
self.include_block |= other.include_block;
}
}
pub(super) fn probe_file(bytes: &[u8]) -> FileSignals {
let set = probe_set();
let Ok(s) = std::str::from_utf8(bytes) else {
return FileSignals::default();
};
let matches = set.matches(s);
FileSignals {
terraform_block: matches.matched(IDX_TERRAFORM),
backend_block: matches.matched(IDX_BACKEND),
resource_block: matches.matched(IDX_RESOURCE),
data_block: matches.matched(IDX_DATA),
variable_block: matches.matched(IDX_VARIABLE),
include_block: matches.matched(IDX_INCLUDE),
}
}
pub(super) fn classify(
rel_path: &Path,
signals: FileSignals,
files: &[DiscoveredFile],
opts: &DiscoveryOptions,
is_workspace_child_named_environments: bool,
) -> (DirKind, ClassificationReason, bool) {
if is_workspace_child_named_environments {
return (
DirKind::Environments,
ClassificationReason::EnvironmentsDir,
false,
);
}
let module_glob_match = opts.module_globs.is_match(rel_path);
let has_terragrunt_hcl = files.iter().any(|f| f.ext == FileExt::TerragruntHcl);
let (component_reason, component_picked) = if has_terragrunt_hcl && signals.include_block {
(Some(ClassificationReason::TerragruntInclude), true)
} else if signals.backend_block && signals.terraform_block {
(Some(ClassificationReason::BackendBlock), true)
} else if (signals.resource_block || signals.data_block) && !module_glob_match {
(Some(ClassificationReason::HasResources), true)
} else {
(None, false)
};
if component_picked {
let ambiguous = component_picked && module_glob_match;
return (
DirKind::Component,
component_reason.unwrap_or(ClassificationReason::Unknown),
ambiguous,
);
}
if module_glob_match {
return (DirKind::Module, ClassificationReason::ModuleGlob, false);
}
let only_variables = signals.variable_block
&& !signals.resource_block
&& !signals.data_block
&& !signals.backend_block
&& !has_terragrunt_hcl;
if only_variables {
return (DirKind::Module, ClassificationReason::VariablesOnly, false);
}
let any_hcl = files.iter().any(|f| f.ext.is_hcl());
if !any_hcl && !files.is_empty() {
return (DirKind::Files, ClassificationReason::DataOnly, false);
}
(DirKind::Other, ClassificationReason::Unknown, false)
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::indexing_slicing, clippy::expect_used)]
mod tests {
use std::sync::Arc;
use super::*;
fn file(rel: &str, ext: FileExt) -> DiscoveredFile {
DiscoveredFile {
path: Arc::from(Path::new(rel)),
ext,
size: 0,
}
}
#[test]
fn test_probe_detects_resource_block() {
let s = b"\nresource \"aws_iam_role\" \"r\" {\n}\n";
let sig = probe_file(s);
assert!(sig.resource_block);
assert!(!sig.backend_block);
}
#[test]
fn test_probe_detects_terraform_with_backend() {
let s = b"terraform {\n backend \"s3\" {\n bucket = \"x\"\n }\n}\n";
let sig = probe_file(s);
assert!(sig.terraform_block);
assert!(sig.backend_block);
}
#[test]
fn test_probe_detects_variable_block() {
let s = b"variable \"name\" {\n type = string\n}\n";
let sig = probe_file(s);
assert!(sig.variable_block);
assert!(!sig.resource_block);
}
#[test]
fn test_probe_detects_terragrunt_include_with_label() {
let s = b"include \"root\" {\n path = find_in_parent_folders()\n}\n";
let sig = probe_file(s);
assert!(sig.include_block);
}
#[test]
fn test_probe_detects_bare_include() {
let s = b"include {\n path = find_in_parent_folders()\n}\n";
let sig = probe_file(s);
assert!(sig.include_block);
}
#[test]
fn test_probe_returns_no_signals_for_invalid_utf8() {
let s = &[0xFFu8, 0xFE, 0xFD];
let sig = probe_file(s);
assert!(!sig.resource_block);
}
#[test]
fn test_classify_terragrunt_dir_with_include_as_component() {
let opts = DiscoveryOptions::defaults();
let files = vec![file("terragrunt.hcl", FileExt::TerragruntHcl)];
let signals = FileSignals {
include_block: true,
..Default::default()
};
let (kind, reason, ambiguous) =
classify(Path::new("services/api"), signals, &files, &opts, false);
assert_eq!(kind, DirKind::Component);
assert_eq!(reason, ClassificationReason::TerragruntInclude);
assert!(!ambiguous);
}
#[test]
fn test_classify_backend_dir_as_component() {
let opts = DiscoveryOptions::defaults();
let files = vec![file("main.tf", FileExt::Tf)];
let signals = FileSignals {
terraform_block: true,
backend_block: true,
..Default::default()
};
let (kind, reason, _) = classify(Path::new("backend-only"), signals, &files, &opts, false);
assert_eq!(kind, DirKind::Component);
assert_eq!(reason, ClassificationReason::BackendBlock);
}
#[test]
fn test_classify_resources_only_as_component() {
let opts = DiscoveryOptions::defaults();
let files = vec![file("main.tf", FileExt::Tf)];
let signals = FileSignals {
resource_block: true,
..Default::default()
};
let (kind, _, _) = classify(Path::new("svc"), signals, &files, &opts, false);
assert_eq!(kind, DirKind::Component);
}
#[test]
fn test_classify_module_glob_match_as_module() {
let opts = DiscoveryOptions::defaults();
let files = vec![file("main.tf", FileExt::Tf)];
let signals = FileSignals {
variable_block: true,
..Default::default()
};
let (kind, reason, _) =
classify(Path::new("modules/iam-role"), signals, &files, &opts, false);
assert_eq!(kind, DirKind::Module);
assert_eq!(reason, ClassificationReason::ModuleGlob);
}
#[test]
fn test_classify_variables_only_as_module() {
let opts = DiscoveryOptions::defaults();
let files = vec![file("variables.tf", FileExt::Tf)];
let signals = FileSignals {
variable_block: true,
..Default::default()
};
let (kind, reason, _) = classify(Path::new("library"), signals, &files, &opts, false);
assert_eq!(kind, DirKind::Module);
assert_eq!(reason, ClassificationReason::VariablesOnly);
}
#[test]
fn test_classify_environments_dir_at_root() {
let opts = DiscoveryOptions::defaults();
let (kind, reason, _) = classify(
Path::new("environments"),
FileSignals::default(),
&[],
&opts,
true,
);
assert_eq!(kind, DirKind::Environments);
assert_eq!(reason, ClassificationReason::EnvironmentsDir);
}
#[test]
fn test_classify_data_only_dir_as_files() {
let opts = DiscoveryOptions::defaults();
let files = vec![file("policy.json", FileExt::Json)];
let (kind, reason, _) = classify(
Path::new("svc/policies"),
FileSignals::default(),
&files,
&opts,
false,
);
assert_eq!(kind, DirKind::Files);
assert_eq!(reason, ClassificationReason::DataOnly);
}
#[test]
fn test_classify_unknown_dir_as_other() {
let opts = DiscoveryOptions::defaults();
let (kind, reason, _) = classify(
Path::new("empty"),
FileSignals::default(),
&[],
&opts,
false,
);
assert_eq!(kind, DirKind::Other);
assert_eq!(reason, ClassificationReason::Unknown);
}
#[test]
fn test_classify_component_inside_modules_glob_marks_ambiguous() {
let opts = DiscoveryOptions::defaults();
let files = vec![file("terragrunt.hcl", FileExt::TerragruntHcl)];
let signals = FileSignals {
include_block: true,
..Default::default()
};
let (kind, _, ambiguous) = classify(
Path::new("modules/odd-component"),
signals,
&files,
&opts,
false,
);
assert_eq!(kind, DirKind::Component);
assert!(ambiguous);
}
}