Skip to main content

polars_io/path_utils/
mod.rs

1use std::borrow::Cow;
2use std::collections::VecDeque;
3use std::path::{Path, PathBuf};
4use std::sync::{Arc, LazyLock};
5
6use polars_buffer::Buffer;
7use polars_core::config;
8use polars_core::error::{PolarsResult, polars_bail, to_compute_err};
9use polars_utils::aliases::PlHashMap;
10use polars_utils::pl_path::{CloudScheme, PlRefPath};
11use polars_utils::pl_str::PlSmallStr;
12
13#[cfg(feature = "cloud")]
14mod hugging_face;
15
16use crate::cloud::CloudOptions;
17
18#[allow(clippy::bind_instead_of_map)]
19pub static POLARS_TEMP_DIR_BASE_PATH: LazyLock<Box<Path>> = LazyLock::new(|| {
20    (|| {
21        let verbose = config::verbose();
22
23        let path = if let Ok(v) = std::env::var("POLARS_TEMP_DIR").map(PathBuf::from) {
24            if verbose {
25                eprintln!("init_temp_dir: sourced from POLARS_TEMP_DIR")
26            }
27            v
28        } else if cfg!(target_family = "unix") {
29            let id = std::env::var("USER")
30                .inspect(|_| {
31                    if verbose {
32                        eprintln!("init_temp_dir: sourced $USER")
33                    }
34                })
35                .or_else(|_e| {
36                    // We shouldn't hit here, but we can fallback to hashing $HOME if blake3 is
37                    // available (it is available when file_cache is activated).
38                    #[cfg(feature = "file_cache")]
39                    {
40                        std::env::var("HOME")
41                            .inspect(|_| {
42                                if verbose {
43                                    eprintln!("init_temp_dir: sourced $HOME")
44                                }
45                            })
46                            .map(|x| blake3::hash(x.as_bytes()).to_hex()[..32].to_string())
47                    }
48                    #[cfg(not(feature = "file_cache"))]
49                    {
50                        Err(_e)
51                    }
52                });
53
54            if let Ok(v) = id {
55                std::env::temp_dir().join(format!("polars-{v}/"))
56            } else {
57                return Err(std::io::Error::other(
58                    "could not load $USER or $HOME environment variables",
59                ));
60            }
61        } else if cfg!(target_family = "windows") {
62            // Setting permissions on Windows is not as easy compared to Unix, but fortunately
63            // the default temporary directory location is underneath the user profile, so we
64            // shouldn't need to do anything.
65            std::env::temp_dir().join("polars/")
66        } else {
67            std::env::temp_dir().join("polars/")
68        }
69        .into_boxed_path();
70
71        let perm_result = create_dir_owner_only(path.as_ref());
72
73        if std::env::var("POLARS_ALLOW_UNSECURED_TEMP_DIR").as_deref() != Ok("1") {
74            perm_result?;
75        }
76
77        std::io::Result::Ok(path)
78    })()
79    .map_err(|e| {
80        std::io::Error::new(
81            e.kind(),
82            format!(
83                "error initializing temporary directory: {e} \
84                 consider explicitly setting POLARS_TEMP_DIR"
85            ),
86        )
87    })
88    .unwrap()
89});
90
91/// Create a directory (and parents) with owner-only permissions (0o700) on Unix.
92pub fn create_dir_owner_only(path: &Path) -> std::io::Result<()> {
93    std::fs::create_dir_all(path)?;
94
95    #[cfg(target_family = "unix")]
96    {
97        use std::os::unix::fs::PermissionsExt;
98
99        std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))?;
100        let perms = std::fs::metadata(path)?.permissions();
101
102        if (perms.mode() % 0o1000) != 0o700 {
103            return Err(std::io::Error::other(format!(
104                "error setting directory permissions: permission mismatch: {perms:?} (path = {path:?})"
105            )));
106        }
107    }
108
109    Ok(())
110}
111
112/// Replaces a "~" in the Path with the home directory.
113pub fn resolve_homedir<'a, S: AsRef<Path> + ?Sized>(path: &'a S) -> Cow<'a, Path> {
114    return inner(path.as_ref());
115
116    fn inner(path: &Path) -> Cow<'_, Path> {
117        if path.starts_with("~") {
118            #[cfg(not(target_family = "wasm"))]
119            if let Some(homedir) = std::env::home_dir() {
120                return Cow::Owned(homedir.join(path.strip_prefix("~").unwrap()));
121            }
122        }
123
124        Cow::Borrowed(path)
125    }
126}
127
128fn has_glob(path: &[u8]) -> bool {
129    return get_glob_start_idx(path).is_some();
130
131    /// Get the index of the first occurrence of a glob symbol.
132    fn get_glob_start_idx(path: &[u8]) -> Option<usize> {
133        memchr::memchr3(b'*', b'?', b'[', path)
134    }
135}
136
137/// Returns `true` for a `file://` URI whose path component carries a percent-escape.
138fn is_file_uri_with_escape(path: &PlRefPath) -> bool {
139    path.scheme().is_some_and(|s| s.is_file()) && path.strip_scheme().contains('%')
140}
141
142/// Decode `%` escapes in `file://` paths and return them as plain local paths
143/// (e.g. `file:///x/foo%3Dbar` -> `/x/foo=bar`).
144///
145/// The `file://` is dropped before decoding, so the result is a plain path, not a URI.
146/// That keeps decoded `?`/`#` as literal filename characters; kept as a URI they'd be read
147/// as a query or fragment and cut the path short.
148///
149/// When `glob` is set, a percent-encoded path is treated as a literal: every glob
150/// metacharacter in the decoded result is escaped (`?` -> `[?]`), so a decoded `?`/`*`
151/// matches the literal character instead of acting as a wildcard. A path with no `%` is
152/// never decoded (see below), so a plain `file:///x/*.parquet` still globs as usual.
153///
154/// Everything else is returned unchanged: `file://` paths with no `%`, plain paths, and
155/// cloud keys (`s3://`, ...), which are taken literally.
156pub fn decode_file_uri_paths(paths: &[PlRefPath], glob: bool) -> Cow<'_, [PlRefPath]> {
157    // Nothing to decode: borrow the input untouched.
158    if !paths.iter().any(is_file_uri_with_escape) {
159        return Cow::Borrowed(paths);
160    }
161
162    Cow::Owned(
163        paths
164            .iter()
165            .map(|path| {
166                if is_file_uri_with_escape(path)
167                    && let Some(decoded) = decode_file_uri_path(path.strip_scheme(), glob)
168                {
169                    PlRefPath::new(decoded)
170                } else {
171                    // Not an encoded file URI, or the escape isn't valid UTF-8: leave the
172                    // path literal and let the downstream open surface any not-found error.
173                    path.clone()
174                }
175            })
176            .collect(),
177    )
178}
179
180/// Percent-decode a path component. When `glob` is set the decoded result is glob-escaped
181/// (via `glob::Pattern::escape`), so a decoded `?`/`*`/`[`/`]` matches the literal character
182/// instead of acting as a wildcard. Returns `None` if the decoded bytes are not valid UTF-8.
183fn decode_file_uri_path(path: &str, glob: bool) -> Option<String> {
184    let decoded = percent_encoding::percent_decode_str(path)
185        .decode_utf8()
186        .ok()?;
187    let path = strip_windows_drive_slash(&decoded);
188    Some(if glob {
189        glob::Pattern::escape(path)
190    } else {
191        path.to_owned()
192    })
193}
194
195/// The local path of a plain path or `file://` URI, without accessing the file system.
196pub fn local_path(path: &PlRefPath) -> Option<Cow<'_, Path>> {
197    let scheme = path.scheme();
198    if scheme.is_some_and(|s| !s.is_file()) {
199        return None;
200    }
201    let ("", local_path) = path.strip_scheme_split_authority()? else {
202        return None;
203    };
204    Some(if scheme.is_some() {
205        Cow::Owned(decode_file_uri_path(local_path, false)?.into())
206    } else {
207        resolve_homedir(local_path)
208    })
209}
210
211/// `strip_scheme` leaves a Windows `file:///C:/x` URI as `/C:/x`; drop the leading slash before
212/// the drive letter so it is a valid local path (`C:/x`). The inverse of the extra slash
213/// `format_file_uri` adds on Windows. A no-op on other platforms (a leading `/` is the root).
214fn strip_windows_drive_slash(path: &str) -> &str {
215    #[cfg(target_family = "windows")]
216    {
217        let b = path.as_bytes();
218        if b.len() >= 3 && b[0] == b'/' && b[1].is_ascii_alphabetic() && b[2] == b':' {
219            return &path[1..];
220        }
221    }
222    path
223}
224
225/// Returns `true` if `expanded_paths` were expanded from a single directory
226pub fn expanded_from_single_directory(paths: &[PlRefPath], expanded_paths: &[PlRefPath]) -> bool {
227    // Single input that isn't a glob
228    paths.len() == 1 && !has_glob(paths[0].strip_scheme().as_bytes())
229    // And isn't a file
230    && {
231        (
232            // For local paths, we can just use `is_dir`
233            !paths[0].has_scheme() && paths[0].as_std_path().is_dir()
234        )
235        || (
236            // For cloud paths, we determine that the input path isn't a file by checking that the
237            // output path differs.
238            expanded_paths.is_empty() || (paths[0] != expanded_paths[0])
239        )
240    }
241}
242
243/// Recursively traverses directories and expands globs if `glob` is `true`.
244pub async fn expand_paths(
245    paths: &[PlRefPath],
246    glob: bool,
247    hidden_file_prefix: &[PlSmallStr],
248    #[allow(unused_variables)] cloud_options: &mut Option<CloudOptions>,
249) -> PolarsResult<Buffer<PlRefPath>> {
250    expand_paths_hive(paths, glob, hidden_file_prefix, cloud_options, false)
251        .await
252        .map(|x| x.0)
253}
254
255/// Byte size per expanded path, as returned by [`expand_paths_hive`].
256///
257/// One slot per path, in expanded-path order; `Some` only when every path
258/// has a known size.
259pub type BytesPerSource = Option<Arc<[u64]>>;
260
261struct HiveIdxTracker<'a> {
262    idx: usize,
263    paths: &'a [PlRefPath],
264    check_directory_level: bool,
265}
266
267impl HiveIdxTracker<'_> {
268    fn update(&mut self, i: usize, path_idx: usize) -> PolarsResult<()> {
269        let check_directory_level = self.check_directory_level;
270        let paths = self.paths;
271
272        if check_directory_level
273            && ![usize::MAX, i].contains(&self.idx)
274            // They could still be the same directory level, just with different name length
275            && (path_idx > 0 && paths[path_idx].parent() != paths[path_idx - 1].parent())
276        {
277            polars_bail!(
278                InvalidOperation:
279                "attempted to read from different directory levels with hive partitioning enabled: \
280                first path: {}, second path: {}",
281                &paths[path_idx - 1],
282                &paths[path_idx],
283            )
284        } else {
285            self.idx = std::cmp::min(self.idx, i);
286            Ok(())
287        }
288    }
289}
290
291#[cfg(feature = "cloud")]
292async fn expand_path_cloud(
293    path: PlRefPath,
294    cloud_options: Option<&CloudOptions>,
295    glob: bool,
296    first_path_has_scheme: bool,
297) -> PolarsResult<(usize, Vec<(PlRefPath, Option<u64>)>)> {
298    let format_path = |scheme: &str, bucket: &str, location: &str| {
299        if first_path_has_scheme {
300            format!("{scheme}://{bucket}/{location}")
301        } else {
302            format!("/{location}")
303        }
304    };
305
306    use polars_utils::io::_limit_path_len_io_err;
307
308    use crate::cloud::object_path_from_str;
309    let path_str = path.as_str();
310
311    let (cloud_location, store) =
312        crate::cloud::build_object_store(path.clone(), cloud_options, glob).await?;
313    let prefix = object_path_from_str(&cloud_location.prefix)?;
314
315    let out = if !path_str.ends_with("/") && (!glob || cloud_location.expansion.is_none()) && {
316        // We need to check if it is a directory for local paths (we can be here due
317        // to FORCE_ASYNC). For cloud paths the convention is that the user must add
318        // a trailing slash `/` to scan directories. We don't infer it as that would
319        // mean sending one network request per path serially (very slow).
320        path.has_scheme() || path.as_std_path().is_file()
321    } {
322        (
323            // A concrete file / prefix, not a LIST result, so no size is known.
324            0,
325            vec![(
326                PlRefPath::new(format_path(
327                    cloud_location.scheme,
328                    &cloud_location.bucket,
329                    prefix.as_ref(),
330                )),
331                None,
332            )],
333        )
334    } else {
335        use futures::TryStreamExt;
336
337        if !path.has_scheme() {
338            // FORCE_ASYNC in the test suite wants us to raise a proper error message
339            // for non-existent file paths. Note we can't do this for cloud paths as
340            // there is no concept of a "directory" - a non-existent path is
341            // indistinguishable from an empty directory.
342            path.as_std_path()
343                .metadata()
344                .map_err(|err| _limit_path_len_io_err(path.as_std_path(), err))?;
345        }
346
347        let cloud_location = &cloud_location;
348        let prefix_ref = &prefix;
349
350        let mut paths = store
351            .exec_with_rebuild_retry_on_err(|s| async move {
352                let out = s
353                    .list(Some(prefix_ref))
354                    .try_filter_map(|x| async move {
355                        // Retain the LIST-reported byte size with the path.
356                        let out = (x.size > 0).then(|| {
357                            (
358                                PlRefPath::new({
359                                    format_path(
360                                        cloud_location.scheme,
361                                        &cloud_location.bucket,
362                                        x.location.as_ref(),
363                                    )
364                                }),
365                                Some(x.size),
366                            )
367                        });
368                        Ok(out)
369                    })
370                    .try_collect::<Vec<_>>()
371                    .await?;
372
373                Ok(out)
374            })
375            .await?;
376
377        // Since Path::parse() removes any trailing slash ('/'), we may need to restore it
378        // to calculate the right byte offset
379        let mut prefix = prefix.to_string();
380        if path_str.ends_with('/') && !prefix.ends_with('/') {
381            prefix.push('/')
382        };
383
384        paths.sort_unstable();
385
386        (
387            format_path(
388                cloud_location.scheme,
389                &cloud_location.bucket,
390                prefix.as_ref(),
391            )
392            .len(),
393            paths,
394        )
395    };
396
397    PolarsResult::Ok(out)
398}
399
400/// Recursively traverses directories and expands globs if `glob` is `true`.
401/// Returns the expanded paths and the index at which to start parsing hive
402/// partitions from the path.
403pub async fn expand_paths_hive(
404    paths: &[PlRefPath],
405    glob: bool,
406    hidden_file_prefix: &[PlSmallStr],
407    #[allow(unused_variables)] cloud_options: &mut Option<CloudOptions>,
408    check_directory_level: bool,
409) -> PolarsResult<(Buffer<PlRefPath>, usize, BytesPerSource)> {
410    let Some(first_path) = paths.first() else {
411        return Ok((vec![].into(), 0, None));
412    };
413
414    let first_path_has_scheme = first_path.has_scheme();
415
416    let is_hidden_file = move |path: &PlRefPath| {
417        path.file_name()
418            .and_then(|x| x.to_str())
419            .is_some_and(|file_name| {
420                hidden_file_prefix
421                    .iter()
422                    .any(|x| file_name.starts_with(x.as_str()))
423            })
424    };
425
426    let mut out_paths = OutPaths {
427        paths: vec![],
428        exts: [None, None],
429        is_hidden_file: &is_hidden_file,
430    };
431
432    // Order-independent, so the path sorting below need not carry sizes; the
433    // aligned vector is built by lookup after expansion.
434    let mut sizes_by_path: PlHashMap<PlRefPath, u64> = PlHashMap::default();
435
436    let mut hive_idx_tracker = HiveIdxTracker {
437        idx: usize::MAX,
438        paths,
439        check_directory_level,
440    };
441
442    if first_path_has_scheme || {
443        cfg!(not(target_family = "windows")) && polars_config::config().force_async()
444    } {
445        #[cfg(feature = "cloud")]
446        {
447            if first_path.scheme() == Some(CloudScheme::Hf) {
448                let (expand_start_idx, paths) = hugging_face::expand_paths_hf(
449                    paths,
450                    check_directory_level,
451                    cloud_options,
452                    glob,
453                )
454                .await?;
455
456                // HF listing doesn't surface per-file sizes here; degrade to None.
457                return Ok((paths.into(), expand_start_idx, None));
458            }
459
460            for (path_idx, path) in paths.iter().enumerate() {
461                use std::borrow::Cow;
462
463                let mut path = Cow::Borrowed(path);
464
465                if matches!(path.scheme(), Some(CloudScheme::Http | CloudScheme::Https)) {
466                    let mut rewrite_aws = false;
467
468                    #[cfg(feature = "aws")]
469                    if let Some(p) = (|| {
470                        use crate::cloud::CloudConfig;
471
472                        // See https://docs.aws.amazon.com/AmazonS3/latest/userguide/VirtualHosting.html#virtual-hosted-style-access
473                        // Path format: https://bucket-name.s3.region-code.amazonaws.com/key-name
474                        let after_scheme = path.strip_scheme();
475
476                        let bucket_end = after_scheme.find(".s3.")?;
477                        let offset = bucket_end + 4;
478                        // Search after offset to prevent matching `.s3.amazonaws.com` (legacy global endpoint URL without region).
479                        let region_end = offset + after_scheme[offset..].find(".amazonaws.com/")?;
480
481                        // Do not convert if '?' (this can be query parameters for AWS presigned URLs).
482                        if after_scheme[..region_end].contains('/') || after_scheme.contains('?') {
483                            return None;
484                        }
485
486                        let bucket = &after_scheme[..bucket_end];
487                        let region = &after_scheme[bucket_end + 4..region_end];
488                        let key = &after_scheme[region_end + 15..];
489
490                        if let CloudConfig::Aws(configs) = cloud_options
491                            .get_or_insert_default()
492                            .config
493                            .get_or_insert_with(|| CloudConfig::Aws(Vec::with_capacity(1)))
494                        {
495                            use object_store::aws::AmazonS3ConfigKey;
496
497                            if !matches!(configs.last(), Some((AmazonS3ConfigKey::Region, _))) {
498                                configs.push((AmazonS3ConfigKey::Region, region.into()))
499                            }
500                        }
501
502                        Some(format!("s3://{bucket}/{key}"))
503                    })() {
504                        path = Cow::Owned(PlRefPath::new(p));
505                        rewrite_aws = true;
506                    }
507
508                    if !rewrite_aws {
509                        out_paths.push(path.into_owned());
510                        hive_idx_tracker.update(0, path_idx)?;
511                        continue;
512                    }
513                }
514
515                let sort_start_idx = out_paths.paths.len();
516
517                if glob && has_glob(path.as_bytes()) {
518                    hive_idx_tracker.update(0, path_idx)?;
519
520                    let iter = crate::async_glob(path.into_owned(), cloud_options.as_ref()).await?;
521
522                    for (url, size) in iter {
523                        // FORCE_ASYNC (no scheme on first path): strip the leading
524                        // `file://` the caller may not be expecting.
525                        let p = if first_path_has_scheme {
526                            PlRefPath::new(url)
527                        } else {
528                            PlRefPath::new(&url[7..])
529                        };
530                        sizes_by_path.insert(p.clone(), size);
531                        out_paths.push(p);
532                    }
533                } else {
534                    let (expand_start_idx, paths) = expand_path_cloud(
535                        path.into_owned(),
536                        cloud_options.as_ref(),
537                        glob,
538                        first_path_has_scheme,
539                    )
540                    .await?;
541                    for (p, size) in paths {
542                        if let Some(size) = size {
543                            sizes_by_path.insert(p.clone(), size);
544                        }
545                        out_paths.push(p);
546                    }
547                    hive_idx_tracker.update(expand_start_idx, path_idx)?;
548                };
549
550                if let Some(mut_slice) = out_paths.paths.get_mut(sort_start_idx..) {
551                    <[PlRefPath]>::sort_unstable(mut_slice);
552                }
553            }
554        }
555        #[cfg(not(feature = "cloud"))]
556        panic!("Feature `cloud` must be enabled to use globbing patterns with cloud urls.")
557    } else {
558        let mut stack: VecDeque<Cow<'_, Path>> = VecDeque::new();
559        let mut paths_scratch: Vec<PathBuf> = vec![];
560
561        for (path_idx, path) in paths.iter().enumerate() {
562            stack.clear();
563            let sort_start_idx = out_paths.paths.len();
564
565            if path.as_std_path().is_dir() {
566                let i = path.as_str().len();
567
568                hive_idx_tracker.update(i, path_idx)?;
569
570                stack.push_back(Cow::Borrowed(path.as_std_path()));
571
572                while let Some(dir) = stack.pop_front() {
573                    let mut last_err = Ok(());
574
575                    paths_scratch.clear();
576                    paths_scratch.extend(std::fs::read_dir(dir)?.map_while(|x| {
577                        match x.map(|x| x.path()) {
578                            Ok(v) => Some(v),
579                            Err(e) => {
580                                last_err = Err(e);
581                                None
582                            },
583                        }
584                    }));
585
586                    last_err?;
587
588                    for path in paths_scratch.drain(..) {
589                        let md = path.metadata()?;
590
591                        if md.is_dir() {
592                            stack.push_back(Cow::Owned(path));
593                        } else if md.len() > 0 {
594                            let p = PlRefPath::try_from_path(&path)?;
595                            sizes_by_path.insert(p.clone(), md.len());
596                            out_paths.push(p);
597                        }
598                    }
599                }
600            } else if glob && has_glob(path.as_bytes()) {
601                hive_idx_tracker.update(0, path_idx)?;
602
603                let Ok(paths) = glob::glob(path.as_str()) else {
604                    polars_bail!(ComputeError: "invalid glob pattern given")
605                };
606
607                for path in paths {
608                    let path = path.map_err(to_compute_err)?;
609                    let md = path.metadata()?;
610                    if !md.is_dir() && md.len() > 0 {
611                        let p = PlRefPath::try_from_path(&path)?;
612                        sizes_by_path.insert(p.clone(), md.len());
613                        out_paths.push(p);
614                    }
615                }
616            } else {
617                hive_idx_tracker.update(0, path_idx)?;
618                out_paths.push(path.clone());
619            };
620
621            if let Some(mut_slice) = out_paths.paths.get_mut(sort_start_idx..) {
622                <[PlRefPath]>::sort_unstable(mut_slice);
623            }
624        }
625    }
626
627    if expanded_from_single_directory(paths, out_paths.paths.as_slice()) {
628        if let [Some((_, p1)), Some((_, p2))] = out_paths.exts {
629            polars_bail!(
630                InvalidOperation: "directory contained paths with different file extensions: \
631                first path: {}, second path: {}. Please use a glob pattern to explicitly specify \
632                which files to read (e.g. 'dir/**/*', 'dir/**/*.parquet')",
633                &p1, &p2
634            )
635        }
636    }
637
638    // All-or-nothing: `Some` only if every path has a known size.
639    let bytes_per_source: BytesPerSource = out_paths
640        .paths
641        .iter()
642        .map(|p| sizes_by_path.get(p).copied())
643        .collect::<Option<Vec<u64>>>()
644        .map(Arc::from);
645
646    return Ok((
647        out_paths.paths.into(),
648        hive_idx_tracker.idx,
649        bytes_per_source,
650    ));
651
652    /// Wrapper around `Vec<PathBuf>` that also tracks file extensions, so that
653    /// we don't have to traverse the entire list again to validate extensions.
654    struct OutPaths<'a, F: Fn(&PlRefPath) -> bool> {
655        paths: Vec<PlRefPath>,
656        exts: [Option<(PlSmallStr, PlRefPath)>; 2],
657        is_hidden_file: &'a F,
658    }
659
660    impl<F> OutPaths<'_, F>
661    where
662        F: Fn(&PlRefPath) -> bool,
663    {
664        fn push(&mut self, value: PlRefPath) {
665            if (self.is_hidden_file)(&value) {
666                return;
667            }
668
669            let exts = &mut self.exts;
670            Self::update_ext_status(exts, &value);
671
672            self.paths.push(value)
673        }
674
675        fn update_ext_status(exts: &mut [Option<(PlSmallStr, PlRefPath)>; 2], value: &PlRefPath) {
676            let ext = value
677                .extension()
678                .map_or(PlSmallStr::EMPTY, PlSmallStr::from);
679
680            if exts[0].is_none() {
681                exts[0] = Some((ext, value.clone()));
682            } else if exts[1].is_none() && ext != exts[0].as_ref().unwrap().0 {
683                exts[1] = Some((ext, value.clone()));
684            }
685        }
686    }
687}
688
689/// Ignores errors from `std::fs::create_dir_all` if the directory exists.
690#[cfg(feature = "file_cache")]
691pub(crate) fn ensure_directory_init(path: &Path) -> std::io::Result<()> {
692    let result = std::fs::create_dir_all(path);
693
694    if path.is_dir() { Ok(()) } else { result }
695}
696
697#[cfg(test)]
698mod tests {
699    use std::path::PathBuf;
700
701    use polars_core::runtime::ASYNC;
702    use polars_utils::pl_path::PlRefPath;
703
704    use super::resolve_homedir;
705
706    #[cfg(not(target_os = "windows"))]
707    #[test]
708    fn test_resolve_homedir() {
709        let paths: Vec<PathBuf> = vec![
710            "~/dir1/dir2/test.csv".into(),
711            "/abs/path/test.csv".into(),
712            "rel/path/test.csv".into(),
713            "/".into(),
714            "~".into(),
715        ];
716
717        let resolved: Vec<PathBuf> = paths
718            .iter()
719            .map(resolve_homedir)
720            .map(|x| x.into_owned())
721            .collect();
722
723        assert_eq!(resolved[0].file_name(), paths[0].file_name());
724        assert!(resolved[0].is_absolute());
725        assert_eq!(resolved[1], paths[1]);
726        assert_eq!(resolved[2], paths[2]);
727        assert_eq!(resolved[3], paths[3]);
728        assert!(resolved[4].is_absolute());
729    }
730
731    #[cfg(target_os = "windows")]
732    #[test]
733    fn test_resolve_homedir_windows() {
734        let paths: Vec<PathBuf> = vec![
735            r#"c:\Users\user1\test.csv"#.into(),
736            r#"~\user1\test.csv"#.into(),
737            "~".into(),
738        ];
739
740        let resolved: Vec<PathBuf> = paths
741            .iter()
742            .map(resolve_homedir)
743            .map(|x| x.into_owned())
744            .collect();
745
746        assert_eq!(resolved[0], paths[0]);
747        assert_eq!(resolved[1].file_name(), paths[1].file_name());
748        assert!(resolved[1].is_absolute());
749        assert!(resolved[2].is_absolute());
750    }
751
752    #[test]
753    fn test_http_path_with_query_parameters_is_not_expanded_as_glob() {
754        // Don't confuse HTTP URL's with query parameters for globs.
755        // See https://github.com/pola-rs/polars/pull/17774
756
757        use super::expand_paths;
758
759        let path = "https://pola.rs/test.csv?token=bear";
760        let paths = &[PlRefPath::new(path)];
761        let out = ASYNC
762            .block_on(expand_paths(paths, true, &[], &mut None))
763            .unwrap();
764        assert_eq!(out.as_ref(), paths);
765    }
766}