Skip to main content

rbt/core/
paths.rs

1//! Project path resolution: relative, absolute, and named multi-root expansion.
2//!
3//! # Roots
4//!
5//! `rbt_project.yml` may declare:
6//!
7//! ```yaml
8//! roots:
9//!   nonprod_lake: /mnt/datalake/acme/nonprod/lake_us/lake
10//!   prod_lake: /mnt/datalake/acme/prod/lake_us/lake
11//! layers:
12//!   staging:
13//!     target_path: $nonprod_lake/silver/stage
14//! ```
15//!
16//! Templates use `$name` or `${name}`. Expansion happens before absolute/relative join.
17//! Absolute paths (after expansion) are used as-is — they never hang under `project_dir`.
18//!
19//! # Path globs
20//!
21//! Bronze `path_glob` uses [`globset`](https://docs.rs/globset) with **literal path
22//! separators**: `*` / `?` never cross `/`; use `**` for recursive directory match
23//! (gitignore-style). When any `path_glob` is set, bronze registration **always** uses
24//! the scan→MemTable path so filters apply; DataFusion listing pushdown is disabled
25//! for that source.
26
27use anyhow::{bail, Context, Result};
28use globset::{GlobBuilder, GlobSet, GlobSetBuilder};
29use std::collections::HashMap;
30use std::path::{Path, PathBuf};
31
32/// Expand `$root` / `${root}` placeholders, then resolve against `project_dir`.
33///
34/// * Remote URIs (`s3://`, …) are returned as [`PathBuf`] of the raw string (unchecked).
35/// * Absolute local paths are returned unchanged (after expansion).
36/// * Relative paths join to `project_dir`.
37pub 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
67/// Same as [`resolve_project_path`] for [`Path`] configs (e.g. layer `target_path`).
68pub 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
86/// Replace `${name}` / `$name` with values from `roots`.
87///
88/// Unknown `$identifiers` that are not in `roots` error with `E_RBT_ROOT_UNKNOWN`.
89pub 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                // ${name}
100                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; // past }
116                    continue;
117                }
118                bail!(
119                    "E_RBT_ROOT_TEMPLATE: unclosed '${{...' in path '{input}'. \
120                     Expected `${{root_name}}`."
121                );
122            }
123            // $name — identifier chars
124            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                // lone `$` or `$/` — keep literally
131                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/// Compiled set of path globs (gitignore-style `**` via globset).
179///
180/// # Semantics
181///
182/// Patterns are compiled with **literal path separators** (`GlobBuilder::literal_separator(true)`):
183/// - `*` and `?` match within a single path segment only
184/// - `**` matches across directories (including zero segments)
185/// - character classes (`[abc]`) work as in globset/gitignore
186///
187/// Empty pattern list matches **everything** (no filter).
188///
189/// # Match candidates
190///
191/// A file matches if **any** pattern matches **any** applicable candidate (OR/OR):
192///
193/// 1. **Relative path** — path of `file` relative to `scan_root`, POSIX `/` separators
194///    (always tried).
195/// 2. **Basename** — file name only, tried when the set contains at least one pattern
196///    with **no** `/` (e.g. `crawlplan.parquet`), so bare filenames match at any depth.
197/// 3. **Absolute path** — full path with `/` separators, tried when the set contains at
198///    least one pattern starting with `/`.
199///
200/// Path-shaped patterns (`*/…`, `**/…`) never fall back to basename-only matching in a
201/// way that would let a shallow `*` over-match deep trees — `*` cannot cross `/`.
202#[derive(Debug, Clone)]
203pub struct PathGlobSet {
204    set: GlobSet,
205    /// True when any pattern has no `/` (basename-style, e.g. `crawlplan.parquet`).
206    match_basename: bool,
207    /// True when any pattern starts with `/` (absolute path match).
208    match_absolute: bool,
209    /// Original patterns (for diagnostics).
210    pub patterns: Vec<String>,
211}
212
213impl PathGlobSet {
214    /// Compile patterns. Empty input matches everything.
215    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            // literal_separator: `*` / `?` never match `/` — only `**` is recursive.
245            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    /// Match `file` under `scan_root` against any compiled pattern (OR).
275    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
304/// Match a file path against one or more globs relative to `scan_root` (or basename).
305pub 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
312/// Compile-time validation of glob patterns (fail fast with clear codes).
313pub 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        // single-segment * does not cross / (literal_separator)
397        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        // double-star still reaches deep trees
407        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}