Skip to content

Add a contents_first option - #19

Open
BaerLKR wants to merge 2 commits into
ririsoft:masterfrom
BaerLKR:master
Open

BaerLKR wants to merge 2 commits into
ririsoft:masterfrom
BaerLKR:master

Conversation

@BaerLKR

@BaerLKR BaerLKR commented Sep 27, 2026 •

Copy link
Copy Markdown

Hello,
while working on another open source project where we move from blocking directory walk to async-stream based work we needed a contents_first equivalent, like in the sync WalkDir crate. So I decided to implement it upstream.

There shouldn't have been a breaking API change due to this.

Feel free to ask for improvements/qualifications as you see fit.
Thanks :)

Comment thread Cargo.toml
Comment thread src/lib.rs
///
/// Panics if the directories depth overflows `usize`.
pub struct WalkDir {
pub struct WalkDir<State = Unfiltered> {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a breaking change, contrary to the PR description. Code compiling on 2.1.0 no longer compiles:

fn build(p: &str) -> WalkDir {
    WalkDir::new(p).filter(|_| async { Filtering::Continue })
}
// error[E0308]: expected `WalkDir<Unfiltered>`, found `WalkDir<Filtered>`

The same applies to struct fields typed WalkDir, wd = wd.filter(..) reassignments, and calling .filter() twice.

The typestate is only needed because the stream is built eagerly in each builder method. Suggestion: store the options and the filter in WalkDir (the filter can be boxed as Box<dyn FnMut(DirEntry) -> BoxedFut<Filtering> + Send>, a type already used in this file), and build the stream lazily on the first poll_next. Then contents_first and filter can be called in any order, and the generic parameter, the Unfiltered / Filtered markers and the State: Unpin bound can all be removed.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried using the file and it resulted in API change for me, but maybe I missed something. Will try that approach.

Regarding the breaking change: cargo-semver check said it is fine, but maybe I used it wrong. The desire to not have breaking changes was the reason for all the typestate shenanigans but oh well
thanks for the feedback

Comment thread src/lib.rs
Comment thread src/lib.rs
Comment thread src/lib.rs
Comment thread src/lib.rs Outdated
None => {
dirs.pop();
walk(dirs, filter).await
let Some(mut popped) = dirs.pop() else {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The else branch is unreachable: we are inside if let Some(..) = dirs.last_mut(), so dirs.pop() always returns Some. Please use expect/unreachable! or restructure.

Comment thread src/lib.rs Outdated
let Some(mut popped) = dirs.pop() else {
return walk(dirs, filter, opts).await;
};
if let Some(deferred_entry) = popped.deferred_files.pop() {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pop() yields files in reverse read_dir order. For example, with read_dir returning f2, f0, f1, the walk yields f1, f0, f2. The order is unspecified anyway, but the reversal is surprising. It disappears if files are no longer deferred; otherwise use a VecDeque with pop_front.

Comment thread src/lib.rs Outdated
return walk(dirs, filter, opts).await;
};
if let Some(deferred_entry) = popped.deferred_files.pop() {
dirs.push(popped);

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After read_dir.next() returned None, the item is pushed back and the next poll calls read_dir.next() again on the exhausted ReadDir (line 282). With async-fs, that means another blocking thread-pool round trip for each deferred file. Please drain the deferred items without re-polling (e.g. read_dir: Option<ReadDir> or an exhausted flag), or stop deferring files, which removes the issue.

Comment thread src/lib.rs Outdated
};

match filtering {
Filtering::IgnoreDir => walk(dirs, filter, opts).await,

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Behavior change: previously read_dir was still called on IgnoreDir directories, so an unreadable ignored directory produced an error. It is now skipped entirely. I think the new behavior is better, but please mention it in the PR description / changelog.

Comment thread src/lib.rs
})
}
#[test]
fn contents_first() -> Result<()> {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: a blank line is missing before #[test].

This test only has a single file per directory, so it would not catch the reversed order. Please also add tests for:

  • contents_first(true) combined with filter, with Ignore and IgnoreDir on directories,
  • multiple files in the same directory (compare sorted or as a set, rather than relying on read_dir order),
  • contents_first(false) as a no-op.

@ririsoft

Copy link
Copy Markdown
Owner

Thanks for the contribution @BaerLKR. Post-order traversal is a useful thing to have, and the filter semantics look right. I checked out the branch: tests and clippy pass. A local probe confirmed that  Ignore ,  IgnoreDir  and  Continue  all behave correctly together with  contents_first(true) . There are a few things to address before merging.

@ririsoft

Copy link
Copy Markdown
Owner

@BaerLKR : Since your pull request I took the opportunity to revisit a long standing bug I had in #13. I did a heavy fix (internal code only, no api change) that changed the recursion algorithm with much improved performances. The fix also handle ignored dir better, as you do in your PR.

Please rebase from version 2.2.0. I am sorry for the additional work on your side.

This currently only works if the filter is applied after enabling post-order traversal
@ririsoft

ririsoft commented Sep 29, 2026 •

Copy link
Copy Markdown
Owner

Hi @BaerLKR,

I am adding more internal changes to the crate to support a more secure version (see my latest commits).

Also I would prefer not to go with your implementation that exposes a parametric structure (or template as you like) to the public interface.

I would rather follow the same design as the sync WalkDir crate, having an inner structure for configuration parameters, and using the builder pattern for passing configuration options.

Give me a bit of time to finish the internal restructuring, and come back to you with another proposal for your request.
Cheers.

@BaerLKR

BaerLKR commented Oct 11, 2026

Copy link
Copy Markdown
Author

Hey,
I really don't want to put pressure on you for pushing this, I just wanna check in if I can continue work on this/help with the other changes you mentioned as I want to continue on the MR that lead to this MR.
Thanks :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants