tfparser_core/discovery/
options.rs1use std::sync::Arc;
12
13use globset::{Glob, GlobSet, GlobSetBuilder};
14use thiserror::Error;
15use typed_builder::TypedBuilder;
16
17pub const MAX_GLOB_PATTERN_BYTES: usize = 256;
21
22pub const MAX_DISCOVERY_THREADS: u32 = 8;
27
28#[derive(Clone, Debug, TypedBuilder)]
32#[non_exhaustive]
33pub struct DiscoveryOptions {
34 #[builder(default = false)]
36 pub follow_symlinks: bool,
37
38 #[builder(default = 16)]
41 pub max_depth: u32,
42
43 #[builder(default = 8 * 1024 * 1024)]
47 pub max_file_size_bytes: u64,
48
49 #[builder(default = 200_000)]
53 pub max_total_files: u64,
54
55 #[builder(default = default_exclude_globset())]
58 pub exclude_globs: Arc<GlobSet>,
59
60 #[builder(default = default_module_globset())]
64 pub module_globs: Arc<GlobSet>,
65
66 #[builder(default = 0)]
70 pub threads: u32,
71}
72
73impl DiscoveryOptions {
74 #[must_use]
79 pub fn defaults() -> Self {
80 Self::builder().build()
81 }
82
83 #[must_use]
87 pub const fn effective_threads(&self) -> u32 {
88 if self.threads > MAX_DISCOVERY_THREADS {
89 MAX_DISCOVERY_THREADS
90 } else {
91 self.threads
92 }
93 }
94}
95
96impl Default for DiscoveryOptions {
97 fn default() -> Self {
98 Self::defaults()
99 }
100}
101
102#[derive(Debug, Error)]
104#[non_exhaustive]
105pub enum GlobConfigError {
106 #[error("glob pattern too long ({observed} > {limit}): `{pattern}`")]
110 TooLong {
111 pattern: String,
113 observed: usize,
115 limit: usize,
117 },
118
119 #[error("invalid glob pattern `{pattern}`: {source}")]
121 Invalid {
122 pattern: String,
124 #[source]
126 source: globset::Error,
127 },
128}
129
130pub fn compile_glob_set<I, S>(patterns: I) -> Result<GlobSet, GlobConfigError>
138where
139 I: IntoIterator<Item = S>,
140 S: AsRef<str>,
141{
142 let mut builder = GlobSetBuilder::new();
143 for pattern in patterns {
144 let pattern_str = pattern.as_ref();
145 if pattern_str.len() > MAX_GLOB_PATTERN_BYTES {
146 return Err(GlobConfigError::TooLong {
147 pattern: pattern_str.to_owned(),
148 observed: pattern_str.len(),
149 limit: MAX_GLOB_PATTERN_BYTES,
150 });
151 }
152 let glob = Glob::new(pattern_str).map_err(|source| GlobConfigError::Invalid {
153 pattern: pattern_str.to_owned(),
154 source,
155 })?;
156 builder.add(glob);
157 }
158 builder.build().map_err(|source| GlobConfigError::Invalid {
159 pattern: String::new(),
160 source,
161 })
162}
163
164fn default_exclude_globset() -> Arc<GlobSet> {
166 let patterns = [
167 "**/.git",
168 "**/.git/**",
169 "**/.terraform",
170 "**/.terraform/**",
171 "**/.terragrunt-cache",
172 "**/.terragrunt-cache/**",
173 ];
174 let set = compile_glob_set(patterns).unwrap_or_else(|_| GlobSet::empty());
175 Arc::new(set)
176}
177
178fn default_module_globset() -> Arc<GlobSet> {
180 let patterns = [
181 "modules/*",
182 "modules/**",
183 "modules-tf12/*",
184 "modules-tf12/**",
185 "**/modules/*",
186 "**/modules-tf12/*",
187 ];
188 let set = compile_glob_set(patterns).unwrap_or_else(|_| GlobSet::empty());
189 Arc::new(set)
190}
191
192#[cfg(test)]
193#[allow(clippy::unwrap_used, clippy::expect_used)]
194mod tests {
195 use std::path::Path;
196
197 use super::*;
198
199 #[test]
200 fn test_should_build_defaults() {
201 let opts = DiscoveryOptions::defaults();
202 assert!(!opts.follow_symlinks);
203 assert_eq!(opts.max_depth, 16);
204 assert_eq!(opts.max_file_size_bytes, 8 * 1024 * 1024);
205 assert_eq!(opts.max_total_files, 200_000);
206 assert!(opts.exclude_globs.is_match(Path::new(".git/HEAD")));
207 assert!(
208 opts.exclude_globs
209 .is_match(Path::new("services/order-service/.terraform/lockfile"))
210 );
211 }
212
213 #[test]
214 fn test_should_match_module_globs_by_default() {
215 let opts = DiscoveryOptions::defaults();
216 assert!(opts.module_globs.is_match(Path::new("modules/iam-role")));
217 assert!(
218 opts.module_globs
219 .is_match(Path::new("terraform/modules/vpc"))
220 );
221 }
222
223 #[test]
224 fn test_should_compile_user_glob_set() {
225 let set = compile_glob_set(["foo/**", "bar/*"]).unwrap();
226 assert!(set.is_match(Path::new("foo/x/y")));
227 assert!(set.is_match(Path::new("bar/x")));
228 assert!(!set.is_match(Path::new("baz")));
229 }
230
231 #[test]
232 fn test_should_reject_overlong_pattern() {
233 let pattern = "a".repeat(MAX_GLOB_PATTERN_BYTES + 1);
234 let err = compile_glob_set([pattern]).unwrap_err();
235 assert!(matches!(err, GlobConfigError::TooLong { .. }));
236 }
237
238 #[test]
239 fn test_should_reject_invalid_glob_pattern() {
240 let err = compile_glob_set(["[unterminated"]).unwrap_err();
241 assert!(matches!(err, GlobConfigError::Invalid { .. }));
242 }
243
244 #[test]
245 fn test_builder_applies_overrides() {
246 let opts: DiscoveryOptions = DiscoveryOptions::builder()
247 .max_depth(4_u32)
248 .follow_symlinks(true)
249 .threads(2_u32)
250 .build();
251 assert_eq!(opts.max_depth, 4);
252 assert!(opts.follow_symlinks);
253 assert_eq!(opts.threads, 2);
254 }
255}