Files
ruff/crates/ruff_db/src/system/os.rs
Micha Reiser 796819e7a0 [ty] Disallow std::env and io methods in most ty crates (#20046)
## Summary

We use the `System` abstraction in ty to abstract away the host/system
on which ty runs.
This has a few benefits:

* Tests can run in full isolation using a memory system (that uses an
in-memory file system)
* The LSP has a custom implementation where `read_to_string` returns the
content as seen by the editor (e.g. unsaved changes) instead of always
returning the content as it is stored on disk
* We don't require any file system polyfills for wasm in the browser


However, it does require extra care that we don't accidentally use
`std::fs` or `std::env` (etc.) methods in ty's code base (which is very
easy).

This PR sets up Clippy and disallows the most common methods, instead
pointing users towards the corresponding `System` methods.

The setup is a bit awkward because clippy doesn't support inheriting
configurations. That means, a crate can only override the entire
workspace configuration or not at all.
The approach taken in this PR is:

* Configure the disallowed methods at the workspace level
* Allow `disallowed_methods` at the workspace level
* Enable the lint at the crate level using the warn attribute (in code)


The obvious downside is that it won't work if we ever want to disallow
other methods, but we can figure that out once we reach that point.

What about false positives: Just add an `allow` and move on with your
life :) This isn't something that we have to enforce strictly; the goal
is to catch accidental misuse.

## Test Plan

Clippy found a place where we incorrectly used `std::fs::read_to_string`
2025-08-22 11:13:47 -07:00

940 lines
32 KiB
Rust

#![allow(clippy::disallowed_methods)]
use super::walk_directory::{
self, DirectoryWalker, WalkDirectoryBuilder, WalkDirectoryConfiguration,
WalkDirectoryVisitorBuilder, WalkState,
};
use crate::max_parallelism;
use crate::system::{
CaseSensitivity, DirectoryEntry, FileType, GlobError, GlobErrorKind, Metadata, Result, System,
SystemPath, SystemPathBuf, SystemVirtualPath, WritableSystem,
};
use filetime::FileTime;
use ruff_notebook::{Notebook, NotebookError};
use rustc_hash::FxHashSet;
use std::num::NonZeroUsize;
use std::panic::RefUnwindSafe;
use std::sync::Arc;
use std::{any::Any, path::PathBuf};
/// A system implementation that uses the OS file system.
#[derive(Debug, Clone)]
pub struct OsSystem {
inner: Arc<OsSystemInner>,
}
#[derive(Default, Debug)]
struct OsSystemInner {
cwd: SystemPathBuf,
real_case_cache: CaseSensitivePathsCache,
case_sensitivity: CaseSensitivity,
/// Overrides the user's configuration directory for testing.
/// This is an `Option<Option<..>>` to allow setting an override of `None`.
#[cfg(feature = "testing")]
user_config_directory_override: std::sync::Mutex<Option<Option<SystemPathBuf>>>,
}
impl OsSystem {
pub fn new(cwd: impl AsRef<SystemPath>) -> Self {
let cwd = cwd.as_ref();
assert!(cwd.as_utf8_path().is_absolute());
let case_sensitivity = detect_case_sensitivity(cwd);
tracing::debug!(
"Architecture: {}, OS: {}, case-sensitive: {case_sensitivity}",
std::env::consts::ARCH,
std::env::consts::OS,
);
Self {
// Spreading `..Default` because it isn't possible to feature gate the initializer of a single field.
inner: Arc::new(OsSystemInner {
cwd: cwd.to_path_buf(),
case_sensitivity,
..Default::default()
}),
}
}
#[cfg(unix)]
fn permissions(metadata: &std::fs::Metadata) -> Option<u32> {
use std::os::unix::fs::PermissionsExt;
Some(metadata.permissions().mode())
}
#[cfg(not(unix))]
fn permissions(_metadata: &std::fs::Metadata) -> Option<u32> {
None
}
}
impl System for OsSystem {
fn path_metadata(&self, path: &SystemPath) -> Result<Metadata> {
let metadata = path.as_std_path().metadata()?;
let last_modified = FileTime::from_last_modification_time(&metadata);
Ok(Metadata {
revision: last_modified.into(),
permissions: Self::permissions(&metadata),
file_type: metadata.file_type().into(),
})
}
fn canonicalize_path(&self, path: &SystemPath) -> Result<SystemPathBuf> {
path.as_utf8_path().canonicalize_utf8().map(|path| {
SystemPathBuf::from_utf8_path_buf(path)
.simplified()
.to_path_buf()
})
}
fn read_to_string(&self, path: &SystemPath) -> Result<String> {
std::fs::read_to_string(path.as_std_path())
}
fn read_to_notebook(&self, path: &SystemPath) -> std::result::Result<Notebook, NotebookError> {
Notebook::from_path(path.as_std_path())
}
fn read_virtual_path_to_string(&self, _path: &SystemVirtualPath) -> Result<String> {
Err(not_found())
}
fn read_virtual_path_to_notebook(
&self,
_path: &SystemVirtualPath,
) -> std::result::Result<Notebook, NotebookError> {
Err(NotebookError::from(not_found()))
}
fn path_exists(&self, path: &SystemPath) -> bool {
path.as_std_path().exists()
}
fn path_exists_case_sensitive(&self, path: &SystemPath, prefix: &SystemPath) -> bool {
if self.case_sensitivity().is_case_sensitive() {
self.path_exists(path)
} else {
self.path_exists_case_sensitive_fast(path)
.unwrap_or_else(|| self.path_exists_case_sensitive_slow(path, prefix))
}
}
fn case_sensitivity(&self) -> CaseSensitivity {
self.inner.case_sensitivity
}
fn current_directory(&self) -> &SystemPath {
&self.inner.cwd
}
#[cfg(not(target_arch = "wasm32"))]
fn user_config_directory(&self) -> Option<SystemPathBuf> {
// In testing, we allow overriding the user configuration directory by using a
// thread local because overriding the environment variables breaks test isolation
// (tests run concurrently) and mutating environment variable in a multithreaded
// application is inherently unsafe.
#[cfg(feature = "testing")]
if let Ok(directory_override) = self.try_get_user_config_directory_override() {
return directory_override;
}
use etcetera::BaseStrategy as _;
let strategy = etcetera::base_strategy::choose_base_strategy().ok()?;
SystemPathBuf::from_path_buf(strategy.config_dir()).ok()
}
// TODO: Remove this feature gating once `ruff_wasm` no longer indirectly depends on `ruff_db` with the
// `os` feature enabled (via `ruff_workspace` -> `ruff_graph` -> `ruff_db`).
#[cfg(target_arch = "wasm32")]
fn user_config_directory(&self) -> Option<SystemPathBuf> {
#[cfg(feature = "testing")]
if let Ok(directory_override) = self.try_get_user_config_directory_override() {
return directory_override;
}
None
}
/// Returns an absolute cache directory on the system.
///
/// On Linux and macOS, uses `$XDG_CACHE_HOME/ty` or `.cache/ty`.
/// On Windows, uses `C:\Users\User\AppData\Local\ty\cache`.
#[cfg(not(target_arch = "wasm32"))]
fn cache_dir(&self) -> Option<SystemPathBuf> {
use etcetera::BaseStrategy as _;
let cache_dir = etcetera::base_strategy::choose_base_strategy()
.ok()
.map(|dirs| dirs.cache_dir().join("ty"))
.map(|cache_dir| {
if cfg!(windows) {
// On Windows, we append `cache` to the LocalAppData directory, i.e., prefer
// `C:\Users\User\AppData\Local\ty\cache` over `C:\Users\User\AppData\Local\ty`.
cache_dir.join("cache")
} else {
cache_dir
}
})
.and_then(|path| SystemPathBuf::from_path_buf(path).ok())
.unwrap_or_else(|| SystemPathBuf::from(".ty_cache"));
Some(cache_dir)
}
// TODO: Remove this feature gating once `ruff_wasm` no longer indirectly depends on `ruff_db` with the
// `os` feature enabled (via `ruff_workspace` -> `ruff_graph` -> `ruff_db`).
#[cfg(target_arch = "wasm32")]
fn cache_dir(&self) -> Option<SystemPathBuf> {
None
}
/// Creates a builder to recursively walk `path`.
///
/// The walker ignores files according to [`ignore::WalkBuilder::standard_filters`]
/// when setting [`WalkDirectoryBuilder::standard_filters`] to true.
fn walk_directory(&self, path: &SystemPath) -> WalkDirectoryBuilder {
WalkDirectoryBuilder::new(path, OsDirectoryWalker {})
}
fn glob(
&self,
pattern: &str,
) -> std::result::Result<
Box<dyn Iterator<Item = std::result::Result<SystemPathBuf, GlobError>>>,
glob::PatternError,
> {
glob::glob(pattern).map(|inner| {
let iterator = inner.map(|result| {
let path = result?;
let system_path = SystemPathBuf::from_path_buf(path).map_err(|path| GlobError {
path,
error: GlobErrorKind::NonUtf8Path,
})?;
Ok(system_path)
});
let boxed: Box<dyn Iterator<Item = _>> = Box::new(iterator);
boxed
})
}
fn as_writable(&self) -> Option<&dyn WritableSystem> {
Some(self)
}
fn as_any(&self) -> &dyn Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
fn read_directory(
&self,
path: &SystemPath,
) -> Result<Box<dyn Iterator<Item = Result<DirectoryEntry>>>> {
Ok(Box::new(path.as_utf8_path().read_dir_utf8()?.map(|res| {
let res = res?;
let file_type = res.file_type()?;
Ok(DirectoryEntry {
path: SystemPathBuf::from_utf8_path_buf(res.into_path()),
file_type: file_type.into(),
})
})))
}
fn env_var(&self, name: &str) -> std::result::Result<String, std::env::VarError> {
std::env::var(name)
}
fn dyn_clone(&self) -> Box<dyn System> {
Box::new(self.clone())
}
}
impl OsSystem {
/// Path sensitive testing if a path exists by canonicalization the path and comparing it with `path`.
///
/// This is faster than the slow path, because it requires a single system call for each path
/// instead of at least one system call for each component between `path` and `prefix`.
///
/// However, using `canonicalize` to resolve the path's casing doesn't work in two cases:
/// * if `path` is a symlink because `canonicalize` then returns the symlink's target and not the symlink's source path.
/// * on Windows: If `path` is a mapped network drive because `canonicalize` then returns the UNC path
/// (e.g. `Z:\` is mapped to `\\server\share` and `canonicalize` then returns `\\?\UNC\server\share`).
///
/// Symlinks and mapped network drives should be rare enough that this fast path is worth trying first,
/// even if it comes at a cost for those rare use cases.
fn path_exists_case_sensitive_fast(&self, path: &SystemPath) -> Option<bool> {
// This is a more forgiving version of `dunce::simplified` that removes all `\\?\` prefixes on Windows.
// We use this more forgiving version because we don't intend on using either path for anything other than comparison
// and the prefix is only relevant when passing the path to other programs and its longer than 200 something
// characters.
fn simplify_ignore_verbatim(path: &SystemPath) -> &SystemPath {
if cfg!(windows) {
if path.as_utf8_path().as_str().starts_with(r"\\?\") {
SystemPath::new(&path.as_utf8_path().as_str()[r"\\?\".len()..])
} else {
path
}
} else {
path
}
}
let simplified = simplify_ignore_verbatim(path);
let Ok(canonicalized) = simplified.as_std_path().canonicalize() else {
// The path doesn't exist or can't be accessed. The path doesn't exist.
return Some(false);
};
let Ok(canonicalized) = SystemPathBuf::from_path_buf(canonicalized) else {
// The original path is valid UTF8 but the canonicalized path isn't. This definitely suggests
// that a symlink is involved. Fall back to the slow path.
tracing::debug!(
"Falling back to the slow case-sensitive path existence check because the canonicalized path of `{simplified}` is not valid UTF-8"
);
return None;
};
let simplified_canonicalized = simplify_ignore_verbatim(&canonicalized);
// Test if the paths differ by anything other than casing. If so, that suggests that
// `path` pointed to a symlink (or some other none reversible path normalization happened).
// In this case, fall back to the slow path.
if simplified_canonicalized.as_str().to_lowercase() != simplified.as_str().to_lowercase() {
tracing::debug!(
"Falling back to the slow case-sensitive path existence check for `{simplified}` because the canonicalized path `{simplified_canonicalized}` differs not only by casing"
);
return None;
}
// If there are no symlinks involved, then `path` exists only if it is the same as the canonicalized path.
Some(simplified_canonicalized == simplified)
}
fn path_exists_case_sensitive_slow(&self, path: &SystemPath, prefix: &SystemPath) -> bool {
// Iterate over the sub-paths up to prefix and check if they match the casing as on disk.
for ancestor in path.ancestors() {
if ancestor == prefix {
break;
}
match self.inner.real_case_cache.has_name_case(ancestor) {
Ok(true) => {
// Component has correct casing, continue with next component
}
Ok(false) => {
// Component has incorrect casing
return false;
}
Err(_) => {
// Directory doesn't exist or can't be accessed. We can assume that the file with
// the given casing doesn't exist.
return false;
}
}
}
true
}
}
impl WritableSystem for OsSystem {
fn create_new_file(&self, path: &SystemPath) -> Result<()> {
std::fs::File::create_new(path).map(drop)
}
fn write_file(&self, path: &SystemPath, content: &str) -> Result<()> {
std::fs::write(path.as_std_path(), content)
}
fn create_directory_all(&self, path: &SystemPath) -> Result<()> {
std::fs::create_dir_all(path.as_std_path())
}
}
impl Default for OsSystem {
fn default() -> Self {
Self::new(
SystemPathBuf::from_path_buf(std::env::current_dir().unwrap_or_default())
.unwrap_or_default(),
)
}
}
#[derive(Debug, Default)]
struct CaseSensitivePathsCache {
by_lower_case: dashmap::DashMap<SystemPathBuf, ListedDirectory>,
}
impl CaseSensitivePathsCache {
/// Test if `path`'s file name uses the exact same casing as the file on disk.
///
/// Returns `false` if the file doesn't exist.
///
/// Components other than the file portion are ignored.
fn has_name_case(&self, path: &SystemPath) -> Result<bool> {
let Some(parent) = path.parent() else {
// The root path is always considered to exist.
return Ok(true);
};
let Some(file_name) = path.file_name() else {
// We can only get here for paths ending in `..` or the root path. Root paths are handled above.
// Return `true` for paths ending in `..` because `..` is the same regardless of casing.
return Ok(true);
};
let lower_case_path = SystemPathBuf::from(parent.as_str().to_lowercase());
let last_modification_time =
FileTime::from_last_modification_time(&parent.as_std_path().metadata()?);
let entry = self.by_lower_case.entry(lower_case_path);
if let dashmap::Entry::Occupied(entry) = &entry {
// Only do a cached lookup if the directory hasn't changed.
if entry.get().last_modification_time == last_modification_time {
tracing::trace!("Use cached case-sensitive entry for directory `{}`", parent);
return Ok(entry.get().names.contains(file_name));
}
}
tracing::trace!(
"Reading directory `{}` for its case-sensitive filenames",
parent
);
let start = std::time::Instant::now();
let mut names = FxHashSet::default();
for entry in parent.as_std_path().read_dir()? {
let Ok(entry) = entry else {
continue;
};
let Ok(name) = entry.file_name().into_string() else {
continue;
};
names.insert(name.into_boxed_str());
}
let directory = entry.insert(ListedDirectory {
last_modification_time,
names,
});
tracing::debug!(
"Caching the case-sensitive paths for directory `{parent}` took {:?}",
start.elapsed()
);
Ok(directory.names.contains(file_name))
}
}
impl RefUnwindSafe for CaseSensitivePathsCache {}
#[derive(Debug, Eq, PartialEq)]
struct ListedDirectory {
last_modification_time: FileTime,
names: FxHashSet<Box<str>>,
}
#[derive(Debug)]
struct OsDirectoryWalker;
impl DirectoryWalker for OsDirectoryWalker {
fn walk(
&self,
visitor_builder: &mut dyn WalkDirectoryVisitorBuilder,
configuration: WalkDirectoryConfiguration,
) {
let WalkDirectoryConfiguration {
paths,
ignore_hidden: hidden,
standard_filters,
} = configuration;
let Some((first, additional)) = paths.split_first() else {
return;
};
let mut builder = ignore::WalkBuilder::new(first.as_std_path());
builder.standard_filters(standard_filters);
builder.hidden(hidden);
for additional_path in additional {
builder.add(additional_path.as_std_path());
}
builder.threads(max_parallelism().min(NonZeroUsize::new(12).unwrap()).get());
builder.build_parallel().run(|| {
let mut visitor = visitor_builder.build();
Box::new(move |entry| {
match entry {
Ok(entry) => {
// SAFETY: The walkdir crate supports `stdin` files and `file_type` can be `None` for these files.
// We don't make use of this feature, which is why unwrapping here is ok.
let file_type = entry.file_type().unwrap();
let depth = entry.depth();
// `walkdir` reports errors related to parsing ignore files as part of the entry.
// These aren't fatal for us. We should keep going even if an ignore file contains a syntax error.
// But we log the error here for better visibility (same as ripgrep, Ruff ignores it)
if let Some(error) = entry.error() {
tracing::warn!("{error}");
}
match SystemPathBuf::from_path_buf(entry.into_path()) {
Ok(path) => {
let directory_entry = walk_directory::DirectoryEntry {
path,
file_type: file_type.into(),
depth,
};
visitor.visit(Ok(directory_entry)).into()
}
Err(path) => {
visitor.visit(Err(walk_directory::Error {
depth: Some(depth),
kind: walk_directory::ErrorKind::NonUtf8Path { path },
}));
// Skip the entire directory because all the paths won't be UTF-8 paths.
ignore::WalkState::Skip
}
}
}
Err(error) => match ignore_to_walk_directory_error(error, None, None) {
Ok(error) => visitor.visit(Err(error)).into(),
Err(error) => {
// This should only be reached when the error is a `.ignore` file related error
// (which, should not be reported here but the `ignore` crate doesn't distinguish between ignore and IO errors).
// Let's log the error to at least make it visible.
tracing::warn!("Failed to traverse directory: {error}.");
ignore::WalkState::Continue
}
},
}
})
});
}
}
#[cold]
fn ignore_to_walk_directory_error(
error: ignore::Error,
path: Option<PathBuf>,
depth: Option<usize>,
) -> std::result::Result<walk_directory::Error, ignore::Error> {
use ignore::Error;
match error {
Error::WithPath { path, err } => ignore_to_walk_directory_error(*err, Some(path), depth),
Error::WithDepth { err, depth } => ignore_to_walk_directory_error(*err, path, Some(depth)),
Error::WithLineNumber { err, .. } => ignore_to_walk_directory_error(*err, path, depth),
Error::Loop { child, ancestor } => {
match (
SystemPathBuf::from_path_buf(child),
SystemPathBuf::from_path_buf(ancestor),
) {
(Ok(child), Ok(ancestor)) => Ok(walk_directory::Error {
depth,
kind: walk_directory::ErrorKind::Loop { child, ancestor },
}),
(Err(child), _) => Ok(walk_directory::Error {
depth,
kind: walk_directory::ErrorKind::NonUtf8Path { path: child },
}),
// We should never reach this because we should never traverse into a non UTF8 path but handle it anyway.
(_, Err(ancestor)) => Ok(walk_directory::Error {
depth,
kind: walk_directory::ErrorKind::NonUtf8Path { path: ancestor },
}),
}
}
Error::Io(err) => match path.map(SystemPathBuf::from_path_buf).transpose() {
Ok(path) => Ok(walk_directory::Error {
depth,
kind: walk_directory::ErrorKind::Io { path, err },
}),
Err(path) => Ok(walk_directory::Error {
depth,
kind: walk_directory::ErrorKind::NonUtf8Path { path },
}),
},
// Ignore related errors, we warn about them but we don't abort iteration because of them.
error @ (Error::Glob { .. }
| Error::UnrecognizedFileType(_)
| Error::InvalidDefinition
| Error::Partial(..)) => Err(error),
}
}
impl From<std::fs::FileType> for FileType {
fn from(file_type: std::fs::FileType) -> Self {
if file_type.is_file() {
FileType::File
} else if file_type.is_dir() {
FileType::Directory
} else {
FileType::Symlink
}
}
}
impl From<WalkState> for ignore::WalkState {
fn from(value: WalkState) -> Self {
match value {
WalkState::Continue => ignore::WalkState::Continue,
WalkState::Skip => ignore::WalkState::Skip,
WalkState::Quit => ignore::WalkState::Quit,
}
}
}
fn not_found() -> std::io::Error {
std::io::Error::new(std::io::ErrorKind::NotFound, "No such file or directory")
}
#[cfg(feature = "testing")]
pub(super) mod testing {
use crate::system::{OsSystem, SystemPathBuf};
impl OsSystem {
/// Overrides the user configuration directory for the current scope
/// (for as long as the returned override is not dropped).
pub fn with_user_config_directory(
&self,
directory: Option<SystemPathBuf>,
) -> UserConfigDirectoryOverrideGuard {
let mut directory_override = self.inner.user_config_directory_override.lock().unwrap();
let previous = directory_override.replace(directory);
UserConfigDirectoryOverrideGuard {
previous,
system: self.clone(),
}
}
/// Returns [`Ok`] if any override is set and [`Err`] otherwise.
pub(super) fn try_get_user_config_directory_override(
&self,
) -> Result<Option<SystemPathBuf>, ()> {
let directory_override = self.inner.user_config_directory_override.lock().unwrap();
match directory_override.as_ref() {
Some(directory_override) => Ok(directory_override.clone()),
None => Err(()),
}
}
}
/// A scoped override of the [user's configuration directory](crate::System::user_config_directory) for the [`OsSystem`].
///
/// Prefer overriding the user's configuration directory for tests that require
/// spawning a new process (e.g. CLI tests) by setting the `APPDATA` (windows)
/// or `XDG_CONFIG_HOME` (unix and other platforms) environment variables.
/// For example, by setting the environment variables when invoking the CLI with insta.
///
/// Requires the `testing` feature.
#[must_use]
pub struct UserConfigDirectoryOverrideGuard {
previous: Option<Option<SystemPathBuf>>,
system: OsSystem,
}
impl Drop for UserConfigDirectoryOverrideGuard {
fn drop(&mut self) {
if let Ok(mut directory_override) =
self.system.inner.user_config_directory_override.try_lock()
{
*directory_override = self.previous.take();
}
}
}
}
#[cfg(not(unix))]
fn detect_case_sensitivity(_path: &SystemPath) -> CaseSensitivity {
// 99% of windows systems aren't case sensitive Don't bother checking.
CaseSensitivity::Unknown
}
#[cfg(unix)]
fn detect_case_sensitivity(path: &SystemPath) -> CaseSensitivity {
use std::os::unix::fs::MetadataExt;
let Ok(original_case_metadata) = path.as_std_path().metadata() else {
return CaseSensitivity::Unknown;
};
let upper_case = SystemPathBuf::from(path.as_str().to_uppercase());
if &*upper_case == path {
return CaseSensitivity::Unknown;
}
match upper_case.as_std_path().metadata() {
Ok(uppercase_meta) => {
// The file system is case insensitive if the upper case and mixed case paths have the same inode.
if uppercase_meta.ino() == original_case_metadata.ino() {
CaseSensitivity::CaseInsensitive
} else {
CaseSensitivity::CaseSensitive
}
}
// In the error case, the file system is case sensitive if the file in all upper case doesn't exist.
Err(error) => {
if error.kind() == std::io::ErrorKind::NotFound {
CaseSensitivity::CaseSensitive
} else {
CaseSensitivity::Unknown
}
}
}
}
#[cfg(test)]
mod tests {
use tempfile::TempDir;
use crate::system::DirectoryEntry;
use crate::system::walk_directory::tests::DirectoryEntryToString;
use super::*;
#[test]
fn read_directory() {
let tempdir = TempDir::new().unwrap();
let tempdir_path = tempdir.path();
std::fs::create_dir_all(tempdir_path.join("a/foo")).unwrap();
let files = &["b.ts", "a/bar.py", "d.rs", "a/foo/bar.py", "a/baz.pyi"];
for path in files {
std::fs::File::create(tempdir_path.join(path)).unwrap();
}
let tempdir_path = SystemPath::from_std_path(tempdir_path).unwrap();
let fs = OsSystem::new(tempdir_path);
let mut sorted_contents: Vec<DirectoryEntry> = fs
.read_directory(&tempdir_path.join("a"))
.unwrap()
.map(Result::unwrap)
.collect();
sorted_contents.sort_by(|a, b| a.path.cmp(&b.path));
let expected_contents = vec![
DirectoryEntry::new(tempdir_path.join("a/bar.py"), FileType::File),
DirectoryEntry::new(tempdir_path.join("a/baz.pyi"), FileType::File),
DirectoryEntry::new(tempdir_path.join("a/foo"), FileType::Directory),
];
assert_eq!(sorted_contents, expected_contents)
}
#[test]
fn read_directory_nonexistent() {
let tempdir = TempDir::new().unwrap();
let fs = OsSystem::new(SystemPath::from_std_path(tempdir.path()).unwrap());
let result = fs.read_directory(SystemPath::new("doesnt_exist"));
assert!(result.is_err_and(|error| error.kind() == std::io::ErrorKind::NotFound));
}
#[test]
fn read_directory_on_file() {
let tempdir = TempDir::new().unwrap();
let tempdir_path = tempdir.path();
std::fs::File::create(tempdir_path.join("a.py")).unwrap();
let tempdir_path = SystemPath::from_std_path(tempdir_path).unwrap();
let fs = OsSystem::new(tempdir_path);
let result = fs.read_directory(&tempdir_path.join("a.py"));
let Err(error) = result else {
panic!("Expected the read_dir() call to fail!");
};
// We can't assert the error kind here because it's apparently an unstable feature!
// https://github.com/rust-lang/rust/issues/86442
// assert_eq!(error.kind(), std::io::ErrorKind::NotADirectory);
// We can't even assert the error message on all platforms, as it's different on Windows,
// where the message is "The directory name is invalid" rather than "Not a directory".
if cfg!(unix) {
assert!(error.to_string().contains("Not a directory"));
}
}
#[test]
fn walk_directory() -> std::io::Result<()> {
let tempdir = TempDir::new()?;
let root = tempdir.path();
std::fs::create_dir_all(root.join("a/b"))?;
std::fs::write(root.join("foo.py"), "print('foo')")?;
std::fs::write(root.join("a/bar.py"), "print('bar')")?;
std::fs::write(root.join("a/baz.py"), "print('baz')")?;
std::fs::write(root.join("a/b/c.py"), "print('c')")?;
let root_sys = SystemPath::from_std_path(root).unwrap();
let system = OsSystem::new(root_sys);
let writer = DirectoryEntryToString::new(root_sys.to_path_buf());
system.walk_directory(root_sys).run(|| {
Box::new(|entry| {
writer.write_entry(entry);
WalkState::Continue
})
});
assert_eq!(
writer.to_string(),
r#"{
"": (
Directory,
0,
),
"a": (
Directory,
1,
),
"a/b": (
Directory,
2,
),
"a/b/c.py": (
File,
3,
),
"a/bar.py": (
File,
2,
),
"a/baz.py": (
File,
2,
),
"foo.py": (
File,
1,
),
}"#
);
Ok(())
}
#[test]
fn walk_directory_ignore() -> std::io::Result<()> {
let tempdir = TempDir::new()?;
let root = tempdir.path();
std::fs::create_dir_all(root.join("a/b"))?;
std::fs::write(root.join("foo.py"), "print('foo')\n")?;
std::fs::write(root.join("a/bar.py"), "print('bar')\n")?;
std::fs::write(root.join("a/baz.py"), "print('baz')\n")?;
// Exclude the `b` directory.
std::fs::write(root.join("a/.ignore"), "b/\n")?;
std::fs::write(root.join("a/b/c.py"), "print('c')\n")?;
let root_sys = SystemPath::from_std_path(root).unwrap();
let system = OsSystem::new(root_sys);
let writer = DirectoryEntryToString::new(root_sys.to_path_buf());
system
.walk_directory(root_sys)
.standard_filters(true)
.run(|| {
Box::new(|entry| {
writer.write_entry(entry);
WalkState::Continue
})
});
assert_eq!(
writer.to_string(),
r#"{
"": (
Directory,
0,
),
"a": (
Directory,
1,
),
"a/bar.py": (
File,
2,
),
"a/baz.py": (
File,
2,
),
"foo.py": (
File,
1,
),
}"#
);
Ok(())
}
#[test]
fn walk_directory_file() -> std::io::Result<()> {
let tempdir = TempDir::new()?;
let root = tempdir.path();
std::fs::write(root.join("foo.py"), "print('foo')\n")?;
let root_sys = SystemPath::from_std_path(root).unwrap();
let system = OsSystem::new(root_sys);
let writer = DirectoryEntryToString::new(root_sys.to_path_buf());
system
.walk_directory(&root_sys.join("foo.py"))
.standard_filters(true)
.run(|| {
Box::new(|entry| {
writer.write_entry(entry);
WalkState::Continue
})
});
assert_eq!(
writer.to_string(),
r#"{
"foo.py": (
File,
0,
),
}"#
);
Ok(())
}
}