1use anyhow::{Result, bail};
2use std::collections::HashSet;
3use std::ffi::OsString;
4use std::fmt;
5use std::fs::OpenOptions;
6use std::io::Write;
7use std::path::{Path, PathBuf};
8use std::sync::atomic::{AtomicU64, Ordering};
9
10const HUB_CACHE_ENV: &str = "HF_HUB_CACHE";
11const HUB_CACHE_ALIAS_ENV: &str = "HUGGINGFACE_HUB_CACHE";
12const HF_HOME_ENV: &str = "HF_HOME";
13const XET_CACHE_ENV: &str = "HF_XET_CACHE";
14const XDG_CACHE_HOME_ENV: &str = "XDG_CACHE_HOME";
15const MESH_LLM_DATA_DIR_ENV: &str = "MESH_LLM_DATA_DIR";
16const WRITE_PROBE_PREFIX: &str = ".mesh-llm-write-probe";
17
18static WRITE_PROBE_SEQUENCE: AtomicU64 = AtomicU64::new(0);
19
20#[derive(Clone, Copy, Debug, Eq, PartialEq)]
21pub enum DownloadDirectoryKind {
22 HuggingFaceHub,
23 HuggingFaceXet,
24}
25
26impl fmt::Display for DownloadDirectoryKind {
27 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
28 match self {
29 Self::HuggingFaceHub => formatter.write_str("Hugging Face Hub cache"),
30 Self::HuggingFaceXet => formatter.write_str("Hugging Face Xet cache"),
31 }
32 }
33}
34
35#[derive(Clone, Debug, Eq, PartialEq)]
36pub struct DownloadDirectoryFallback {
37 pub kind: DownloadDirectoryKind,
38 pub requested: PathBuf,
39 pub selected: PathBuf,
40 pub error: String,
41 pub raw_os_error: Option<i32>,
42}
43
44impl fmt::Display for DownloadDirectoryFallback {
45 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
46 write!(
47 formatter,
48 "{} {} is not writable ({}); using {}. Set {} or {} to choose a different writable location.",
49 self.kind,
50 self.requested.display(),
51 self.error,
52 self.selected.display(),
53 match self.kind {
54 DownloadDirectoryKind::HuggingFaceHub => HUB_CACHE_ENV,
55 DownloadDirectoryKind::HuggingFaceXet => XET_CACHE_ENV,
56 },
57 MESH_LLM_DATA_DIR_ENV,
58 )
59 }
60}
61
62#[derive(Clone, Debug, Eq, PartialEq)]
63pub struct PreparedDownloadDirectories {
64 pub hub_cache: PathBuf,
65 pub xet_cache: PathBuf,
66 pub fallbacks: Vec<DownloadDirectoryFallback>,
67}
68
69impl PreparedDownloadDirectories {
70 pub unsafe fn apply_to_process_environment(&self) {
78 unsafe {
80 std::env::set_var(HUB_CACHE_ENV, &self.hub_cache);
81 std::env::set_var(XET_CACHE_ENV, &self.xet_cache);
82 }
83 }
84}
85
86pub fn huggingface_hub_cache_dir() -> PathBuf {
87 requested_hub_cache_dir()
88}
89
90pub fn huggingface_xet_cache_dir() -> PathBuf {
91 requested_xet_cache_dir()
92}
93
94pub fn mesh_llm_cache_dir() -> PathBuf {
95 dirs::cache_dir()
96 .or_else(|| dirs::home_dir().map(|home| home.join(".cache")))
97 .unwrap_or_else(|| std::env::temp_dir().join("mesh-llm-cache"))
98 .join("mesh-llm")
99}
100
101pub fn prepare_download_directories() -> Result<PreparedDownloadDirectories> {
102 prepare_download_directories_in(
103 requested_hub_cache_dir(),
104 requested_xet_cache_dir(),
105 fallback_data_roots(),
106 )
107}
108
109pub fn download_cache_diagnostic() -> String {
110 download_cache_diagnostic_for(&huggingface_hub_cache_dir())
111}
112
113pub fn download_cache_diagnostic_for(hub_cache: &Path) -> String {
114 format!(
115 "Hub cache: {}; Xet cache: {}. Ensure both paths are writable, or set {} to a writable application-data directory",
116 hub_cache.display(),
117 huggingface_xet_cache_dir().display(),
118 MESH_LLM_DATA_DIR_ENV,
119 )
120}
121
122fn requested_hub_cache_dir() -> PathBuf {
123 env_path(HUB_CACHE_ENV)
124 .or_else(|| env_path(HUB_CACHE_ALIAS_ENV))
125 .or_else(|| env_path(HF_HOME_ENV).map(|path| path.join("hub")))
126 .or_else(|| env_path(XDG_CACHE_HOME_ENV).map(|path| path.join("huggingface").join("hub")))
127 .or_else(|| dirs::cache_dir().map(|path| path.join("huggingface").join("hub")))
128 .unwrap_or_else(|| default_data_root().join("huggingface").join("hub"))
129}
130
131fn requested_xet_cache_dir() -> PathBuf {
132 env_path(XET_CACHE_ENV)
133 .or_else(|| env_path(HF_HOME_ENV).map(|path| path.join("xet")))
134 .or_else(|| env_path(XDG_CACHE_HOME_ENV).map(|path| path.join("huggingface").join("xet")))
135 .or_else(|| dirs::cache_dir().map(|path| path.join("huggingface").join("xet")))
136 .unwrap_or_else(|| default_data_root().join("huggingface").join("xet"))
137}
138
139fn env_path(key: &str) -> Option<PathBuf> {
140 let value = std::env::var_os(key)?;
141 nonempty_path(value)
142}
143
144fn nonempty_path(value: OsString) -> Option<PathBuf> {
145 if value.to_str().is_some_and(|value| value.trim().is_empty()) {
146 return None;
147 }
148 let path = PathBuf::from(value);
149 (!path.as_os_str().is_empty()).then_some(path)
150}
151
152fn default_data_root() -> PathBuf {
153 env_path(MESH_LLM_DATA_DIR_ENV)
154 .or_else(|| dirs::data_local_dir().map(|path| path.join("mesh-llm")))
155 .or_else(|| dirs::home_dir().map(|path| path.join(".mesh-llm").join("data")))
156 .unwrap_or_else(|| std::env::temp_dir().join("mesh-llm-data"))
157}
158
159fn fallback_data_roots() -> Vec<PathBuf> {
160 let mut roots = Vec::new();
161 if let Some(path) = env_path(MESH_LLM_DATA_DIR_ENV) {
162 roots.push(path);
163 }
164 if let Some(path) = dirs::data_local_dir() {
165 roots.push(path.join("mesh-llm"));
166 }
167 if let Some(path) = dirs::home_dir() {
168 roots.push(path.join(".mesh-llm").join("data"));
169 }
170 roots.push(std::env::temp_dir().join("mesh-llm-data"));
171 deduplicate_paths(roots)
172}
173
174fn deduplicate_paths(paths: Vec<PathBuf>) -> Vec<PathBuf> {
175 let mut seen = HashSet::new();
176 paths
177 .into_iter()
178 .filter(|path| seen.insert(path.clone()))
179 .collect()
180}
181
182fn prepare_download_directories_in(
183 requested_hub: PathBuf,
184 requested_xet: PathBuf,
185 fallback_roots: Vec<PathBuf>,
186) -> Result<PreparedDownloadDirectories> {
187 let (hub_cache, hub_fallback) = prepare_directory(
188 DownloadDirectoryKind::HuggingFaceHub,
189 requested_hub,
190 fallback_roots
191 .iter()
192 .map(|root| root.join("huggingface").join("hub")),
193 )?;
194 let (xet_cache, xet_fallback) = prepare_directory(
195 DownloadDirectoryKind::HuggingFaceXet,
196 requested_xet,
197 fallback_roots
198 .iter()
199 .map(|root| root.join("huggingface").join("xet")),
200 )?;
201 let fallbacks = hub_fallback.into_iter().chain(xet_fallback).collect();
202 Ok(PreparedDownloadDirectories {
203 hub_cache,
204 xet_cache,
205 fallbacks,
206 })
207}
208
209fn prepare_directory(
210 kind: DownloadDirectoryKind,
211 requested: PathBuf,
212 fallbacks: impl IntoIterator<Item = PathBuf>,
213) -> Result<(PathBuf, Option<DownloadDirectoryFallback>)> {
214 let requested_error = match probe_writable_directory(&requested) {
215 Ok(()) => return Ok((requested, None)),
216 Err(error) => error,
217 };
218 let mut attempts = vec![format!("{}: {}", requested.display(), requested_error)];
219 for fallback in deduplicate_paths(fallbacks.into_iter().collect()) {
220 if fallback == requested {
221 continue;
222 }
223 match probe_writable_directory(&fallback) {
224 Ok(()) => {
225 return Ok((
226 fallback.clone(),
227 Some(DownloadDirectoryFallback {
228 kind,
229 requested,
230 selected: fallback,
231 error: requested_error.to_string(),
232 raw_os_error: requested_error.raw_os_error(),
233 }),
234 ));
235 }
236 Err(error) => attempts.push(format!("{}: {}", fallback.display(), error)),
237 }
238 }
239 bail!(
240 "no writable {kind} directory was available; tried {}",
241 attempts.join("; ")
242 )
243}
244
245fn probe_writable_directory(path: &Path) -> std::io::Result<()> {
246 std::fs::create_dir_all(path)?;
247 let sequence = WRITE_PROBE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
248 let probe = path.join(format!(
249 "{WRITE_PROBE_PREFIX}-{}-{sequence}",
250 std::process::id()
251 ));
252 let mut file = OpenOptions::new()
253 .write(true)
254 .create_new(true)
255 .open(&probe)?;
256 file.write_all(b"mesh-llm")?;
257 drop(file);
258 std::fs::remove_file(&probe)
259}
260
261#[cfg(test)]
262mod tests {
263 use super::*;
264
265 #[test]
266 fn writable_requested_directories_are_preserved() {
267 let temp = tempfile::tempdir().unwrap();
268 let hub = temp.path().join("requested-hub");
269 let xet = temp.path().join("requested-xet");
270
271 let prepared = prepare_download_directories_in(
272 hub.clone(),
273 xet.clone(),
274 vec![temp.path().join("fallback")],
275 )
276 .unwrap();
277
278 assert_eq!(prepared.hub_cache, hub);
279 assert_eq!(prepared.xet_cache, xet);
280 assert!(prepared.fallbacks.is_empty());
281 }
282
283 #[test]
284 fn non_directory_cache_path_uses_writable_data_fallback() {
285 let temp = tempfile::tempdir().unwrap();
286 let blocked_parent = temp.path().join("not-a-directory");
287 std::fs::write(&blocked_parent, b"file").unwrap();
288 let fallback = temp.path().join("data");
289
290 let prepared = prepare_download_directories_in(
291 blocked_parent.join("hub"),
292 blocked_parent.join("xet"),
293 vec![fallback.clone()],
294 )
295 .unwrap();
296
297 assert_eq!(prepared.hub_cache, fallback.join("huggingface").join("hub"));
298 assert_eq!(prepared.xet_cache, fallback.join("huggingface").join("xet"));
299 assert_eq!(prepared.fallbacks.len(), 2);
300 assert!(
301 prepared
302 .fallbacks
303 .iter()
304 .all(|warning| !warning.error.is_empty())
305 );
306 }
307
308 #[cfg(any(target_os = "linux", target_os = "macos"))]
309 #[test]
310 fn operating_system_read_only_directory_uses_writable_fallback() {
311 let temp = tempfile::tempdir().unwrap();
312 let read_only_root = if cfg!(target_os = "macos") {
313 PathBuf::from("/System")
314 } else {
315 PathBuf::from("/sys")
316 };
317 let requested_hub = read_only_root.join("mesh-llm-issue-980").join("hub");
318 let writable_xet = temp.path().join("xet");
319
320 let prepared = prepare_download_directories_in(
321 requested_hub.clone(),
322 writable_xet.clone(),
323 vec![temp.path().join("data")],
324 )
325 .unwrap();
326
327 assert_ne!(prepared.hub_cache, requested_hub);
328 assert_eq!(prepared.xet_cache, writable_xet);
329 assert_eq!(prepared.fallbacks.len(), 1);
330 let warning = &prepared.fallbacks[0];
331 assert!(matches!(
332 warning.raw_os_error,
333 Some(30) | Some(13) | Some(1)
334 ));
335 }
336
337 #[test]
338 fn empty_environment_path_is_ignored() {
339 assert_eq!(nonempty_path(OsString::new()), None);
340 assert_eq!(nonempty_path(OsString::from(" ")), None);
341 }
342
343 #[test]
344 fn read_only_filesystem_warning_includes_os_error_and_recovery_path() {
345 let warning = DownloadDirectoryFallback {
346 kind: DownloadDirectoryKind::HuggingFaceXet,
347 requested: PathBuf::from("/"),
348 selected: PathBuf::from("/writable/data/huggingface/xet"),
349 error: std::io::Error::from_raw_os_error(30).to_string(),
350 raw_os_error: Some(30),
351 };
352
353 let message = warning.to_string();
354 assert!(message.contains("Read-only file system"));
355 assert!(message.contains("/writable/data/huggingface/xet"));
356 assert!(message.contains("HF_XET_CACHE"));
357 assert!(message.contains("MESH_LLM_DATA_DIR"));
358 }
359}