Skip to main content

tfparser_core/discovery/
fs_walker.rs

1//! [`FsDiscoverer`]: walks the real filesystem with `ignore::WalkBuilder`,
2//! groups files by directory, runs the shallow probe + classifier from
3//! [`super::classifier`], and produces a deterministic [`Discovered`].
4
5use 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/// Default [`Discoverer`] implementation.
27///
28/// Stateless and `Send + Sync`; cheap to construct (no fields). The
29/// per-walk caps and excludes are supplied via [`DiscoveryOptions`].
30#[derive(Clone, Copy, Debug, Default)]
31pub struct FsDiscoverer;
32
33impl FsDiscoverer {
34    /// Construct an [`FsDiscoverer`].
35    #[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
48/// Internal entry point — separates the trait dispatch from the actual logic
49/// so we can call it from tests without going through the trait.
50fn 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    // Final safety — running through the same canonicalizer the rest of the
300    // pipeline uses ensures the helpers agree on what "inside" means.
301    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        // We do NOT call WalkBuilder::max_filesize because that drops the
321        // entry silently. Discovery's contract is to *surface* over-cap
322        // files as diagnostics so the operator knows what was skipped, so
323        // the size check happens in the consumer loop below instead.
324        .standard_filters(true)
325        .git_ignore(true)
326        .git_exclude(true)
327        .git_global(true)
328        .require_git(false)
329        .hidden(true);
330
331    // The `ignore` crate's WalkBuilder takes overrides (gitignore-style)
332    // but not a `GlobSet` directly. The exclude_globs from
333    // `DiscoveryOptions` are applied per-entry in the consumer loop.
334    builder.build()
335}
336
337/// Probe every HCL-shaped file in `files`, capping per-file work at
338/// `max_file_bytes` (so a 4 MiB `.tf` does not bloat discovery time).
339fn 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            // Already surfaced as a Diagnostic in the walk loop; skip the
347            // probe to avoid re-tripping the cap.
348            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
363/// `<workspace_root>/environments` (no deeper). Spec § 3.2.
364fn 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        // `Files` directories don't surface in components/modules; they are
575        // implicitly tracked via tracing::debug. The presence/absence here is
576        // structural, not asserted by Discovery's public API. We just verify
577        // discovery does not error.
578        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    // Symlinks are unix-only.
608    #[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        // Component still classified (the real file is there), and the
619        // symlink path emits a `Trace` diagnostic.
620        assert_eq!(d.components.len(), 1);
621        let _ = PathBuf::from(root); // keep tempdir alive
622    }
623}