1use std::{
6 collections::{BTreeMap, BTreeSet},
7 path::{Path, PathBuf},
8 sync::Arc,
9};
10
11use ignore::WalkBuilder;
12use tracing::{debug, instrument, warn};
13
14use super::{
15 Discovered, DiscoveredDir, DiscoveredFile, Discoverer, DiscoveryOptions,
16 classifier::{FileSignals, classify, probe_file},
17 types::DirKind,
18};
19use crate::{
20 Diagnostic, Error, LimitKind, Result, Severity,
21 diagnostic::Diagnostic as Diag,
22 ir::FileExt,
23 util::paths::{self, SymlinkPolicy},
24};
25
26#[derive(Clone, Copy, Debug, Default)]
31pub struct FsDiscoverer;
32
33impl FsDiscoverer {
34 #[must_use]
36 pub const fn new() -> Self {
37 Self
38 }
39}
40
41impl Discoverer for FsDiscoverer {
42 #[instrument(level = "debug", skip(self, opts), fields(root = %root.display()))]
43 fn discover(&self, root: &Path, opts: &DiscoveryOptions) -> Result<Discovered> {
44 run_discovery(root, opts)
45 }
46}
47
48fn run_discovery(root: &Path, opts: &DiscoveryOptions) -> Result<Discovered> {
51 let canonical_root = canonicalize_root(root, opts)?;
52 let WalkOutput {
53 groups,
54 seen_dirs,
55 mut diagnostics,
56 } = walk_workspace(&canonical_root, opts)?;
57 let mut classified =
58 classify_dirs(&canonical_root, opts, &groups, &seen_dirs, &mut diagnostics);
59 classified
60 .components
61 .sort_by(|a, b| a.path.as_os_str().cmp(b.path.as_os_str()));
62 classified
63 .modules
64 .sort_by(|a, b| a.path.as_os_str().cmp(b.path.as_os_str()));
65 let root_hcl = find_root_hcl(&canonical_root);
66 Ok(Discovered {
67 root: Arc::from(canonical_root.as_path()),
68 components: classified.components,
69 modules: classified.modules,
70 envs_dir: classified.envs_dir,
71 root_hcl,
72 diagnostics,
73 })
74}
75
76struct WalkOutput {
77 groups: BTreeMap<PathBuf, Vec<DiscoveredFile>>,
78 seen_dirs: BTreeSet<PathBuf>,
79 diagnostics: Vec<Diagnostic>,
80}
81
82fn walk_workspace(canonical_root: &Path, opts: &DiscoveryOptions) -> Result<WalkOutput> {
83 let mut state = WalkState::new();
84 let walker = build_walker(canonical_root, opts);
85 for result in walker {
86 match result {
87 Ok(entry) => process_walk_entry(canonical_root, opts, &entry, &mut state)?,
88 Err(err) => state.diagnostics.push(walk_error_to_diagnostic(&err)),
89 }
90 }
91 Ok(WalkOutput {
92 groups: state.groups,
93 seen_dirs: state.seen_dirs,
94 diagnostics: state.diagnostics,
95 })
96}
97
98struct WalkState {
99 groups: BTreeMap<PathBuf, Vec<DiscoveredFile>>,
100 seen_dirs: BTreeSet<PathBuf>,
101 diagnostics: Vec<Diagnostic>,
102 total_files: u64,
103}
104
105impl WalkState {
106 fn new() -> Self {
107 let mut seen_dirs = BTreeSet::new();
108 seen_dirs.insert(PathBuf::new());
109 Self {
110 groups: BTreeMap::new(),
111 seen_dirs,
112 diagnostics: Vec::new(),
113 total_files: 0,
114 }
115 }
116}
117
118fn process_walk_entry(
119 canonical_root: &Path,
120 opts: &DiscoveryOptions,
121 entry: &ignore::DirEntry,
122 state: &mut WalkState,
123) -> Result<()> {
124 if entry.depth() == 0 {
125 return Ok(());
126 }
127 let entry_path = entry.path().to_path_buf();
128 let Ok(rel) = entry_path.strip_prefix(canonical_root) else {
129 state.diagnostics.push(Diag::new(
130 Severity::Warn,
131 "TF1101",
132 format!(
133 "dropping entry outside workspace root: {}",
134 entry_path.display()
135 ),
136 ));
137 return Ok(());
138 };
139 let rel_path = rel.to_path_buf();
140 if opts.exclude_globs.is_match(&rel_path) {
141 return Ok(());
142 }
143 let Some(file_type) = entry.file_type() else {
144 return Ok(());
145 };
146 if file_type.is_dir() {
147 state.seen_dirs.insert(rel_path);
148 return Ok(());
149 }
150 if !file_type.is_file() {
151 if file_type.is_symlink() {
152 state.diagnostics.push(Diag::new(
153 Severity::Trace,
154 "TF1110",
155 format!("skipped symlink: {}", rel_path.display()),
156 ));
157 }
158 return Ok(());
159 }
160 let metadata = match entry.metadata() {
161 Ok(m) => m,
162 Err(err) => {
163 state.diagnostics.push(Diag::new(
164 Severity::Warn,
165 "TF1102",
166 format!("metadata error for {}: {err}", rel_path.display()),
167 ));
168 return Ok(());
169 }
170 };
171 let size = metadata.len();
172 if size > opts.max_file_size_bytes {
173 state.diagnostics.push(Diag::limit(
174 LimitKind::FileSize,
175 "TF1103",
176 format!(
177 "file exceeds size limit and was skipped: {} ({size} > {})",
178 rel_path.display(),
179 opts.max_file_size_bytes
180 ),
181 ));
182 return Ok(());
183 }
184 let Some(ext) = FileExt::classify(&rel_path) else {
185 return Ok(());
186 };
187 state.total_files = state.total_files.checked_add(1).ok_or(Error::Limit {
188 kind: LimitKind::TotalFiles,
189 observed: u64::MAX,
190 limit: opts.max_total_files,
191 })?;
192 if state.total_files > opts.max_total_files {
193 return Err(Error::Limit {
194 kind: LimitKind::TotalFiles,
195 observed: state.total_files,
196 limit: opts.max_total_files,
197 });
198 }
199 let parent_rel = rel_path
200 .parent()
201 .map_or_else(PathBuf::new, Path::to_path_buf);
202 state.seen_dirs.insert(parent_rel.clone());
203 let file = DiscoveredFile {
204 path: Arc::from(rel_path.as_path()),
205 ext,
206 size,
207 };
208 state.groups.entry(parent_rel).or_default().push(file);
209 Ok(())
210}
211
212struct Classified {
213 components: Vec<DiscoveredDir>,
214 modules: Vec<DiscoveredDir>,
215 envs_dir: Option<Arc<Path>>,
216}
217
218fn classify_dirs(
219 canonical_root: &Path,
220 opts: &DiscoveryOptions,
221 groups: &BTreeMap<PathBuf, Vec<DiscoveredFile>>,
222 seen_dirs: &BTreeSet<PathBuf>,
223 diagnostics: &mut Vec<Diagnostic>,
224) -> Classified {
225 let mut components: Vec<DiscoveredDir> = Vec::new();
226 let mut modules: Vec<DiscoveredDir> = Vec::new();
227 let mut envs_dir: Option<Arc<Path>> = None;
228 for rel_dir in seen_dirs {
229 let files = groups.get(rel_dir).cloned().unwrap_or_default();
230 let signals = aggregate_signals(canonical_root, &files, opts.max_file_size_bytes);
231 let is_workspace_envs_dir = is_workspace_environments_dir(rel_dir);
232 let (kind, reason, ambiguous) =
233 classify(rel_dir, signals, &files, opts, is_workspace_envs_dir);
234 if ambiguous {
235 diagnostics.push(Diag::new(
236 Severity::Info,
237 "TF1120",
238 format!(
239 "directory is ambiguous (matches both component and module heuristics); \
240 component classification kept: {}",
241 rel_dir.display()
242 ),
243 ));
244 }
245 let dir_arc: Arc<Path> = Arc::from(rel_dir.as_path());
246 match kind {
247 DirKind::Component => {
248 components.push(DiscoveredDir::new(dir_arc, kind, reason, files));
249 }
250 DirKind::Module => {
251 modules.push(DiscoveredDir::new(dir_arc, kind, reason, files));
252 }
253 DirKind::Environments => {
254 envs_dir = Some(dir_arc);
255 }
256 DirKind::Files | DirKind::Other => {
257 debug!(?rel_dir, ?kind, "discovery: non-component dir");
258 }
259 }
260 }
261 Classified {
262 components,
263 modules,
264 envs_dir,
265 }
266}
267
268fn find_root_hcl(canonical_root: &Path) -> Option<Arc<Path>> {
269 for candidate_rel in [Path::new("root.hcl"), Path::new("terraform/root.hcl")] {
270 if canonical_root.join(candidate_rel).is_file() {
271 return Some(Arc::from(candidate_rel));
272 }
273 }
274 None
275}
276
277fn canonicalize_root(root: &Path, opts: &DiscoveryOptions) -> Result<PathBuf> {
278 let policy = if opts.follow_symlinks {
279 SymlinkPolicy::Follow
280 } else {
281 SymlinkPolicy::Reject
282 };
283 paths::reject_nul(root).map_err(|_| Error::Io {
284 path: root.to_path_buf(),
285 source: std::io::Error::new(std::io::ErrorKind::InvalidInput, "path contains NUL"),
286 })?;
287
288 if !root.exists() {
289 return Err(Error::Io {
290 path: root.to_path_buf(),
291 source: std::io::Error::new(std::io::ErrorKind::NotFound, "workspace root not found"),
292 });
293 }
294 let canonical = std::fs::canonicalize(root).map_err(|source| Error::Io {
295 path: root.to_path_buf(),
296 source,
297 })?;
298
299 let resolved =
302 paths::canonicalize_inside(&canonical, &canonical, policy).map_err(|err| match err {
303 paths::PathSafetyError::Io { path, source } => Error::Io { path, source },
304 other => Error::Io {
305 path: canonical.clone(),
306 source: std::io::Error::new(
307 std::io::ErrorKind::PermissionDenied,
308 other.to_string(),
309 ),
310 },
311 })?;
312 Ok(resolved)
313}
314
315fn build_walker(root: &Path, opts: &DiscoveryOptions) -> ignore::Walk {
316 let mut builder = WalkBuilder::new(root);
317 builder
318 .follow_links(opts.follow_symlinks)
319 .max_depth(Some(opts.max_depth as usize))
320 .standard_filters(true)
325 .git_ignore(true)
326 .git_exclude(true)
327 .git_global(true)
328 .require_git(false)
329 .hidden(true);
330
331 builder.build()
335}
336
337fn aggregate_signals(root: &Path, files: &[DiscoveredFile], max_file_bytes: u64) -> FileSignals {
340 let mut signals = FileSignals::default();
341 for file in files {
342 if !file.ext.is_hcl() {
343 continue;
344 }
345 if file.size > max_file_bytes {
346 continue;
349 }
350 let abs = root.join(&*file.path);
351 let bytes = match std::fs::read(&abs) {
352 Ok(b) => b,
353 Err(err) => {
354 warn!(path = %abs.display(), error = %err, "discovery: probe read failed");
355 continue;
356 }
357 };
358 signals.merge(probe_file(&bytes));
359 }
360 signals
361}
362
363fn is_workspace_environments_dir(rel: &Path) -> bool {
365 let mut comps = rel.components();
366 let first = comps.next();
367 let second = comps.next();
368 matches!(first, Some(c) if c.as_os_str() == "environments") && second.is_none()
369}
370
371fn walk_error_to_diagnostic(err: &ignore::Error) -> Diagnostic {
372 Diag::new(Severity::Warn, "TF1100", format!("walk error: {err}"))
373}
374
375#[cfg(test)]
376#[allow(
377 clippy::unwrap_used,
378 clippy::expect_used,
379 clippy::panic,
380 clippy::indexing_slicing
381)]
382mod tests {
383 use std::{fs, path::PathBuf};
384
385 use tempfile::tempdir;
386
387 use super::{super::classifier::ClassificationReason, *};
388 use crate::ir::FileExt;
389
390 fn write(root: &Path, rel: &str, content: &str) {
391 let path = root.join(rel);
392 if let Some(parent) = path.parent() {
393 fs::create_dir_all(parent).unwrap();
394 }
395 fs::write(path, content).unwrap();
396 }
397
398 #[test]
399 fn test_should_discover_single_component() {
400 let tmp = tempdir().unwrap();
401 let root = tmp.path();
402 write(
403 root,
404 "services/api/main.tf",
405 "resource \"aws_iam_role\" \"r\" {\n name = \"x\"\n}\n",
406 );
407 let opts = DiscoveryOptions::defaults();
408 let d = FsDiscoverer.discover(root, &opts).unwrap();
409 assert_eq!(d.components.len(), 1, "{d:?}");
410 assert_eq!(&*d.components[0].path, Path::new("services/api"));
411 assert_eq!(d.components[0].reason, ClassificationReason::HasResources);
412 assert!(d.modules.is_empty());
413 }
414
415 #[test]
416 fn test_should_discover_module_via_glob() {
417 let tmp = tempdir().unwrap();
418 let root = tmp.path();
419 write(
420 root,
421 "modules/iam-role/variables.tf",
422 "variable \"name\" {\n type = string\n}\n",
423 );
424 let opts = DiscoveryOptions::defaults();
425 let d = FsDiscoverer.discover(root, &opts).unwrap();
426 assert_eq!(d.modules.len(), 1, "{d:?}");
427 assert_eq!(d.modules[0].kind, DirKind::Module);
428 }
429
430 #[test]
431 fn test_should_discover_terragrunt_component_with_include() {
432 let tmp = tempdir().unwrap();
433 let root = tmp.path();
434 write(
435 root,
436 "services/api/terragrunt.hcl",
437 "include \"root\" {\n path = find_in_parent_folders()\n}\n",
438 );
439 let opts = DiscoveryOptions::defaults();
440 let d = FsDiscoverer.discover(root, &opts).unwrap();
441 assert_eq!(d.components.len(), 1);
442 assert_eq!(
443 d.components[0].reason,
444 ClassificationReason::TerragruntInclude
445 );
446 }
447
448 #[test]
449 fn test_should_skip_files_larger_than_cap() {
450 let tmp = tempdir().unwrap();
451 let root = tmp.path();
452 let big = "a".repeat(100);
453 write(root, "svc/main.tf", &format!("{big}\n"));
454 let opts: DiscoveryOptions = DiscoveryOptions::builder()
455 .max_file_size_bytes(50_u64)
456 .build();
457 let d = FsDiscoverer.discover(root, &opts).unwrap();
458 assert!(
459 d.diagnostics.iter().any(|x| x.code.as_ref() == "TF1103"),
460 "{d:?}"
461 );
462 }
463
464 #[test]
465 fn test_should_enforce_total_file_cap() {
466 let tmp = tempdir().unwrap();
467 let root = tmp.path();
468 for i in 0..5 {
469 write(root, &format!("svc/f{i}.tf"), "");
470 }
471 let opts: DiscoveryOptions = DiscoveryOptions::builder().max_total_files(3_u64).build();
472 let err = FsDiscoverer.discover(root, &opts).unwrap_err();
473 assert!(matches!(
474 err,
475 Error::Limit {
476 kind: LimitKind::TotalFiles,
477 ..
478 }
479 ));
480 }
481
482 #[test]
483 fn test_should_detect_environments_dir() {
484 let tmp = tempdir().unwrap();
485 let root = tmp.path();
486 write(root, "environments/staging.tfvars", "");
487 let opts = DiscoveryOptions::defaults();
488 let d = FsDiscoverer.discover(root, &opts).unwrap();
489 assert!(d.envs_dir.is_some(), "{d:?}");
490 }
491
492 #[test]
493 fn test_should_detect_root_hcl() {
494 let tmp = tempdir().unwrap();
495 let root = tmp.path();
496 write(root, "root.hcl", "remote_state {}\n");
497 let opts = DiscoveryOptions::defaults();
498 let d = FsDiscoverer.discover(root, &opts).unwrap();
499 assert_eq!(d.root_hcl.as_deref(), Some(Path::new("root.hcl")));
500 }
501
502 #[test]
503 fn test_should_reject_missing_root() {
504 let tmp = tempdir().unwrap();
505 let missing = tmp.path().join("not-here");
506 let err = FsDiscoverer
507 .discover(&missing, &DiscoveryOptions::defaults())
508 .unwrap_err();
509 assert!(matches!(err, Error::Io { .. }));
510 }
511
512 #[test]
513 fn test_should_classify_environments_dir_only_at_root_level() {
514 let tmp = tempdir().unwrap();
515 let root = tmp.path();
516 write(root, "platform/environments/x.tfvars", "");
517 let opts = DiscoveryOptions::defaults();
518 let d = FsDiscoverer.discover(root, &opts).unwrap();
519 assert!(d.envs_dir.is_none(), "nested env dir should not match");
520 }
521
522 #[test]
523 fn test_should_emit_ambiguity_diag_for_component_in_modules_glob() {
524 let tmp = tempdir().unwrap();
525 let root = tmp.path();
526 write(
527 root,
528 "modules/strange/terragrunt.hcl",
529 "include \"root\" {\n path = find_in_parent_folders()\n}\n",
530 );
531 let opts = DiscoveryOptions::defaults();
532 let d = FsDiscoverer.discover(root, &opts).unwrap();
533 assert!(
534 d.components
535 .iter()
536 .any(|c| c.path.as_ref() == Path::new("modules/strange"))
537 );
538 assert!(
539 d.diagnostics.iter().any(|x| x.code.as_ref() == "TF1120"),
540 "{d:?}"
541 );
542 }
543
544 #[test]
545 fn test_should_be_deterministic_in_ordering() {
546 let tmp = tempdir().unwrap();
547 let root = tmp.path();
548 for name in ["zeta", "alpha", "mu"] {
549 write(
550 root,
551 &format!("services/{name}/main.tf"),
552 "resource \"r\" \"r\" {}\n",
553 );
554 }
555 let opts = DiscoveryOptions::defaults();
556 let d = FsDiscoverer.discover(root, &opts).unwrap();
557 let names: Vec<_> = d
558 .components
559 .iter()
560 .map(|c| c.path.to_string_lossy().into_owned())
561 .collect();
562 let mut sorted = names.clone();
563 sorted.sort();
564 assert_eq!(names, sorted);
565 }
566
567 #[test]
568 fn test_should_classify_files_only_dir() {
569 let tmp = tempdir().unwrap();
570 let root = tmp.path();
571 write(root, "policies/foo.json", "{}");
572 let opts = DiscoveryOptions::defaults();
573 let d = FsDiscoverer.discover(root, &opts).unwrap();
574 assert!(d.components.is_empty());
579 assert!(d.modules.is_empty());
580 }
581
582 #[test]
583 fn test_should_record_files_for_component() {
584 let tmp = tempdir().unwrap();
585 let root = tmp.path();
586 write(root, "svc/main.tf", "resource \"r\" \"r\" {}\n");
587 write(root, "svc/outputs.tf", "output \"o\" { value = 1 }\n");
588 let opts = DiscoveryOptions::defaults();
589 let d = FsDiscoverer.discover(root, &opts).unwrap();
590 assert_eq!(d.components.len(), 1);
591 let exts: Vec<_> = d.components[0].files.iter().map(|f| f.ext).collect();
592 assert!(exts.iter().all(|e| *e == FileExt::Tf));
593 }
594
595 #[test]
596 fn test_canonical_root_strips_dot_dot() {
597 let tmp = tempdir().unwrap();
598 let root = tmp.path();
599 write(root, "svc/main.tf", "resource \"r\" \"r\" {}\n");
600 let nested = root.join("svc/..");
601 let d = FsDiscoverer
602 .discover(&nested, &DiscoveryOptions::defaults())
603 .unwrap();
604 assert!(!d.root.to_string_lossy().contains(".."));
605 }
606
607 #[cfg(unix)]
609 #[test]
610 fn test_should_skip_symlinks_by_default() {
611 let tmp = tempdir().unwrap();
612 let root = tmp.path();
613 write(root, "real/main.tf", "resource \"r\" \"r\" {}\n");
614 std::os::unix::fs::symlink(root.join("real/main.tf"), root.join("real/main_link.tf"))
615 .unwrap();
616 let opts = DiscoveryOptions::defaults();
617 let d = FsDiscoverer.discover(root, &opts).unwrap();
618 assert_eq!(d.components.len(), 1);
621 let _ = PathBuf::from(root); }
623}