Skip to main content

dre_core/
target.rs

1//! The target path: the folder DRE writes its generated files to (compiled SQL, run outputs,
2//! schema snapshots, `run_results.json`, the manifest). `target/` in the project root unless
3//! set, highest first, by `--target-path`, `DRE_TARGET_PATH` or `target_path:` in
4//! `dre_project.yml`. It's always a local (or mounted) path, never inside the project's sources.
5//!
6//! Unrelated to a profile's `target` (the environment a connection uses).
7
8use std::fmt;
9use std::path::{Component, Path, PathBuf};
10
11use crate::lookups::LOOKUPS_DIR;
12use crate::project::{MACROS_DIR, PROJECT_FILE, REPORTS_DIR, TARGET_DIR};
13
14pub const ENV: &str = "DRE_TARGET_PATH";
15/// The project-file key.
16pub const KEY: &str = "target_path";
17/// Dropped in every target folder DRE creates, so `dre clean` only deletes DRE's own folders.
18pub const MARKER: &str = ".dre_target";
19
20/// Where the target path was set.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum Source {
23    Flag,
24    Env,
25    Project,
26    Default,
27}
28
29impl fmt::Display for Source {
30    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
31        f.write_str(match self {
32            Source::Flag => "--target-path",
33            Source::Env => ENV,
34            Source::Project => "`target_path` in dre_project.yml",
35            Source::Default => "the default target path",
36        })
37    }
38}
39
40/// A resolved target path.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct TargetPath {
43    /// Absolute, or the root joined with the relative value.
44    pub dir: PathBuf,
45    pub source: Source,
46}
47
48impl TargetPath {
49    pub fn default_for(root: &Path) -> TargetPath {
50        TargetPath {
51            dir: root.join(TARGET_DIR),
52            source: Source::Default,
53        }
54    }
55}
56
57/// Resolve the target path for the project at `root`: `flag`, else `DRE_TARGET_PATH`, else the
58/// project file's `target_path` (`project_value`), else `target/`. Checks that it's local and
59/// clear of the project's sources.
60pub fn resolve(root: &Path, flag: Option<&str>, project_value: Option<&str>) -> Result<TargetPath, String> {
61    let env = std::env::var(ENV).ok();
62    let (value, source) = match (flag, env.as_deref(), project_value) {
63        (Some(v), _, _) => (v, Source::Flag),
64        (None, Some(v), _) if !v.is_empty() => (v, Source::Env),
65        (None, _, Some(v)) => (v, Source::Project),
66        _ => return Ok(TargetPath::default_for(root)),
67    };
68    let fail = |why: String| format!("{source}: target path `{value}` {why}");
69    if value.trim().is_empty() {
70        return Err(fail("is empty".into()));
71    }
72    if looks_like_url(value) {
73        return Err(fail(
74            "is a URL; the target path must be a local or mounted path (a Databricks Volume under /Volumes/..., an NFS/EFS share, a gcsfuse mount). To copy outputs to object storage, deliver them with a destination".into(),
75        ));
76    }
77    let expanded = expand_home(value);
78    let dir = clean(&if expanded.is_absolute() {
79        expanded
80    } else {
81        root.join(expanded)
82    });
83    check(root, &dir).map_err(fail)?;
84    Ok(TargetPath { dir, source })
85}
86
87/// `scheme://…`, but not a Windows drive (`C:\`, `C:/`).
88fn looks_like_url(v: &str) -> bool {
89    let Some((scheme, _)) = v.split_once("://") else {
90        return false;
91    };
92    scheme.len() > 1
93        && scheme.starts_with(|c: char| c.is_ascii_alphabetic())
94        && scheme
95            .chars()
96            .all(|c| c.is_ascii_alphanumeric() || "+-.".contains(c))
97}
98
99fn expand_home(v: &str) -> PathBuf {
100    let home = || std::env::home_dir().unwrap_or_default();
101    if v == "~" {
102        return home();
103    }
104    match v.strip_prefix("~/").or_else(|| v.strip_prefix("~\\")) {
105        Some(rest) => home().join(rest),
106        None => PathBuf::from(v),
107    }
108}
109
110/// `p` with `.` and `..` removed lexically.
111fn clean(p: &Path) -> PathBuf {
112    let mut out = PathBuf::new();
113    for c in p.components() {
114        match c {
115            Component::CurDir => {}
116            Component::ParentDir => {
117                if !out.pop() {
118                    out.push(c);
119                }
120            }
121            c => out.push(c),
122        }
123    }
124    out
125}
126
127/// An absolute, symlink-resolved form of `p` for comparisons, whether or not it exists yet: the
128/// deepest existing ancestor is canonicalised and the rest appended.
129fn canonical(p: &Path) -> PathBuf {
130    let abs = clean(&std::path::absolute(p).unwrap_or_else(|_| p.to_path_buf()));
131    let mut existing = abs.as_path();
132    let mut rest = Vec::new();
133    loop {
134        if let Ok(c) = existing.canonicalize() {
135            let mut out = strip_verbatim(c);
136            for r in rest.iter().rev() {
137                out.push(r);
138            }
139            return out;
140        }
141        match (existing.parent(), existing.file_name()) {
142            (Some(parent), Some(name)) => {
143                rest.push(name.to_os_string());
144                existing = parent;
145            }
146            _ => return abs,
147        }
148    }
149}
150
151/// Windows' `canonicalize` returns `\\?\C:\…`; compare without the prefix.
152fn strip_verbatim(p: PathBuf) -> PathBuf {
153    let s = p.to_string_lossy();
154    match s.strip_prefix(r"\\?\UNC\") {
155        Some(rest) => PathBuf::from(format!(r"\\{rest}")),
156        None => match s.strip_prefix(r"\\?\") {
157            Some(rest) => PathBuf::from(rest),
158            None => p,
159        },
160    }
161}
162
163/// Refuse the project root, a folder containing it, and anything inside the project's sources
164/// or its plugins.
165fn check(root: &Path, dir: &Path) -> Result<(), String> {
166    let (root, dir) = (canonical(root), canonical(dir));
167    if dir == root {
168        return Err("is the project root; generated files can't mix with the project's sources".into());
169    }
170    if root.starts_with(&dir) {
171        return Err(
172            "contains the project; pick a folder outside it, or one inside it such as `target`".into(),
173        );
174    }
175    for inner in [REPORTS_DIR, MACROS_DIR, LOOKUPS_DIR, crate::plugins::DEPS_DIR] {
176        if dir.starts_with(root.join(inner)) {
177            return Err(format!(
178                "is inside `{inner}/`; generated files there would be read as project files"
179            ));
180        }
181    }
182    Ok(())
183}
184
185/// The target folder relative to the root when it's inside the project (so project scanning
186/// can skip it), comparing resolved paths.
187pub fn inside(root: &Path, dir: &Path) -> Option<PathBuf> {
188    canonical(dir)
189        .strip_prefix(canonical(root))
190        .ok()
191        .map(Path::to_path_buf)
192}
193
194/// `target_path:` from `dre_project.yml`, read on its own (for `dre clean`, which doesn't load
195/// the project). A missing or unreadable file, or no key, is `None`.
196pub fn project_value(root: &Path) -> Option<String> {
197    let text = std::fs::read_to_string(root.join(PROJECT_FILE)).ok()?;
198    let v: serde_yaml_ng::Value = serde_yaml_ng::from_str(&text).ok()?;
199    v.get(KEY)?.as_str().map(str::to_string)
200}
201
202/// Create the target folder (with its parents) if needed. Only a folder DRE creates gets the
203/// ownership marker that lets `dre clean` delete it.
204pub fn ensure(dir: &Path) -> std::io::Result<()> {
205    let fresh = !dir.exists();
206    std::fs::create_dir_all(dir)?;
207    if fresh {
208        std::fs::write(
209            dir.join(MARKER),
210            "DRE writes its generated files here; `dre clean` may delete this folder.\n",
211        )?;
212    }
213    Ok(())
214}
215
216/// What `dre clean` did.
217#[derive(Debug, PartialEq, Eq)]
218pub enum Cleaned {
219    Removed,
220    Missing,
221}
222
223/// `dre clean`: delete the target folder, but only one DRE made (it has the marker) or the
224/// default `target/` of the project (which may predate the marker).
225pub fn clean_dir(root: &Path, t: &TargetPath) -> Result<Cleaned, String> {
226    if !t.dir.exists() {
227        return Ok(Cleaned::Missing);
228    }
229    let default = canonical(&t.dir) == canonical(&root.join(TARGET_DIR));
230    if !default && !t.dir.join(MARKER).is_file() {
231        return Err(format!(
232            "refusing to delete {} (from {}): DRE didn't create it (it has no {MARKER} file). Check the path, or delete the folder yourself",
233            t.dir.display(),
234            t.source
235        ));
236    }
237    std::fs::remove_dir_all(&t.dir).map_err(|e| format!("can't remove {}: {e}", t.dir.display()))?;
238    Ok(Cleaned::Removed)
239}