1use anyhow::{bail, Context, Result};
28use globset::{GlobBuilder, GlobSet, GlobSetBuilder};
29use std::collections::HashMap;
30use std::path::{Path, PathBuf};
31
32pub fn resolve_project_path(
38 project_dir: &Path,
39 configured: &str,
40 roots: &HashMap<String, String>,
41) -> Result<PathBuf> {
42 let raw = configured.trim();
43 if raw.is_empty() {
44 return Ok(project_dir.to_path_buf());
45 }
46 let expanded = expand_roots(raw, roots).with_context(|| {
47 format!(
48 "E_RBT_PATH_EXPAND: failed expanding path template '{raw}' \
49 (project_dir={})",
50 project_dir.display()
51 )
52 })?;
53 if expanded.is_empty() {
54 return Ok(project_dir.to_path_buf());
55 }
56 if is_remote_uri(&expanded) {
57 return Ok(PathBuf::from(expanded));
58 }
59 let p = Path::new(&expanded);
60 if p.is_absolute() {
61 Ok(p.to_path_buf())
62 } else {
63 Ok(project_dir.join(p))
64 }
65}
66
67pub fn resolve_configured_path(
69 project_dir: &Path,
70 configured: &Path,
71 roots: &HashMap<String, String>,
72) -> Result<PathBuf> {
73 let s = configured.to_string_lossy();
74 resolve_project_path(project_dir, &s, roots).with_context(|| {
75 format!(
76 "E_RBT_LAYER_PATH: failed resolving configured path '{}' \
77 (project_dir={}). Hint: use an absolute path, a path relative to the \
78 project, or a `$root` / `${{root}}` template defined under `roots:` in \
79 rbt_project.yml.",
80 configured.display(),
81 project_dir.display()
82 )
83 })
84}
85
86pub fn expand_roots(input: &str, roots: &HashMap<String, String>) -> Result<String> {
90 if !input.contains('$') {
91 return Ok(input.to_string());
92 }
93 let mut out = String::with_capacity(input.len());
94 let chars: Vec<char> = input.chars().collect();
95 let mut i = 0;
96 while i < chars.len() {
97 if chars[i] == '$' {
98 if i + 1 < chars.len() && chars[i + 1] == '{' {
99 if let Some(end) = chars[i + 2..].iter().position(|&c| c == '}') {
101 let name: String = chars[i + 2..i + 2 + end].iter().collect();
102 if name.is_empty() {
103 bail!("E_RBT_ROOT_TEMPLATE: empty root name '${{}}' in path '{input}'");
104 }
105 let val = roots.get(&name).ok_or_else(|| {
106 anyhow::anyhow!(
107 "E_RBT_ROOT_UNKNOWN: path template references unknown root \
108 '${{{name}}}'. Known roots: {}. \
109 Define it under `roots:` in rbt_project.yml, e.g. \
110 `roots: {{ {name}: /absolute/lake/path }}`.",
111 root_keys(roots),
112 )
113 })?;
114 out.push_str(val);
115 i = i + 3 + end; continue;
117 }
118 bail!(
119 "E_RBT_ROOT_TEMPLATE: unclosed '${{...' in path '{input}'. \
120 Expected `${{root_name}}`."
121 );
122 }
123 let start = i + 1;
125 let mut j = start;
126 while j < chars.len() && (chars[j].is_ascii_alphanumeric() || chars[j] == '_') {
127 j += 1;
128 }
129 if j == start {
130 out.push('$');
132 i += 1;
133 continue;
134 }
135 let name: String = chars[start..j].iter().collect();
136 let val = roots.get(&name).ok_or_else(|| {
137 anyhow::anyhow!(
138 "E_RBT_ROOT_UNKNOWN: path template references unknown root \
139 '${name}'. Known roots: {}. \
140 Define it under `roots:` in rbt_project.yml.",
141 root_keys(roots),
142 )
143 })?;
144 out.push_str(val);
145 i = j;
146 continue;
147 }
148 out.push(chars[i]);
149 i += 1;
150 }
151 Ok(out)
152}
153
154fn root_keys(roots: &HashMap<String, String>) -> String {
155 let mut keys: Vec<_> = roots.keys().cloned().collect();
156 keys.sort();
157 if keys.is_empty() {
158 "(none — define `roots:` in rbt_project.yml)".into()
159 } else {
160 keys.join(", ")
161 }
162}
163
164pub fn is_remote_uri(path: &str) -> bool {
165 let lower = path.to_ascii_lowercase();
166 lower.starts_with("s3://")
167 || lower.starts_with("s3a://")
168 || lower.starts_with("gs://")
169 || lower.starts_with("gcs://")
170 || lower.starts_with("az://")
171 || lower.starts_with("abfs://")
172 || lower.starts_with("abfss://")
173 || lower.starts_with("http://")
174 || lower.starts_with("https://")
175 || lower.starts_with("file://")
176}
177
178#[derive(Debug, Clone)]
203pub struct PathGlobSet {
204 set: GlobSet,
205 match_basename: bool,
207 match_absolute: bool,
209 pub patterns: Vec<String>,
211}
212
213impl PathGlobSet {
214 pub fn compile(globs: &[String]) -> Result<Self> {
216 if globs.is_empty() {
217 return Ok(Self {
218 set: GlobSet::empty(),
219 match_basename: false,
220 match_absolute: false,
221 patterns: Vec::new(),
222 });
223 }
224 let mut builder = GlobSetBuilder::new();
225 let mut match_basename = false;
226 let mut match_absolute = false;
227 for (i, g) in globs.iter().enumerate() {
228 let trimmed = g.trim();
229 if trimmed.is_empty() {
230 bail!(
231 "E_RBT_PATH_GLOB_INVALID: path_glob[{i}] is empty. \
232 Use a filename (e.g. `crawlplan.parquet`) or a relative pattern \
233 (e.g. `**/raw_snoop/crawlplan.parquet`). \
234 Hint: `*` does not cross `/`; use `**` for recursive match."
235 );
236 }
237 if trimmed.contains('/') {
238 if trimmed.starts_with('/') {
239 match_absolute = true;
240 }
241 } else {
242 match_basename = true;
243 }
244 let glob = GlobBuilder::new(trimmed)
246 .literal_separator(true)
247 .build()
248 .map_err(|e| {
249 anyhow::anyhow!(
250 "E_RBT_PATH_GLOB_INVALID: path_glob[{i}] pattern '{trimmed}' is not valid: {e}. \
251 Hint: use globset/gitignore syntax with literal separators \
252 (`*` single segment, `**` recursive, `?`, `[abc]`)."
253 )
254 })?;
255 builder.add(glob);
256 }
257 let set = builder.build().map_err(|e| {
258 anyhow::anyhow!(
259 "E_RBT_PATH_GLOB_INVALID: failed building glob set from patterns {globs:?}: {e}"
260 )
261 })?;
262 Ok(Self {
263 set,
264 match_basename,
265 match_absolute,
266 patterns: globs.iter().map(|s| s.trim().to_string()).collect(),
267 })
268 }
269
270 pub fn is_empty(&self) -> bool {
271 self.patterns.is_empty()
272 }
273
274 pub fn matches(&self, file: &Path, scan_root: &Path) -> bool {
276 if self.patterns.is_empty() {
277 return true;
278 }
279 let rel = file
280 .strip_prefix(scan_root)
281 .unwrap_or(file)
282 .to_string_lossy()
283 .replace('\\', "/");
284 if self.set.is_match(rel.as_str()) {
285 return true;
286 }
287 if self.match_basename {
288 if let Some(name) = file.file_name().and_then(|n| n.to_str()) {
289 if self.set.is_match(name) {
290 return true;
291 }
292 }
293 }
294 if self.match_absolute {
295 let abs = file.to_string_lossy().replace('\\', "/");
296 if self.set.is_match(abs.as_str()) {
297 return true;
298 }
299 }
300 false
301 }
302}
303
304pub fn path_matches_globs(file: &Path, scan_root: &Path, globs: &[String]) -> bool {
306 match PathGlobSet::compile(globs) {
307 Ok(set) => set.matches(file, scan_root),
308 Err(_) => false,
309 }
310}
311
312pub fn validate_glob_patterns(globs: &[String]) -> Result<()> {
314 PathGlobSet::compile(globs).map(|_| ())
315}
316
317#[cfg(test)]
318mod tests {
319 use super::*;
320 use std::collections::HashMap;
321
322 #[test]
323 fn absolute_path_not_joined_under_project() {
324 let roots = HashMap::new();
325 let p = resolve_project_path(
326 Path::new("/home/proj"),
327 "/mnt/datalake/acme/nonprod/lake_us/lake/silver",
328 &roots,
329 )
330 .unwrap();
331 assert_eq!(
332 p,
333 PathBuf::from("/mnt/datalake/acme/nonprod/lake_us/lake/silver")
334 );
335 }
336
337 #[test]
338 fn relative_path_joins_project() {
339 let roots = HashMap::new();
340 let p = resolve_project_path(Path::new("/home/proj"), "lake/silver", &roots).unwrap();
341 assert_eq!(p, PathBuf::from("/home/proj/lake/silver"));
342 }
343
344 #[test]
345 fn root_template_dollar_and_braces() {
346 let mut roots = HashMap::new();
347 roots.insert(
348 "nonprod_lake".into(),
349 "/mnt/datalake/acme/nonprod/lake_us/lake".into(),
350 );
351 let a = expand_roots("$nonprod_lake/lz/runs", &roots).unwrap();
352 assert_eq!(a, "/mnt/datalake/acme/nonprod/lake_us/lake/lz/runs");
353 let b = expand_roots("${nonprod_lake}/silver/stage", &roots).unwrap();
354 assert_eq!(b, "/mnt/datalake/acme/nonprod/lake_us/lake/silver/stage");
355 }
356
357 #[test]
358 fn unknown_root_errors_with_hint() {
359 let roots = HashMap::new();
360 let err = expand_roots("$missing/x", &roots).unwrap_err().to_string();
361 assert!(err.contains("E_RBT_ROOT_UNKNOWN"));
362 assert!(err.contains("roots:"));
363 }
364
365 #[test]
366 fn path_glob_double_star_and_basename() {
367 let root = Path::new("/lake/lz/runs");
368 let file = Path::new(
369 "/lake/lz/runs/domain=x.com/report_date=2026-07-29/run_id=r1/raw_snoop/crawlplan.parquet",
370 );
371 let set = PathGlobSet::compile(&["**/crawlplan.parquet".into()]).unwrap();
372 assert!(set.matches(file, root));
373 let set2 = PathGlobSet::compile(&["crawlplan.parquet".into()]).unwrap();
374 assert!(set2.matches(file, root));
375 let set3 = PathGlobSet::compile(&["**/enriched_scrape.parquet".into()]).unwrap();
376 assert!(!set3.matches(file, root));
377 assert!(PathGlobSet::compile(&[]).unwrap().matches(file, root));
378 }
379
380 #[test]
381 fn path_glob_or_semantics() {
382 let root = Path::new("/lake");
383 let a = Path::new("/lake/a/foo.parquet");
384 let b = Path::new("/lake/b/bar.parquet");
385 let set =
386 PathGlobSet::compile(&["**/foo.parquet".into(), "**/bar.parquet".into()]).unwrap();
387 assert!(set.matches(a, root));
388 assert!(set.matches(b, root));
389 }
390
391 #[test]
392 fn path_glob_nested_single_star() {
393 let root = Path::new("/lake/lz");
394 let file =
395 Path::new("/lake/lz/domain=x/report_date=d/run_id=r/raw_snoop/crawlplan.parquet");
396 let shallow = PathGlobSet::compile(&["*/crawlplan.parquet".into()]).unwrap();
398 assert!(
399 !shallow.matches(file, root),
400 "*/crawlplan.parquet must not match a 5-segment relative path"
401 );
402 let one_level = Path::new("/lake/lz/raw_snoop/crawlplan.parquet");
403 assert!(shallow.matches(one_level, root));
404 let deep = PathGlobSet::compile(&["*/*/*/*/crawlplan.parquet".into()]).unwrap();
405 assert!(deep.matches(file, root));
406 let any = PathGlobSet::compile(&["**/crawlplan.parquet".into()]).unwrap();
408 assert!(any.matches(file, root));
409 }
410
411 #[test]
412 fn path_glob_basename_only_matches_any_depth() {
413 let root = Path::new("/lake/lz");
414 let deep = Path::new("/lake/lz/a/b/c/crawlplan.parquet");
415 let set = PathGlobSet::compile(&["crawlplan.parquet".into()]).unwrap();
416 assert!(set.matches(deep, root));
417 assert!(!PathGlobSet::compile(&["other.parquet".into()])
418 .unwrap()
419 .matches(deep, root));
420 }
421
422 #[test]
423 fn path_glob_absolute_pattern() {
424 let root = Path::new("/lake");
425 let file = Path::new("/mnt/datalake/acme/lz/runs/x.pb");
426 let set = PathGlobSet::compile(&["/mnt/datalake/**/*.pb".into()]).unwrap();
427 assert!(set.matches(file, root));
428 assert!(!PathGlobSet::compile(&["/other/**/*.pb".into()])
429 .unwrap()
430 .matches(file, root));
431 }
432
433 #[test]
434 fn invalid_glob_rejected() {
435 assert!(validate_glob_patterns(&["**/ok.parquet".into()]).is_ok());
436 assert!(validate_glob_patterns(&["file[".into()]).is_err());
437 assert!(validate_glob_patterns(&["".into()]).is_err());
438 }
439}