Repository: sharkdp/fd
Pull request #812 (2021-08-08 16:10:24)

Append trailing path separators to directories
Solves #436.

Add a trailing slash (or custom path separator, if provided) to directories.
This only affects output; path args for `exec` are processed normally.


sharkdp: Thank you very much for your contribution!



>  When `--path-separator` is supplied display that instead.

:+1: 

>  Also, pass `-p` to `ls` when using `--list-details`.

Nice! ... but:

> Add a `-T/--trailing-slash` flag to help distinguish files from directories when not using colors.
> Do we go with `-T`?

As my comments in #436 suggest (*"What I would vote against is having a new command line option for this."*), I would rather not add a new command-line option for this, but rather make this the new default, if we decide to go for it. Now that I think about it again, I'm not sure why I was so strictly against adding a new option. Maybe because it makes interaction with other tools potentially inconsistent (if they can't rely on it behaving either way). I would not add a new short flag for this, however. `--trailing-slash` is okay. `--trailing-path-separator` would be more accurate. This seems to be an option that users either use in scripts or in an `alias fd="fd --trailing-path-separator"`, so the length of the name shouldn't matter too much. What do you think?

> To determine if the entry is a directory, we make an additional `stat` call. This shouldn't be that significant performance-wise, but maybe we could cache the result from the colorization and use that instead?

`stat` calls can definitely be the bottleneck. Especially if there are MANY search results. I'm pretty sure we can design a benchmark to measure the difference (unless the OS somehow caches the results?). We should definitely look at this.
tmccombs: We already have to know if it is a directory to know if it should be recursively searched right? Could that be propogated to where we print the path to avoid an additional stat call?
yyogo: @sharkdp - renaming the flag and dropping the short flag seems fine IMO, I mostly added it as a placeholder anyway.
yyogo: I've refactored WorkerResult to contain a DirEntry instead of just the path, this seems to slightly improve performance since we don't need to reallocate the path buffer, and makes the additional stat call unnecessary.
yyogo: another consideration: GNU ls allows setting an arbitrary "indicator" for directories rather than just a path separator. Do we want to support this feature as well?
sharkdp: > I've refactored WorkerResult to contain a DirEntry instead of just the path, this seems to slightly improve the performance of `spawn_senders()` since we don't need to reallocate the path buffer, and makes the additional stat call unnecessary.

Thank you very much. Are you still available to work on this? Can we get rid of the new command-line option and make this the new default?
yyogo: Hey, yeah I can fix the conflicts and flip the flag so it can be disabled, would that be ok?
sharkdp: > Hey, yeah I can fix the conflicts and flip the flag so it can be disabled, would that be ok?

sounds great
sharkdp: I would actually still prefer to completely remove the command-line option. We don't need everything to be configurable in `fd`. It's an "opinionated" tool with a (hopefully) good set of defaults. And I don't really see why someone would want to remove the trailing slash except for aesthetic reasons. Do you?
wookayin: I second this. Removing trailing slashes is fairly easy using regex, but the opposite (adding trailing slashes only if a directory) is hard so why we are implementing this feature.

However, one possible scenario I could imagine is the backward compatibility. For example, we ship this feature from some particular version, say fd v8.3. From fd 8.3+ it will show trailing spaces by default. Some scripts or other tools written in the future would assume this default behavior of trailing slashes on directories when manipulating paths, but when it runs with *older versions of fd* (which can happen in many circumstances, e.g., a user haven't updated fd yet) it will behave differently. For example,`somedirectorysomefile` instead of `somedirectory/somefile`. If we had an explicit command-line option to be fail-proof, it would instead throw an error. I am not saying that the default shouldn't change --- I agree directories should have trailing slashes appended.

That said, this would be just a rare corner cases so I'm happy with changing the behavior to as we wish in #436. Having command-line options doesn't completely eliminate the possibility of such cases and users can rely on version checking or something like that..
tmccombs: It looks like there are now some merge conflicts. @yyogo would you be able to resolve those?
yyogo: I'm on it
yyogo: there are a bunch of tests I have to fix, that might take a bit
sharkdp: The test changes will likely clash with the ones in https://github.com/sharkdp/fd/pull/861. So let's maybe wait for that to be merged?
yyogo: there's a pretty severe regression with `--color=never` (~2x time), I assume it's because we now have to stat each file to check if it's a folder
yyogo: @sharkdp ready to merge

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2a6e89d..5c2cce9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,6 +14,7 @@
 ## Changes
 
 - Changed `-u` flag to be equivalent to `-HI`. Multiple `-u` flags still allowed but do nothing, see #840 (@jacksontheel)
+- Directories are now printed with an additional path separator at the end: `foo/bar/`, see #436 and #812 (@yyogo)
 
 ## Other
 
diff --git a/src/config.rs b/src/config.rs
index 697744b..79cad4d 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -111,6 +111,9 @@ pub struct Config {
     /// The separator used to print file paths.
     pub path_separator: Option<String>,
 
+    /// The actual separator, either the system default separator or `path_separator`
+    pub actual_path_separator: String,
+
     /// The maximum number of search results
     pub max_results: Option<usize>,
 
diff --git a/src/dir_entry.rs b/src/dir_entry.rs
new file mode 100644
index 0000000..5def5de
--- /dev/null
+++ b/src/dir_entry.rs
@@ -0,0 +1,92 @@
+use std::{
+    fs::{FileType, Metadata},
+    path::{Path, PathBuf},
+};
+
+use once_cell::unsync::OnceCell;
+
+enum DirEntryInner {
+    Normal(ignore::DirEntry),
+    BrokenSymlink(PathBuf),
+}
+
+pub struct DirEntry {
+    inner: DirEntryInner,
+    metadata: OnceCell<Option<Metadata>>,
+}
+
+impl DirEntry {
+    #[inline]
+    pub fn normal(e: ignore::DirEntry) -> Self {
+        Self {
+            inner: DirEntryInner::Normal(e),
+            metadata: OnceCell::new(),
+        }
+    }
+
+    pub fn broken_symlink(path: PathBuf) -> Self {
+        Self {
+            inner: DirEntryInner::BrokenSymlink(path),
+            metadata: OnceCell::new(),
+        }
+    }
+
+    pub fn path(&self) -> &Path {
+        match &self.inner {
+            DirEntryInner::Normal(e) => e.path(),
+            DirEntryInner::BrokenSymlink(pathbuf) => pathbuf.as_path(),
+        }
+    }
+
+    pub fn into_path(self) -> PathBuf {
+        match self.inner {
+            DirEntryInner::Normal(e) => e.into_path(),
+            DirEntryInner::BrokenSymlink(p) => p,
+        }
+    }
+
+    pub fn file_type(&self) -> Option<FileType> {
+        match &self.inner {
+            DirEntryInner::Normal(e) => e.file_type(),
+            DirEntryInner::BrokenSymlink(_) => self.metadata().map(|m| m.file_type()),
+        }
+    }
+
+    pub fn metadata(&self) -> Option<&Metadata> {
+        self.metadata
+            .get_or_init(|| match &self.inner {
+                DirEntryInner::Normal(e) => e.metadata().ok(),
+                DirEntryInner::BrokenSymlink(path) => path.symlink_metadata().ok(),
+            })
+            .as_ref()
+    }
+
+    pub fn depth(&self) -> Option<usize> {
+        match &self.inner {
+            DirEntryInner::Normal(e) => Some(e.depth()),
+            DirEntryInner::BrokenSymlink(_) => None,
+        }
+    }
+}
+
+impl PartialEq for DirEntry {
+    #[inline]
+    fn eq(&self, other: &Self) -> bool {
+        self.path() == other.path()
+    }
+}
+impl Eq for DirEntry {}
+
+impl PartialOrd for DirEntry {
+    #[inline]
+    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
+        self.path().partial_cmp(other.path())
+    }
+}
+
+impl Ord for DirEntry {
+    #[inline]
+    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
+        self.path().cmp(other.path())
+    }
+}
diff --git a/src/exec/job.rs b/src/exec/job.rs
index f192543..304792b 100644
--- a/src/exec/job.rs
+++ b/src/exec/job.rs
@@ -1,7 +1,7 @@
-use std::path::PathBuf;
 use std::sync::mpsc::Receiver;
 use std::sync::{Arc, Mutex};
 
+use crate::dir_entry::DirEntry;
 use crate::error::print_error;
 use crate::exit_codes::{merge_exitcodes, ExitCode};
 use crate::walk::WorkerResult;
@@ -25,8 +25,8 @@ pub fn job(
 
         // Obtain the next result from the receiver, else if the channel
         // has closed, exit from the loop
-        let value: PathBuf = match lock.recv() {
-            Ok(WorkerResult::Entry(path)) => path,
+        let dir_entry: DirEntry = match lock.recv() {
+            Ok(WorkerResult::Entry(dir_entry)) => dir_entry,
             Ok(WorkerResult::Error(err)) => {
                 if show_filesystem_errors {
                     print_error(err.to_string());
@@ -39,7 +39,7 @@ pub fn job(
         // Drop the lock so that other threads can read from the receiver.
         drop(lock);
         // Generate a command, execute it and store its exit code.
-        results.push(cmd.execute(&value, Arc::clone(&out_perm), buffer_output))
+        results.push(cmd.execute(dir_entry.path(), Arc::clone(&out_perm), buffer_output))
     }
     // Returns error in case of any error.
     merge_exitcodes(results)
@@ -51,15 +51,17 @@ pub fn batch(
     show_filesystem_errors: bool,
     limit: usize,
 ) -> ExitCode {
-    let paths = rx.iter().filter_map(|value| match value {
-        WorkerResult::Entry(path) => Some(path),
-        WorkerResult::Error(err) => {
-            if show_filesystem_errors {
-                print_error(err.to_string());
+    let paths = rx
+        .into_iter()
+        .filter_map(|worker_result| match worker_result {
+            WorkerResult::Entry(dir_entry) => Some(dir_entry.into_path()),
+            WorkerResult::Error(err) => {
+                if show_filesystem_errors {
+                    print_error(err.to_string());
+                }
+                None
             }
-            None
-        }
-    });
+        });
     if limit == 0 {
         // no limit
         return cmd.execute_batch(paths);
diff --git a/src/filesystem.rs b/src/filesystem.rs
index 6680ae5..a025895 100644
--- a/src/filesystem.rs
+++ b/src/filesystem.rs
@@ -9,7 +9,7 @@ use std::path::{Path, PathBuf};
 
 use normpath::PathExt;
 
-use crate::walk;
+use crate::dir_entry;
 
 pub fn path_absolute_form(path: &Path) -> io::Result<PathBuf> {
     if path.is_absolute() {
@@ -51,7 +51,7 @@ pub fn is_executable(_: &fs::Metadata) -> bool {
     false
 }
 
-pub fn is_empty(entry: &walk::DirEntry) -> bool {
+pub fn is_empty(entry: &dir_entry::DirEntry) -> bool {
     if let Some(file_type) = entry.file_type() {
         if file_type.is_dir() {
             if let Ok(mut entries) = fs::read_dir(entry.path()) {
diff --git a/src/filetypes.rs b/src/filetypes.rs
index fc0962d..1209549 100644
--- a/src/filetypes.rs
+++ b/src/filetypes.rs
@@ -1,5 +1,5 @@
+use crate::dir_entry;
 use crate::filesystem;
-use crate::walk;
 
 /// Whether or not to show
 #[derive(Default)]
@@ -14,7 +14,7 @@ pub struct FileTypes {
 }
 
 impl FileTypes {
-    pub fn should_ignore(&self, entry: &walk::DirEntry) -> bool {
+    pub fn should_ignore(&self, entry: &dir_entry::DirEntry) -> bool {
         if let Some(ref entry_type) = entry.file_type() {
             (!self.files && entry_type.is_file())
                 || (!self.directories && entry_type.is_dir())
diff --git a/src/main.rs b/src/main.rs
index 6909ebf..9c16ff0 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,5 +1,6 @@
 mod app;
 mod config;
+mod dir_entry;
 mod error;
 mod exec;
 mod exit_codes;
@@ -221,6 +222,9 @@ fn construct_config(matches: clap::ArgMatches, pattern_regex: &str) -> Result<Co
     let path_separator = matches
         .value_of("path-separator")
         .map_or_else(filesystem::default_path_separator, |s| Some(s.to_owned()));
+    let actual_path_separator = path_separator
+        .clone()
+        .unwrap_or_else(|| std::path::MAIN_SEPARATOR.to_string());
     check_path_separator_length(path_separator.as_deref())?;
 
     let size_limits = extract_size_limits(&matches)?;
@@ -367,6 +371,7 @@ fn construct_config(matches: clap::ArgMatches, pattern_regex: &str) -> Result<Co
         owner_constraint,
         show_filesystem_errors: matches.is_present("show-errors"),
         path_separator,
+        actual_path_separator,
         max_results: matches
             .value_of("max-results")
             .map(|n| n.parse::<usize>())
diff --git a/src/output.rs b/src/output.rs
index bcb9505..261dbf3 100644
--- a/src/output.rs
+++ b/src/output.rs
@@ -5,6 +5,7 @@ use std::path::Path;
 use lscolors::{Indicator, LsColors, Style};
 
 use crate::config::Config;
+use crate::dir_entry::DirEntry;
 use crate::error::print_error;
 use crate::exit_codes::ExitCode;
 use crate::filesystem::strip_current_dir;
@@ -13,18 +14,21 @@ fn replace_path_separator(path: &str, new_path_separator: &str) -> String {
     path.replace(std::path::MAIN_SEPARATOR, new_path_separator)
 }
 
-// TODO: this function is performance critical and can probably be optimized
-pub fn print_entry<W: Write>(stdout: &mut W, entry: &Path, config: &Config) {
-    let path = if config.strip_cwd_prefix {
-        strip_current_dir(entry)
+fn stripped_path<'a>(entry: &'a DirEntry, config: &Config) -> &'a Path {
+    let path = entry.path();
+    if config.strip_cwd_prefix {
+        strip_current_dir(path)
     } else {
-        entry
-    };
+        path
+    }
+}
 
+// TODO: this function is performance critical and can probably be optimized
+pub fn print_entry<W: Write>(stdout: &mut W, entry: &DirEntry, config: &Config) {
     let r = if let Some(ref ls_colors) = config.ls_colors {
-        print_entry_colorized(stdout, path, config, ls_colors)
+        print_entry_colorized(stdout, entry, config, ls_colors)
     } else {
-        print_entry_uncolorized(stdout, path, config)
+        print_entry_uncolorized(stdout, entry, config)
     };
 
     if let Err(e) = r {
@@ -38,15 +42,39 @@ pub fn print_entry<W: Write>(stdout: &mut W, entry: &Path, config: &Config) {
     }
 }
 
+// Display a trailing slash if the path is a directory and the config option is enabled.
+// If the path_separator option is set, display that instead.
+// The trailing slash will not be colored.
+#[inline]
+fn print_trailing_slash<W: Write>(
+    stdout: &mut W,
+    entry: &DirEntry,
+    config: &Config,
+    style: Option<&Style>,
+) -> io::Result<()> {
+    if entry.file_type().map_or(false, |ft| ft.is_dir()) {
+        write!(
+            stdout,
+            "{}",
+            style
+                .map(Style::to_ansi_term_style)
+                .unwrap_or_default()
+                .paint(&config.actual_path_separator)
+        )?;
+    }
+    Ok(())
+}
+
 // TODO: this function is performance critical and can probably be optimized
 fn print_entry_colorized<W: Write>(
     stdout: &mut W,
-    path: &Path,
+    entry: &DirEntry,
     config: &Config,
     ls_colors: &LsColors,
 ) -> io::Result<()> {
     // Split the path between the parent and the last component
     let mut offset = 0;
+    let path = stripped_path(entry, config);
     let path_str = path.to_string_lossy();
 
     if let Some(parent) = path.parent() {
@@ -74,11 +102,18 @@ fn print_entry_colorized<W: Write>(
     }
 
     let style = ls_colors
-        .style_for_path(path)
+        .style_for_path_with_metadata(path, entry.metadata())
         .map(Style::to_ansi_term_style)
         .unwrap_or_default();
     write!(stdout, "{}", style.paint(&path_str[offset..]))?;
 
+    print_trailing_slash(
+        stdout,
+        entry,
+        config,
+        ls_colors.style_for_indicator(Indicator::Directory),
+    )?;
+
     if config.null_separator {
         write!(stdout, "\0")?;
     } else {
@@ -91,42 +126,46 @@ fn print_entry_colorized<W: Write>(
 // TODO: this function is performance critical and can probably be optimized
 fn print_entry_uncolorized_base<W: Write>(
     stdout: &mut W,
-    path: &Path,
+    entry: &DirEntry,
     config: &Config,
 ) -> io::Result<()> {
     let separator = if config.null_separator { "\0" } else { "\n" };
+    let path = stripped_path(entry, config);
 
     let mut path_string = path.to_string_lossy();
     if let Some(ref separator) = config.path_separator {
         *path_string.to_mut() = replace_path_separator(&path_string, separator);
     }
-    write!(stdout, "{}{}", path_string, separator)
+    write!(stdout, "{}", path_string)?;
+    print_trailing_slash(stdout, entry, config, None)?;
+    write!(stdout, "{}", separator)
 }
 
 #[cfg(not(unix))]
 fn print_entry_uncolorized<W: Write>(
     stdout: &mut W,
-    path: &Path,
+    entry: &DirEntry,
     config: &Config,
 ) -> io::Result<()> {
-    print_entry_uncolorized_base(stdout, path, config)
+    print_entry_uncolorized_base(stdout, entry, config)
 }
 
 #[cfg(unix)]
 fn print_entry_uncolorized<W: Write>(
     stdout: &mut W,
-    path: &Path,
+    entry: &DirEntry,
     config: &Config,
 ) -> io::Result<()> {
     use std::os::unix::ffi::OsStrExt;
 
     if config.interactive_terminal || config.path_separator.is_some() {
         // Fall back to the base implementation
-        print_entry_uncolorized_base(stdout, path, config)
+        print_entry_uncolorized_base(stdout, entry, config)
     } else {
         // Print path as raw bytes, allowing invalid UTF-8 filenames to be passed to other processes
         let separator = if config.null_separator { b"\0" } else { b"\n" };
-        stdout.write_all(path.as_os_str().as_bytes())?;
+        stdout.write_all(stripped_path(entry, config).as_os_str().as_bytes())?;
+        print_trailing_slash(stdout, entry, config, None)?;
         stdout.write_all(separator)
     }
 }
diff --git a/src/walk.rs b/src/walk.rs
index d947ade..463417e 100644
--- a/src/walk.rs
+++ b/src/walk.rs
@@ -1,8 +1,7 @@
 use std::ffi::OsStr;
-use std::fs::{FileType, Metadata};
 use std::io;
 use std::mem;
-use std::path::{Path, PathBuf};
+use std::path::PathBuf;
 use std::sync::atomic::{AtomicBool, Ordering};
 use std::sync::mpsc::{channel, Receiver, RecvTimeoutError, Sender};
 use std::sync::{Arc, Mutex};
@@ -13,10 +12,10 @@ use std::{borrow::Cow, io::Write};
 use anyhow::{anyhow, Result};
 use ignore::overrides::OverrideBuilder;
 use ignore::{self, WalkBuilder};
-use once_cell::unsync::OnceCell;
 use regex::bytes::Regex;
 
 use crate::config::Config;
+use crate::dir_entry::DirEntry;
 use crate::error::print_error;
 use crate::exec;
 use crate::exit_codes::{merge_exitcodes, ExitCode};
@@ -36,7 +35,7 @@ enum ReceiverMode {
 
 /// The Worker threads can result in a valid entry having PathBuf or an error.
 pub enum WorkerResult {
-    Entry(PathBuf),
+    Entry(DirEntry),
     Error(ignore::Error),
 }
 
@@ -181,7 +180,7 @@ struct ReceiverBuffer<W> {
     /// The deadline to switch to streaming mode.
     deadline: Instant,
     /// The buffer of quickly received paths.
-    buffer: Vec<PathBuf>,
+    buffer: Vec<DirEntry>,
     /// Result count.
     num_results: usize,
 }
@@ -242,20 +241,20 @@ impl<W: Write> ReceiverBuffer<W> {
     /// Wait for a result or state change.
     fn poll(&mut self) -> Result<(), ExitCode> {
         match self.recv() {
-            Ok(WorkerResult::Entry(path)) => {
+            Ok(WorkerResult::Entry(dir_entry)) => {
                 if self.config.quiet {
                     return Err(ExitCode::HasResults(true));
                 }
 
                 match self.mode {
                     ReceiverMode::Buffering => {
-                        self.buffer.push(path);
+                        self.buffer.push(dir_entry);
                         if self.buffer.len() > MAX_BUFFER_LENGTH {
                             self.stream()?;
                         }
                     }
                     ReceiverMode::Streaming => {
-                        self.print(&path)?;
+                        self.print(&dir_entry)?;
                         self.flush()?;
                     }
                 }
@@ -284,8 +283,8 @@ impl<W: Write> ReceiverBuffer<W> {
     }
 
     /// Output a path.
-    fn print(&mut self, path: &Path) -> Result<(), ExitCode> {
-        output::print_entry(&mut self.stdout, path, &self.config);
+    fn print(&mut self, entry: &DirEntry) -> Result<(), ExitCode> {
+        output::print_entry(&mut self.stdout, entry, &self.config);
 
         if self.interrupt_flag.load(Ordering::Relaxed) {
             // Ignore any errors on flush, because we're about to exit anyway
@@ -396,62 +395,6 @@ fn spawn_receiver(
     })
 }
 
-enum DirEntryInner {
-    Normal(ignore::DirEntry),
-    BrokenSymlink(PathBuf),
-}
-
-pub struct DirEntry {
-    inner: DirEntryInner,
-    metadata: OnceCell<Option<Metadata>>,
-}
-
-impl DirEntry {
-    fn normal(e: ignore::DirEntry) -> Self {
-        Self {
-            inner: DirEntryInner::Normal(e),
-            metadata: OnceCell::new(),
-        }
-    }
-
-    fn broken_symlink(path: PathBuf) -> Self {
-        Self {
-            inner: DirEntryInner::BrokenSymlink(path),
-            metadata: OnceCell::new(),
-        }
-    }
-
-    pub fn path(&self) -> &Path {
-        match &self.inner {
-            DirEntryInner::Normal(e) => e.path(),
-            DirEntryInner::BrokenSymlink(pathbuf) => pathbuf.as_path(),
-        }
-    }
-
-    pub fn file_type(&self) -> Option<FileType> {
-        match &self.inner {
-            DirEntryInner::Normal(e) => e.file_type(),
-            DirEntryInner::BrokenSymlink(_) => self.metadata().map(|m| m.file_type()),
-        }
-    }
-
-    pub fn metadata(&self) -> Option<&Metadata> {
-        self.metadata
-            .get_or_init(|| match &self.inner {
-                DirEntryInner::Normal(e) => e.metadata().ok(),
-                DirEntryInner::BrokenSymlink(path) => path.symlink_metadata().ok(),
-            })
-            .as_ref()
-    }
-
-    pub fn depth(&self) -> Option<usize> {
-        match &self.inner {
-            DirEntryInner::Normal(e) => Some(e.depth()),
-            DirEntryInner::BrokenSymlink(_) => None,
-        }
-    }
-}
-
 fn spawn_senders(
     config: &Arc<Config>,
     quit_flag: &Arc<AtomicBool>,
@@ -602,7 +545,7 @@ fn spawn_senders(
                 }
             }
 
-            let send_result = tx_thread.send(WorkerResult::Entry(entry_path.to_owned()));
+            let send_result = tx_thread.send(WorkerResult::Entry(entry));
 
             if send_result.is_err() {
                 return ignore::WalkState::Quit;


Test patch:
diff --git a/tests/tests.rs b/tests/tests.rs
index fb4ec59..2b83e64 100644
--- a/tests/tests.rs
+++ b/tests/tests.rs
@@ -72,7 +72,7 @@ fn test_simple() {
         ./one/two/c.foo
         ./one/two/C.Foo2
         ./one/two/three/d.foo
-        ./one/two/three/directory_foo",
+        ./one/two/three/directory_foo/",
     );
 }
 
@@ -82,14 +82,14 @@ fn test_empty_pattern() {
     let te = TestEnv::new(DEFAULT_DIRS, DEFAULT_FILES);
     let expected = "./a.foo
     ./e1 e2
-    ./one
+    ./one/
     ./one/b.foo
-    ./one/two
+    ./one/two/
     ./one/two/c.foo
     ./one/two/C.Foo2
-    ./one/two/three
+    ./one/two/three/
     ./one/two/three/d.foo
-    ./one/two/three/directory_foo
+    ./one/two/three/directory_foo/
     ./symlink";
 
     te.assert_output(&["--regex"], expected);
@@ -171,24 +171,24 @@ fn test_explicit_root_path() {
         one/two/c.foo
         one/two/C.Foo2
         one/two/three/d.foo
-        one/two/three/directory_foo",
+        one/two/three/directory_foo/",
     );
 
     te.assert_output(
         &["foo", "one/two/three"],
         "one/two/three/d.foo
-        one/two/three/directory_foo",
+        one/two/three/directory_foo/",
     );
 
     te.assert_output_subdirectory(
-        "one/two",
+        "one/two/",
         &["foo", "../../"],
         "../../a.foo
         ../../one/b.foo
         ../../one/two/c.foo
         ../../one/two/C.Foo2
         ../../one/two/three/d.foo
-        ../../one/two/three/directory_foo",
+        ../../one/two/three/directory_foo/",
     );
 
     te.assert_output_subdirectory(
@@ -196,9 +196,9 @@ fn test_explicit_root_path() {
         &["", ".."],
         "../c.foo
         ../C.Foo2
-        ../three
+        ../three/
         ../three/d.foo
-        ../three/directory_foo",
+        ../three/directory_foo/",
     );
 }
 
@@ -387,7 +387,7 @@ fn test_full_path() {
             &format!("^{prefix}.*three.*foo$", prefix = prefix),
         ],
         "./one/two/three/d.foo
-        ./one/two/three/directory_foo",
+        ./one/two/three/directory_foo/",
     );
 }
 
@@ -404,7 +404,7 @@ fn test_hidden() {
         ./one/two/c.foo
         ./one/two/C.Foo2
         ./one/two/three/d.foo
-        ./one/two/three/directory_foo",
+        ./one/two/three/directory_foo/",
     );
 }
 
@@ -444,7 +444,7 @@ fn test_no_ignore() {
         ./one/two/c.foo
         ./one/two/C.Foo2
         ./one/two/three/d.foo
-        ./one/two/three/directory_foo",
+        ./one/two/three/directory_foo/",
     );
 
     te.assert_output(
@@ -457,7 +457,7 @@ fn test_no_ignore() {
         ./one/two/c.foo
         ./one/two/C.Foo2
         ./one/two/three/d.foo
-        ./one/two/three/directory_foo",
+        ./one/two/three/directory_foo/",
     );
 }
 
@@ -610,7 +610,7 @@ fn test_no_ignore_vcs() {
         ./one/two/c.foo
         ./one/two/C.Foo2
         ./one/two/three/d.foo
-        ./one/two/three/directory_foo",
+        ./one/two/three/directory_foo/",
     );
 }
 
@@ -664,7 +664,7 @@ fn test_no_ignore_aliases() {
         ./one/two/c.foo
         ./one/two/C.Foo2
         ./one/two/three/d.foo
-        ./one/two/three/directory_foo",
+        ./one/two/three/directory_foo/",
     );
 }
 
@@ -763,7 +763,7 @@ fn test_print0() {
         ./one/two/C.Foo2NULL
         ./one/two/c.fooNULL
         ./one/two/three/d.fooNULL
-        ./one/two/three/directory_fooNULL",
+        ./one/two/three/directory_foo/NULL",
     );
 }
 
@@ -776,12 +776,12 @@ fn test_max_depth() {
         &["--max-depth", "3"],
         "./a.foo
         ./e1 e2
-        ./one
+        ./one/
         ./one/b.foo
-        ./one/two
+        ./one/two/
         ./one/two/c.foo
         ./one/two/C.Foo2
-        ./one/two/three
+        ./one/two/three/
         ./symlink",
     );
 
@@ -789,9 +789,9 @@ fn test_max_depth() {
         &["--max-depth", "2"],
         "./a.foo
         ./e1 e2
-        ./one
+        ./one/
         ./one/b.foo
-        ./one/two
+        ./one/two/
         ./symlink",
     );
 
@@ -799,7 +799,7 @@ fn test_max_depth() {
         &["--max-depth", "1"],
         "./a.foo
         ./e1 e2
-        ./one
+        ./one/
         ./symlink",
     );
 }
@@ -813,15 +813,15 @@ fn test_min_depth() {
         &["--min-depth", "3"],
         "./one/two/c.foo
         ./one/two/C.Foo2
-        ./one/two/three
+        ./one/two/three/
         ./one/two/three/d.foo
-        ./one/two/three/directory_foo",
+        ./one/two/three/directory_foo/",
     );
 
     te.assert_output(
         &["--min-depth", "4"],
         "./one/two/three/d.foo
-        ./one/two/three/directory_foo",
+        ./one/two/three/directory_foo/",
     );
 }
 
@@ -834,7 +834,7 @@ fn test_exact_depth() {
         &["--exact-depth", "3"],
         "./one/two/c.foo
         ./one/two/C.Foo2
-        ./one/two/three",
+        ./one/two/three/",
     );
 }
 
@@ -854,19 +854,19 @@ fn test_prune() {
 
     te.assert_output(
         &["foo"],
-        "./foo
+        "./foo/
         ./foo/foo.file
         ./foo/bar/foo.file
         ./bar/foo.file
-        ./bar/foo
+        ./bar/foo/
         ./bar/foo/foo.file
         ./baz/foo.file",
     );
 
     te.assert_output(
         &["--prune", "foo"],
-        "./foo
-        ./bar/foo
+        "./foo/
+        ./bar/foo/
         ./bar/foo.file
         ./baz/foo.file",
     );
@@ -882,14 +882,14 @@ fn test_absolute_path() {
         &format!(
             "{abs_path}/a.foo
             {abs_path}/e1 e2
-            {abs_path}/one
+            {abs_path}/one/
             {abs_path}/one/b.foo
-            {abs_path}/one/two
+            {abs_path}/one/two/
             {abs_path}/one/two/c.foo
             {abs_path}/one/two/C.Foo2
-            {abs_path}/one/two/three
+            {abs_path}/one/two/three/
             {abs_path}/one/two/three/d.foo
-            {abs_path}/one/two/three/directory_foo
+            {abs_path}/one/two/three/directory_foo/
             {abs_path}/symlink",
             abs_path = &abs_path
         ),
@@ -903,7 +903,7 @@ fn test_absolute_path() {
             {abs_path}/one/two/c.foo
             {abs_path}/one/two/C.Foo2
             {abs_path}/one/two/three/d.foo
-            {abs_path}/one/two/three/directory_foo",
+            {abs_path}/one/two/three/directory_foo/",
             abs_path = &abs_path
         ),
     );
@@ -922,7 +922,7 @@ fn test_implicit_absolute_path() {
             {abs_path}/one/two/c.foo
             {abs_path}/one/two/C.Foo2
             {abs_path}/one/two/three/d.foo
-            {abs_path}/one/two/three/directory_foo",
+            {abs_path}/one/two/three/directory_foo/",
             abs_path = &abs_path
         ),
     );
@@ -942,7 +942,7 @@ fn test_normalized_absolute_path() {
             {abs_path}/one/two/c.foo
             {abs_path}/one/two/C.Foo2
             {abs_path}/one/two/three/d.foo
-            {abs_path}/one/two/three/directory_foo",
+            {abs_path}/one/two/three/directory_foo/",
             abs_path = &abs_path
         ),
     );
@@ -967,18 +967,18 @@ fn test_type() {
 
     te.assert_output(
         &["--type", "d"],
-        "./one
-        ./one/two
-        ./one/two/three
-        ./one/two/three/directory_foo",
+        "./one/
+        ./one/two/
+        ./one/two/three/
+        ./one/two/three/directory_foo/",
     );
 
     te.assert_output(
         &["--type", "d", "--type", "l"],
-        "./one
-        ./one/two
-        ./one/two/three
-        ./one/two/three/directory_foo
+        "./one/
+        ./one/two/
+        ./one/two/three/
+        ./one/two/three/directory_foo/
         ./symlink",
     );
 
@@ -1005,10 +1005,10 @@ fn test_type_executable() {
     te.assert_output(
         &["--type", "executable", "--type", "directory"],
         "./executable-file.sh
-        ./one
-        ./one/two
-        ./one/two/three
-        ./one/two/three/directory_foo",
+        ./one/
+        ./one/two/
+        ./one/two/three/
+        ./one/two/three/directory_foo/",
     );
 }
 
@@ -1025,18 +1025,18 @@ fn test_type_empty() {
     te.assert_output(
         &["--type", "empty"],
         "./0_bytes.foo
-        ./dir_empty",
+        ./dir_empty/",
     );
 
     te.assert_output(
         &["--type", "empty", "--type", "file", "--type", "directory"],
         "./0_bytes.foo
-        ./dir_empty",
+        ./dir_empty/",
     );
 
     te.assert_output(&["--type", "empty", "--type", "file"], "./0_bytes.foo");
 
-    te.assert_output(&["--type", "empty", "--type", "directory"], "./dir_empty");
+    te.assert_output(&["--type", "empty", "--type", "directory"], "./dir_empty/");
 }
 
 /// File extension (--extension)
@@ -1107,12 +1107,12 @@ fn test_no_extension() {
     te.assert_output(
         &["^[^.]+$"],
         "./aa
-        ./one
+        ./one/
         ./one/bb
-        ./one/two
-        ./one/two/three
+        ./one/two/
+        ./one/two/three/
         ./one/two/three/d
-        ./one/two/three/directory_foo
+        ./one/two/three/directory_foo/
         ./symlink",
     );
 
@@ -1152,14 +1152,14 @@ fn test_symlink_as_root() {
             "{dir}/a.foo
             {dir}/broken_symlink
             {dir}/e1 e2
-            {dir}/one
+            {dir}/one/
             {dir}/one/b.foo
-            {dir}/one/two
+            {dir}/one/two/
             {dir}/one/two/c.foo
             {dir}/one/two/C.Foo2
-            {dir}/one/two/three
+            {dir}/one/two/three/
             {dir}/one/two/three/d.foo
-            {dir}/one/two/three/directory_foo
+            {dir}/one/two/three/directory_foo/
             {dir}/symlink",
             dir = &parent_parent
         ),
@@ -1178,9 +1178,9 @@ fn test_symlink_and_absolute_path() {
         &format!(
             "{abs_path}/{expected_path}/c.foo
             {abs_path}/{expected_path}/C.Foo2
-            {abs_path}/{expected_path}/three
+            {abs_path}/{expected_path}/three/
             {abs_path}/{expected_path}/three/d.foo
-            {abs_path}/{expected_path}/three/directory_foo",
+            {abs_path}/{expected_path}/three/directory_foo/",
             abs_path = &abs_path,
             expected_path = expected_path
         ),
@@ -1196,9 +1196,9 @@ fn test_symlink_as_absolute_root() {
         &format!(
             "{abs_path}/symlink/c.foo
             {abs_path}/symlink/C.Foo2
-            {abs_path}/symlink/three
+            {abs_path}/symlink/three/
             {abs_path}/symlink/three/d.foo
-            {abs_path}/symlink/three/directory_foo",
+            {abs_path}/symlink/three/directory_foo/",
             abs_path = &abs_path
         ),
     );
@@ -1220,9 +1220,9 @@ fn test_symlink_and_full_path() {
             &format!("^{prefix}.*three", prefix = prefix),
         ],
         &format!(
-            "{abs_path}/{expected_path}/three
+            "{abs_path}/{expected_path}/three/
             {abs_path}/{expected_path}/three/d.foo
-            {abs_path}/{expected_path}/three/directory_foo",
+            {abs_path}/{expected_path}/three/directory_foo/",
             abs_path = &abs_path,
             expected_path = expected_path
         ),
@@ -1241,9 +1241,9 @@ fn test_symlink_and_full_path_abs_path() {
             &format!("{abs_path}/symlink", abs_path = abs_path),
         ],
         &format!(
-            "{abs_path}/symlink/three
+            "{abs_path}/symlink/three/
             {abs_path}/symlink/three/d.foo
-            {abs_path}/symlink/three/directory_foo",
+            {abs_path}/symlink/three/directory_foo/",
             abs_path = &abs_path
         ),
     );
@@ -1255,32 +1255,32 @@ fn test_excludes() {
 
     te.assert_output(
         &["--exclude", "*.foo"],
-        "./one
-        ./one/two
+        "./one/
+        ./one/two/
         ./one/two/C.Foo2
-        ./one/two/three
-        ./one/two/three/directory_foo
+        ./one/two/three/
+        ./one/two/three/directory_foo/
         ./e1 e2
         ./symlink",
     );
 
     te.assert_output(
         &["--exclude", "*.foo", "--exclude", "*.Foo2"],
-        "./one
-        ./one/two
-        ./one/two/three
-        ./one/two/three/directory_foo
+        "./one/
+        ./one/two/
+        ./one/two/three/
+        ./one/two/three/directory_foo/
         ./e1 e2
         ./symlink",
     );
 
     te.assert_output(
         &["--exclude", "*.foo", "--exclude", "*.Foo2", "foo"],
-        "./one/two/three/directory_foo",
+        "./one/two/three/directory_foo/",
     );
 
     te.assert_output(
-        &["--exclude", "one/two", "foo"],
+        &["--exclude", "one/two/", "foo"],
         "./a.foo
         ./one/b.foo",
     );
@@ -1289,11 +1289,11 @@ fn test_excludes() {
         &["--exclude", "one/**/*.foo"],
         "./a.foo
         ./e1 e2
-        ./one
-        ./one/two
+        ./one/
+        ./one/two/
         ./one/two/C.Foo2
-        ./one/two/three
-        ./one/two/three/directory_foo
+        ./one/two/three/
+        ./one/two/three/directory_foo/
         ./symlink",
     );
 }
@@ -1866,7 +1866,7 @@ fn test_custom_path_separator() {
         one=two=c.foo
         one=two=C.Foo2
         one=two=three=d.foo
-        one=two=three=directory_foo",
+        one=two=three=directory_foo=",
     );
 }
 
@@ -1877,20 +1877,20 @@ fn test_base_directory() {
     te.assert_output(
         &["--base-directory", "one"],
         "./b.foo
-        ./two
+        ./two/
         ./two/c.foo
         ./two/C.Foo2
-        ./two/three
+        ./two/three/
         ./two/three/d.foo
-        ./two/three/directory_foo",
+        ./two/three/directory_foo/",
     );
 
     te.assert_output(
-        &["--base-directory", "one/two", "foo"],
+        &["--base-directory", "one/two/", "foo"],
         "./c.foo
         ./C.Foo2
         ./three/d.foo
-        ./three/directory_foo",
+        ./three/directory_foo/",
     );
 
     // Explicit root path
@@ -1899,12 +1899,12 @@ fn test_base_directory() {
         "two/c.foo
         two/C.Foo2
         two/three/d.foo
-        two/three/directory_foo",
+        two/three/directory_foo/",
     );
 
     // Ignore base directory when absolute path is used
     let (te, abs_path) = get_test_env_with_abs_path(DEFAULT_DIRS, DEFAULT_FILES);
-    let abs_base_dir = &format!("{abs_path}/one/two", abs_path = &abs_path);
+    let abs_base_dir = &format!("{abs_path}/one/two/", abs_path = &abs_path);
     te.assert_output(
         &["--base-directory", abs_base_dir, "foo", &abs_path],
         &format!(
@@ -1913,7 +1913,7 @@ fn test_base_directory() {
             {abs_path}/one/two/c.foo
             {abs_path}/one/two/C.Foo2
             {abs_path}/one/two/three/d.foo
-            {abs_path}/one/two/three/directory_foo",
+            {abs_path}/one/two/three/directory_foo/",
             abs_path = &abs_path
         ),
     );
@@ -2069,14 +2069,14 @@ fn test_strip_cwd_prefix() {
         &["--strip-cwd-prefix", "."],
         "a.foo
         e1 e2
-        one
+        one/
         one/b.foo
-        one/two
+        one/two/
         one/two/c.foo
         one/two/C.Foo2
-        one/two/three
+        one/two/three/
         one/two/three/d.foo
-        one/two/three/directory_foo
+        one/two/three/directory_foo/
         symlink",
     );
 }