1use 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";
15pub const KEY: &str = "target_path";
17pub const MARKER: &str = ".dre_target";
19
20#[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#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct TargetPath {
43 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
57pub 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
87fn 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
110fn 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
127fn 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
151fn 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
163fn 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
185pub 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
194pub 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
202pub 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#[derive(Debug, PartialEq, Eq)]
218pub enum Cleaned {
219 Removed,
220 Missing,
221}
222
223pub 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}