Cross-platform path expansion, normalization, resolution, traversal, searching, and caching for Rust.
path-rs complements std::path::{Path, PathBuf}. It does not replace them.
use std::path::{Path, PathBuf};
use path_rs::{expand_input, normalize, ExpandOptions};
let expanded = expand_input("~/projects", &ExpandOptions::default())?;
let clean = normalize("foo/./bar/../baz")?;
assert_eq!(clean, PathBuf::from("foo/baz"));
let _ = expanded;CLI tools, build systems, deployers, and workspace managers repeatedly reimplement:
~and environment variable expansion- platform config/data/cache locations
- Windows drive / UNC / verbatim path quirks
- lexical normalization vs filesystem canonicalization
- safe-ish joining under a root
- directory listing and glob search
- optional discovery caching
path-rs provides one coherent, well-tested layer for those jobs while leaving filesystem identity to Path / PathBuf.
| Type | Role |
|---|---|
std::path::Path / PathBuf |
Native path representation and OS APIs |
path-rs |
Expansion, lexical ops, platform dirs, listing, search, cache |
Internal representation is always Path / PathBuf / OsStr — never String as a filesystem path.
[dependencies]
path-rs = "0.1"Default features: listing, search.
| Operation | Filesystem access | Requires existence | Resolves symlinks |
|---|---|---|---|
expand_input |
No* | No | No |
normalize |
No | No | No |
resolve_against |
No | No | No |
join_relative |
No | No | No |
resolve_inside |
No | No | No |
canonicalize_existing |
Yes | Yes | Yes |
list |
Yes | Root yes | Configurable |
search |
Yes | Root yes | Configurable |
* Home / platform directory resolution may consult environment variables and platform APIs.
use path_rs::{
expand_input, normalize, resolve_inside, ExpandOptions,
};
let input = expand_input("%USERPROFILE%\\Documents", &ExpandOptions::default())?;
let clean = normalize(&input)?;
// Lexical containment — not symlink-safe:
let inside = resolve_inside(r"C:\repo", "src\\main.rs");
let _ = (clean, inside);use path_rs::{expand_input, ExpandOptions};
let opts = ExpandOptions {
expand_tilde: true,
expand_percent_variables: true,
expand_dollar_variables: true,
translate_wsl_paths: false,
reject_undefined_variables: true,
trim_cli_input: true,
max_expansion_depth: 8,
};
let path = expand_input("~/work/${PROJECT}", &opts)?;Supported forms:
~,~/path,~\path(leading component only; no~user)%VAR%(including%%escape)$VAR,${VAR}- Optional WSL:
/mnt/c/...→C:\...whentranslate_wsl_pathsis enabled
Never expands $(command), backticks, or shell syntax.
Undefined variables: strict mode errors; permissive mode leaves the token unchanged.
use path_rs::{normalize, canonicalize_existing};
// Lexical only — no I/O:
let n = normalize("foo/./bar/../baz")?;
// Filesystem — must exist, resolves symlinks:
let c = canonicalize_existing(".")?;Do not call canonicalize merely to “clean” a path.
use path_rs::{join_relative, resolve_against, resolve_inside};
let a = resolve_against("/repo", "src/main.rs")?;
let b = join_relative("/repo", "src/main.rs")?; // rejects absolute children
let c = resolve_inside("/repo", "../etc/passwd"); // Err(RootEscape)Security: lexical containment is not a symlink-safe boundary. See SECURITY.md.
Handles drive-absolute (C:\, C:/), rejects drive-relative (C:foo, C:) by default, preserves UNC and verbatim prefixes, and classifies device namespaces (\\.\).
use path_rs::{is_drive_relative, is_unc, is_verbatim};
use std::path::Path;
#[cfg(windows)]
{
assert!(is_drive_relative(Path::new(r"C:foo")));
assert!(is_unc(Path::new(r"\\server\share")));
assert!(is_verbatim(Path::new(r"\\?\C:\repo")));
}Windows reserved device names (CON, NUL, COM1, …) can be detected for app-name validation and policy checks.
Long path behavior follows the OS and process configuration; this crate does not invent MAX_PATH guarantees.
Translation is opt-in (ExpandOptions::translate_wsl_paths or translate_wsl_path).
Only /mnt/<single-letter-drive>/... is recognized. /mnt/data is not treated as a drive.
use path_rs::{path_to_utf8, path_to_string_lossy};
let s = path_to_utf8(std::path::Path::new("foo"))?;
let log = path_to_string_lossy(std::path::Path::new("foo")); // logs/UI onlyNever round-trip lossy UTF-8 back into filesystem operations.
With feature unicode, logical_path_key provides NFC comparison keys (not filesystem paths).
use path_rs::{list, ListOptions};
let entries = list(
".",
&ListOptions::new()
.recursive(true)
.include_hidden(false)
.max_depth(Some(8)),
)?;Defaults: no symlink following, no cache, hidden files excluded, fail-fast errors, deterministic path sort when enabled.
use path_rs::{search, SearchRequest};
let hits = search(&SearchRequest::new(".", ["**/*.rs", "**/Cargo.toml"]))?;Patterns match paths relative to the search root. Use search_with for predicate-based filters.
use path_rs::{app_paths_with_options, AppPathsOptions};
let paths = app_paths_with_options(AppPathsOptions {
application_name: "my-tool".into(),
environment_override: Some("MY_TOOL_HOME".into()),
create_directories: true,
root_policy: None,
})?;
// Product-specific subdirs (e.g. "mutations") stay in the application layer:
let _custom = paths.root_dir.join("custom-subdir");Never conflate the global application root with a repository root or the process CWD.
use path_rs::{path_identity_key, PathIdentityOptions, CaseNormalization};
let key = path_identity_key(
r"C:\Users\Floris\Repo",
PathIdentityOptions {
case: CaseNormalization::AsciiLowercase,
..PathIdentityOptions::default()
},
)?;
// Comparison only — not a filesystem path.use path_rs::{discover_where, DiscoveryOptions};
// Application supplies the domain predicate (e.g. "has a marker file"):
let roots = discover_where(".", &DiscoveryOptions::default(), |path, _| {
path.join("Cargo.toml").is_file()
})?;Skip lists (node_modules, .git, …) are caller-configured, not hardcoded.
Caching is opt-in (CachePolicy::Bypass by default).
use path_rs::{
search_with_cache, CacheMode, CacheOptions, CachePolicy, MemoryCache, SearchRequest,
};
use std::sync::Arc;
use std::time::Duration;
let cache = Arc::new(MemoryCache::new(CacheOptions {
mode: CacheMode::Memory,
ttl: Some(Duration::from_secs(60)),
max_entries: 128,
validate_metadata: false,
}));
let mut req = SearchRequest::new(".", ["**/*.rs"]);
req.cache = CachePolicy::ReadThrough;
let hits = search_with_cache(&req, Some(cache.as_ref()))?;Cache keys include root, patterns, and traversal options — not the root alone.
Feature persistent-cache enables a versioned on-disk cache under the platform cache directory.
Never use cache hits as a security boundary.
- Lexical normalization ≠ canonicalization
- Lexical root containment ≠ symlink-safe containment
- Cached results are not authoritative
- Environment expansion is untrusted input
- Glob/search can produce large result sets — set limits
See SECURITY.md.
| Platform | Status |
|---|---|
| Windows | Supported |
| macOS | Supported |
| Linux | Supported |
| Other Unix-like | Best-effort via dirs / std |
CI runs on Ubuntu, macOS, and Windows.
| Feature | Default | Description |
|---|---|---|
listing |
yes | Directory listing (walkdir) |
search |
yes | Glob/predicate search (globset, implies listing) |
persistent-cache |
no | On-disk discovery cache |
unicode |
no | NFC logical path keys |
async |
no | spawn_blocking list/search wrappers |
Rust 1.85 (edition 2024).
cargo run --example expand
cargo run --example normalize
cargo run --example resolve
cargo run --example dirs
cargo run --example identity
cargo run --example inspect
cargo run --example match_path
cargo run --example platform
cargo run --example text_utf8
cargo run --example text_utf8 --features unicode
cargo run --example list # listing
cargo run --example discovery # listing
cargo run --example search # search
cargo run --example cache # search
cargo run --example persistent_cache --features persistent-cacheSee CONTRIBUTING.md.
Licensed under either of:
- Apache License, Version 2.0 (LICENSE-APACHE)
- MIT license (LICENSE-MIT)
at your option.