Skip to content

Commit beef4e5

Browse files
committed
fix: expand and trim HATCH_DATA_DIR; skip empty configured virtual paths (PR #460)
1 parent 4da16a5 commit beef4e5

1 file changed

Lines changed: 110 additions & 3 deletions

File tree

crates/pet-hatch/src/lib.rs

Lines changed: 110 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -245,8 +245,13 @@ fn get_default_virtual_dir(environment: &dyn Environment) -> Option<PathBuf> {
245245
// platform-default envs to Hatch when the user has redirected Hatch
246246
// elsewhere.
247247
if let Some(custom) = environment.get_env_var("HATCH_DATA_DIR".to_string()) {
248-
if !custom.is_empty() {
249-
return Some(norm_case(append_virtual_subdir(PathBuf::from(custom))));
248+
let trimmed = custom.trim();
249+
if !trimmed.is_empty() {
250+
// Expand ~ / ${HOME} / ${USERNAME} so a value like
251+
// `HATCH_DATA_DIR=~/.local/share/hatch` resolves to the user
252+
// home rather than a literal `~` directory.
253+
let expanded = expand_path(PathBuf::from(trimmed));
254+
return Some(norm_case(append_virtual_subdir(expanded)));
250255
}
251256
}
252257
Some(norm_case(append_virtual_subdir(platform_default_data_dir(
@@ -388,10 +393,17 @@ struct HatchDirs {
388393
fn resolve_project_virtual_dirs(workspace: &Path) -> Vec<PathBuf> {
389394
let mut dirs = Vec::new();
390395
for raw in read_configured_virtual_paths(workspace) {
396+
// Skip empty/whitespace values. Without this, `virtual = ""` would
397+
// resolve to the workspace root and we'd misclassify any venv
398+
// directly under the workspace (e.g. `./.venv`) as Hatch-managed.
399+
let trimmed = raw.trim();
400+
if trimmed.is_empty() {
401+
continue;
402+
}
391403
// Expand ~ and ${HOME}/${USERNAME} so configured values like
392404
// "~/.virtualenvs" resolve to the user home rather than being
393405
// joined onto the workspace as a relative path.
394-
let expanded = expand_path(PathBuf::from(&raw));
406+
let expanded = expand_path(PathBuf::from(trimmed));
395407
let resolved = if expanded.is_absolute() {
396408
expanded
397409
} else {
@@ -999,4 +1011,99 @@ mod tests {
9991011
let expected = norm_case(custom.join("env").join("virtual"));
10001012
assert_eq!(get_default_virtual_dir(&env), Some(expected));
10011013
}
1014+
1015+
#[test]
1016+
fn default_virtual_dir_expands_tilde_in_hatch_data_dir() {
1017+
// A value like `HATCH_DATA_DIR=~/.local/share/hatch` must be
1018+
// expanded against the user's home rather than be treated as a
1019+
// literal `~` directory.
1020+
let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
1021+
1022+
let temp = TempDir::new().unwrap();
1023+
let fake_home = temp.path().join("home");
1024+
fs::create_dir_all(&fake_home).unwrap();
1025+
1026+
let prev_home = std::env::var_os("HOME");
1027+
let prev_user_profile = std::env::var_os("USERPROFILE");
1028+
std::env::set_var("HOME", &fake_home);
1029+
std::env::set_var("USERPROFILE", &fake_home);
1030+
1031+
let mut vars = HashMap::new();
1032+
vars.insert(
1033+
"HATCH_DATA_DIR".to_string(),
1034+
"~/.local/share/hatch".to_string(),
1035+
);
1036+
let env = TestEnv {
1037+
home: Some(fake_home.clone()),
1038+
vars,
1039+
};
1040+
let resolved = get_default_virtual_dir(&env);
1041+
1042+
match prev_home {
1043+
Some(v) => std::env::set_var("HOME", v),
1044+
None => std::env::remove_var("HOME"),
1045+
}
1046+
match prev_user_profile {
1047+
Some(v) => std::env::set_var("USERPROFILE", v),
1048+
None => std::env::remove_var("USERPROFILE"),
1049+
}
1050+
1051+
let expected = norm_case(
1052+
fake_home
1053+
.join(".local")
1054+
.join("share")
1055+
.join("hatch")
1056+
.join("env")
1057+
.join("virtual"),
1058+
);
1059+
assert_eq!(resolved, Some(expected));
1060+
}
1061+
1062+
#[test]
1063+
fn default_virtual_dir_treats_whitespace_hatch_data_dir_as_unset() {
1064+
// Whitespace-only HATCH_DATA_DIR must be treated as unset so we
1065+
// fall back to the platform default rather than resolving to
1066+
// a literal whitespace directory.
1067+
let temp = TempDir::new().unwrap();
1068+
let mut vars = HashMap::new();
1069+
vars.insert("HATCH_DATA_DIR".to_string(), " ".to_string());
1070+
let env = TestEnv {
1071+
home: Some(temp.path().to_path_buf()),
1072+
vars,
1073+
};
1074+
// Should NOT be the literal " /env/virtual"; should resolve via
1075+
// the platform default (or None if home is unavailable).
1076+
let resolved = get_default_virtual_dir(&env);
1077+
if let Some(p) = resolved {
1078+
assert!(!p.to_string_lossy().contains(" "));
1079+
}
1080+
}
1081+
1082+
#[test]
1083+
fn resolve_project_virtual_dirs_skips_empty_value() {
1084+
// `virtual = ""` must not resolve to the workspace root and
1085+
// misclassify unrelated venvs under the workspace as Hatch.
1086+
let temp = TempDir::new().unwrap();
1087+
let project = temp.path().join("proj");
1088+
fs::create_dir_all(&project).unwrap();
1089+
fs::write(
1090+
project.join("pyproject.toml"),
1091+
b"[tool.hatch.dirs.env]\nvirtual = \"\"\n",
1092+
)
1093+
.unwrap();
1094+
assert!(resolve_project_virtual_dirs(&project).is_empty());
1095+
}
1096+
1097+
#[test]
1098+
fn resolve_project_virtual_dirs_skips_whitespace_value() {
1099+
let temp = TempDir::new().unwrap();
1100+
let project = temp.path().join("proj");
1101+
fs::create_dir_all(&project).unwrap();
1102+
fs::write(
1103+
project.join("pyproject.toml"),
1104+
b"[tool.hatch.dirs.env]\nvirtual = \" \"\n",
1105+
)
1106+
.unwrap();
1107+
assert!(resolve_project_virtual_dirs(&project).is_empty());
1108+
}
10021109
}

0 commit comments

Comments
 (0)