From 5fae2878790f04a59cafc7ceb0dd470eddf619a8 Mon Sep 17 00:00:00 2001 From: Pistonight Date: Sat, 11 Jul 2026 22:02:51 -0700 Subject: [PATCH 1/6] temp impl --- packages/copper-proc-macros/Cargo.toml | 4 +- packages/copper/Cargo.toml | 22 +- packages/copper/src/fs/mod.rs | 2 + packages/copper/src/fs/walk2.rs | 290 +++++++++++++++++++++++++ packages/copper/src/str/glob.rs | 201 +++++++++++++++++ packages/copper/src/str/mod.rs | 4 + packages/promethium/Cargo.toml | 6 +- 7 files changed, 514 insertions(+), 15 deletions(-) create mode 100644 packages/copper/src/fs/walk2.rs create mode 100644 packages/copper/src/str/glob.rs diff --git a/packages/copper-proc-macros/Cargo.toml b/packages/copper-proc-macros/Cargo.toml index 9432e11..4776802 100644 --- a/packages/copper-proc-macros/Cargo.toml +++ b/packages/copper-proc-macros/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pistonite-cu-proc-macros" -version = "0.2.8" +version = "0.2.9" edition = "2024" description = "Proc-macros for Cu" repository = "https://github.com/Pistonite/cu" @@ -12,7 +12,7 @@ exclude = [ [dependencies.pm] package = "pistonite-pm" -version = "0.2.6" +version = "0.2.7" path = "../promethium" features = ["full"] diff --git a/packages/copper/Cargo.toml b/packages/copper/Cargo.toml index b7843f1..b29c30a 100644 --- a/packages/copper/Cargo.toml +++ b/packages/copper/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pistonite-cu" -version = "0.8.4" +version = "0.9.0" edition = "2024" description = "Battery-included common utils to speed up development of rust tools" repository = "https://github.com/Pistonite/cu" @@ -11,19 +11,19 @@ exclude = [ ] [dependencies] -pistonite-cu-proc-macros = { version = "0.2.8", path = "../copper-proc-macros" } +pistonite-cu-proc-macros = { version = "0.2.9", path = "../copper-proc-macros" } # --- Always enabled --- -anyhow = "1.0.102" -log = "0.4.30" +anyhow = "1.0.103" +log = "0.4.33" # --- Command Line Interface --- oneshot = { version = "0.2.1", optional = true, features = ["std"] } -env_filter = { version = "1.0.1", optional = true } +env_filter = { version = "2.0.0", optional = true } terminal_size = { version = "0.4.4", optional = true } unicode-width = { version = "0.2.2", features = ["cjk"], optional = true } clap = { version = "4.6.1", features = ["derive"], optional = true } -regex = { version = "1.12.3", optional = true } +regex = { version = "1.13.0", optional = true } ctrlc = { version = "3.5.2", optional = true } # --- Coroutine --- @@ -34,11 +34,13 @@ tokio = { version = "1.52.3", optional = true, features = [ # --- File System --- dunce = {version="1.0.5", optional = true} -which = {version = "8.0.2", optional = true } +which = {version = "8.0.4", optional = true } pathdiff = {version = "0.2.3", optional=true} filetime = { version = "0.2.29", optional = true} glob = { version = "0.3.3", optional = true } -spin = {version = "0.12.0", optional = true} # for PIO +fast-glob = { version = "1.0.1", optional = true } +ignore = { version = "0.4.28", optional = true } +spin = {version = "0.12.1", optional = true} # for PIO # serde serde = { version = "1.0.228", features = ["derive"], optional = true } @@ -62,7 +64,7 @@ version = "1.52.3" features = [ "macros", "rt-multi-thread", "time" ] [features] -default = [] +default = ["full"] full = [ "cli", "coroutine-heavy", @@ -96,7 +98,7 @@ process = [ # enable spawning child processes ] fs = [ # enable file system and path util "dep:which", "dep:pathdiff", "dep:dunce", "dep:filetime", "dep:glob", - "tokio?/fs" + "tokio?/fs", "dep:ignore", "dep:fast-glob" ] # Enable parsing utils diff --git a/packages/copper/src/fs/mod.rs b/packages/copper/src/fs/mod.rs index d97f873..6ac827c 100644 --- a/packages/copper/src/fs/mod.rs +++ b/packages/copper/src/fs/mod.rs @@ -19,3 +19,5 @@ pub use glob::*; pub mod bin; pub use filetime::FileTime as Time; + +pub mod walk2; diff --git a/packages/copper/src/fs/walk2.rs b/packages/copper/src/fs/walk2.rs new file mode 100644 index 0000000..c517f60 --- /dev/null +++ b/packages/copper/src/fs/walk2.rs @@ -0,0 +1,290 @@ +use std::ffi::OsStr; +use std::fs::{FileType, Metadata}; +use std::path::{Component, Path, PathBuf}; +use std::sync::Arc; + +use ignore::{WalkBuilder as IgnoreWalkBuilder, Walk as IgnoreWalk, DirEntry as IgnoreDirEntry}; +use ignore::overrides::OverrideBuilder; + +use crate::pre::*; + +/// Create a directory walker builder +#[inline(always)] +pub fn walker(root: impl AsRef) -> WalkBuilder { + WalkBuilder::new(root.as_ref().to_path_buf()) +} + +/// Walk the directory with default settings: +/// - Include hidden files +/// - Does not use git-ignore or other ignore files +/// - Does not follow links +/// - Does not use glob to include or exclude files +#[inline(always)] +pub fn walk(root: impl AsRef) -> cu::Result { + walker(root).walk() +} + +/// A wrapper for the walk builder that provides a simpler API for most +/// cases. See [`walker`] +pub struct WalkBuilder { + inner: IgnoreWalkBuilder, + overrides: OverrideBuilder, + has_overrides: bool, + include_dir_entries: bool, + root: PathBuf, +} + +impl WalkBuilder { + fn new(root: PathBuf) -> Self { + let mut inner = IgnoreWalkBuilder::new(root.clone()); + inner.require_git(true); + inner.ignore(false); + inner.hidden(false); + let mut s = Self { + inner, + overrides: OverrideBuilder::new(root.clone()), + has_overrides: false, + include_dir_entries: false, + root, + }; + s.git(false); + s + } + + + /// Return the inner WalkBuilder from the `ignore` crate for advanced configuration. + /// Note that the `overrides` matcher will be replaced when building the walker + pub fn as_inner_mut(&mut self) -> &mut IgnoreWalkBuilder { + &mut self.inner + } + + /// Add glob patterns to be included. By default all paths are included + pub fn glob_includes(&mut self, globs: impl Iterator>) -> crate::Result<&mut Self> { + for g in globs { + let g = g.as_ref(); + crate::check!(self.overrides.add(g), "failed to add glob include pattern: '{g}'")?; + self.has_overrides = true; + } + Ok(self) + } + + /// Add glob patterns to be excluded. By default none + pub fn glob_excludes(&mut self, globs: impl Iterator>) -> crate::Result<&mut Self> { + let mut s = String::new(); + s.push('!'); + for g in globs { + let g = g.as_ref(); + s.push_str(g); + crate::check!(self.overrides.add(&s), "failed to add glob exclude pattern: '{s}'")?; + s.truncate(1); + self.has_overrides = true; + } + Ok(self) + } + + /// Set if directory entries should be returned while iterating, default is false. + /// When enabled, also reads the symlinks and ignore links to directories (but the files in + /// the link target will still not be returned unless `follow_links` is enabled) + #[inline(always)] + pub fn include_dir_entries(&mut self, include: bool) -> &mut Self { + self.include_dir_entries = include; + self + } + + /// Enable reading `.gitignore` and git exclude configs (mostly) the same behavior as git. + /// Default is disabled. + #[inline(always)] + pub fn git(&mut self, yes: bool) -> &mut Self { + self.inner.git_global(yes); + self.inner.git_ignore(yes); + self.inner.git_exclude(yes); + self + } + + /// Ignore hidden files. Default false + #[inline(always)] + pub fn ignore_hidden(&mut self, yes: bool) -> &mut Self { + self.inner.hidden(yes); + self + } + + /// Follow symlinks. Default false + pub fn follow_links(&mut self, yes: bool) -> &mut Self { + self.inner.follow_links(yes); + self + } + + /// Add custom ignore file name + #[inline(always)] + pub fn add_ignore_filename(&mut self, ignore_file: &str) -> &mut Self { + self.inner.add_custom_ignore_filename(ignore_file); + self + } + + /// Build the directory walker + pub fn walk(mut self) -> cu::Result { + if self.has_overrides { + let overrides = cu::check!(self.overrides.build(), "walk: failed to build glob pattern overrides")?; + self.inner.overrides(overrides); + } + let walk = self.inner.build(); + Ok(Walk { + inner: walk, + include_dir_entries: self.include_dir_entries, + root: Arc::new(self.root), + }) + } +} + +pub struct Walk { + inner: IgnoreWalk, + include_dir_entries: bool, + root: Arc +} + +impl Iterator for Walk { + type Item = crate::Result; + + fn next(&mut self) -> Option { + match self.next_internal() { + Err(e) => Some(Err(e)), + Ok(None) => None, + Ok(Some(e)) => Some(Ok(e)), + } + } +} + +impl Walk { + fn next_internal(&mut self) -> crate::Result> { + let (entry, file_type) = loop { + let entry = crate::some!(self.inner.next()); + let entry = crate::check!(entry, "walk: failed to read the next entry")?; + match entry.file_type() { + None => { + // stdin + continue; + } + Some(t) => { + if self.include_dir_entries { + break (entry, t); + } + // filter out dir entries + if t.is_file() { + break (entry, t); + } + if t.is_dir() { + crate::trace!("walk: skipping directory: '{}'", entry.path().display()); + continue; + } + if t.is_symlink() && entry.path().is_dir() { + crate::trace!("walk: skipping symlinked directory: '{}'", entry.path().display()); + continue; + } + crate::trace!("walk: skipping entry with unknown file type: '{}'", entry.path().display()); + continue; + } + } + }; + Ok(Some(WalkEntry { + root: Arc::clone(&self.root), + inner: entry, + file_type + })) + } +} + +pub struct WalkEntry { + root: Arc, + inner: IgnoreDirEntry, + file_type: FileType, +} + +impl WalkEntry { + /// Get the root of the walk + #[inline(always)] + pub fn root(&self) -> &Path { + &self.root + } + + /// Get the depth of this entry, `0` is root, `1` is an entry in the root, etc. + pub fn depth(&self) -> usize { + self.inner.depth() + } + + /// Get the path by joining the walk root and the relative + /// path of the entry + #[inline(always)] + pub fn path(&self) -> &Path { + self.inner.path() + } + + /// Get the relative path of this entry from the walk root, without leading `./` + /// + /// Return `None` if the entry is root + pub fn rel_path(&self) -> cu::Result> { + // ensure root is a prefix of inner path + let root_norm = self.root.normalize()?; + // note we cannot normalize the path after join since it might be a symlink + let path_norm = root_norm.join(self.inner.path()); + let mut root_iter = root_norm.components().filter(|x| !matches!(x, Component::CurDir)); + let mut path_iter = path_norm.components().filter(|x| !matches!(x, Component::CurDir)); + loop { + let Some(root_comp) = root_iter.next() else { + break; + }; + let path_comp = cu::check!(path_iter.next(), + "unexpected: walk entry path is shorter than root")?; + cu::ensure!(root_comp == path_comp, + "unexpected: walk entry path is not in root")?; + } + let Some(next) = path_iter.next() else { + // is root + return Ok(None); + }; + let mut p = PathBuf::new(); + p.push(next); + p.extend(path_iter); + Ok(Some(p)) + } + + /// Get the file type + #[inline(always)] + pub fn file_type(&self) -> FileType { + self.file_type + } + + /// Check if the entry is a file. + #[inline(always)] + pub fn is_file(&self) -> bool { + self.file_type.is_file() + } + + /// Check if the entry is a directory. + #[inline(always)] + pub fn is_dir(&self) -> bool { + self.file_type.is_dir() + } + + /// Check if the entry is a symlink. + #[inline(always)] + pub fn is_symlink(&self) -> bool { + self.file_type.is_symlink() + } + + /// Get the file name + #[inline(always)] + pub fn file_name(&self) -> Option<&OsStr> { + self.inner.path().file_name() + } + + /// Get the entry metadata + pub fn metadata(&self) -> crate::Result { + crate::check!( + self.inner.metadata(), + "failed to get metadata for file '{}' while walking directory '{}'", + self.inner.path().try_to_rel_from(&*self.root).display(), + self.root.display() + ) + } + +} diff --git a/packages/copper/src/str/glob.rs b/packages/copper/src/str/glob.rs new file mode 100644 index 0000000..b20921e --- /dev/null +++ b/packages/copper/src/str/glob.rs @@ -0,0 +1,201 @@ +//! Glob patterns with brace expansion. +//! +//! [`GlobPattern`] wraps rust-lang's `glob` crate to add support for +//! brace alternation (`{a,b}`), which `glob::Pattern` does not implement. +//! The input is expanded into every permutation it describes, and each +//! permutation is compiled into its own `glob::Pattern`. A string matches +//! the [`GlobPattern`] if it matches any of them. +//! +//! Expansion is capped at 4096 permutations, since alternations multiply. + +use std::path::Path; + +use ::glob::Pattern; +use smallvec::SmallVec; + +/// Maximum number of patterns a single input may expand into. +const MAX_PERMUTATIONS: usize = 4096; + +/// A glob pattern that additionally supports brace alternation (`{a,b}`). +/// +/// This is a wrapper for rust-lang's `glob` crate. All of the usual glob +/// syntax is supported (`*`, `**`, `?`, and `[...]` character classes), +/// plus `{a,b}` segments, which mean "either `a` or `b`". +/// +/// ```rust,no_run +/// # use pistonite_cu as cu; +/// let p = cu::str::GlobPattern::new("**/*.{rs,toml}").unwrap(); +/// assert!(p.matches("src/main.rs")); +/// assert!(p.matches("src/Cargo.toml")); +/// assert!(!p.matches("src/README.md")); +/// ``` +#[cfg_attr(any(docsrs, feature = "nightly"), doc(cfg(feature = "fs")))] +#[derive(Debug, Clone)] +pub struct GlobPattern(SmallVec<[Pattern; 4]>); + +impl GlobPattern { + /// Compile a glob pattern, expanding any `{a,b}` alternations. + /// + /// Alternations may be nested (`{a,{b,c}}`) and combined + /// (`{a,b}/{c,d}` describes 4 patterns). A single alternative + /// (`{a}`) is allowed and simply means `a`. + /// + /// Note that `glob` has no escape character. To match a literal `{`, + /// wrap it in a character class: `[{]`. Braces and commas inside + /// `[...]` are always literal and are never expanded. + /// + /// # Errors + /// Malformed alternations are rejected rather than being silently + /// treated as literal text, so that typos surface instead of quietly + /// matching nothing: + /// - an unmatched `{` or `}`, as in `a{b` or `a}b` + /// - an empty alternative, as in `{a,}`, `{,a}`, or `{}` + /// + /// This also errors if the pattern expands to more than 4096 + /// permutations, or if any expanded permutation is not a valid glob. + pub fn new(pattern: &str) -> crate::Result { + let _ = pattern; + todo!("brace expansion + Pattern compilation") + } + + /// Check if the string matches any of the expanded patterns. + pub fn matches(&self, s: &str) -> bool { + let _ = s; + todo!() + } + + /// Check if the path matches any of the expanded patterns. + pub fn matches_path(&self, path: &Path) -> bool { + let _ = path; + todo!() + } +} + +#[cfg(test)] +mod tests { + use std::path::Path; + + use super::GlobPattern; + + fn pattern(p: &str) -> GlobPattern { + GlobPattern::new(p).expect("pattern should compile") + } + + #[test] + fn no_braces() { + let p = pattern("**/*.rs"); + assert!(p.matches("src/main.rs")); + assert!(!p.matches("src/README.md")); + } + + #[test] + fn alternation() { + let p = pattern("**/*.{rs,toml}"); + assert!(p.matches("src/main.rs")); + assert!(p.matches("src/Cargo.toml")); + assert!(!p.matches("src/README.md")); + } + + #[test] + fn cross_product() { + let p = pattern("{a,b}/{c,d}"); + assert!(p.matches("a/c")); + assert!(p.matches("a/d")); + assert!(p.matches("b/c")); + assert!(p.matches("b/d")); + assert!(!p.matches("a/b")); + assert!(!p.matches("c/a")); + } + + #[test] + fn nested() { + let p = pattern("{a,{b,c}}"); + assert!(p.matches("a")); + assert!(p.matches("b")); + assert!(p.matches("c")); + assert!(!p.matches("d")); + } + + #[test] + fn single_alternative() { + let p = pattern("{a}"); + assert!(p.matches("a")); + assert!(!p.matches("{a}")); + } + + #[test] + fn bracket_literal_brace() { + let p = pattern("[{]"); + assert!(p.matches("{")); + assert!(!p.matches("[")); + } + + #[test] + fn bracket_comma() { + // a character class, not an alternation + let p = pattern("[a,b]"); + assert!(p.matches("a")); + assert!(p.matches(",")); + assert!(p.matches("b")); + assert!(!p.matches("a,b")); + } + + #[test] + fn bracket_negated_comma() { + let p = pattern("[!,]"); + assert!(p.matches("a")); + assert!(!p.matches(",")); + } + + #[test] + fn bracket_close_first() { + // a leading `]` in a character class is literal + let p = pattern("[]]"); + assert!(p.matches("]")); + + let p = pattern("[!]]"); + assert!(p.matches("a")); + assert!(!p.matches("]")); + } + + #[test] + fn err_unmatched_open() { + assert!(GlobPattern::new("a{b").is_err()); + } + + #[test] + fn err_unmatched_close() { + assert!(GlobPattern::new("a}b").is_err()); + assert!(GlobPattern::new("{a,b}}").is_err()); + } + + #[test] + fn err_empty_alternative() { + assert!(GlobPattern::new("{a,}").is_err()); + assert!(GlobPattern::new("{,a}").is_err()); + assert!(GlobPattern::new("{}").is_err()); + } + + #[test] + fn cap_at_boundary() { + // 2^12 == 4096, exactly at the cap + assert!(GlobPattern::new(&"{a,b}".repeat(12)).is_ok()); + // 2^13 == 8192, over the cap + assert!(GlobPattern::new(&"{a,b}".repeat(13)).is_err()); + } + + #[test] + fn deep_nesting_does_not_overflow() { + // A comma-less alternation adds no permutations, so the cap never + // fires. A recursive expander would exhaust the stack here; we only + // care that this returns at all, not what it returns. + let _ = GlobPattern::new(&"{a}".repeat(10_000)); + } + + #[test] + fn matches_path_agrees_with_matches() { + let p = pattern("**/*.{rs,toml}"); + assert!(p.matches_path(Path::new("src/main.rs"))); + assert!(!p.matches_path(Path::new("src/README.md"))); + } +} diff --git a/packages/copper/src/str/mod.rs b/packages/copper/src/str/mod.rs index 715e20f..2087600 100644 --- a/packages/copper/src/str/mod.rs +++ b/packages/copper/src/str/mod.rs @@ -14,6 +14,10 @@ pub use osstring::{OsStrExtension, OsStrExtensionOwned}; mod path; #[cfg(feature = "fs")] pub use path::PathExtension; +#[cfg(feature = "fs")] +mod glob; +#[cfg(feature = "fs")] +pub use glob::GlobPattern; // path macro just depends on the std path functions and does not require fs mod path_macro; diff --git a/packages/promethium/Cargo.toml b/packages/promethium/Cargo.toml index 6870ef0..be4075d 100644 --- a/packages/promethium/Cargo.toml +++ b/packages/promethium/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pistonite-pm" -version = "0.2.6" +version = "0.2.7" edition = "2024" description = "Procedural Macro Common Utils" repository = "https://github.com/Pistonite/cu" @@ -12,8 +12,8 @@ exclude = [ [dependencies] proc-macro2 = { version = "1.0.106", default-features = false } -quote = { version = "1.0.45", default-features = false } -syn = { version = "2.0.117", default-features = false } +quote = { version = "1.0.46", default-features = false } +syn = { version = "2.0.118", default-features = false } [features] default = [ From fca0adebe129aeb5dde4dc458906b81226a80f7b Mon Sep 17 00:00:00 2001 From: Pistonight Date: Sat, 11 Jul 2026 22:38:49 -0700 Subject: [PATCH 2/6] doc and test --- packages/copper/Cargo.toml | 3 +- packages/copper/src/fs/walk2.rs | 234 ++++++++++++++-- packages/copper/src/str/glob.rs | 201 -------------- packages/copper/src/str/mod.rs | 4 - packages/copper/tests/walk2.rs | 472 ++++++++++++++++++++++++++++++++ 5 files changed, 687 insertions(+), 227 deletions(-) delete mode 100644 packages/copper/src/str/glob.rs create mode 100644 packages/copper/tests/walk2.rs diff --git a/packages/copper/Cargo.toml b/packages/copper/Cargo.toml index b29c30a..f835d30 100644 --- a/packages/copper/Cargo.toml +++ b/packages/copper/Cargo.toml @@ -40,6 +40,7 @@ filetime = { version = "0.2.29", optional = true} glob = { version = "0.3.3", optional = true } fast-glob = { version = "1.0.1", optional = true } ignore = { version = "0.4.28", optional = true } +smallvec = { version = "1.15.2", optional = true } spin = {version = "0.12.1", optional = true} # for PIO # serde @@ -98,7 +99,7 @@ process = [ # enable spawning child processes ] fs = [ # enable file system and path util "dep:which", "dep:pathdiff", "dep:dunce", "dep:filetime", "dep:glob", - "tokio?/fs", "dep:ignore", "dep:fast-glob" + "tokio?/fs", "dep:ignore", "dep:fast-glob", "dep:smallvec" ] # Enable parsing utils diff --git a/packages/copper/src/fs/walk2.rs b/packages/copper/src/fs/walk2.rs index c517f60..de49f65 100644 --- a/packages/copper/src/fs/walk2.rs +++ b/packages/copper/src/fs/walk2.rs @@ -1,3 +1,51 @@ +//! Recursive directory walking. +//! +//! A thin, opinionated wrapper around the [`ignore`](https://docs.rs/ignore) +//! crate that exposes a simpler builder API for the most common cases. +//! +//! Use [`walk`] for a quick recursive walk with sensible defaults, or [`walker`] +//! to configure the walk through a [`WalkBuilder`] before running it. +//! +//! # Defaults +//! Out of the box (see [`walk`]) the walker: +//! - **includes** hidden files (dotfiles), +//! - does **not** read `.gitignore` or other ignore/VCS files, +//! - does **not** follow symbolic links, +//! - applies **no** glob include/exclude filters, +//! - yields **files only** — directory entries are skipped. +//! +//! Every one of these can be changed through [`WalkBuilder`]. +//! +//! # Examples +//! +//! Walk the current directory and print every file: +//! ```rust,no_run +//! # use pistonite_cu as cu; +//! fn print_files() -> cu::Result<()> { +//! for entry in cu::fs::walk2::walk(".")? { +//! let entry = entry?; +//! cu::info!("{}", entry.path().display()); +//! } +//! Ok(()) +//! } +//! ``` +//! +//! Configure the walk with the builder — respect `.gitignore`, follow symlinks, +//! and only include Rust and TOML files (note brace alternation is supported): +//! ```rust,no_run +//! # use pistonite_cu as cu; +//! fn print_sources() -> cu::Result<()> { +//! let mut builder = cu::fs::walk2::walker("src"); +//! builder.git(true).follow_links(true); +//! builder.glob_includes(["**/*.{rs,toml}"].into_iter())?; +//! for entry in builder.walk()? { +//! let entry = entry?; +//! cu::info!("{}", entry.path().display()); +//! } +//! Ok(()) +//! } +//! ``` + use std::ffi::OsStr; use std::fs::{FileType, Metadata}; use std::path::{Component, Path, PathBuf}; @@ -8,24 +56,78 @@ use ignore::overrides::OverrideBuilder; use crate::pre::*; -/// Create a directory walker builder +/// Create a [`WalkBuilder`] rooted at `root` to configure a walk. +/// +/// Use this when you need to change the defaults (glob filters, gitignore, +/// following links, etc.); otherwise reach for [`walk`]. +/// +/// ```rust,no_run +/// # use pistonite_cu as cu; +/// fn walk_dirs_too() -> cu::Result<()> { +/// let mut builder = cu::fs::walk2::walker("."); +/// builder.include_dir_entries(true); +/// for entry in builder.walk()? { +/// let entry = entry?; +/// cu::info!("{} (dir: {})", entry.path().display(), entry.is_dir()); +/// } +/// Ok(()) +/// } +/// ``` #[inline(always)] pub fn walker(root: impl AsRef) -> WalkBuilder { WalkBuilder::new(root.as_ref().to_path_buf()) } -/// Walk the directory with default settings: -/// - Include hidden files -/// - Does not use git-ignore or other ignore files -/// - Does not follow links -/// - Does not use glob to include or exclude files +/// Recursively walk `root` with default settings. +/// +/// The defaults are: +/// - includes hidden files, +/// - does not use `.gitignore` or other ignore files, +/// - does not follow symbolic links, +/// - does not apply any glob include/exclude filters, +/// - yields files only (directory entries are skipped). +/// +/// Use [`walker`] to change any of these. The returned [`Walk`] is an iterator +/// of [`WalkEntry`] results. +/// +/// ```rust,no_run +/// # use pistonite_cu as cu; +/// fn count_files() -> cu::Result { +/// let mut count = 0; +/// for entry in cu::fs::walk2::walk(".")? { +/// let _entry = entry?; +/// count += 1; +/// } +/// Ok(count) +/// } +/// ``` #[inline(always)] pub fn walk(root: impl AsRef) -> cu::Result { walker(root).walk() } -/// A wrapper for the walk builder that provides a simpler API for most -/// cases. See [`walker`] +/// Builder for a directory [`Walk`], providing a simpler API over the +/// `ignore` crate for the most common cases. +/// +/// Create one with [`walker`], configure it with the methods below, then call +/// [`walk`](WalkBuilder::walk) to produce the [`Walk`] iterator. Configuration +/// methods return `&mut Self` (or `cu::Result<&mut Self>` for the fallible glob +/// setters) so they can be chained. +/// +/// ```rust,no_run +/// # use pistonite_cu as cu; +/// fn configured() -> cu::Result<()> { +/// let mut builder = cu::fs::walk2::walker("."); +/// builder.ignore_hidden(true).include_dir_entries(true); +/// for entry in builder.walk()? { +/// let entry = entry?; +/// cu::info!("{}", entry.path().display()); +/// } +/// Ok(()) +/// } +/// ``` +/// +/// See [`walker`]. pub struct WalkBuilder { inner: IgnoreWalkBuilder, overrides: OverrideBuilder, @@ -58,7 +160,25 @@ impl WalkBuilder { &mut self.inner } - /// Add glob patterns to be included. By default all paths are included + /// Add glob patterns to include. By default all paths are included. + /// + /// Patterns use gitignore-style glob syntax (as implemented by the `ignore` + /// crate), which supports `*`, `**`, `?`, `[...]` character classes, and + /// `{a,b}` brace alternation. Once any include pattern is added, only paths + /// matching at least one include (and no exclude) are yielded. + /// + /// ```rust,no_run + /// # use pistonite_cu as cu; + /// fn only_sources() -> cu::Result<()> { + /// let mut builder = cu::fs::walk2::walker("."); + /// // include Rust and TOML files anywhere in the tree + /// builder.glob_includes(["**/*.{rs,toml}"].into_iter())?; + /// for entry in builder.walk()? { + /// cu::info!("{}", entry?.path().display()); + /// } + /// Ok(()) + /// } + /// ``` pub fn glob_includes(&mut self, globs: impl Iterator>) -> crate::Result<&mut Self> { for g in globs { let g = g.as_ref(); @@ -68,7 +188,23 @@ impl WalkBuilder { Ok(self) } - /// Add glob patterns to be excluded. By default none + /// Add glob patterns to exclude. By default nothing is excluded. + /// + /// Patterns use the same gitignore-style syntax as + /// [`glob_includes`](Self::glob_includes), including `{a,b}` brace + /// alternation. Excludes take precedence over includes. + /// + /// ```rust,no_run + /// # use pistonite_cu as cu; + /// fn skip_logs_and_tmp() -> cu::Result<()> { + /// let mut builder = cu::fs::walk2::walker("."); + /// builder.glob_excludes(["**/*.{log,tmp}"].into_iter())?; + /// for entry in builder.walk()? { + /// cu::info!("{}", entry?.path().display()); + /// } + /// Ok(()) + /// } + /// ``` pub fn glob_excludes(&mut self, globs: impl Iterator>) -> crate::Result<&mut Self> { let mut s = String::new(); s.push('!'); @@ -82,17 +218,24 @@ impl WalkBuilder { Ok(self) } - /// Set if directory entries should be returned while iterating, default is false. - /// When enabled, also reads the symlinks and ignore links to directories (but the files in - /// the link target will still not be returned unless `follow_links` is enabled) + /// Set whether directory entries are returned while iterating. Default is + /// `false` (only files are yielded). + /// + /// When enabled, directory entries and symlinks-to-directories are also + /// yielded (as well as the root of the walk, at depth 0). Note that the + /// files *inside* a symlinked directory are still not returned unless + /// [`follow_links`](Self::follow_links) is also enabled. #[inline(always)] pub fn include_dir_entries(&mut self, include: bool) -> &mut Self { self.include_dir_entries = include; self } - /// Enable reading `.gitignore` and git exclude configs (mostly) the same behavior as git. - /// Default is disabled. + /// Enable reading `.gitignore` and git exclude configs, (mostly) matching + /// git's own behavior. Default is disabled. + /// + /// Because this relies on git configuration, ignore rules are only applied + /// when the walk root is inside a git repository. #[inline(always)] pub fn git(&mut self, yes: bool) -> &mut Self { self.inner.git_global(yes); @@ -101,27 +244,38 @@ impl WalkBuilder { self } - /// Ignore hidden files. Default false + /// Skip hidden files (dotfiles). Default is `false` (hidden files are + /// included). #[inline(always)] pub fn ignore_hidden(&mut self, yes: bool) -> &mut Self { self.inner.hidden(yes); self } - /// Follow symlinks. Default false + /// Follow symbolic links. Default is `false`. + /// + /// When enabled, symlinked directories are descended into. Following a + /// dangling (broken) symlink surfaces an error from the [`Walk`] iterator + /// rather than being silently skipped. pub fn follow_links(&mut self, yes: bool) -> &mut Self { self.inner.follow_links(yes); self } - /// Add custom ignore file name + /// Add a custom ignore file name (in addition to any enabled by [`git`](Self::git)). + /// + /// Files with this name are read as gitignore-style ignore lists. Unlike + /// [`git`](Self::git), custom ignore files are honored regardless of whether + /// the walk root is inside a git repository. #[inline(always)] pub fn add_ignore_filename(&mut self, ignore_file: &str) -> &mut Self { self.inner.add_custom_ignore_filename(ignore_file); self } - /// Build the directory walker + /// Build the [`Walk`] iterator from this configuration. + /// + /// Fails if any configured glob patterns cannot be compiled. pub fn walk(mut self) -> cu::Result { if self.has_overrides { let overrides = cu::check!(self.overrides.build(), "walk: failed to build glob pattern overrides")?; @@ -136,6 +290,23 @@ impl WalkBuilder { } } +/// An iterator over the entries of a directory walk. +/// +/// Created by [`walk`] or [`WalkBuilder::walk`]. Each item is a +/// `cu::Result`; an `Err` indicates a failure reading a particular +/// entry (for example, following a broken symlink) and does not necessarily +/// stop the iteration. +/// +/// ```rust,no_run +/// # use pistonite_cu as cu; +/// fn list() -> cu::Result<()> { +/// for entry in cu::fs::walk2::walk(".")? { +/// let entry = entry?; +/// cu::info!("depth {}: {}", entry.depth(), entry.path().display()); +/// } +/// Ok(()) +/// } +/// ``` pub struct Walk { inner: IgnoreWalk, include_dir_entries: bool, @@ -193,6 +364,11 @@ impl Walk { } } +/// A single entry produced by a [`Walk`]. +/// +/// Provides access to the entry's [`path`](Self::path), its +/// [`depth`](Self::depth) relative to the walk root, its +/// [`file_type`](Self::file_type), and lazily-read [`metadata`](Self::metadata). pub struct WalkEntry { root: Arc, inner: IgnoreDirEntry, @@ -218,9 +394,25 @@ impl WalkEntry { self.inner.path() } - /// Get the relative path of this entry from the walk root, without leading `./` + /// Get this entry's path relative to the walk root, without a leading `./`. + /// + /// Returns `Ok(None)` when the entry *is* the root of the walk (only emitted + /// when [`include_dir_entries`](WalkBuilder::include_dir_entries) is + /// enabled). Returns an error if the entry path is unexpectedly not + /// contained within the walk root. /// - /// Return `None` if the entry is root + /// ```rust,no_run + /// # use pistonite_cu as cu; + /// fn print_relative() -> cu::Result<()> { + /// for entry in cu::fs::walk2::walk("src")? { + /// let entry = entry?; + /// if let Some(rel) = entry.rel_path()? { + /// cu::info!("{}", rel.display()); + /// } + /// } + /// Ok(()) + /// } + /// ``` pub fn rel_path(&self) -> cu::Result> { // ensure root is a prefix of inner path let root_norm = self.root.normalize()?; diff --git a/packages/copper/src/str/glob.rs b/packages/copper/src/str/glob.rs deleted file mode 100644 index b20921e..0000000 --- a/packages/copper/src/str/glob.rs +++ /dev/null @@ -1,201 +0,0 @@ -//! Glob patterns with brace expansion. -//! -//! [`GlobPattern`] wraps rust-lang's `glob` crate to add support for -//! brace alternation (`{a,b}`), which `glob::Pattern` does not implement. -//! The input is expanded into every permutation it describes, and each -//! permutation is compiled into its own `glob::Pattern`. A string matches -//! the [`GlobPattern`] if it matches any of them. -//! -//! Expansion is capped at 4096 permutations, since alternations multiply. - -use std::path::Path; - -use ::glob::Pattern; -use smallvec::SmallVec; - -/// Maximum number of patterns a single input may expand into. -const MAX_PERMUTATIONS: usize = 4096; - -/// A glob pattern that additionally supports brace alternation (`{a,b}`). -/// -/// This is a wrapper for rust-lang's `glob` crate. All of the usual glob -/// syntax is supported (`*`, `**`, `?`, and `[...]` character classes), -/// plus `{a,b}` segments, which mean "either `a` or `b`". -/// -/// ```rust,no_run -/// # use pistonite_cu as cu; -/// let p = cu::str::GlobPattern::new("**/*.{rs,toml}").unwrap(); -/// assert!(p.matches("src/main.rs")); -/// assert!(p.matches("src/Cargo.toml")); -/// assert!(!p.matches("src/README.md")); -/// ``` -#[cfg_attr(any(docsrs, feature = "nightly"), doc(cfg(feature = "fs")))] -#[derive(Debug, Clone)] -pub struct GlobPattern(SmallVec<[Pattern; 4]>); - -impl GlobPattern { - /// Compile a glob pattern, expanding any `{a,b}` alternations. - /// - /// Alternations may be nested (`{a,{b,c}}`) and combined - /// (`{a,b}/{c,d}` describes 4 patterns). A single alternative - /// (`{a}`) is allowed and simply means `a`. - /// - /// Note that `glob` has no escape character. To match a literal `{`, - /// wrap it in a character class: `[{]`. Braces and commas inside - /// `[...]` are always literal and are never expanded. - /// - /// # Errors - /// Malformed alternations are rejected rather than being silently - /// treated as literal text, so that typos surface instead of quietly - /// matching nothing: - /// - an unmatched `{` or `}`, as in `a{b` or `a}b` - /// - an empty alternative, as in `{a,}`, `{,a}`, or `{}` - /// - /// This also errors if the pattern expands to more than 4096 - /// permutations, or if any expanded permutation is not a valid glob. - pub fn new(pattern: &str) -> crate::Result { - let _ = pattern; - todo!("brace expansion + Pattern compilation") - } - - /// Check if the string matches any of the expanded patterns. - pub fn matches(&self, s: &str) -> bool { - let _ = s; - todo!() - } - - /// Check if the path matches any of the expanded patterns. - pub fn matches_path(&self, path: &Path) -> bool { - let _ = path; - todo!() - } -} - -#[cfg(test)] -mod tests { - use std::path::Path; - - use super::GlobPattern; - - fn pattern(p: &str) -> GlobPattern { - GlobPattern::new(p).expect("pattern should compile") - } - - #[test] - fn no_braces() { - let p = pattern("**/*.rs"); - assert!(p.matches("src/main.rs")); - assert!(!p.matches("src/README.md")); - } - - #[test] - fn alternation() { - let p = pattern("**/*.{rs,toml}"); - assert!(p.matches("src/main.rs")); - assert!(p.matches("src/Cargo.toml")); - assert!(!p.matches("src/README.md")); - } - - #[test] - fn cross_product() { - let p = pattern("{a,b}/{c,d}"); - assert!(p.matches("a/c")); - assert!(p.matches("a/d")); - assert!(p.matches("b/c")); - assert!(p.matches("b/d")); - assert!(!p.matches("a/b")); - assert!(!p.matches("c/a")); - } - - #[test] - fn nested() { - let p = pattern("{a,{b,c}}"); - assert!(p.matches("a")); - assert!(p.matches("b")); - assert!(p.matches("c")); - assert!(!p.matches("d")); - } - - #[test] - fn single_alternative() { - let p = pattern("{a}"); - assert!(p.matches("a")); - assert!(!p.matches("{a}")); - } - - #[test] - fn bracket_literal_brace() { - let p = pattern("[{]"); - assert!(p.matches("{")); - assert!(!p.matches("[")); - } - - #[test] - fn bracket_comma() { - // a character class, not an alternation - let p = pattern("[a,b]"); - assert!(p.matches("a")); - assert!(p.matches(",")); - assert!(p.matches("b")); - assert!(!p.matches("a,b")); - } - - #[test] - fn bracket_negated_comma() { - let p = pattern("[!,]"); - assert!(p.matches("a")); - assert!(!p.matches(",")); - } - - #[test] - fn bracket_close_first() { - // a leading `]` in a character class is literal - let p = pattern("[]]"); - assert!(p.matches("]")); - - let p = pattern("[!]]"); - assert!(p.matches("a")); - assert!(!p.matches("]")); - } - - #[test] - fn err_unmatched_open() { - assert!(GlobPattern::new("a{b").is_err()); - } - - #[test] - fn err_unmatched_close() { - assert!(GlobPattern::new("a}b").is_err()); - assert!(GlobPattern::new("{a,b}}").is_err()); - } - - #[test] - fn err_empty_alternative() { - assert!(GlobPattern::new("{a,}").is_err()); - assert!(GlobPattern::new("{,a}").is_err()); - assert!(GlobPattern::new("{}").is_err()); - } - - #[test] - fn cap_at_boundary() { - // 2^12 == 4096, exactly at the cap - assert!(GlobPattern::new(&"{a,b}".repeat(12)).is_ok()); - // 2^13 == 8192, over the cap - assert!(GlobPattern::new(&"{a,b}".repeat(13)).is_err()); - } - - #[test] - fn deep_nesting_does_not_overflow() { - // A comma-less alternation adds no permutations, so the cap never - // fires. A recursive expander would exhaust the stack here; we only - // care that this returns at all, not what it returns. - let _ = GlobPattern::new(&"{a}".repeat(10_000)); - } - - #[test] - fn matches_path_agrees_with_matches() { - let p = pattern("**/*.{rs,toml}"); - assert!(p.matches_path(Path::new("src/main.rs"))); - assert!(!p.matches_path(Path::new("src/README.md"))); - } -} diff --git a/packages/copper/src/str/mod.rs b/packages/copper/src/str/mod.rs index 2087600..715e20f 100644 --- a/packages/copper/src/str/mod.rs +++ b/packages/copper/src/str/mod.rs @@ -14,10 +14,6 @@ pub use osstring::{OsStrExtension, OsStrExtensionOwned}; mod path; #[cfg(feature = "fs")] pub use path::PathExtension; -#[cfg(feature = "fs")] -mod glob; -#[cfg(feature = "fs")] -pub use glob::GlobPattern; // path macro just depends on the std path functions and does not require fs mod path_macro; diff --git a/packages/copper/tests/walk2.rs b/packages/copper/tests/walk2.rs new file mode 100644 index 0000000..542898a --- /dev/null +++ b/packages/copper/tests/walk2.rs @@ -0,0 +1,472 @@ +//! Fixture tests for [`cu::fs::walk2`]. +//! +//! Each test builds a stub directory tree in its own uniquely-named temp +//! directory (under `CARGO_TARGET_TMPDIR`) so tests can run in parallel, and +//! tears it down with a drop guard. Symlink-dependent cases are `#[cfg(unix)]` +//! since Windows symlink creation requires elevated privileges. +#![cfg(feature = "fs")] + +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; + +use pistonite_cu as cu; +use cu::pre::*; +use pistonite_cu::fs::walk2::{Walk, walk, walker}; + +/// A temp directory that is recursively removed when dropped. +struct Fixture { + root: PathBuf, +} + +impl Drop for Fixture { + fn drop(&mut self) { + // best-effort cleanup; ignore errors during teardown + let _ = cu::fs::rec_remove(&self.root); + } +} + +impl Fixture { + fn root(&self) -> &Path { + &self.root + } +} + +/// Build the fixture tree in a fresh temp dir named after the calling test. +/// +/// Tree: +/// ```text +/// root/ +/// a.txt +/// b.log +/// .hidden.txt +/// .gitignore # "b.log\nignored/\n" +/// .customignore # "d.rs\n" +/// .git/ # empty marker so require_git(true) sees a repo +/// sub/ +/// c.txt +/// d.rs +/// nested/e.txt +/// .hiddendir/f.txt +/// ignored/g.txt +/// ``` +/// On unix, additionally: +/// ```text +/// link_to_file -> a.txt +/// link_to_dir -> sub +/// ``` +/// (A dangling link is created only by the broken-symlink test, since following +/// it is an error and would interfere with the other symlink cases.) +fn make_fixture(name: &str) -> cu::Result { + let root = PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join(format!("walk2_{name}")); + // start from a clean slate in case a previous run left something behind + cu::fs::make_dir_empty(&root)?; + cu::fs::write(root.join("a.txt"), "a")?; + cu::fs::write(root.join("b.log"), "b")?; + cu::fs::write(root.join(".hidden.txt"), "hidden")?; + cu::fs::write(root.join(".gitignore"), "b.log\nignored/\n")?; + cu::fs::write(root.join(".customignore"), "d.rs\n")?; + cu::fs::make_dir(root.join(".git"))?; + + cu::fs::make_dir(root.join("sub"))?; + cu::fs::write(root.join("sub/c.txt"), "c")?; + cu::fs::write(root.join("sub/d.rs"), "d")?; + cu::fs::make_dir(root.join("sub/nested"))?; + cu::fs::write(root.join("sub/nested/e.txt"), "e")?; + + cu::fs::make_dir(root.join(".hiddendir"))?; + cu::fs::write(root.join(".hiddendir/f.txt"), "f")?; + + cu::fs::make_dir(root.join("ignored"))?; + cu::fs::write(root.join("ignored/g.txt"), "g")?; + + #[cfg(unix)] + { + make_symlink("a.txt", &root.join("link_to_file"))?; + make_symlink("sub", &root.join("link_to_dir"))?; + } + + Ok(Fixture { root }) +} + +/// Create a symlink at `link` pointing at `target`, with error context. +#[cfg(unix)] +fn make_symlink(target: &str, link: &Path) -> cu::Result<()> { + cu::check!( + std::os::unix::fs::symlink(target, link), + "failed to create symlink '{}' -> '{target}'", + link.display() + ) +} + +/// Normalize a relative path to a forward-slash string for stable comparisons. +fn norm(p: &Path) -> String { + p.to_string_lossy().replace('\\', "/") +} + +/// Drain a walk into the set of relative-path strings it yields. +/// +/// The root entry (`rel_path() == None`, only emitted with dir entries enabled) +/// is represented as `"."`. +fn collect(w: Walk) -> cu::Result> { + let mut set = BTreeSet::new(); + for entry in w { + let entry = entry?; + match entry.rel_path()? { + Some(p) => { + set.insert(norm(&p)); + } + None => { + set.insert(".".to_string()); + } + } + } + Ok(set) +} + +fn set(items: &[&str]) -> BTreeSet { + items.iter().map(|s| s.to_string()).collect() +} + +// --- cross-platform cases (no symlinks) -------------------------------------- + +#[test] +fn default_returns_files_only() -> cu::Result<()> { + let fx = make_fixture("default_returns_files_only")?; + let got = collect(walk(fx.root())?)?; + // Every regular file, hidden included; no directories, and (on unix) no + // symlink entries leak in. + let expected = set(&[ + "a.txt", + "b.log", + ".hidden.txt", + ".gitignore", + ".customignore", + "sub/c.txt", + "sub/d.rs", + "sub/nested/e.txt", + ".hiddendir/f.txt", + "ignored/g.txt", + ]); + assert_eq!(got, expected); + Ok(()) +} + +#[test] +fn include_dir_entries_true() -> cu::Result<()> { + let fx = make_fixture("include_dir_entries_true")?; + let mut b = walker(fx.root()); + b.include_dir_entries(true); + let got = collect(b.walk()?)?; + + // directory entries now appear... + for dir in ["sub", "sub/nested", ".hiddendir", "ignored"] { + assert!(got.contains(dir), "expected dir entry {dir:?} in {got:?}"); + } + // ...alongside the files... + assert!(got.contains("sub/c.txt")); + // ...and the root entry (rel_path == None). + assert!(got.contains("."), "expected root entry in {got:?}"); + Ok(()) +} + +#[test] +fn ignore_hidden_true() -> cu::Result<()> { + let fx = make_fixture("ignore_hidden_true")?; + let mut b = walker(fx.root()); + b.ignore_hidden(true); + let got = collect(b.walk()?)?; + + for hidden in [ + ".hidden.txt", + ".gitignore", + ".customignore", + ".hiddendir/f.txt", + ] { + assert!( + !got.contains(hidden), + "hidden {hidden:?} should be excluded: {got:?}" + ); + } + assert!(got.contains("a.txt")); + assert!(got.contains("sub/c.txt")); + Ok(()) +} + +#[test] +fn glob_includes_txt() -> cu::Result<()> { + let fx = make_fixture("glob_includes_txt")?; + let mut b = walker(fx.root()); + b.glob_includes(["*.txt"].into_iter())?; + let got = collect(b.walk()?)?; + + assert!(got.contains("a.txt")); + assert!(got.contains("sub/c.txt")); + assert!(got.contains("sub/nested/e.txt")); + assert!( + !got.contains("b.log"), + "non-txt should be excluded: {got:?}" + ); + assert!( + !got.contains("sub/d.rs"), + "non-txt should be excluded: {got:?}" + ); + Ok(()) +} + +#[test] +fn glob_excludes_log() -> cu::Result<()> { + let fx = make_fixture("glob_excludes_log")?; + let mut b = walker(fx.root()); + b.glob_excludes(["*.log"].into_iter())?; + let got = collect(b.walk()?)?; + + assert!(!got.contains("b.log"), "*.log should be excluded: {got:?}"); + assert!(got.contains("a.txt")); + assert!(got.contains("sub/d.rs")); + Ok(()) +} + +#[test] +fn glob_includes_brace_alternation() -> cu::Result<()> { + let fx = make_fixture("glob_includes_brace_alternation")?; + let mut b = walker(fx.root()); + // brace alternation: include both .txt and .log, but not .rs + b.glob_includes(["*.{txt,log}"].into_iter())?; + let got = collect(b.walk()?)?; + + assert!(got.contains("a.txt"), "txt should be included: {got:?}"); + assert!(got.contains("b.log"), "log should be included: {got:?}"); + assert!(got.contains("sub/c.txt")); + assert!(!got.contains("sub/d.rs"), "rs should be excluded: {got:?}"); + Ok(()) +} + +#[test] +fn glob_includes_multiple_patterns() -> cu::Result<()> { + let fx = make_fixture("glob_includes_multiple_patterns")?; + let mut b = walker(fx.root()); + // multiple include patterns are OR-ed together + b.glob_includes(["*.rs", "*.log"].into_iter())?; + let got = collect(b.walk()?)?; + + assert!(got.contains("sub/d.rs"), "{got:?}"); + assert!(got.contains("b.log"), "{got:?}"); + assert!(!got.contains("a.txt"), "txt should be excluded: {got:?}"); + Ok(()) +} + +#[test] +fn glob_excludes_brace_alternation() -> cu::Result<()> { + let fx = make_fixture("glob_excludes_brace_alternation")?; + let mut b = walker(fx.root()); + // brace alternation in an exclude: drop both .log and .rs + b.glob_excludes(["*.{log,rs}"].into_iter())?; + let got = collect(b.walk()?)?; + + assert!(!got.contains("b.log"), "log should be excluded: {got:?}"); + assert!(!got.contains("sub/d.rs"), "rs should be excluded: {got:?}"); + assert!(got.contains("a.txt"), "txt should remain: {got:?}"); + assert!(got.contains("sub/c.txt")); + Ok(()) +} + +#[test] +fn git_true_respects_gitignore() -> cu::Result<()> { + let fx = make_fixture("git_true_respects_gitignore")?; + let mut b = walker(fx.root()); + b.git(true); + let got = collect(b.walk()?)?; + + assert!( + !got.contains("b.log"), + "gitignored file should be excluded: {got:?}" + ); + assert!( + !got.contains("ignored/g.txt"), + "gitignored dir contents should be excluded: {got:?}" + ); + assert!(got.contains("a.txt")); + assert!(got.contains("sub/c.txt")); + Ok(()) +} + +#[test] +fn custom_ignore_filename() -> cu::Result<()> { + let fx = make_fixture("custom_ignore_filename")?; + let mut b = walker(fx.root()); + b.add_ignore_filename(".customignore"); + let got = collect(b.walk()?)?; + + assert!( + !got.contains("sub/d.rs"), + "custom-ignored file should be excluded: {got:?}" + ); + assert!(got.contains("a.txt")); + assert!(got.contains("sub/c.txt")); + Ok(()) +} + +#[test] +fn entry_metadata_depth_relpath() -> cu::Result<()> { + let fx = make_fixture("entry_metadata_depth_relpath")?; + let mut b = walker(fx.root()); + b.include_dir_entries(true); + + let mut saw_root = false; + let mut saw_top_file = false; + let mut saw_nested_file = false; + let mut saw_dir = false; + + for entry in b.walk()? { + let entry = entry?; + match entry.rel_path()? { + None => { + // the root entry + assert_eq!(entry.depth(), 0, "root depth should be 0"); + assert!(entry.is_dir()); + saw_root = true; + } + Some(rel) => { + let rel = norm(&rel); + // rel_path must never carry a leading "./" + assert!(!rel.starts_with("./"), "rel_path has leading ./: {rel}"); + match rel.as_str() { + "a.txt" => { + assert_eq!(entry.depth(), 1); + assert!(entry.is_file()); + assert_eq!(entry.file_name().unwrap(), "a.txt"); + entry.metadata()?; + saw_top_file = true; + } + "sub/nested/e.txt" => { + assert_eq!(entry.depth(), 3, "nested file should be depth 3"); + assert!(entry.is_file()); + saw_nested_file = true; + } + "sub" => { + assert_eq!(entry.depth(), 1); + assert!(entry.is_dir()); + saw_dir = true; + } + _ => {} + } + } + } + } + + assert!(saw_root, "did not observe root entry"); + assert!(saw_top_file, "did not observe a.txt"); + assert!(saw_nested_file, "did not observe sub/nested/e.txt"); + assert!(saw_dir, "did not observe sub dir"); + Ok(()) +} + +// --- unix-only cases (symlinks) ---------------------------------------------- + +#[cfg(unix)] +#[test] +fn symlink_to_file_skipped_by_default() -> cu::Result<()> { + let fx = make_fixture("symlink_to_file_skipped_by_default")?; + let got = collect(walk(fx.root())?)?; + + // symlink-to-file is not a plain file, so it is skipped by default + assert!( + !got.contains("link_to_file"), + "symlink to file should be skipped: {got:?}" + ); + // symlinked directory is not descended (no follow) + assert!( + !got.contains("link_to_dir"), + "symlinked dir should be skipped: {got:?}" + ); + assert!( + !got.iter().any(|p| p.starts_with("link_to_dir/")), + "symlinked dir must not be descended: {got:?}" + ); + Ok(()) +} + +#[cfg(unix)] +#[test] +fn symlink_entries_with_include_dir_entries() -> cu::Result<()> { + let fx = make_fixture("symlink_entries_with_include_dir_entries")?; + let mut b = walker(fx.root()); + b.include_dir_entries(true); + let got = collect(b.walk()?)?; + + // links surface as entries when dir entries are included... + assert!( + got.contains("link_to_file"), + "expected link_to_file entry: {got:?}" + ); + assert!( + got.contains("link_to_dir"), + "expected link_to_dir entry: {got:?}" + ); + // ...but the symlinked dir is still not descended without follow_links. + assert!( + !got.iter().any(|p| p.starts_with("link_to_dir/")), + "symlinked dir must not be descended without follow_links: {got:?}" + ); + Ok(()) +} + +#[cfg(unix)] +#[test] +fn follow_links_descends_symlinked_dir() -> cu::Result<()> { + let fx = make_fixture("follow_links_descends_symlinked_dir")?; + let mut b = walker(fx.root()); + b.follow_links(true); + let got = collect(b.walk()?)?; + + // following the link exposes the target dir's files under the link name + assert!( + got.contains("link_to_dir/c.txt"), + "follow_links should descend symlinked dir: {got:?}" + ); + assert!(got.contains("link_to_dir/nested/e.txt"), "{got:?}"); + Ok(()) +} + +#[cfg(unix)] +#[test] +fn broken_symlink_without_follow_does_not_error() -> cu::Result<()> { + let fx = make_fixture("broken_symlink_without_follow_does_not_error")?; + make_symlink("does_not_exist", &fx.root().join("link_broken"))?; + + // Without following, the entry is read via symlink_metadata, so a dangling + // target is fine: the walk completes without yielding an error. + let mut b = walker(fx.root()); + b.include_dir_entries(true); + let got = collect(b.walk()?)?; + assert!( + got.contains("link_broken"), + "broken link should still surface as an entry: {got:?}" + ); + + // default walk (dir entries off) skips it as a non-file, also without error. + let files = collect(walk(fx.root())?)?; + assert!( + !files.contains("link_broken"), + "broken link is not a file: {files:?}" + ); + Ok(()) +} + +#[cfg(unix)] +#[test] +fn broken_symlink_with_follow_surfaces_error() -> cu::Result<()> { + let fx = make_fixture("broken_symlink_with_follow_surfaces_error")?; + make_symlink("does_not_exist", &fx.root().join("link_broken"))?; + + // With follow_links, resolving the dangling target fails, and the walk + // reports it as an error rather than silently skipping. + let mut b = walker(fx.root()); + b.include_dir_entries(true).follow_links(true); + let saw_error = b.walk()?.any(|entry| entry.is_err()); + assert!( + saw_error, + "following a dangling symlink should surface an error" + ); + Ok(()) +} From b1d38d1b7b1e85d5a178bd853f620c1f2973d210 Mon Sep 17 00:00:00 2001 From: Pistonight Date: Sat, 11 Jul 2026 22:49:22 -0700 Subject: [PATCH 3/6] remove old --- packages/copper/src/fs/dir.rs | 34 ++++-- packages/copper/src/fs/glob.rs | 62 ---------- packages/copper/src/fs/mod.rs | 7 +- packages/copper/src/fs/walk.rs | 204 --------------------------------- packages/copper/tests/walk2.rs | 37 +++--- 5 files changed, 42 insertions(+), 302 deletions(-) delete mode 100644 packages/copper/src/fs/glob.rs delete mode 100644 packages/copper/src/fs/walk.rs diff --git a/packages/copper/src/fs/dir.rs b/packages/copper/src/fs/dir.rs index 32b9bd3..2df13e9 100644 --- a/packages/copper/src/fs/dir.rs +++ b/packages/copper/src/fs/dir.rs @@ -331,24 +331,34 @@ fn rec_copy_inefficiently_impl(from: &Path, to: &Path) -> crate::Result<()> { // cache paths that are known to be directories in `to` let mut dir_cache = BTreeSet::new(); - let mut walk = crate::fs::walk(from)?; - while let Some(entry) = walk.next() { + let mut walker = crate::fs::walker(from); + walker.include_dir_entries(true); + for entry in walker.walk()? { let entry = entry?; + let rel_path = cu::check!(entry.rel_path()?, "failed to get relative path for entry")?; if !entry.is_dir() { + let Some(file_name) = entry.file_name() else { + cu::trace!("skipping file with no file name: '{}'", entry.path().display()); + continue; + }; + let containing = if entry.depth() > 1 { + let parent = cu::check!(rel_path.parent(), "failed to get relative path of containing directory for entry")?; + to.join(parent) + } else { + to.to_path_buf() + }; + let target = containing.join(file_name); // ensure parent directories exists - if !dir_cache.contains(entry.rel_containing) { - let dir_path = to.join(entry.rel_containing); - make_dir_impl(&dir_path)?; - dir_cache.insert(dir_path); + if !dir_cache.contains(&containing) { + make_dir_impl(&containing)?; + dir_cache.insert(containing); } // copy the file - crate::fs::copy( - entry.path(), - to.join(entry.rel_containing).join(&entry.file_name), - )?; + crate::fs::copy(entry.path(), target)?; } else { - let dir_path = to.join(entry.rel_containing); - make_dir_impl(&dir_path)?; + let target_path = to.join(rel_path); + make_dir_impl(&target_path)?; + dir_cache.insert(target_path); } } diff --git a/packages/copper/src/fs/glob.rs b/packages/copper/src/fs/glob.rs deleted file mode 100644 index 7a00e20..0000000 --- a/packages/copper/src/fs/glob.rs +++ /dev/null @@ -1,62 +0,0 @@ -use std::path::{Path, PathBuf}; - -use crate::pre::*; - -/// Resolve glob pattern from a base path. -/// -/// The pattern is joined onto the base path. If the pattern -/// is absolute, then the base path has no effect, as it will -/// be replaced by the pattern. -/// -/// This is a thin wrapper around rust-lang's `glob` crate. -/// This returns an iterator over the paths that match -/// the glob pattern. -/// -/// If the resulting pattern is absolute, then it will only match absolute -/// paths that matches the pattern (rather than the behavior -/// of, for example, `.gitignore`) -pub fn glob_from(path: impl AsRef, pattern: &str) -> crate::Result { - let path = path.as_ref().join(pattern); - let pattern = crate::check!( - path.into_utf8(), - "base path is not UTF-8 while trying to glob: {pattern}" - )?; - glob(&pattern) -} - -/// Resolve glob pattern from the current directory. -/// -/// This is a thin wrapper around rust-lang's `glob` crate. -/// This returns an iterator over the paths that match -/// the glob pattern. -/// -/// If the pattern is absolute, then it will only match absolute -/// paths that matches the pattern (rather than the behavior -/// of, for example, `.gitignore`) -pub fn glob(pattern: &str) -> crate::Result { - let iter = crate::check!( - ::glob::glob(pattern), - "failed to parse glob pattern: {pattern}" - )?; - Ok(Glob(iter)) -} - -/// Iterator for [`glob`](function@glob) and [`glob_from`] -/// -/// This is a thin wrapper for rust-lang's `glob` crate -/// that converts the error to anyhow. -pub struct Glob(::glob::Paths); -impl Iterator for Glob { - type Item = crate::Result; - - fn next(&mut self) -> Option { - match self.0.next()? { - Ok(p) => Some(Ok(p)), - Err(e) => { - let path = e.path(); - let outer = crate::fmterr!("glob: cannot read '{}'", path.display()); - Some(Err(e.into_error()).context(outer)) - } - } - } -} diff --git a/packages/copper/src/fs/mod.rs b/packages/copper/src/fs/mod.rs index 6ac827c..bf1b631 100644 --- a/packages/copper/src/fs/mod.rs +++ b/packages/copper/src/fs/mod.rs @@ -12,12 +12,9 @@ mod read; pub use read::*; mod write; pub use write::*; -mod walk; -pub use walk::*; -mod glob; -pub use glob::*; +mod walk2; +pub use walk2::*; pub mod bin; pub use filetime::FileTime as Time; -pub mod walk2; diff --git a/packages/copper/src/fs/walk.rs b/packages/copper/src/fs/walk.rs deleted file mode 100644 index 5321ac1..0000000 --- a/packages/copper/src/fs/walk.rs +++ /dev/null @@ -1,204 +0,0 @@ -use std::ffi::OsString; -use std::fs::{FileType, Metadata}; -use std::path::{Path, PathBuf}; - -use crate::pre::*; - -/// Recursively walk a directory -pub fn walk(path: impl AsRef) -> crate::Result { - walk_with(path, AlwaysRecurse) -} - -pub fn walk_with(path: impl AsRef, should_recurse: F) -> crate::Result> -where - F: for<'a> WalkShouldRecursePredicate>, -{ - let path = path.as_ref().to_path_buf(); - crate::trace!("walk '{}'", path.display()); - let reader = crate::check!( - std::fs::read_dir(&path), - "cannot read directory '{}'", - path.display() - )?; - // reserve with initial capacity - #[allow(clippy::vec_init_then_push)] - let mut stack = Vec::with_capacity(4); - stack.push((reader, 1)); - Ok(Walk { - root: path, - rel_containing: PathBuf::new(), - stack, - should_recurse, - }) -} -pub struct Walk { - root: PathBuf, - /// The path of the containing directory - /// of the current entry, relative from the root of the walk - rel_containing: PathBuf, - /// last element of the stack is the current directory being read - /// (dir, depth) - stack: Vec<(std::fs::ReadDir, usize)>, - - should_recurse: F, -} - -impl Walk -where - F: for<'b> WalkShouldRecursePredicate>, -{ - #[allow(clippy::should_implement_trait)] - // ^ iterator does not allow returning items referencing data from the iterator - pub fn next(&mut self) -> Option>> { - loop { - let (dir, depth) = self.stack.last_mut()?; - // find next item in the current dir - let entry = match dir.next() { - None => { - // current directory is done, go back to parent - self.stack.pop(); - self.rel_containing.pop(); - continue; - } - Some(Err(e)) => { - return Some(Err(e).context(format!( - "failed to read directory entry while walking '{}'", - self.root.display() - ))); - } - Some(Ok(entry)) => entry, - }; - let file_type = match entry.file_type() { - Err(e) => { - return Some(Err(e).context(format!( - "failed to read directory entry type while walking '{}'", - self.root.display() - ))); - } - Ok(x) => x, - }; - let file_name = entry.file_name(); - let depth = *depth; - if file_type.is_dir() { - let entry = WalkEntry { - root: &self.root, - file_type, - rel_containing: &self.rel_containing, - file_name, - depth, - entry, - }; - if !self.should_recurse.should_recurse(&entry) { - continue; - } - // enter the directory - self.rel_containing.push(entry.file_name); - let dir = self.root.join(&self.rel_containing); - let read_dir = match std::fs::read_dir(dir) { - Err(e) => { - let rel_containing2 = self.rel_containing.display().to_string(); - self.rel_containing.pop(); - return Some(Err(e).context(format!( - "failed to read nested directory '{}' while walking '{}'", - rel_containing2, - self.root.display() - ))); - } - Ok(read_dir) => read_dir, - }; - - self.stack.push((read_dir, depth + 1)); - continue; - } - let entry = WalkEntry { - root: &self.root, - file_type, - rel_containing: &self.rel_containing, - file_name, - depth, - entry, - }; - return Some(Ok(entry)); - } - } -} - -pub struct WalkEntry<'a> { - /// Root path of the walk - pub root: &'a Path, - /// Type of the entry - pub file_type: FileType, - /// The directory that contains the current entry, relative - /// to the root where the walk started, without the leading `./`. - pub rel_containing: &'a Path, - /// File name of the current entry being visited - pub file_name: OsString, - - /// Depth of the current entry, compared to root. - /// This equals the number of segments in the relative path, - /// minimum 1 (when the entry is directly under root). - pub depth: usize, - - /// Inner entry - entry: std::fs::DirEntry, -} -impl WalkEntry<'_> { - /// Get the path by joining the walk root and the relative - /// path of the entry - #[inline(always)] - pub fn path(&self) -> PathBuf { - self.entry.path() - } - - /// Get the relative path of this entry, from the walk root - #[inline(always)] - pub fn rel_path(&self) -> PathBuf { - self.rel_containing.join(&self.file_name) - } - - /// Check if the entry is a file. Convenience wrapper for `self.file_type.is_file()` - #[inline(always)] - pub fn is_file(&self) -> bool { - self.file_type.is_file() - } - - /// Check if the entry is a directory. Convenience wrapper for `self.file_type.is_dir()` - #[inline(always)] - pub fn is_dir(&self) -> bool { - self.file_type.is_dir() - } - - /// Check if the entry is a symlink. Convenience wrapper for `self.file_type.is_symlink()` - #[inline(always)] - pub fn is_symlink(&self) -> bool { - self.file_type.is_symlink() - } - - /// Get the entry metadata - pub fn metadata(&self) -> crate::Result { - crate::check!( - self.entry.metadata(), - "failed to get metadata for file '{}' while walking directory '{}'", - self.rel_path().display(), - self.root.display() - ) - } -} - -pub trait WalkShouldRecursePredicate { - fn should_recurse(&mut self, entry: &E) -> bool; -} -pub struct AlwaysRecurse; -impl WalkShouldRecursePredicate for AlwaysRecurse { - fn should_recurse(&mut self, _: &E) -> bool { - true - } -} -impl<'a, F> WalkShouldRecursePredicate> for F -where - F: for<'b> Fn(&WalkEntry<'b>) -> bool, -{ - fn should_recurse(&mut self, entry: &WalkEntry<'a>) -> bool { - (self)(entry) - } -} diff --git a/packages/copper/tests/walk2.rs b/packages/copper/tests/walk2.rs index 542898a..d134b77 100644 --- a/packages/copper/tests/walk2.rs +++ b/packages/copper/tests/walk2.rs @@ -11,7 +11,6 @@ use std::path::{Path, PathBuf}; use pistonite_cu as cu; use cu::pre::*; -use pistonite_cu::fs::walk2::{Walk, walk, walker}; /// A temp directory that is recursively removed when dropped. struct Fixture { @@ -107,7 +106,7 @@ fn norm(p: &Path) -> String { /// /// The root entry (`rel_path() == None`, only emitted with dir entries enabled) /// is represented as `"."`. -fn collect(w: Walk) -> cu::Result> { +fn collect(w: cu::fs::Walk) -> cu::Result> { let mut set = BTreeSet::new(); for entry in w { let entry = entry?; @@ -132,7 +131,7 @@ fn set(items: &[&str]) -> BTreeSet { #[test] fn default_returns_files_only() -> cu::Result<()> { let fx = make_fixture("default_returns_files_only")?; - let got = collect(walk(fx.root())?)?; + let got = collect(cu::fs::walk(fx.root())?)?; // Every regular file, hidden included; no directories, and (on unix) no // symlink entries leak in. let expected = set(&[ @@ -154,7 +153,7 @@ fn default_returns_files_only() -> cu::Result<()> { #[test] fn include_dir_entries_true() -> cu::Result<()> { let fx = make_fixture("include_dir_entries_true")?; - let mut b = walker(fx.root()); + let mut b = cu::fs::walker(fx.root()); b.include_dir_entries(true); let got = collect(b.walk()?)?; @@ -172,7 +171,7 @@ fn include_dir_entries_true() -> cu::Result<()> { #[test] fn ignore_hidden_true() -> cu::Result<()> { let fx = make_fixture("ignore_hidden_true")?; - let mut b = walker(fx.root()); + let mut b = cu::fs::walker(fx.root()); b.ignore_hidden(true); let got = collect(b.walk()?)?; @@ -195,7 +194,7 @@ fn ignore_hidden_true() -> cu::Result<()> { #[test] fn glob_includes_txt() -> cu::Result<()> { let fx = make_fixture("glob_includes_txt")?; - let mut b = walker(fx.root()); + let mut b = cu::fs::walker(fx.root()); b.glob_includes(["*.txt"].into_iter())?; let got = collect(b.walk()?)?; @@ -216,7 +215,7 @@ fn glob_includes_txt() -> cu::Result<()> { #[test] fn glob_excludes_log() -> cu::Result<()> { let fx = make_fixture("glob_excludes_log")?; - let mut b = walker(fx.root()); + let mut b = cu::fs::walker(fx.root()); b.glob_excludes(["*.log"].into_iter())?; let got = collect(b.walk()?)?; @@ -229,7 +228,7 @@ fn glob_excludes_log() -> cu::Result<()> { #[test] fn glob_includes_brace_alternation() -> cu::Result<()> { let fx = make_fixture("glob_includes_brace_alternation")?; - let mut b = walker(fx.root()); + let mut b = cu::fs::walker(fx.root()); // brace alternation: include both .txt and .log, but not .rs b.glob_includes(["*.{txt,log}"].into_iter())?; let got = collect(b.walk()?)?; @@ -244,7 +243,7 @@ fn glob_includes_brace_alternation() -> cu::Result<()> { #[test] fn glob_includes_multiple_patterns() -> cu::Result<()> { let fx = make_fixture("glob_includes_multiple_patterns")?; - let mut b = walker(fx.root()); + let mut b = cu::fs::walker(fx.root()); // multiple include patterns are OR-ed together b.glob_includes(["*.rs", "*.log"].into_iter())?; let got = collect(b.walk()?)?; @@ -258,7 +257,7 @@ fn glob_includes_multiple_patterns() -> cu::Result<()> { #[test] fn glob_excludes_brace_alternation() -> cu::Result<()> { let fx = make_fixture("glob_excludes_brace_alternation")?; - let mut b = walker(fx.root()); + let mut b = cu::fs::walker(fx.root()); // brace alternation in an exclude: drop both .log and .rs b.glob_excludes(["*.{log,rs}"].into_iter())?; let got = collect(b.walk()?)?; @@ -273,7 +272,7 @@ fn glob_excludes_brace_alternation() -> cu::Result<()> { #[test] fn git_true_respects_gitignore() -> cu::Result<()> { let fx = make_fixture("git_true_respects_gitignore")?; - let mut b = walker(fx.root()); + let mut b = cu::fs::walker(fx.root()); b.git(true); let got = collect(b.walk()?)?; @@ -293,7 +292,7 @@ fn git_true_respects_gitignore() -> cu::Result<()> { #[test] fn custom_ignore_filename() -> cu::Result<()> { let fx = make_fixture("custom_ignore_filename")?; - let mut b = walker(fx.root()); + let mut b = cu::fs::walker(fx.root()); b.add_ignore_filename(".customignore"); let got = collect(b.walk()?)?; @@ -309,7 +308,7 @@ fn custom_ignore_filename() -> cu::Result<()> { #[test] fn entry_metadata_depth_relpath() -> cu::Result<()> { let fx = make_fixture("entry_metadata_depth_relpath")?; - let mut b = walker(fx.root()); + let mut b = cu::fs::walker(fx.root()); b.include_dir_entries(true); let mut saw_root = false; @@ -367,7 +366,7 @@ fn entry_metadata_depth_relpath() -> cu::Result<()> { #[test] fn symlink_to_file_skipped_by_default() -> cu::Result<()> { let fx = make_fixture("symlink_to_file_skipped_by_default")?; - let got = collect(walk(fx.root())?)?; + let got = collect(cu::fs::walk(fx.root())?)?; // symlink-to-file is not a plain file, so it is skipped by default assert!( @@ -390,7 +389,7 @@ fn symlink_to_file_skipped_by_default() -> cu::Result<()> { #[test] fn symlink_entries_with_include_dir_entries() -> cu::Result<()> { let fx = make_fixture("symlink_entries_with_include_dir_entries")?; - let mut b = walker(fx.root()); + let mut b = cu::fs::walker(fx.root()); b.include_dir_entries(true); let got = collect(b.walk()?)?; @@ -415,7 +414,7 @@ fn symlink_entries_with_include_dir_entries() -> cu::Result<()> { #[test] fn follow_links_descends_symlinked_dir() -> cu::Result<()> { let fx = make_fixture("follow_links_descends_symlinked_dir")?; - let mut b = walker(fx.root()); + let mut b = cu::fs::walker(fx.root()); b.follow_links(true); let got = collect(b.walk()?)?; @@ -436,7 +435,7 @@ fn broken_symlink_without_follow_does_not_error() -> cu::Result<()> { // Without following, the entry is read via symlink_metadata, so a dangling // target is fine: the walk completes without yielding an error. - let mut b = walker(fx.root()); + let mut b = cu::fs::walker(fx.root()); b.include_dir_entries(true); let got = collect(b.walk()?)?; assert!( @@ -445,7 +444,7 @@ fn broken_symlink_without_follow_does_not_error() -> cu::Result<()> { ); // default walk (dir entries off) skips it as a non-file, also without error. - let files = collect(walk(fx.root())?)?; + let files = collect(cu::fs::walk(fx.root())?)?; assert!( !files.contains("link_broken"), "broken link is not a file: {files:?}" @@ -461,7 +460,7 @@ fn broken_symlink_with_follow_surfaces_error() -> cu::Result<()> { // With follow_links, resolving the dangling target fails, and the walk // reports it as an error rather than silently skipping. - let mut b = walker(fx.root()); + let mut b = cu::fs::walker(fx.root()); b.include_dir_entries(true).follow_links(true); let saw_error = b.walk()?.any(|entry| entry.is_err()); assert!( From 00f2af5dc1e082243b39940dab5790cfb3619b69 Mon Sep 17 00:00:00 2001 From: Pistonight Date: Sat, 11 Jul 2026 23:03:48 -0700 Subject: [PATCH 4/6] fix --- packages/copper/Cargo.toml | 8 +-- packages/copper/examples/walk.rs | 18 ++++--- packages/copper/src/fs/dir.rs | 10 +++- packages/copper/src/fs/mod.rs | 1 - packages/copper/src/fs/walk2.rs | 89 ++++++++++++++++++++------------ packages/copper/tests/walk2.rs | 14 ++--- 6 files changed, 84 insertions(+), 56 deletions(-) diff --git a/packages/copper/Cargo.toml b/packages/copper/Cargo.toml index f835d30..d8c156a 100644 --- a/packages/copper/Cargo.toml +++ b/packages/copper/Cargo.toml @@ -37,10 +37,7 @@ dunce = {version="1.0.5", optional = true} which = {version = "8.0.4", optional = true } pathdiff = {version = "0.2.3", optional=true} filetime = { version = "0.2.29", optional = true} -glob = { version = "0.3.3", optional = true } -fast-glob = { version = "1.0.1", optional = true } ignore = { version = "0.4.28", optional = true } -smallvec = { version = "1.15.2", optional = true } spin = {version = "0.12.1", optional = true} # for PIO # serde @@ -65,7 +62,7 @@ version = "1.52.3" features = [ "macros", "rt-multi-thread", "time" ] [features] -default = ["full"] +default = [] full = [ "cli", "coroutine-heavy", @@ -98,8 +95,7 @@ process = [ # enable spawning child processes "tokio/process", ] fs = [ # enable file system and path util - "dep:which", "dep:pathdiff", "dep:dunce", "dep:filetime", "dep:glob", - "tokio?/fs", "dep:ignore", "dep:fast-glob", "dep:smallvec" + "dep:which", "dep:pathdiff", "dep:dunce", "dep:filetime", "tokio?/fs", "dep:ignore", ] # Enable parsing utils diff --git a/packages/copper/examples/walk.rs b/packages/copper/examples/walk.rs index e5e301b..3142545 100644 --- a/packages/copper/examples/walk.rs +++ b/packages/copper/examples/walk.rs @@ -2,23 +2,25 @@ use pistonite_cu as cu; #[cu::cli] fn main(_: cu::cli::Flags) -> cu::Result<()> { - let mut src = cu::fs::walk("src")?; + let src = cu::fs::walk("src")?; cu::cli::set_thread_name("walk"); - while let Some(entry) = src.next() { + for entry in src { let entry = entry?; cu::info!( - "{} {} {}", - entry.depth, + "{} {} {:?}", + entry.depth(), entry.path().display(), - entry.rel_path().display(), + entry.rel_path()? ) } cu::cli::set_thread_name("glob"); - let glob = cu::fs::glob_from("..", "./**/*.rs")?; - for entry in glob { + let mut glob = cu::fs::walker(".."); + glob.glob_includes(["**/*.rs"])?; + + for entry in glob.walk()? { let entry = entry?; - cu::info!("{}", entry.display()); + cu::info!("{entry:?}"); } Ok(()) diff --git a/packages/copper/src/fs/dir.rs b/packages/copper/src/fs/dir.rs index 2df13e9..7d39576 100644 --- a/packages/copper/src/fs/dir.rs +++ b/packages/copper/src/fs/dir.rs @@ -338,11 +338,17 @@ fn rec_copy_inefficiently_impl(from: &Path, to: &Path) -> crate::Result<()> { let rel_path = cu::check!(entry.rel_path()?, "failed to get relative path for entry")?; if !entry.is_dir() { let Some(file_name) = entry.file_name() else { - cu::trace!("skipping file with no file name: '{}'", entry.path().display()); + cu::trace!( + "skipping file with no file name: '{}'", + entry.path().display() + ); continue; }; let containing = if entry.depth() > 1 { - let parent = cu::check!(rel_path.parent(), "failed to get relative path of containing directory for entry")?; + let parent = cu::check!( + rel_path.parent(), + "failed to get relative path of containing directory for entry" + )?; to.join(parent) } else { to.to_path_buf() diff --git a/packages/copper/src/fs/mod.rs b/packages/copper/src/fs/mod.rs index bf1b631..c7ff725 100644 --- a/packages/copper/src/fs/mod.rs +++ b/packages/copper/src/fs/mod.rs @@ -17,4 +17,3 @@ pub use walk2::*; pub mod bin; pub use filetime::FileTime as Time; - diff --git a/packages/copper/src/fs/walk2.rs b/packages/copper/src/fs/walk2.rs index de49f65..09521f2 100644 --- a/packages/copper/src/fs/walk2.rs +++ b/packages/copper/src/fs/walk2.rs @@ -22,7 +22,7 @@ //! ```rust,no_run //! # use pistonite_cu as cu; //! fn print_files() -> cu::Result<()> { -//! for entry in cu::fs::walk2::walk(".")? { +//! for entry in cu::fs::walk(".")? { //! let entry = entry?; //! cu::info!("{}", entry.path().display()); //! } @@ -35,7 +35,7 @@ //! ```rust,no_run //! # use pistonite_cu as cu; //! fn print_sources() -> cu::Result<()> { -//! let mut builder = cu::fs::walk2::walker("src"); +//! let mut builder = cu::fs::walker("src"); //! builder.git(true).follow_links(true); //! builder.glob_includes(["**/*.{rs,toml}"].into_iter())?; //! for entry in builder.walk()? { @@ -51,8 +51,8 @@ use std::fs::{FileType, Metadata}; use std::path::{Component, Path, PathBuf}; use std::sync::Arc; -use ignore::{WalkBuilder as IgnoreWalkBuilder, Walk as IgnoreWalk, DirEntry as IgnoreDirEntry}; use ignore::overrides::OverrideBuilder; +use ignore::{DirEntry as IgnoreDirEntry, Walk as IgnoreWalk, WalkBuilder as IgnoreWalkBuilder}; use crate::pre::*; @@ -64,7 +64,7 @@ use crate::pre::*; /// ```rust,no_run /// # use pistonite_cu as cu; /// fn walk_dirs_too() -> cu::Result<()> { -/// let mut builder = cu::fs::walk2::walker("."); +/// let mut builder = cu::fs::walker("."); /// builder.include_dir_entries(true); /// for entry in builder.walk()? { /// let entry = entry?; @@ -94,7 +94,7 @@ pub fn walker(root: impl AsRef) -> WalkBuilder { /// # use pistonite_cu as cu; /// fn count_files() -> cu::Result { /// let mut count = 0; -/// for entry in cu::fs::walk2::walk(".")? { +/// for entry in cu::fs::walk(".")? { /// let _entry = entry?; /// count += 1; /// } @@ -117,7 +117,7 @@ pub fn walk(root: impl AsRef) -> cu::Result { /// ```rust,no_run /// # use pistonite_cu as cu; /// fn configured() -> cu::Result<()> { -/// let mut builder = cu::fs::walk2::walker("."); +/// let mut builder = cu::fs::walker("."); /// builder.ignore_hidden(true).include_dir_entries(true); /// for entry in builder.walk()? { /// let entry = entry?; @@ -153,7 +153,6 @@ impl WalkBuilder { s } - /// Return the inner WalkBuilder from the `ignore` crate for advanced configuration. /// Note that the `overrides` matcher will be replaced when building the walker pub fn as_inner_mut(&mut self) -> &mut IgnoreWalkBuilder { @@ -170,7 +169,7 @@ impl WalkBuilder { /// ```rust,no_run /// # use pistonite_cu as cu; /// fn only_sources() -> cu::Result<()> { - /// let mut builder = cu::fs::walk2::walker("."); + /// let mut builder = cu::fs::walker("."); /// // include Rust and TOML files anywhere in the tree /// builder.glob_includes(["**/*.{rs,toml}"].into_iter())?; /// for entry in builder.walk()? { @@ -179,10 +178,16 @@ impl WalkBuilder { /// Ok(()) /// } /// ``` - pub fn glob_includes(&mut self, globs: impl Iterator>) -> crate::Result<&mut Self> { + pub fn glob_includes( + &mut self, + globs: impl IntoIterator>, + ) -> crate::Result<&mut Self> { for g in globs { let g = g.as_ref(); - crate::check!(self.overrides.add(g), "failed to add glob include pattern: '{g}'")?; + crate::check!( + self.overrides.add(g), + "failed to add glob include pattern: '{g}'" + )?; self.has_overrides = true; } Ok(self) @@ -197,7 +202,7 @@ impl WalkBuilder { /// ```rust,no_run /// # use pistonite_cu as cu; /// fn skip_logs_and_tmp() -> cu::Result<()> { - /// let mut builder = cu::fs::walk2::walker("."); + /// let mut builder = cu::fs::walker("."); /// builder.glob_excludes(["**/*.{log,tmp}"].into_iter())?; /// for entry in builder.walk()? { /// cu::info!("{}", entry?.path().display()); @@ -205,13 +210,19 @@ impl WalkBuilder { /// Ok(()) /// } /// ``` - pub fn glob_excludes(&mut self, globs: impl Iterator>) -> crate::Result<&mut Self> { + pub fn glob_excludes( + &mut self, + globs: impl IntoIterator>, + ) -> crate::Result<&mut Self> { let mut s = String::new(); s.push('!'); for g in globs { let g = g.as_ref(); s.push_str(g); - crate::check!(self.overrides.add(&s), "failed to add glob exclude pattern: '{s}'")?; + crate::check!( + self.overrides.add(&s), + "failed to add glob exclude pattern: '{s}'" + )?; s.truncate(1); self.has_overrides = true; } @@ -278,7 +289,10 @@ impl WalkBuilder { /// Fails if any configured glob patterns cannot be compiled. pub fn walk(mut self) -> cu::Result { if self.has_overrides { - let overrides = cu::check!(self.overrides.build(), "walk: failed to build glob pattern overrides")?; + let overrides = cu::check!( + self.overrides.build(), + "walk: failed to build glob pattern overrides" + )?; self.inner.overrides(overrides); } let walk = self.inner.build(); @@ -300,7 +314,7 @@ impl WalkBuilder { /// ```rust,no_run /// # use pistonite_cu as cu; /// fn list() -> cu::Result<()> { -/// for entry in cu::fs::walk2::walk(".")? { +/// for entry in cu::fs::walk(".")? { /// let entry = entry?; /// cu::info!("depth {}: {}", entry.depth(), entry.path().display()); /// } @@ -310,7 +324,7 @@ impl WalkBuilder { pub struct Walk { inner: IgnoreWalk, include_dir_entries: bool, - root: Arc + root: Arc, } impl Iterator for Walk { @@ -328,7 +342,7 @@ impl Iterator for Walk { impl Walk { fn next_internal(&mut self) -> crate::Result> { let (entry, file_type) = loop { - let entry = crate::some!(self.inner.next()); + let entry = crate::some!(self.inner.next()); let entry = crate::check!(entry, "walk: failed to read the next entry")?; match entry.file_type() { None => { @@ -348,10 +362,16 @@ impl Walk { continue; } if t.is_symlink() && entry.path().is_dir() { - crate::trace!("walk: skipping symlinked directory: '{}'", entry.path().display()); + crate::trace!( + "walk: skipping symlinked directory: '{}'", + entry.path().display() + ); continue; } - crate::trace!("walk: skipping entry with unknown file type: '{}'", entry.path().display()); + crate::trace!( + "walk: skipping entry with unknown file type: '{}'", + entry.path().display() + ); continue; } } @@ -359,7 +379,7 @@ impl Walk { Ok(Some(WalkEntry { root: Arc::clone(&self.root), inner: entry, - file_type + file_type, })) } } @@ -369,6 +389,7 @@ impl Walk { /// Provides access to the entry's [`path`](Self::path), its /// [`depth`](Self::depth) relative to the walk root, its /// [`file_type`](Self::file_type), and lazily-read [`metadata`](Self::metadata). +#[derive(Debug)] pub struct WalkEntry { root: Arc, inner: IgnoreDirEntry, @@ -404,7 +425,7 @@ impl WalkEntry { /// ```rust,no_run /// # use pistonite_cu as cu; /// fn print_relative() -> cu::Result<()> { - /// for entry in cu::fs::walk2::walk("src")? { + /// for entry in cu::fs::walk("src")? { /// let entry = entry?; /// if let Some(rel) = entry.rel_path()? { /// cu::info!("{}", rel.display()); @@ -418,16 +439,21 @@ impl WalkEntry { let root_norm = self.root.normalize()?; // note we cannot normalize the path after join since it might be a symlink let path_norm = root_norm.join(self.inner.path()); - let mut root_iter = root_norm.components().filter(|x| !matches!(x, Component::CurDir)); - let mut path_iter = path_norm.components().filter(|x| !matches!(x, Component::CurDir)); - loop { - let Some(root_comp) = root_iter.next() else { - break; - }; - let path_comp = cu::check!(path_iter.next(), - "unexpected: walk entry path is shorter than root")?; - cu::ensure!(root_comp == path_comp, - "unexpected: walk entry path is not in root")?; + let root_iter = root_norm + .components() + .filter(|x| !matches!(x, Component::CurDir)); + let mut path_iter = path_norm + .components() + .filter(|x| !matches!(x, Component::CurDir)); + for root_comp in root_iter { + let path_comp = cu::check!( + path_iter.next(), + "unexpected: walk entry path is shorter than root" + )?; + cu::ensure!( + root_comp == path_comp, + "unexpected: walk entry path is not in root" + )?; } let Some(next) = path_iter.next() else { // is root @@ -478,5 +504,4 @@ impl WalkEntry { self.root.display() ) } - } diff --git a/packages/copper/tests/walk2.rs b/packages/copper/tests/walk2.rs index d134b77..94b03a7 100644 --- a/packages/copper/tests/walk2.rs +++ b/packages/copper/tests/walk2.rs @@ -1,4 +1,4 @@ -//! Fixture tests for [`cu::fs::walk2`]. +//! Fixture tests for `cu::fs::walk`. //! //! Each test builds a stub directory tree in its own uniquely-named temp //! directory (under `CARGO_TARGET_TMPDIR`) so tests can run in parallel, and @@ -9,8 +9,8 @@ use std::collections::BTreeSet; use std::path::{Path, PathBuf}; -use pistonite_cu as cu; use cu::pre::*; +use pistonite_cu as cu; /// A temp directory that is recursively removed when dropped. struct Fixture { @@ -195,7 +195,7 @@ fn ignore_hidden_true() -> cu::Result<()> { fn glob_includes_txt() -> cu::Result<()> { let fx = make_fixture("glob_includes_txt")?; let mut b = cu::fs::walker(fx.root()); - b.glob_includes(["*.txt"].into_iter())?; + b.glob_includes(["*.txt"])?; let got = collect(b.walk()?)?; assert!(got.contains("a.txt")); @@ -216,7 +216,7 @@ fn glob_includes_txt() -> cu::Result<()> { fn glob_excludes_log() -> cu::Result<()> { let fx = make_fixture("glob_excludes_log")?; let mut b = cu::fs::walker(fx.root()); - b.glob_excludes(["*.log"].into_iter())?; + b.glob_excludes(["*.log"])?; let got = collect(b.walk()?)?; assert!(!got.contains("b.log"), "*.log should be excluded: {got:?}"); @@ -230,7 +230,7 @@ fn glob_includes_brace_alternation() -> cu::Result<()> { let fx = make_fixture("glob_includes_brace_alternation")?; let mut b = cu::fs::walker(fx.root()); // brace alternation: include both .txt and .log, but not .rs - b.glob_includes(["*.{txt,log}"].into_iter())?; + b.glob_includes(["*.{txt,log}"])?; let got = collect(b.walk()?)?; assert!(got.contains("a.txt"), "txt should be included: {got:?}"); @@ -245,7 +245,7 @@ fn glob_includes_multiple_patterns() -> cu::Result<()> { let fx = make_fixture("glob_includes_multiple_patterns")?; let mut b = cu::fs::walker(fx.root()); // multiple include patterns are OR-ed together - b.glob_includes(["*.rs", "*.log"].into_iter())?; + b.glob_includes(["*.rs", "*.log"])?; let got = collect(b.walk()?)?; assert!(got.contains("sub/d.rs"), "{got:?}"); @@ -259,7 +259,7 @@ fn glob_excludes_brace_alternation() -> cu::Result<()> { let fx = make_fixture("glob_excludes_brace_alternation")?; let mut b = cu::fs::walker(fx.root()); // brace alternation in an exclude: drop both .log and .rs - b.glob_excludes(["*.{log,rs}"].into_iter())?; + b.glob_excludes(["*.{log,rs}"])?; let got = collect(b.walk()?)?; assert!(!got.contains("b.log"), "log should be excluded: {got:?}"); From 7e498c144f77dffff07e99729f4b6784d5df739e Mon Sep 17 00:00:00 2001 From: Pistonight Date: Sun, 12 Jul 2026 11:50:49 -0700 Subject: [PATCH 5/6] improve curdir matching --- packages/copper/src/fs/walk2.rs | 188 ++++++++++++-------------------- packages/copper/tests/walk2.rs | 21 +++- 2 files changed, 87 insertions(+), 122 deletions(-) diff --git a/packages/copper/src/fs/walk2.rs b/packages/copper/src/fs/walk2.rs index 09521f2..1391a17 100644 --- a/packages/copper/src/fs/walk2.rs +++ b/packages/copper/src/fs/walk2.rs @@ -1,51 +1,3 @@ -//! Recursive directory walking. -//! -//! A thin, opinionated wrapper around the [`ignore`](https://docs.rs/ignore) -//! crate that exposes a simpler builder API for the most common cases. -//! -//! Use [`walk`] for a quick recursive walk with sensible defaults, or [`walker`] -//! to configure the walk through a [`WalkBuilder`] before running it. -//! -//! # Defaults -//! Out of the box (see [`walk`]) the walker: -//! - **includes** hidden files (dotfiles), -//! - does **not** read `.gitignore` or other ignore/VCS files, -//! - does **not** follow symbolic links, -//! - applies **no** glob include/exclude filters, -//! - yields **files only** — directory entries are skipped. -//! -//! Every one of these can be changed through [`WalkBuilder`]. -//! -//! # Examples -//! -//! Walk the current directory and print every file: -//! ```rust,no_run -//! # use pistonite_cu as cu; -//! fn print_files() -> cu::Result<()> { -//! for entry in cu::fs::walk(".")? { -//! let entry = entry?; -//! cu::info!("{}", entry.path().display()); -//! } -//! Ok(()) -//! } -//! ``` -//! -//! Configure the walk with the builder — respect `.gitignore`, follow symlinks, -//! and only include Rust and TOML files (note brace alternation is supported): -//! ```rust,no_run -//! # use pistonite_cu as cu; -//! fn print_sources() -> cu::Result<()> { -//! let mut builder = cu::fs::walker("src"); -//! builder.git(true).follow_links(true); -//! builder.glob_includes(["**/*.{rs,toml}"].into_iter())?; -//! for entry in builder.walk()? { -//! let entry = entry?; -//! cu::info!("{}", entry.path().display()); -//! } -//! Ok(()) -//! } -//! ``` - use std::ffi::OsStr; use std::fs::{FileType, Metadata}; use std::path::{Component, Path, PathBuf}; @@ -56,22 +8,21 @@ use ignore::{DirEntry as IgnoreDirEntry, Walk as IgnoreWalk, WalkBuilder as Igno use crate::pre::*; -/// Create a [`WalkBuilder`] rooted at `root` to configure a walk. +/// Create a walker to walk `root` recursively. /// -/// Use this when you need to change the defaults (glob filters, gitignore, -/// following links, etc.); otherwise reach for [`walk`]. +/// See [`WalkBuilder`] for configurations. [`cu::fs::walk`] can be used directly for the default +/// configuration. /// /// ```rust,no_run /// # use pistonite_cu as cu; -/// fn walk_dirs_too() -> cu::Result<()> { -/// let mut builder = cu::fs::walker("."); -/// builder.include_dir_entries(true); -/// for entry in builder.walk()? { -/// let entry = entry?; -/// cu::info!("{} (dir: {})", entry.path().display(), entry.is_dir()); -/// } -/// Ok(()) +/// # fn walk_dirs_too() -> cu::Result<()> { +/// let mut builder = cu::fs::walker("."); +/// builder.include_dir_entries(true); +/// for entry in builder.walk()? { +/// let entry = entry?; +/// cu::info!("{} (dir: {})", entry.path().display(), entry.is_dir()); /// } +/// # Ok(()) } /// ``` #[inline(always)] pub fn walker(root: impl AsRef) -> WalkBuilder { @@ -87,19 +38,18 @@ pub fn walker(root: impl AsRef) -> WalkBuilder { /// - does not apply any glob include/exclude filters, /// - yields files only (directory entries are skipped). /// -/// Use [`walker`] to change any of these. The returned [`Walk`] is an iterator -/// of [`WalkEntry`] results. +/// To change the configuration, use [`cu::fs::walker`] /// /// ```rust,no_run /// # use pistonite_cu as cu; -/// fn count_files() -> cu::Result { -/// let mut count = 0; -/// for entry in cu::fs::walk(".")? { -/// let _entry = entry?; -/// count += 1; -/// } -/// Ok(count) +/// # fn count_files() -> cu::Result { +/// let mut count = 0; +/// for entry in cu::fs::walk(".")? { +/// let _entry = entry?; +/// count += 1; /// } +/// cu::info!("number of files: {count}"); +/// # Ok(count) } /// ``` #[inline(always)] pub fn walk(root: impl AsRef) -> cu::Result { @@ -109,25 +59,7 @@ pub fn walk(root: impl AsRef) -> cu::Result { /// Builder for a directory [`Walk`], providing a simpler API over the /// `ignore` crate for the most common cases. /// -/// Create one with [`walker`], configure it with the methods below, then call -/// [`walk`](WalkBuilder::walk) to produce the [`Walk`] iterator. Configuration -/// methods return `&mut Self` (or `cu::Result<&mut Self>` for the fallible glob -/// setters) so they can be chained. -/// -/// ```rust,no_run -/// # use pistonite_cu as cu; -/// fn configured() -> cu::Result<()> { -/// let mut builder = cu::fs::walker("."); -/// builder.ignore_hidden(true).include_dir_entries(true); -/// for entry in builder.walk()? { -/// let entry = entry?; -/// cu::info!("{}", entry.path().display()); -/// } -/// Ok(()) -/// } -/// ``` -/// -/// See [`walker`]. +/// Created with [`cu::fs::walker`]. pub struct WalkBuilder { inner: IgnoreWalkBuilder, overrides: OverrideBuilder, @@ -166,17 +98,21 @@ impl WalkBuilder { /// `{a,b}` brace alternation. Once any include pattern is added, only paths /// matching at least one include (and no exclude) are yielded. /// + /// ## Caution + /// If the glob pattern starts with `./`, the dot is removed instead of matching + /// literal `./`. Patterns starting with `../` still + /// matches the literal `../` (meaning the root must start with `../` to produce any match) + /// /// ```rust,no_run /// # use pistonite_cu as cu; - /// fn only_sources() -> cu::Result<()> { - /// let mut builder = cu::fs::walker("."); - /// // include Rust and TOML files anywhere in the tree - /// builder.glob_includes(["**/*.{rs,toml}"].into_iter())?; - /// for entry in builder.walk()? { - /// cu::info!("{}", entry?.path().display()); - /// } - /// Ok(()) + /// # fn only_sources() -> cu::Result<()> { + /// let mut walker = cu::fs::walker("."); + /// // include Rust and TOML files anywhere in the tree + /// walker.glob_includes(["**/*.{rs,toml}"])?; + /// for entry in walker.walk()? { + /// cu::info!("{}", entry?.path().display()); /// } + /// # Ok(()) } /// ``` pub fn glob_includes( &mut self, @@ -184,10 +120,18 @@ impl WalkBuilder { ) -> crate::Result<&mut Self> { for g in globs { let g = g.as_ref(); - crate::check!( - self.overrides.add(g), - "failed to add glob include pattern: '{g}'" - )?; + if g.starts_with("./") || g.starts_with(".\\") { + let g2 = &g[1..]; + crate::check!( + self.overrides.add(g2), + "failed to add glob include pattern: '{g2}' (resolved from '{g}')" + )?; + } else { + crate::check!( + self.overrides.add(g), + "failed to add glob include pattern: '{g}'" + )?; + } self.has_overrides = true; } Ok(self) @@ -195,20 +139,20 @@ impl WalkBuilder { /// Add glob patterns to exclude. By default nothing is excluded. /// - /// Patterns use the same gitignore-style syntax as - /// [`glob_includes`](Self::glob_includes), including `{a,b}` brace - /// alternation. Excludes take precedence over includes. + /// ## Caution + /// If the glob pattern starts with `./`, the dot is removed instead of matching + /// literal `./`. Patterns starting with `../` still + /// matches the literal `../` (meaning the root must start with `../` to produce any match) /// /// ```rust,no_run /// # use pistonite_cu as cu; - /// fn skip_logs_and_tmp() -> cu::Result<()> { - /// let mut builder = cu::fs::walker("."); - /// builder.glob_excludes(["**/*.{log,tmp}"].into_iter())?; - /// for entry in builder.walk()? { - /// cu::info!("{}", entry?.path().display()); - /// } - /// Ok(()) + /// # fn skip_logs_and_tmp() -> cu::Result<()> { + /// let mut builder = cu::fs::walker("."); + /// builder.glob_excludes(["**/*.{log,tmp}"])?; + /// for entry in builder.walk()? { + /// cu::info!("{}", entry?.path().display()); /// } + /// # Ok(()) } /// ``` pub fn glob_excludes( &mut self, @@ -218,11 +162,20 @@ impl WalkBuilder { s.push('!'); for g in globs { let g = g.as_ref(); - s.push_str(g); - crate::check!( - self.overrides.add(&s), - "failed to add glob exclude pattern: '{s}'" - )?; + if g.starts_with("./") || g.starts_with(".\\") { + let g2 = &g[1..]; + s.push_str(g2); + crate::check!( + self.overrides.add(&s), + "failed to add glob exclude pattern: '{g2}' (resolved from '{g}')" + )?; + } else { + s.push_str(g); + crate::check!( + self.overrides.add(&s), + "failed to add glob exclude pattern: '{g}'" + )?; + } s.truncate(1); self.has_overrides = true; } @@ -255,7 +208,7 @@ impl WalkBuilder { self } - /// Skip hidden files (dotfiles). Default is `false` (hidden files are + /// Skip hidden files. Default is `false` (i.e. hidden files are /// included). #[inline(always)] pub fn ignore_hidden(&mut self, yes: bool) -> &mut Self { @@ -264,10 +217,7 @@ impl WalkBuilder { } /// Follow symbolic links. Default is `false`. - /// - /// When enabled, symlinked directories are descended into. Following a - /// dangling (broken) symlink surfaces an error from the [`Walk`] iterator - /// rather than being silently skipped. + #[inline(always)] pub fn follow_links(&mut self, yes: bool) -> &mut Self { self.inner.follow_links(yes); self diff --git a/packages/copper/tests/walk2.rs b/packages/copper/tests/walk2.rs index 94b03a7..d5635cb 100644 --- a/packages/copper/tests/walk2.rs +++ b/packages/copper/tests/walk2.rs @@ -216,21 +216,36 @@ fn glob_includes_txt() -> cu::Result<()> { fn glob_excludes_log() -> cu::Result<()> { let fx = make_fixture("glob_excludes_log")?; let mut b = cu::fs::walker(fx.root()); - b.glob_excludes(["*.log"])?; + b.glob_excludes(["./*.log"])?; let got = collect(b.walk()?)?; assert!(!got.contains("b.log"), "*.log should be excluded: {got:?}"); - assert!(got.contains("a.txt")); + assert!(got.contains("a.txt"), "{got:?}"); assert!(got.contains("sub/d.rs")); Ok(()) } +#[test] +fn glob_includes_brace_alternation_root() -> cu::Result<()> { + let fx = make_fixture("glob_includes_brace_alternation_root")?; + let mut b = cu::fs::walker(fx.root()); + // brace alternation: include both .txt and .log, but not .rs + b.glob_includes(["./*.{txt,log}"])?; + let got = collect(b.walk()?)?; + + assert!(got.contains("a.txt"), "txt should be included: {got:?}"); + assert!(got.contains("b.log"), "log should be included: {got:?}"); + assert!(!got.contains("sub/c.txt")); + assert!(!got.contains("sub/d.rs"), "rs should be excluded: {got:?}"); + Ok(()) +} + #[test] fn glob_includes_brace_alternation() -> cu::Result<()> { let fx = make_fixture("glob_includes_brace_alternation")?; let mut b = cu::fs::walker(fx.root()); // brace alternation: include both .txt and .log, but not .rs - b.glob_includes(["*.{txt,log}"])?; + b.glob_includes(["./**/*.{txt,log}", "./*.{txt,log}"])?; let got = collect(b.walk()?)?; assert!(got.contains("a.txt"), "txt should be included: {got:?}"); From 4b7753f175a02397d1b00982a54bf5fcf58115df Mon Sep 17 00:00:00 2001 From: Pistonight Date: Sun, 12 Jul 2026 13:53:39 -0700 Subject: [PATCH 6/6] clean up --- packages/copper/src/cli/print_init.rs | 9 ++++++++- packages/copper/src/fs/walk2.rs | 7 +++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/copper/src/cli/print_init.rs b/packages/copper/src/cli/print_init.rs index 3e17ac5..f18abfd 100644 --- a/packages/copper/src/cli/print_init.rs +++ b/packages/copper/src/cli/print_init.rs @@ -186,7 +186,14 @@ pub trait LogConfig { pub struct DefaultLogConfig; impl LogConfig for DefaultLogConfig { fn process(&self, record: &lv::LogRecord) -> (lv::Lv, bool) { + // downgrade logs from dependencies to trace + if let Some(m) = record.module_path() { + if m.starts_with("ignore") { + return (lv::T, true); + } + } + let level: lv::Lv = record.level().into(); - (record.level().into(), level == lv::T) + (level, level == lv::T) } } diff --git a/packages/copper/src/fs/walk2.rs b/packages/copper/src/fs/walk2.rs index 1391a17..0126715 100644 --- a/packages/copper/src/fs/walk2.rs +++ b/packages/copper/src/fs/walk2.rs @@ -308,12 +308,15 @@ impl Walk { break (entry, t); } if t.is_dir() { - crate::trace!("walk: skipping directory: '{}'", entry.path().display()); + crate::trace!( + "walk: not emitting entry for directory: '{}'", + entry.path().display() + ); continue; } if t.is_symlink() && entry.path().is_dir() { crate::trace!( - "walk: skipping symlinked directory: '{}'", + "walk: not emitting entry for symlinked directory: '{}'", entry.path().display() ); continue;