From 84bea42b6c433ba5444675eb3195d21fddc6ebd9 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 13 Aug 2026 12:45:40 +0200 Subject: [PATCH] Visualize scanner related tests (#20412) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR improves the scanner related tests a bit. Right now all the scanner tests (a lot of them) are somewhat hard to reason about and to figure out whether these are actually correct or not. With this PR, the existing are extended with a visualization of the file system tree, but with indicators to know whether or not a file was scanned or not. If a `.gitignore` is present, it's content is also shown to make it easier to see why some files are included or not included. One of the outputs look like this: ``` . ├── ✗ .gitignore │ src/ignored-by-gitignore.html └── ✓ src ├── ✗ ignore-by-extension.bin ├── ✓ ignored-by-gitignore.html ├── ✗ ignored-by-source-not.html ├── ✓ keep-me.html └── ✓ new-file.html ``` I'm sure we can cleanup a lot of these tests, simplify, deduplicate, but I didn't want to do that as part of this PR. This PR is purely adding this visualization. This uses the [insta.rs](https://insta.rs) library for (inline) snapshot tests. If at some point the snapshots change, then you get a nice UI: image ## Test plan 1. All tests should pass [ci-all] 2. Nothing in the code itself changed, so nothing should break --- Stack created with GitHub Stacks CLI • Give Feedback 💬 --- Cargo.lock | 49 +- crates/oxide/Cargo.toml | 1 + crates/oxide/tests/scanner.rs | 1334 ++++++++++++++++++++++++++++++++- 3 files changed, 1353 insertions(+), 31 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8a10094e3..73fd9e995 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -59,6 +59,17 @@ dependencies = [ "syn", ] +[[package]] +name = "console" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c" +dependencies = [ + "encode_unicode", + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "convert_case" version = "0.11.0" @@ -126,6 +137,12 @@ version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7fcaabb2fef8c910e7f4c7ce9f67a1283a1715879a7c230ca9d6d1ae31f16d91" +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "errno" version = "0.3.9" @@ -296,6 +313,18 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "insta" +version = "1.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f0f8fee8c926415c58d6ae43a08523a26faccb2323f5e6b644fe7dd4ef6b82" +dependencies = [ + "console", + "once_cell", + "similar", + "tempfile", +] + [[package]] name = "itertools" version = "0.11.0" @@ -445,9 +474,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.19.0" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "overload" @@ -602,6 +631,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + [[package]] name = "slab" version = "0.4.12" @@ -647,6 +682,7 @@ dependencies = [ "fast-glob", "globwalk", "ignore 0.4.33", + "insta", "log", "pretty_assertions", "rayon", @@ -832,6 +868,15 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-targets" version = "0.52.6" diff --git a/crates/oxide/Cargo.toml b/crates/oxide/Cargo.toml index 0965320c9..f8a5f6137 100644 --- a/crates/oxide/Cargo.toml +++ b/crates/oxide/Cargo.toml @@ -20,6 +20,7 @@ ignore = { path = "../ignore" } regex = "1.11.1" [dev-dependencies] +insta = "1.48.0" tempfile = "3.13.0" pretty_assertions = "1.4.1" unicode-width = "0.2.0" diff --git a/crates/oxide/tests/scanner.rs b/crates/oxide/tests/scanner.rs index 10a455d8b..a1f985586 100644 --- a/crates/oxide/tests/scanner.rs +++ b/crates/oxide/tests/scanner.rs @@ -1,5 +1,6 @@ #[cfg(test)] mod scanner { + use insta::assert_snapshot; use pretty_assertions::assert_eq; use std::path::{Path, PathBuf}; use std::process::Command; @@ -55,6 +56,157 @@ mod scanner { globs: Vec, normalized_sources: Vec, candidates: Vec, + tree: String, + } + + /// Renders the directory as a tree, annotating every file and folder with an indicator that + /// shows whether the scanner picked it up: + /// + /// - `✓` — scanned + /// - `✗` — ignored / skipped + /// + /// Symlinks are rendered as `link → target`, where the target is shown relative to the folder + /// containing the symlink. The contents of every `.gitignore` file are printed right below the + /// file itself. + /// + /// Folders that are a git repository root (they contain a `.git` folder) are marked with + /// `(git)`, because ignore rules behave differently inside and outside of a repository. + fn fs_tree(root: &Path, scanned: &[String]) -> String { + /// Computes the relative path from `parent` to `target`, where both are relative to the + /// same root, e.g.: `b/c` seen from `b` is `c`, and the root itself seen from `b` is `..`. + fn relative_to(target: &Path, parent: &Path) -> String { + let target = target.components().collect::>(); + let parent = parent.components().collect::>(); + + let common = target + .iter() + .zip(&parent) + .take_while(|(a, b)| **a == **b) + .count(); + + let mut parts = vec![".."; parent.len() - common]; + parts.extend( + target[common..] + .iter() + .map(|component| component.as_os_str().to_str().unwrap()), + ); + + if parts.is_empty() { + // The symlink points to its own parent folder + ".".into() + } else { + parts.join("/") + } + } + + fn walk( + dir: &Path, + root: &Path, + prefix: &str, + scanned: &[String], + visited: &mut Vec, + out: &mut String, + ) { + let mut entries = fs::read_dir(dir) + .unwrap() + .filter_map(Result::ok) + .filter(|entry| entry.file_name() != ".git") + .collect::>(); + entries.sort_by_key(|entry| entry.file_name()); + + for (i, entry) in entries.iter().enumerate() { + let last = i == entries.len() - 1; + let connector = if last { "└── " } else { "├── " }; + let child_prefix = if last { " " } else { "│ " }; + + let path = entry.path(); + let name = entry.file_name().to_string_lossy().to_string(); + let rel = path + .strip_prefix(root) + .unwrap() + .to_string_lossy() + .replace('\\', "/"); + + let file_type = entry.file_type().unwrap(); + + // a file is scanned when it shows up in the scanned files list. a folder is + // considered scanned when any scanned file lives inside of it. paths that go + // through a symlink count towards the symlink, not towards the target directory. + let dir_prefix = format!("{rel}/"); + let is_scanned = scanned + .iter() + .any(|file| file == &rel || file.starts_with(&dir_prefix)); + let indicator = if is_scanned { "✓" } else { "✗" }; + + let mut display_name = name.clone(); + if path.join(".git").exists() { + display_name = format!("{display_name} (git)"); + } + if file_type.is_symlink() { + if let Ok(target) = fs::read_link(&path) { + // Show the target relative to the folder containing the symlink, or as-is + // when it points outside of the tree. + let target = match (target.strip_prefix(root), dir.strip_prefix(root)) { + (Ok(target), Ok(parent)) => relative_to(target, parent), + _ => target.to_string_lossy().replace('\\', "/"), + }; + display_name = format!("{name} → {target}"); + + // The symlink points to a target that doesn't exist + if path.metadata().is_err() { + display_name = format!("{display_name} (broken)"); + } + } + } + + out.push_str(&format!("{prefix}{connector}{indicator} {display_name}\n")); + + // Print the contents of `.gitignore` files below the file itself, so the ignore + // rules are visible in the same output. + if name == ".gitignore" { + if let Ok(contents) = fs::read_to_string(&path) { + for line in contents.lines() { + let line = format!("{prefix}{child_prefix} {line}"); + out.push_str(line.trim_end()); + out.push('\n'); + } + } + } + + // Descend into directories, including symlinked ones. The paths inside a symlinked + // directory are computed through the symlink, so the indicators show what the + // scanner saw via that route. A visited stack prevents symlink cycles from + // recursing forever. + if path.metadata().map(|meta| meta.is_dir()).unwrap_or(false) { + let Ok(canonical) = dunce::canonicalize(&path) else { + continue; + }; + if visited.contains(&canonical) { + continue; + } + + visited.push(canonical); + walk( + &path, + root, + &format!("{prefix}{child_prefix}"), + scanned, + visited, + out, + ); + visited.pop(); + } + } + } + + let mut visited = vec![dunce::canonicalize(root).unwrap()]; + let mut out = if root.join(".git").exists() { + String::from(". (git)\n") + } else { + String::from(".\n") + }; + walk(root, root, "", scanned, &mut visited, &mut out); + out } fn create_files_in(dir: &path::Path, paths: &[(&str, &str)]) { @@ -167,11 +319,14 @@ mod scanner { .collect::>(); normalized_sources.sort(); + let tree = fs_tree(&dir, &files); + ScanResult { files, globs, normalized_sources, candidates, + tree, } } @@ -185,6 +340,7 @@ mod scanner { files, globs, normalized_sources, + tree, .. } = scan(&[ ("index.html", ""), @@ -192,6 +348,14 @@ mod scanner { ("b.html", ""), ("c.html", ""), ]); + + assert_snapshot!(tree, @" + . (git) + ├── ✓ a.html + ├── ✓ b.html + ├── ✓ c.html + └── ✓ index.html + "); assert_eq!(files, vec!["a.html", "b.html", "c.html", "index.html"]); assert_eq!(globs, vec!["*"]); assert_eq!(normalized_sources, vec!["**/*"]); @@ -203,6 +367,7 @@ mod scanner { files, globs, normalized_sources, + tree, .. } = scan(&[ (".gitignore", "b.html"), @@ -211,6 +376,17 @@ mod scanner { ("b.html", ""), ("c.html", ""), ]); + + assert_snapshot!(tree, @" + . (git) + ├── ✗ .gitignore + │ b.html + ├── ✓ a.html + ├── ✗ b.html + ├── ✓ c.html + └── ✓ index.html + "); + assert_eq!(files, vec!["a.html", "c.html", "index.html"]); assert_eq!(globs, vec!["*"]); assert_eq!(normalized_sources, vec!["**/*"]); @@ -222,6 +398,7 @@ mod scanner { files, globs, normalized_sources, + tree, .. } = scan(&[ ("index.html", ""), @@ -232,6 +409,20 @@ mod scanner { ("public/deeply/nested/c.html", ""), ]); + assert_snapshot!(tree, @" + . (git) + ├── ✓ index.html + └── ✓ public + ├── ✓ a.html + ├── ✓ b.html + ├── ✓ c.html + ├── ✓ deeply + │ └── ✓ nested + │ └── ✓ c.html + └── ✓ nested + └── ✓ c.html + "); + assert_eq!( files, vec![ @@ -253,6 +444,7 @@ mod scanner { files, globs, normalized_sources, + tree, .. } = scan(&[ ("index.html", ""), @@ -266,6 +458,25 @@ mod scanner { ("public/very/deeply/nested/a.html", ""), ]); + assert_snapshot!(tree, @" + . (git) + ├── ✓ index.html + └── ✓ public + ├── ✓ a.html + ├── ✓ b.html + ├── ✓ c.html + ├── ✓ nested + │ ├── ✓ a.html + │ ├── ✓ again + │ │ └── ✓ a.html + │ ├── ✓ b.html + │ └── ✓ c.html + └── ✓ very + └── ✓ deeply + └── ✓ nested + └── ✓ a.html + "); + assert_eq!( files, vec![ @@ -290,6 +501,7 @@ mod scanner { files, globs, normalized_sources, + tree, .. } = scan(&[ (".gitignore", "public/b.html\na.html"), @@ -299,6 +511,18 @@ mod scanner { ("public/c.html", ""), ]); + assert_snapshot!(tree, @" + . (git) + ├── ✗ .gitignore + │ public/b.html + │ a.html + ├── ✓ index.html + └── ✓ public + ├── ✗ a.html + ├── ✗ b.html + └── ✓ c.html + "); + assert_eq!(files, vec!["index.html", "public/c.html",]); assert_eq!(globs, vec!["*"]); assert_eq!(normalized_sources, vec!["**/*"]); @@ -310,6 +534,7 @@ mod scanner { files, globs, normalized_sources, + tree, .. } = scan(&[ ("index.html", ""), @@ -318,6 +543,15 @@ mod scanner { ("src/c.html", ""), ]); + assert_snapshot!(tree, @" + . (git) + ├── ✓ index.html + └── ✓ src + ├── ✓ a.html + ├── ✓ b.html + └── ✓ c.html + "); + assert_eq!( files, vec!["index.html", "src/a.html", "src/b.html", "src/c.html"] @@ -335,6 +569,7 @@ mod scanner { files, globs, normalized_sources, + tree, .. } = scan(&[ ("index.html", ""), @@ -343,6 +578,14 @@ mod scanner { ("c.lock", ""), ]); + assert_snapshot!(tree, @" + . (git) + ├── ✗ a.mp4 + ├── ✗ b.png + ├── ✗ c.lock + └── ✓ index.html + "); + assert_eq!(files, vec!["index.html"]); assert_eq!(globs, vec!["*"]); assert_eq!(normalized_sources, vec!["**/*"]); @@ -355,6 +598,7 @@ mod scanner { files, globs, normalized_sources, + tree, .. } = scan(&[ // Looks like `.pages` binary extension, but it's a folder @@ -368,6 +612,16 @@ mod scanner { ), ]); + assert_snapshot!(tree, @" + . (git) + ├── ✗ .gitignore + │ other.pages + ├── ✗ other.pages + │ └── ✗ index.html + └── ✓ some.pages + └── ✓ index.html + "); + assert_eq!(files, vec!["some.pages/index.html"]); assert_eq!(globs, vec!["*", "some.pages/**/*.{aspx,astro,cjs,cts,eex,erb,gjs,gts,haml,handlebars,hbs,heex,html,jade,js,jsx,liquid,md,mdx,mjs,mts,mustache,njk,nunjucks,php,pug,py,razor,rb,rhtml,rs,slim,svelte,tpl,ts,tsx,twig,vue}"]); assert_eq!(normalized_sources, vec!["**/*"]); @@ -379,6 +633,7 @@ mod scanner { files, globs, normalized_sources, + tree, .. } = scan(&[ ("index.html", ""), @@ -387,6 +642,14 @@ mod scanner { ("c.less", ""), ]); + assert_snapshot!(tree, @" + . (git) + ├── ✓ a.css + ├── ✗ b.sass + ├── ✗ c.less + └── ✓ index.html + "); + assert_eq!(files, vec!["a.css", "index.html"]); assert_eq!(globs, vec!["*"]); assert_eq!(normalized_sources, vec!["**/*"]); @@ -398,9 +661,16 @@ mod scanner { files, globs, normalized_sources, + tree, .. } = scan(&[("src/index.my-extension", "")]); + assert_snapshot!(tree, @" + . (git) + └── ✓ src + └── ✓ index.my-extension + "); + assert_eq!(files, vec!["src/index.my-extension"]); assert_eq!(globs, vec!["*", "src/**/*.{aspx,astro,cjs,cts,eex,erb,gjs,gts,haml,handlebars,hbs,heex,html,jade,js,jsx,liquid,md,mdx,mjs,mts,mustache,my-extension,njk,nunjucks,php,pug,py,razor,rb,rhtml,rs,slim,svelte,tpl,ts,tsx,twig,vue}"]); assert_eq!(normalized_sources, vec!["**/*"]); @@ -412,6 +682,7 @@ mod scanner { files, globs, normalized_sources, + tree, .. } = scan(&[ ("index.html", ""), @@ -419,6 +690,13 @@ mod scanner { ("yarn.lock", ""), ]); + assert_snapshot!(tree, @" + . (git) + ├── ✓ index.html + ├── ✗ package-lock.json + └── ✗ yarn.lock + "); + assert_eq!(files, vec!["index.html"]); assert_eq!(globs, vec!["*"]); assert_eq!(normalized_sources, vec!["**/*"]); @@ -430,6 +708,7 @@ mod scanner { files, globs, normalized_sources, + tree, .. } = scan(&[ // Explicitly listed root files @@ -474,6 +753,58 @@ mod scanner { ("nested-d/very/deeply/nested/directory/again/foo.html", ""), ]); + assert_snapshot!(tree, @" + . (git) + ├── ✓ bar.html + ├── ✓ baz.html + ├── ✓ foo.html + ├── ✓ nested-a + │ ├── ✓ bar.html + │ ├── ✓ baz.html + │ └── ✓ foo.html + ├── ✓ nested-b + │ └── ✓ deeply-nested + │ ├── ✓ bar.html + │ ├── ✓ baz.html + │ └── ✓ foo.html + ├── ✓ nested-c + │ ├── ✗ .gitignore + │ │ ignored-folder/ + │ ├── ✓ bar.html + │ ├── ✓ baz.html + │ ├── ✓ foo.html + │ ├── ✗ ignored-folder + │ │ ├── ✗ bar.html + │ │ ├── ✗ baz.html + │ │ └── ✗ foo.html + │ └── ✓ sibling-folder + │ ├── ✓ bar.html + │ ├── ✓ baz.html + │ └── ✓ foo.html + └── ✓ nested-d + ├── ✗ .gitignore + │ deep/ + ├── ✓ bar.html + ├── ✓ baz.html + ├── ✓ foo.html + └── ✓ very + └── ✓ deeply + └── ✓ nested + ├── ✓ bar.html + ├── ✓ baz.html + ├── ✗ deep + │ ├── ✗ bar.html + │ ├── ✗ baz.html + │ └── ✗ foo.html + ├── ✓ directory + │ ├── ✓ again + │ │ └── ✓ foo.html + │ ├── ✓ bar.html + │ ├── ✓ baz.html + │ └── ✓ foo.html + └── ✓ foo.html + "); + assert_eq!( files, vec![ @@ -528,6 +859,7 @@ mod scanner { let ScanResult { candidates, normalized_sources, + tree, .. } = scan(&[ // The gitignore file is used to filter out files but not scanned for candidates @@ -550,6 +882,21 @@ mod scanner { ("index4.svelte", ""), ]); + assert_snapshot!(tree, @" + . (git) + ├── ✗ .gitignore + │ # md:font-bold + │ foo.html + ├── ✗ foo.html + ├── ✗ foo.jpg + ├── ✓ index.angular.html + ├── ✓ index.html + ├── ✓ index.svelte + ├── ✓ index2.svelte + ├── ✓ index3.svelte + └── ✓ index4.svelte + "); + assert_eq!( candidates, vec![ @@ -573,12 +920,21 @@ mod scanner { let ScanResult { candidates, normalized_sources, + tree, .. } = scan_with_globs( &[("foo/bar/baz/foo.html", "content-['foo.html']")], vec!["@source '**/*'", "@source './foo/bar/baz/..'"], ); + assert_snapshot!(tree, @" + . (git) + └── ✓ foo + └── ✓ bar + └── ✓ baz + └── ✓ foo.html + "); + assert_eq!(candidates, vec!["content-['foo.html']"]); assert_eq!(normalized_sources, vec!["**/*", "foo/bar/**/*"]); } @@ -610,6 +966,19 @@ mod scanner { ]); let candidates = scanner.scan(); + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . + ├── ✗ project-a + │ └── ✗ src + │ └── ✗ index.css + └── ✓ project-b + ├── ✓ ignored + │ ├── ✓ except.html + │ └── ✗ ignored.html + └── ✓ keep + └── ✓ keep.html + "); + assert_eq!(candidates, vec!["content-['GOOD-1']", "content-['GOOD-2']"]); } @@ -619,12 +988,18 @@ mod scanner { let ScanResult { candidates, normalized_sources, + tree, .. } = scan_with_globs( &[("my-file", "content-['my-file']")], vec!["@source '**/*'", "@source './my-file'"], ); + assert_snapshot!(tree, @" + . (git) + └── ✓ my-file + "); + assert_eq!(candidates, vec!["content-['my-file']"]); assert_eq!(normalized_sources, vec!["**/*", "my-file"]); } @@ -635,6 +1010,7 @@ mod scanner { let ScanResult { candidates, normalized_sources, + tree, .. } = scan_with_globs( &[ @@ -654,6 +1030,14 @@ mod scanner { ], ); + assert_snapshot!(tree, @" + . (git) + ├── ✓ my-folder.bin + │ └── ✓ foo.html + └── ✓ my-folder.templates + └── ✓ foo.html + "); + assert_eq!( candidates, vec![ @@ -672,6 +1056,7 @@ mod scanner { let ScanResult { candidates, normalized_sources, + tree, .. } = scan_with_globs( &[ @@ -682,6 +1067,11 @@ mod scanner { vec!["@source '**/*'", "@source '*.styl'"], ); + assert_snapshot!(tree, @" + . (git) + └── ✓ foo.styl + "); + assert_eq!(candidates, vec!["content-['foo.styl']"]); assert_eq!(normalized_sources, vec!["**/*", "*.styl"]); } @@ -693,6 +1083,8 @@ mod scanner { files, globs, normalized_sources, + tree, + .. } = scan_with_globs( &[ ( @@ -707,6 +1099,18 @@ mod scanner { vec!["@source './blog/*/foo/bar/baz/**/*'"], ); + assert_snapshot!(tree, @" + . (git) + └── ✓ blog + └── ✓ 2024 + └── ✓ foo + └── ✓ bar + ├── ✓ baz + │ └── ✓ index.html + └── ✗ qux + └── ✗ index.html + "); + assert_eq!( candidates, vec!["content-['blog/2024/foo/bar/baz/index.html']"] @@ -721,6 +1125,7 @@ mod scanner { let ScanResult { candidates, normalized_sources, + tree, .. } = scan_with_globs( &[ @@ -734,6 +1139,19 @@ mod scanner { vec!["@source '**/*'", "@source './**/*.{styl}'"], ); + assert_snapshot!(tree, @" + . (git) + └── ✓ app + ├── ✓ (theme) + │ └── ✓ page.styl + ├── ✓ [...slug] + │ └── ✓ page.styl + ├── ✓ [[...slug]] + │ └── ✓ page.styl + └── ✓ [slug] + └── ✓ page.styl + "); + assert_eq!( candidates, vec![ @@ -775,6 +1193,14 @@ mod scanner { let mut scanner = Scanner::new(sources); let candidates = scanner.scan(); + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . (git) + ├── ✓ project-a + │ └── ✓ index.html + └── ✓ project-b + └── ✓ index.html + "); + // We've done the initial scan and found the files assert_eq!( candidates, @@ -790,6 +1216,7 @@ mod scanner { let ScanResult { candidates, normalized_sources, + tree, .. } = scan_with_globs( &[ @@ -802,6 +1229,13 @@ mod scanner { vec!["@source '**/*'", "@source 'foo.styl'"], ); + assert_snapshot!(tree, @" + . (git) + ├── ✗ .gitignore + │ foo.styl + └── ✓ foo.styl + "); + assert_eq!(candidates, vec!["content-['foo.styl']"]); assert_eq!(normalized_sources, vec!["**/*", "foo.styl"]); } @@ -831,6 +1265,14 @@ mod scanner { let mut scanner = Scanner::new(sources); let candidates = scanner.scan(); + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . (git) + ├── ✓ project-a + │ └── ✓ index.html + └── ✓ project-b + └── ✓ index.html + "); + // We've done the initial scan and found the files assert_eq!( candidates, @@ -935,6 +1377,24 @@ mod scanner { "content-['project-b/sub1/sub2/new.html']" ] ); + + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . (git) + ├── ✓ project-a + │ ├── ✓ index.html + │ ├── ✓ new.html + │ └── ✓ sub1 + │ └── ✓ sub2 + │ ├── ✓ index.html + │ └── ✓ new.html + └── ✓ project-b + ├── ✓ index.html + ├── ✓ new.html + └── ✓ sub1 + └── ✓ sub2 + ├── ✓ index.html + └── ✓ new.html + "); } #[test] @@ -966,6 +1426,14 @@ mod scanner { vec!["src/index.html", "src/keep.html", "src/remove.html"] ); + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . (git) + └── ✓ src + ├── ✓ index.html + ├── ✓ keep.html + └── ✓ remove.html + "); + fs::remove_file(dir.join("src/remove.html")).unwrap(); scanner.scan(); @@ -973,6 +1441,13 @@ mod scanner { scanned_files(&mut scanner, &dir), vec!["src/index.html", "src/keep.html"] ); + + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . (git) + └── ✓ src + ├── ✓ index.html + └── ✓ keep.html + "); } #[test] @@ -1013,6 +1488,18 @@ mod scanner { ] ); + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . (git) + └── ✓ src + ├── ✓ index.html + ├── ✓ keep + │ └── ✓ index.html + └── ✓ remove + ├── ✓ index.html + └── ✓ nested + └── ✓ index.html + "); + fs::remove_dir_all(dir.join("src/remove")).unwrap(); scanner.scan(); @@ -1020,6 +1507,14 @@ mod scanner { scanned_files(&mut scanner, &dir), vec!["src/index.html", "src/keep/index.html"] ); + + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . (git) + └── ✓ src + ├── ✓ index.html + └── ✓ keep + └── ✓ index.html + "); } #[test] @@ -1046,6 +1541,13 @@ mod scanner { scanner.scan(); + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . (git) + ├── ✓ index.html + └── ✓ src + └── ✓ index.html + "); + let globs = scanned_globs(&mut scanner, &dir); assert!(globs.iter().any(|glob| glob.starts_with("src/**/*"))); @@ -1053,6 +1555,11 @@ mod scanner { scanner.scan(); + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . (git) + └── ✓ index.html + "); + let globs = scanned_globs(&mut scanner, &dir); assert!(!globs.iter().any(|glob| glob.starts_with("src/**/*"))); } @@ -1111,6 +1618,8 @@ mod scanner { files, globs, normalized_sources, + tree, + .. } = scan_with_globs( &[ ("src/index.ts", "content-['src/index.ts']"), @@ -1138,6 +1647,25 @@ mod scanner { ], ); + assert_snapshot!(tree, @" + . (git) + └── ✓ src + ├── ✓ admin + │ └── ✓ foo + │ └── ✓ template.html + ├── ✗ colors + │ ├── ✗ blue.tsx + │ ├── ✗ green.tsx + │ └── ✗ red.jsx + ├── ✗ index.ts + ├── ✓ templates + │ └── ✓ index.html + └── ✗ utils + ├── ✗ date.ts + ├── ✗ file.ts + └── ✗ string.ts + "); + assert_eq!( candidates, vec![ @@ -1183,6 +1711,8 @@ mod scanner { files, globs, normalized_sources, + tree, + .. } = scan_with_globs( // Typically skipped &[ @@ -1194,6 +1724,15 @@ mod scanner { vec!["@source '**/*'", "@source 'src/**/*.{exe,bin}'"], ); + assert_snapshot!(tree, @" + . (git) + ├── ✗ out + │ └── ✗ out.exe + └── ✓ src + ├── ✓ index.bin + └── ✓ index.exe + "); + assert_eq!( candidates, vec!["content-['src/index.bin']", "content-['src/index.exe']",] @@ -1221,6 +1760,8 @@ mod scanner { files, globs, normalized_sources, + tree, + .. } = scan_with_globs( &[ ("index.html", "content-['index.html']"), @@ -1245,6 +1786,21 @@ mod scanner { ], ); + assert_snapshot!(tree, @" + . (git) + ├── ✗ index.html + └── ✓ src + ├── ✓ admin + │ ├── ✗ ignore.html + │ └── ✓ index.html + ├── ✓ dashboard + │ ├── ✗ ignore.html + │ └── ✓ index.html + ├── ✗ ignore.html + ├── ✗ index.html + └── ✗ lib.ts + "); + assert_eq!( candidates, vec![ @@ -1264,7 +1820,10 @@ mod scanner { #[test] fn it_should_restrict_explicit_file_sources_to_the_matching_file() { let ScanResult { - candidates, files, .. + candidates, + files, + tree, + .. } = scan_with_globs( &[ ("src/foo.html", "content-['src/foo.html']"), @@ -1273,6 +1832,13 @@ mod scanner { vec!["@source './src/foo.html'"], ); + assert_snapshot!(tree, @" + . (git) + └── ✓ src + ├── ✗ bar.html + └── ✓ foo.html + "); + assert_eq!(candidates, vec!["content-['src/foo.html']"]); assert_eq!(files, vec!["src/foo.html"]); } @@ -1280,7 +1846,10 @@ mod scanner { #[test] fn it_should_combine_multiple_restricted_sources_for_the_same_base() { let ScanResult { - candidates, files, .. + candidates, + files, + tree, + .. } = scan_with_globs( &[ ("src/foo.html", "content-['src/foo.html']"), @@ -1290,6 +1859,14 @@ mod scanner { vec!["@source './src/foo.html'", "@source './src/bar.html'"], ); + assert_snapshot!(tree, @" + . (git) + └── ✓ src + ├── ✓ bar.html + ├── ✗ baz.html + └── ✓ foo.html + "); + assert_eq!( candidates, vec!["content-['src/bar.html']", "content-['src/foo.html']"] @@ -1313,7 +1890,10 @@ mod scanner { ]; let ScanResult { - candidates, files, .. + candidates, + files, + tree, + .. } = scan_with_globs( paths_with_content, vec![ @@ -1322,6 +1902,17 @@ mod scanner { ], ); + assert_snapshot!(tree, @" + . (git) + ├── ✓ component-sources.classes.txt + ├── ✗ ignore-me + │ └── ✗ component.html + ├── ✗ ignore-me.txt + └── ✓ nested + ├── ✓ component.html + └── ✗ ignore-me.html + "); + assert_eq!( candidates, vec![ @@ -1336,7 +1927,10 @@ mod scanner { // Same setup, but with the root-level source declared first let ScanResult { - candidates, files, .. + candidates, + files, + tree, + .. } = scan_with_globs( paths_with_content, vec![ @@ -1345,6 +1939,17 @@ mod scanner { ], ); + assert_snapshot!(tree, @" + . (git) + ├── ✓ component-sources.classes.txt + ├── ✗ ignore-me + │ └── ✗ component.html + ├── ✗ ignore-me.txt + └── ✓ nested + ├── ✓ component.html + └── ✗ ignore-me.html + "); + assert_eq!( candidates, vec![ @@ -1361,12 +1966,21 @@ mod scanner { #[test] fn it_should_allow_later_ignores_to_override_restricted_sources() { let ScanResult { - candidates, files, .. + candidates, + files, + tree, + .. } = scan_with_globs( &[("src/foo.html", "content-['src/foo.html']")], vec!["@source './src/foo.html'", "@source not './src/foo.html'"], ); + assert_snapshot!(tree, @" + . (git) + └── ✗ src + └── ✗ foo.html + "); + assert!(candidates.is_empty()); assert!(files.is_empty()); } @@ -1375,7 +1989,10 @@ mod scanner { fn it_should_handle_sources_with_parent_patterns() { { let ScanResult { - candidates, files, .. + candidates, + files, + tree, + .. } = scan_with_globs( &[ ("src/foo.html", "content-['src/foo.html']"), @@ -1385,6 +2002,15 @@ mod scanner { vec!["@source './src/ba*/*.html'"], ); + assert_snapshot!(tree, @" + . (git) + └── ✓ src + ├── ✓ bar + │ ├── ✓ ignore.html + │ └── ✓ index.html + └── ✗ foo.html + "); + assert_eq!( candidates, vec![ @@ -1397,7 +2023,10 @@ mod scanner { { let ScanResult { - candidates, files, .. + candidates, + files, + tree, + .. } = scan_with_globs( &[ ("src/foo.html", "content-['src/foo.html']"), @@ -1410,13 +2039,25 @@ mod scanner { ], ); + assert_snapshot!(tree, @" + . (git) + └── ✓ src + ├── ✓ bar + │ ├── ✗ ignore.html + │ └── ✓ index.html + └── ✗ foo.html + "); + assert_eq!(candidates, vec!["content-['src/bar/index.html']"]); assert_eq!(files, vec!["src/bar/index.html"]); } { let ScanResult { - candidates, files, .. + candidates, + files, + tree, + .. } = scan_with_globs( &[ ("src/foo.html", "content-['src/foo.html']"), @@ -1426,13 +2067,25 @@ mod scanner { vec!["@source '**/*'", "@source not './src/ba*/*.html'"], ); + assert_snapshot!(tree, @" + . (git) + └── ✓ src + ├── ✗ bar + │ ├── ✗ ignore.html + │ └── ✗ index.html + └── ✓ foo.html + "); + assert_eq!(candidates, vec!["content-['src/foo.html']"]); assert_eq!(files, vec!["src/foo.html"]); } { let ScanResult { - candidates, files, .. + candidates, + files, + tree, + .. } = scan_with_globs( &[ ("src/foo.html", "content-['src/foo.html']"), @@ -1446,6 +2099,15 @@ mod scanner { ], ); + assert_snapshot!(tree, @" + . (git) + └── ✓ src + ├── ✓ bar + │ ├── ✗ ignore.html + │ └── ✓ index.html + └── ✓ foo.html + "); + assert_eq!( candidates, vec!["content-['src/bar/index.html']", "content-['src/foo.html']"] @@ -1460,7 +2122,10 @@ mod scanner { // specific `@source` points at a single file inside a subdirectory. The restriction // added for the explicit file must not hide its siblings from the broad source. let ScanResult { - candidates, files, .. + candidates, + files, + tree, + .. } = scan_with_globs( &[ ("src/components/button.html", "content-['button']"), @@ -1469,6 +2134,14 @@ mod scanner { vec!["@source '**/*'", "@source './src/components/button.html'"], ); + assert_snapshot!(tree, @" + . (git) + └── ✓ src + └── ✓ components + ├── ✓ button.html + └── ✓ card.html + "); + assert_eq!(candidates, vec!["content-['button']", "content-['card']"]); assert_eq!( files, @@ -1481,7 +2154,10 @@ mod scanner { // Same as above, but the broad source is an auto-detected directory (`@source "src"`) // and the explicit file lives in a nested subdirectory of it. let ScanResult { - candidates, files, .. + candidates, + files, + tree, + .. } = scan_with_globs( &[ ("src/components/button.html", "content-['button']"), @@ -1490,6 +2166,14 @@ mod scanner { vec!["@source 'src'", "@source './src/components/button.html'"], ); + assert_snapshot!(tree, @" + . (git) + └── ✓ src + └── ✓ components + ├── ✓ button.html + └── ✓ card.html + "); + assert_eq!(candidates, vec!["content-['button']", "content-['card']"]); assert_eq!( files, @@ -1499,7 +2183,9 @@ mod scanner { #[test] fn root_file_source_should_not_suppress_sibling_source_roots() { - let ScanResult { candidates, .. } = scan_with_globs( + let ScanResult { + candidates, tree, .. + } = scan_with_globs( &[ ("index.css", ""), ("src/index.html", "content-['src/index.html']"), @@ -1513,6 +2199,17 @@ mod scanner { ], ); + assert_snapshot!(tree, @" + . (git) + ├── ✓ index.css + ├── ✓ pages + │ ├── ✓ foo.html + │ └── ✓ nested + │ └── ✓ foo.html + └── ✓ src + └── ✓ index.html + "); + assert_eq!( candidates, vec![ @@ -1530,6 +2227,8 @@ mod scanner { files, globs, normalized_sources, + tree, + .. } = scan_with_globs( &[ (".gitignore", "ignore-1.html\nweb/ignore-2.html"), @@ -1541,6 +2240,19 @@ mod scanner { vec!["@source './src'", "@source './web'"], ); + assert_snapshot!(tree, @" + . (git) + ├── ✗ .gitignore + │ ignore-1.html + │ web/ignore-2.html + ├── ✓ src + │ └── ✓ index.html + └── ✓ web + ├── ✗ ignore-1.html + ├── ✗ ignore-2.html + └── ✓ index.html + "); + assert_eq!( candidates, vec!["content-['src/index.html']", "content-['web/index.html']",] @@ -1558,6 +2270,8 @@ mod scanner { files, globs, normalized_sources, + tree, + .. } = scan_with_globs( &[ ("src/logo.jpg", "content-['/src/logo.jpg']"), @@ -1566,6 +2280,13 @@ mod scanner { vec!["@source './src/logo.{jpg,png}'"], ); + assert_snapshot!(tree, @" + . (git) + └── ✓ src + ├── ✓ logo.jpg + └── ✓ logo.png + "); + assert_eq!( candidates, vec!["content-['/src/logo.jpg']", "content-['/src/logo.png']"] @@ -1582,6 +2303,8 @@ mod scanner { files, globs, normalized_sources, + tree, + .. } = scan_with_globs( &[ (".gitignore", "ignore-1.html\n/web/ignore-2.html"), @@ -1591,6 +2314,17 @@ mod scanner { ], vec!["@source './web'", "@source './web/ignore-1.html'"], ); + + assert_snapshot!(tree, @" + . (git) + ├── ✗ .gitignore + │ ignore-1.html + │ /web/ignore-2.html + └── ✓ web + ├── ✓ ignore-1.html + ├── ✗ ignore-2.html + └── ✓ index.html + "); assert_eq!( candidates, vec![ @@ -1611,7 +2345,10 @@ mod scanner { // A `.gitignore` that ignores everything (`/*`) and then whitelists specific // directories and files using negated patterns. let ScanResult { - files, candidates, .. + files, + candidates, + tree, + .. } = scan(&[ ( ".gitignore", @@ -1629,6 +2366,28 @@ mod scanner { ), ]); + assert_snapshot!(tree, @" + . (git) + ├── ✗ .gitignore + │ /* + │ !/app + │ !/public + │ !/package.json + │ !/.gitignore + ├── ✓ app + │ └── ✓ index.html + ├── ✗ build + │ └── ✗ generated.html + ├── ✗ logs + │ └── ✗ dev.log + ├── ✗ node_modules + │ └── ✗ my-ui-lib + │ └── ✗ index.html + ├── ✓ package.json + └── ✓ public + └── ✓ index.html + "); + assert_eq!( files, vec!["app/index.html", "package.json", "public/index.html"] @@ -1654,7 +2413,10 @@ mod scanner { // !/foo/bar // ``` let ScanResult { - files, candidates, .. + files, + candidates, + tree, + .. } = scan(&[ ( ".gitignore", @@ -1671,6 +2433,25 @@ mod scanner { ("foo/baz/index.html", "content-['foo/baz/index.html']"), ]); + assert_snapshot!(tree, @" + . (git) + ├── ✗ .gitignore + │ # exclude everything except directory foo/bar + │ /* + │ !/foo + │ /foo/* + │ !/foo/bar + ├── ✓ foo + │ ├── ✓ bar + │ │ ├── ✓ index.html + │ │ └── ✓ nested + │ │ └── ✓ index.html + │ ├── ✗ baz + │ │ └── ✗ index.html + │ └── ✗ index.html + └── ✗ index.html + "); + assert_eq!( files, vec!["foo/bar/index.html", "foo/bar/nested/index.html"] @@ -1691,7 +2472,10 @@ mod scanner { // contents. Only when the directory itself is ignored (by an ancestor // `.gitignore`, see the tests above) do we bypass the ignore rules. let ScanResult { - files, candidates, .. + files, + candidates, + tree, + .. } = scan_with_globs( &[ ("vendor/.gitignore", "ignored.html"), @@ -1701,6 +2485,15 @@ mod scanner { vec!["@source 'vendor'"], ); + assert_snapshot!(tree, @" + . (git) + └── ✓ vendor + ├── ✗ .gitignore + │ ignored.html + ├── ✗ ignored.html + └── ✓ index.html + "); + assert_eq!(files, vec!["vendor/index.html"]); assert_eq!(candidates, vec!["content-['vendor/index.html']"]); } @@ -1711,6 +2504,7 @@ mod scanner { candidates, files, globs, + tree, .. } = scan_with_globs( &[ @@ -1722,6 +2516,18 @@ mod scanner { vec!["@source './src/ef*/*.html'"], ); + assert_snapshot!(tree, @" + . (git) + ├── ✗ .gitignore + │ src/efgh/ + └── ✓ src + ├── ✗ abcd + │ └── ✗ index.html + └── ✓ efgh + ├── ✗ ignore.js + └── ✓ index.html + "); + assert_eq!(candidates, vec!["content-['src/efgh/index.html']"]); assert_eq!(files, vec!["src/efgh/index.html"]); assert_eq!(globs, vec!["src/ef*/*.html"]); @@ -1797,7 +2603,33 @@ mod scanner { ), ]; - let candidates = Scanner::new(sources.clone()).scan(); + let mut scanner = Scanner::new(sources.clone()); + let candidates = scanner.scan(); + + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . + └── ✓ home + ├── ✗ .gitignore + │ ignore-home.html + └── ✓ project + ├── ✗ .gitignore + │ ignore-project.html + └── ✓ apps + ├── ✗ .gitignore + │ ignore-apps.html + ├── ✓ admin + │ └── ✓ index.html + ├── ✓ dashboard + │ └── ✓ index.html + └── ✓ web + ├── ✗ .gitignore + │ ignore-web.html + ├── ✗ ignore-apps.html + ├── ✗ ignore-home.html + ├── ✗ ignore-project.html + ├── ✗ ignore-web.html + └── ✓ index.html + "); // All ignore files are applied because there's no git repo assert_eq!( @@ -1815,7 +2647,33 @@ mod scanner { .arg("init") .current_dir(dir.join("home")) .output(); - let candidates = Scanner::new(sources.clone()).scan(); + let mut scanner = Scanner::new(sources.clone()); + let candidates = scanner.scan(); + + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . + └── ✓ home (git) + ├── ✗ .gitignore + │ ignore-home.html + └── ✓ project + ├── ✗ .gitignore + │ ignore-project.html + └── ✓ apps + ├── ✗ .gitignore + │ ignore-apps.html + ├── ✓ admin + │ └── ✓ index.html + ├── ✓ dashboard + │ └── ✓ index.html + └── ✓ web + ├── ✗ .gitignore + │ ignore-web.html + ├── ✗ ignore-apps.html + ├── ✗ ignore-home.html + ├── ✗ ignore-project.html + ├── ✗ ignore-web.html + └── ✓ index.html + "); assert_eq!( candidates, @@ -1834,7 +2692,33 @@ mod scanner { .arg("init") .current_dir(dir.join("home/project")) .output(); - let candidates = Scanner::new(sources.clone()).scan(); + let mut scanner = Scanner::new(sources.clone()); + let candidates = scanner.scan(); + + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . + └── ✓ home + ├── ✗ .gitignore + │ ignore-home.html + └── ✓ project (git) + ├── ✗ .gitignore + │ ignore-project.html + └── ✓ apps + ├── ✗ .gitignore + │ ignore-apps.html + ├── ✓ admin + │ └── ✓ index.html + ├── ✓ dashboard + │ └── ✓ index.html + └── ✓ web + ├── ✗ .gitignore + │ ignore-web.html + ├── ✗ ignore-apps.html + ├── ✓ ignore-home.html + ├── ✗ ignore-project.html + ├── ✗ ignore-web.html + └── ✓ index.html + "); assert_eq!( candidates, @@ -1854,7 +2738,33 @@ mod scanner { .arg("init") .current_dir(dir.join("home/project/apps")) .output(); - let candidates = Scanner::new(sources.clone()).scan(); + let mut scanner = Scanner::new(sources.clone()); + let candidates = scanner.scan(); + + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . + └── ✓ home + ├── ✗ .gitignore + │ ignore-home.html + └── ✓ project + ├── ✗ .gitignore + │ ignore-project.html + └── ✓ apps (git) + ├── ✗ .gitignore + │ ignore-apps.html + ├── ✓ admin + │ └── ✓ index.html + ├── ✓ dashboard + │ └── ✓ index.html + └── ✓ web + ├── ✗ .gitignore + │ ignore-web.html + ├── ✗ ignore-apps.html + ├── ✓ ignore-home.html + ├── ✓ ignore-project.html + ├── ✗ ignore-web.html + └── ✓ index.html + "); assert_eq!( candidates, @@ -1876,7 +2786,33 @@ mod scanner { .current_dir(dir.join("home/project/apps/web")) .output(); - let candidates = Scanner::new(sources.clone()).scan(); + let mut scanner = Scanner::new(sources.clone()); + let candidates = scanner.scan(); + + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . + └── ✓ home + ├── ✗ .gitignore + │ ignore-home.html + └── ✓ project + ├── ✗ .gitignore + │ ignore-project.html + └── ✓ apps + ├── ✗ .gitignore + │ ignore-apps.html + ├── ✓ admin + │ └── ✓ index.html + ├── ✓ dashboard + │ └── ✓ index.html + └── ✓ web (git) + ├── ✗ .gitignore + │ ignore-web.html + ├── ✓ ignore-apps.html + ├── ✓ ignore-home.html + ├── ✓ ignore-project.html + ├── ✗ ignore-web.html + └── ✓ index.html + "); assert_eq!( candidates, @@ -1910,7 +2846,15 @@ mod scanner { public_source_entry_from_pattern(dir.clone(), "@source not 'src/ignore-me.html'"), ]; - let candidates = Scanner::new(sources.clone()).scan(); + let mut scanner = Scanner::new(sources.clone()); + let candidates = scanner.scan(); + + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . + └── ✓ src + ├── ✗ ignore-me.html + └── ✓ keep-me.html + "); assert_eq!(candidates, vec!["content-['keep-me.html']"]); } @@ -1937,7 +2881,17 @@ mod scanner { ), ]; - let candidates = Scanner::new(sources.clone()).scan(); + let mut scanner = Scanner::new(sources.clone()); + let candidates = scanner.scan(); + + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . + └── ✓ src + └── ✓ app + └── ✓ [foo] + ├── ✗ ignore-me.html + └── ✓ keep-me.html + "); assert_eq!(candidates, vec!["content-['keep-me.html']"]); } @@ -1964,6 +2918,12 @@ mod scanner { let candidates = scanner.scan(); assert_eq!(candidates, vec!["content-['src/keep-me.html']"]); + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . + └── ✓ src + └── ✓ keep-me.html + "); + // Create new files that should definitely be ignored create_files_in( &dir, @@ -1991,6 +2951,18 @@ mod scanner { let candidates = scanner.scan(); + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . + ├── ✗ .gitignore + │ src/ignored-by-gitignore.html + └── ✓ src + ├── ✗ ignore-by-extension.bin + ├── ✓ ignored-by-gitignore.html + ├── ✗ ignored-by-source-not.html + ├── ✓ keep-me.html + └── ✓ new-file.html + "); + assert_eq!( candidates, vec![ @@ -2020,6 +2992,11 @@ mod scanner { let candidates = scanner.scan(); assert!(candidates.is_empty()); + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . + └── ✗ foo.styl + "); + // Explicitly allow `.styl` files let mut scanner = Scanner::new(vec![ public_source_entry_from_pattern(dir.clone(), "@source '**/*'"), @@ -2028,6 +3005,11 @@ mod scanner { let candidates = scanner.scan(); assert_eq!(candidates, vec!["content-['foo.styl']"]); + + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . + └── ✓ foo.styl + "); } #[test] @@ -2052,6 +3034,13 @@ mod scanner { let candidates = scanner.scan(); assert!(candidates.is_empty()); + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . + ├── ✗ .gitignore + │ index.html + └── ✗ index.html + "); + let mut scanner = Scanner::new(vec![ public_source_entry_from_pattern(dir.clone(), "@source '**/*'"), public_source_entry_from_pattern(dir.clone(), "@source './*.html'"), @@ -2059,6 +3048,13 @@ mod scanner { let candidates = scanner.scan(); assert_eq!(candidates, vec!["content-['index.html']"]); + + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . + ├── ✗ .gitignore + │ index.html + └── ✓ index.html + "); } #[test] @@ -2093,6 +3089,17 @@ mod scanner { let candidates = scanner.scan(); assert_eq!(candidates, vec!["content-['src/index.html']"]); + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . + ├── ✗ .gitignore + │ node_modules + ├── ✗ node_modules + │ └── ✗ my-ui-lib + │ └── ✗ index.html + └── ✓ src + └── ✓ index.html + "); + // Explicitly listing all `*.html` files, should not include `node_modules` because it's // ignored let sources = vec![public_source_entry_from_pattern( @@ -2104,6 +3111,17 @@ mod scanner { let candidates = scanner.scan(); assert_eq!(candidates, vec!["content-['src/index.html']"]); + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . + ├── ✗ .gitignore + │ node_modules + ├── ✗ node_modules + │ └── ✗ my-ui-lib + │ └── ✗ index.html + └── ✓ src + └── ✓ index.html + "); + // Explicitly listing all `*.html` files // Explicitly list the `node_modules/my-ui-lib` // @@ -2121,12 +3139,25 @@ mod scanner { "content-['src/index.html']" ] ); + + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . + ├── ✗ .gitignore + │ node_modules + ├── ✓ node_modules + │ └── ✓ my-ui-lib + │ └── ✓ index.html + └── ✓ src + └── ✓ index.html + "); } // https://github.com/tailwindlabs/tailwindcss/issues/19844 #[test] fn test_allow_explicit_sources_ignored_by_allow_list_gitignore() { - let ScanResult { candidates, .. } = scan_with_globs( + let ScanResult { + candidates, tree, .. + } = scan_with_globs( &[ (".gitignore", "*\n!/app\n!/app/design\n!/app/design/**\n"), ( @@ -2141,6 +3172,27 @@ mod scanner { vec!["@source 'vendor/acme/theme'"], ); + assert_snapshot!(tree, @" + . (git) + ├── ✗ .gitignore + │ * + │ !/app + │ !/app/design + │ !/app/design/** + ├── ✗ app + │ └── ✗ design + │ └── ✗ frontend + │ └── ✗ theme + │ └── ✗ templates + │ └── ✗ component.phtml + └── ✓ vendor + └── ✓ acme + └── ✓ theme + └── ✓ module + └── ✓ templates + └── ✓ component.phtml + "); + assert_eq!( candidates, vec!["content-['vendor/acme/theme/module/templates/component.phtml']"] @@ -2154,6 +3206,8 @@ mod scanner { files, globs, normalized_sources, + tree, + .. } = scan_with_globs( &[ ( @@ -2172,6 +3226,17 @@ mod scanner { vec!["@source '**/*'"], ); + assert_snapshot!(tree, @" + . (git) + ├── ✗ node_modules + │ └── ✗ index.html + └── ✓ packages + └── ✓ web + ├── ✓ index.html + └── ✗ node_modules + └── ✗ index.html + "); + assert_eq!(candidates, vec!["content-['packages/web/index.html']"]); assert_eq!(files, vec!["packages/web/index.html",]); @@ -2186,6 +3251,8 @@ mod scanner { files, globs, normalized_sources, + tree, + .. } = scan_with_globs( &[ (".gitignore", "node_modules\ndist"), @@ -2201,6 +3268,18 @@ mod scanner { vec!["@source 'node_modules/my-ui-lib'"], ); + assert_snapshot!(tree, @" + . (git) + ├── ✗ .gitignore + │ node_modules + │ dist + └── ✓ node_modules + └── ✓ my-ui-lib + ├── ✓ dist + │ └── ✓ index.html + └── ✗ node.exe + "); + assert_eq!( candidates, vec!["content-['node_modules/my-ui-lib/dist/index.html']"] @@ -2242,6 +3321,17 @@ mod scanner { let candidates = scanner.scan(); assert_eq!(candidates, vec!["content-['src/components/button.tsx']"]); + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . + ├── ✗ .gitignore + │ *.jsx + │ generated/ + └── ✓ src + └── ✓ components + ├── ✗ button.jsx + └── ✓ button.tsx + "); + // Create 2 new files, one "good" and one "bad" file, and manually scan them. This should // only return the "good" file because the "bad" one is ignored by a `.gitignore` file. create_files_in( @@ -2302,6 +3392,8 @@ mod scanner { files, globs, normalized_sources, + tree, + .. } = scan_with_globs( &[ ("src/💩.js", "content-['src/💩.js']"), @@ -2311,6 +3403,15 @@ mod scanner { vec!["@source '**/*'", "@source not 'src/🤦‍♂️'"], ); + assert_snapshot!(tree, @" + . (git) + └── ✓ src + ├── ✓ 💩.js + ├── ✗ 🤦‍♂️ + │ └── ✗ foo.tsx + └── ✓ 🤦‍♂️.tsx + "); + assert_eq!( candidates, vec!["content-['src/💩.js']", "content-['src/🤦‍♂️.tsx']"] @@ -2347,6 +3448,23 @@ mod scanner { )]); let candidates = scanner.scan(); + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . + ├── ✗ .gitignore + │ node_modules + │ dist + └── ✓ node_modules + ├── ✓ .pnpm + │ └── ✓ @org+my-ui-library + │ └── ✓ dist + │ └── ✓ index.ts + └── ✓ @org + ├── ✓ .gitkeep + └── ✓ my-ui-library → ../.pnpm/@org+my-ui-library + └── ✓ dist + └── ✓ index.ts + "); + assert_eq!( candidates, vec!["content-['node_modules/.pnpm/@org+my-ui-library/dist/index.ts']"] @@ -2358,6 +3476,23 @@ mod scanner { )]); let candidates = scanner.scan(); + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . + ├── ✗ .gitignore + │ node_modules + │ dist + └── ✓ node_modules + ├── ✓ .pnpm + │ └── ✓ @org+my-ui-library + │ └── ✓ dist + │ └── ✓ index.ts + └── ✓ @org + ├── ✗ .gitkeep + └── ✓ my-ui-library → ../.pnpm/@org+my-ui-library + └── ✓ dist + └── ✓ index.ts + "); + assert_eq!( candidates, vec!["content-['node_modules/.pnpm/@org+my-ui-library/dist/index.ts']"] @@ -2369,6 +3504,23 @@ mod scanner { )]); let candidates = scanner.scan(); + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . + ├── ✗ .gitignore + │ node_modules + │ dist + └── ✓ node_modules + ├── ✓ .pnpm + │ └── ✓ @org+my-ui-library + │ └── ✓ dist + │ └── ✓ index.ts + └── ✓ @org + ├── ✓ .gitkeep + └── ✓ my-ui-library → ../.pnpm/@org+my-ui-library + └── ✓ dist + └── ✓ index.ts + "); + assert_eq!( candidates, vec!["content-['node_modules/.pnpm/@org+my-ui-library/dist/index.ts']"] @@ -2386,11 +3538,14 @@ mod scanner { ], ); - // Create recursive symlinks - let _ = symlink(dir.join("a"), dir.join("b")); - let _ = symlink(dir.join("b/c"), dir.join("c")); - let _ = symlink(dir.join("b/root"), &dir); - let _ = symlink(dir.join("c"), dir.join("a")); + // Create recursive symlinks: + // + // - `a → b`, `b/c → c`, `c → a` form a cycle + // - `b/root → .` points back at the root directory + let _ = symlink(dir.join("b"), dir.join("a")); + let _ = symlink(dir.join("c"), dir.join("b/c")); + let _ = symlink(&dir, dir.join("b/root")); + let _ = symlink(dir.join("a"), dir.join("c")); let mut scanner = Scanner::new(vec![public_source_entry_from_pattern( dir.clone(), @@ -2398,6 +3553,24 @@ mod scanner { )]); let candidates = scanner.scan(); + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . + ├── ✓ a → b + │ ├── ✗ c → ../c + │ ├── ✓ index.html + │ └── ✗ root → .. + ├── ✓ b + │ ├── ✗ c → ../c + │ ├── ✓ index.html + │ └── ✗ root → .. + ├── ✓ c → a + │ ├── ✗ c → . + │ ├── ✓ index.html + │ └── ✗ root → .. + └── ✓ z + └── ✓ index.html + "); + assert_eq!( candidates, vec!["content-['b/index.html']", "content-['z/index.html']"] @@ -2422,6 +3595,14 @@ mod scanner { )]); let candidates = scanner.scan(); + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . + ├── ✓ abcd + │ └── ✓ xyz.html + └── ✓ efgh → abcd + └── ✓ xyz.html + "); + assert_eq!(candidates, vec!["content-['abcd/xyz.html']"]); // Partially referencing the symlinked folder with a glob, should find the file @@ -2462,6 +3643,18 @@ mod scanner { ]); let candidates = scanner.scan(); + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . + ├── ✗ actual-dir + │ └── ✗ ignore.html + ├── ✗ actual-file.html + ├── ✗ linked-dir → actual-dir + │ └── ✗ ignore.html + ├── ✗ linked-file.html → actual-file.html + └── ✓ src + └── ✓ keep.html + "); + assert_eq!(candidates, vec!["content-['src/keep.html']"]); let mut scanner = Scanner::new(vec![ @@ -2499,6 +3692,16 @@ mod scanner { )]); let candidates = scanner.scan(); + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . + ├── ✓ actual-dir + │ └── ✓ ignore.html + └── ✓ project + ├── ✓ keep.html + └── ✓ linked-dir → ../actual-dir + └── ✓ ignore.html + "); + assert_eq!( candidates, vec![ @@ -2513,6 +3716,16 @@ mod scanner { ]); let candidates = scanner.scan(); + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . + ├── ✗ actual-dir + │ └── ✗ ignore.html + └── ✓ project + ├── ✓ keep.html + └── ✗ linked-dir → ../actual-dir + └── ✗ ignore.html + "); + assert_eq!(candidates, vec!["content-['project/keep.html']"]); } @@ -2570,6 +3783,23 @@ mod scanner { ] ); + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . + ├── ✓ linked.html → packages/other/index.html + ├── ✓ node_modules + │ └── ✓ repro → ../packages/repro + │ ├── ✓ nested + │ │ └── ✓ deep.html + │ └── ✓ source.html + └── ✓ packages + ├── ✓ other + │ └── ✓ index.html + └── ✓ repro + ├── ✓ nested + │ └── ✓ deep.html + └── ✓ source.html + "); + // Both the symlinked paths and the canonical paths should be tracked, such that file // watchers watching the returned files also watch the real files on disk. let files = scanned_files(&mut scanner, &dir); @@ -2603,6 +3833,16 @@ mod scanner { let candidates = scanner.scan(); assert_eq!(candidates, vec!["content-['v1']"]); + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . + ├── ✓ node_modules + │ └── ✓ repro → ../packages/repro + │ └── ✓ source.html + └── ✓ packages + └── ✓ repro + └── ✓ source.html + "); + // Update the real file on disk. This is the path file watchers will report changes for. create_files_in(&dir, &[("packages/repro/source.html", "content-['v2']")]); @@ -2630,6 +3870,16 @@ mod scanner { let candidates = scanner.scan(); assert_eq!(candidates, vec!["content-['a']"]); + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . + ├── ✓ node_modules + │ └── ✓ repro → ../packages/repro + │ └── ✓ a.html + └── ✓ packages + └── ✓ repro + └── ✓ a.html + "); + // Create a new file in the real directory. File watchers watching the real directory // will report the new file with its canonical path. create_files_in(&dir, &[("packages/repro/b.html", "content-['b']")]); @@ -2639,12 +3889,26 @@ mod scanner { "html".into(), )]); assert_eq!(candidates, vec!["content-['b']"]); + + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . + ├── ✓ node_modules + │ └── ✓ repro → ../packages/repro + │ ├── ✓ a.html + │ └── ✓ b.html + └── ✓ packages + └── ✓ repro + ├── ✓ a.html + └── ✗ b.html + "); } // https://github.com/tailwindlabs/tailwindcss/pull/20408 #[test] fn test_resolving_globs_does_not_traverse_gitignored_directories() { - let ScanResult { files, globs, .. } = scan_with_globs( + let ScanResult { + files, globs, tree, .. + } = scan_with_globs( &[ (".gitignore", "/vendor\n"), ("vendor/pkg/canary/index.html", ""), @@ -2653,6 +3917,18 @@ mod scanner { vec!["@source '**/*'", "@source './vendor/pkg/canary'"], ); + assert_snapshot!(tree, @" + . (git) + ├── ✗ .gitignore + │ /vendor + ├── ✓ src + │ └── ✓ index.html + └── ✓ vendor + └── ✓ pkg + └── ✓ canary + └── ✓ index.html + "); + assert_eq!( files, vec!["src/index.html", "vendor/pkg/canary/index.html"]