A fast, fd-like file search tool for Windows, written in C++20.
sniff <pattern> [paths…] [options] walks a directory tree using the native Win32
directory API, filters the entries against a composed filter chain, and prints the
results. It is an MVP: everything from argument parsing down to the filesystem scan is
real, tested, and wired together.
- Working MVP —
sniff.exesearches real directories with filters, formats, and Ctrl+C cancellation, with a parallel (work-stealing) directory walk. - 209 unit + integration tests (GoogleTest), clean
/W4 /WXbuild. - See
docs/design/DECISIONS.mdfor the architectural decisions behind the project, and Known limitations below.
- Windows (traversal uses the Win32 API).
- CMake ≥ 3.20.
- A C++20 compiler (MSVC,
/W4 /WX).
cmake -S . -B build # configure (MSVC: /W4 /WX, UTF-8)
cmake --build build # build
build\Debug\sniff.exe # the binary
ctest --test-dir build # run the 209 testsGoogleTest is downloaded by CMake FetchContent into build/_deps/ (gitignored) and
cached after the first configure.
sniff is benchmarked against fd 10.4.2 with hyperfine 1.20.0 on this
machine: 11th Gen Intel Core i3-1125G4, 4 cores / 8 threads, Windows 10,
NTFS. Both binaries are scanned over one identical corpus — the project's
source tree replicated into 64 modules, 5,443 files / 1,664 directories — with
each command returning exactly the same result set. 10 runs after 3 warm-ups,
stdout discarded so console rendering is not measured.
| Scenario | sniff (current) | fd (10.4.2) | Ratio |
|---|---|---|---|
List every name (sniff '*' -g / fd .) |
69.6 ± 3.5 ms | 87.6 ± 5.4 ms | sniff 1.26× |
All .cpp files (--extension cpp / -e cpp) |
56.0 ± 2.9 ms | 87.2 ± 3.5 ms | sniff 1.56× |
Literal cmake, case-insensitive (-F -i) |
51.0 ± 3.5 ms | 86.7 ± 3.7 ms | sniff 1.70× |
Raw hyperfine output (mean ± σ, min, max, relative):
| Command | Mean [ms] | Min [ms] | Max [ms] | Relative |
|:---|---:|---:|---:|---:|
| sniff '*' <corpus> -g | 69.6 ± 3.5 | 63.9 | 77.4 | 1.36 ± 0.12 |
| fd --no-ignore -I . <corpus> | 87.6 ± 5.4 | 82.6 | 100.5 | 1.72 ± 0.16 |
| sniff '*' <corpus> -g --extension cpp | 56.0 ± 2.9 | 52.2 | 60.4 | 1.10 ± 0.09 |
| fd --no-ignore -e cpp . <corpus> | 87.2 ± 3.5 | 81.7 | 93.6 | 1.71 ± 0.13 |
| sniff cmake <corpus> -F -i | 51.0 ± 3.5 | 46.4 | 58.7 | 1.00 |
| fd --no-ignore -F -i cmake <corpus> | 86.7 ± 3.7 | 81.0 | 91.8 | 1.70 ± 0.14 |
Methodology notes:
- Sniff is a Release build (
/O2); both tools scan the exact same files. - The work-stealing thread pool parallelizes the directory walk across the CPU cores; entries are buffered and replayed to the (single-threaded) sink once traversal finishes.
- Glob patterns run through a compiled token matcher and literal/regex
fast paths instead of
std::regex, so a pattern like'*'short-circuits to "match everything" and regex patterns without metacharacters degrade to a plain substring search. - fd always excludes git plumbing files (
.gitkeep,.gitignore) even with--no-ignore --hidden, so those placeholders are left out of the corpus and fd is given--no-ignore -Ito neutralize its ignore/hidden defaults. Every pair returns the same number of results. - Commands run through
cmd /c "… > NUL"so the search work, not terminal output, is timed.
sniff <pattern> [paths…] [options]
- The first positional is the search pattern; the remaining positionals are scan
roots (default
.when omitted). - Patterns are regular expressions by default, matched unanchored against the file
name. Use
--globfor shell-style globs or--fixed-stringsfor a literal substring. - In glob mode
.is a literal dot and*matches any characters; to match everything use'*'. A pattern like'*.cpp'already finds.cppfiles anywhere in the tree, so no leading.*is needed. - Regardless of mode the matcher uses compile-time fast paths: a glob without
wildcards is exact whole-name equality, a bare
*matches everything, and a regex without metacharacters falls back to a literal substring search. Only truly "meta" patterns touchstd::regex. - Files and directories are matched and reported.
PowerShell note: quote patterns so the shell doesn't expand them first:
sniff '*.cpp' . --glob(use single quotes).
sniff '\.cpp$' . # regex: every .cpp under the current dir
sniff '*.cpp' . -g # glob version
sniff 'Config\.h$' src --glob # search only inside src/
sniff cpp . -F # literal substring "cpp" anywhere in a name
sniff '*' . -g --extension cpp # only .cpp files, anywhere in the tree
sniff '*' . -g --type d # only directories
sniff '*' . -g --max-depth 2 # two levels deep
sniff '*' . -g --size +10k # files larger than 10 KB
sniff '*' . -g -S -1m # files smaller than 1 MB
sniff '*' . -g --changed-within 2d # modified in the last 2 days
sniff '*' . -g --changed-before '2026-01-01 00:00:00' # modified before a date
sniff '*' . -g --hidden -i # include dot-files, case-insensitive
sniff '*' . -g --format tree # render as a tree
sniff '*' . -g --format json # machine-readable JSON| Option | Short | Meaning |
|---|---|---|
--glob |
-g |
shell glob pattern (*, ?, [...]), matched against the full name |
--fixed-strings |
-F |
literal substring search |
--ignore-case |
-i |
case-insensitive matching (glob/regex/fixed) |
--case-sensitive |
-s |
force case-sensitive matching (default) |
--full-path |
-p |
match against the full path instead of the file name |
| Option | Short | Meaning |
|---|---|---|
--type TYPE |
-t |
keep entries of a type; repeatable. Tokens: f/file, d/dir/directory, l/symlink, x/executable, e/empty |
--extension EXT |
-e |
keep files with this extension; repeatable (leading dot optional) |
--exclude MATCH |
-E |
drop entries whose case-insensitive path contains this text; repeatable |
--size SIZESPEC |
-S |
size filter, see Sizes |
--max-depth N |
-d |
don't descend deeper than N levels |
--min-depth N |
don't report entries shallower than N levels | |
--changed-within D |
modified within the last duration, e.g. 90s 5m 2h 1d 3w |
|
--changed-before T |
modified before an absolute timestamp, YYYY-MM-DD [HH:MM:SS] |
|
--created-within D |
created within the last duration | |
--created-before T |
created before an absolute timestamp, YYYY-MM-DD [HH:MM:SS] |
| Option | Short | Meaning |
|---|---|---|
--hidden |
-H |
include hidden entries (excluded by default) |
--follow |
-L |
follow symlinked directories during traversal (cycle-safe) |
--format FMT |
output format: table (default), tree, json |
Long options accept --name=value. -- ends option parsing, so a path beginning with
- can be passed literally. Several options are repeatable (--type, --extension,
--exclude).
--size accepts [+-]NUM[UNIT]:
- No sign means an exact size;
+means "at least",-means "at most". - Units (case-insensitive):
b,k,m,g,t(powers of 10) andki,mi,gi,ti(powers of 2). - Examples:
--size +10k,--size -1g,--size 5ki,-S+2m,-S-10k.
- table (default): columns
TYPE NAME SIZE MODIFIED(f/d/lfor file / directory / symlink; sizes like2.5 MiB; timestamps in UTCYYYY-MM-DD HH:MM:SS). - tree: ASCII-free UTF-8 box-drawing tree derived from entry paths.
- json:
{"entries":[{"name","path","type","size","created","modified","hidden"}]}.
| Code | Meaning |
|---|---|
0 |
success (including zero matches) |
1 |
any error (bad option, missing pattern, missing root, invalid pattern/date/size) |
130 |
interrupted by Ctrl+C during a search |
src/
interface/ CLI parsing + output formatting (CliController, CommandParser, formatters)
application/ use-case orchestration (SearchService), DTOs, ISearchService port
domain/ pure logic: entry model, MatchMode/CaseMode, 8 filters, Matcher,
Sorter, FilterChain/FilterFactory, CancellationToken, IEntryRepository port
infrastructure/ Win32 filesystem scanner (Win32Scanner + Win32ScanCore) and a
work-stealing thread pool (ThreadPool) that parallelizes the walk
tests/ GoogleTest suites (incl. temp-dir integration + end-to-end CLI tests)
docs/design/ architecture decision log (DECISIONS.md)
docs/architecture/ snapshot architecture with diagrams (ARCHITECTURE.md)
third-party/ placeholder for future vendored dependencies (empty)
- A pattern is always required; "list everything" is not an invocation yet.
- Rendering assumes a UTF-8-capable console/terminal (Windows Terminal, modern conhost);
legacy
conhostmay mis-draw the tree glyphs. - Hidden directories are still walked at the filesystem level even when excluded from output (a future traversal optimization).
--type executableuses an extension/PATHEXT heuristic (there is no unix exec bit on Windows).- No progress/status output on the CLI yet (
QueryResultStatsis computed internally).
More background and the rulebook behind these decisions live in
docs/design/DECISIONS.md.