diff --git a/crates/uffs-client/src/protocol/cli_args.rs b/crates/uffs-client/src/protocol/cli_args.rs index 7ffdd09aa..f1dfec88c 100644 --- a/crates/uffs-client/src/protocol/cli_args.rs +++ b/crates/uffs-client/src/protocol/cli_args.rs @@ -108,6 +108,9 @@ impl SearchParams { } "--exclude" => raw.exclude = Some(flag_val(&arg, "--exclude", &mut iter)?), "--in-path" => raw.in_path = Some(flag_val(&arg, "--in-path", &mut iter)?), + "--not-in-path" => { + raw.path_excludes = Some(flag_val(&arg, "--not-in-path", &mut iter)?); + } "--type" => raw.type_filter = Some(flag_val(&arg, "--type", &mut iter)?), "--ext" => raw.ext = Some(flag_val(&arg, "--ext", &mut iter)?), "--month" => raw.month = Some(flag_val(&arg, "--month", &mut iter)?), @@ -331,6 +334,7 @@ struct RawCliArgs { older_accessed: Option, exclude: Option, in_path: Option, + path_excludes: Option, type_filter: Option, month: Option, between: Option, @@ -690,6 +694,7 @@ impl RawCliArgs { ext: self.ext, exclude, path_contains: self.in_path, + path_excludes: self.path_excludes, type_filter: self.type_filter, min_bulkiness: self.min_bulkiness, max_bulkiness: self.max_bulkiness, diff --git a/crates/uffs-client/src/protocol/mod.rs b/crates/uffs-client/src/protocol/mod.rs index 77b869379..64dbcab61 100644 --- a/crates/uffs-client/src/protocol/mod.rs +++ b/crates/uffs-client/src/protocol/mod.rs @@ -353,6 +353,12 @@ pub struct SearchParams { /// portion of the path, not the filename. #[serde(skip_serializing_if = "Option::is_none")] pub path_contains: Option, + /// Directory-path **exclude** patterns — a comma-separated list of globs + /// (e.g. `"*appdata*,*.cargo*,*.rustup*"`). A record is dropped when its + /// directory portion matches **any** of them. Inverse of `path_contains`; + /// the comma-list lets several noise dirs be excluded in one query. + #[serde(skip_serializing_if = "Option::is_none")] + pub path_excludes: Option, /// File type/category filter (e.g. `"code"`, `"document"`, `"picture"`). #[serde(skip_serializing_if = "Option::is_none")] pub type_filter: Option, @@ -573,6 +579,7 @@ impl Default for SearchParams { ext: None, exclude: None, path_contains: None, + path_excludes: None, type_filter: None, min_bulkiness: None, max_bulkiness: None, diff --git a/crates/uffs-core/src/search/filters/apply.rs b/crates/uffs-core/src/search/filters/apply.rs index 835fa540a..28285b4bb 100644 --- a/crates/uffs-core/src/search/filters/apply.rs +++ b/crates/uffs-core/src/search/filters/apply.rs @@ -16,7 +16,9 @@ impl SearchFilters { /// (full path, semantic type). #[must_use] pub const fn needs_display_row_filter(&self) -> bool { - self.path_contains_lower.is_some() || self.type_filter.is_some() + self.path_contains_lower.is_some() + || self.path_excludes_lower.is_some() + || self.type_filter.is_some() } } @@ -193,6 +195,13 @@ fn apply_derived_filters(row: &DisplayRow, filters: &SearchFilters) -> bool { return false; } } + // ── Directory-path exclude filter (drop if dir matches ANY) ── + if let Some(excludes) = &filters.path_excludes_lower { + let dir_lower = row.path_dir().to_ascii_lowercase(); + if excludes.iter().any(|pat| name_matches(&dir_lower, pat)) { + return false; + } + } // ── Type/category filter ──────────────────────────────────── if let Some(wanted) = &filters.type_filter && crate::search::derived::semantic_type_for_row(row) != wanted.as_str() diff --git a/crates/uffs-core/src/search/filters/mod.rs b/crates/uffs-core/src/search/filters/mod.rs index 65d7adc63..6a5b2f713 100644 --- a/crates/uffs-core/src/search/filters/mod.rs +++ b/crates/uffs-core/src/search/filters/mod.rs @@ -6,6 +6,11 @@ //! [`SearchFilters`] holds pre-parsed filter criteria. All parsing (time //! bounds, attribute bits) happens at construction time so the hot `retain` //! loop is branch-only. +//! +//! Exception: the `SearchFilters` / `SearchFilterParams` definitions and the +//! `from_params` constructor stay together so the full per-field filter +//! contract is auditable in one place (see +//! `scripts/ci/file_size_exceptions.txt`). mod apply; mod attr_parsing; @@ -89,6 +94,9 @@ pub struct SearchFilters { /// Directory-path pattern (glob, lowered). Matches against `path_dir()` /// only. pub path_contains_lower: Option, + /// Directory-path **exclude** globs (lowered, separator-normalized). A + /// record is dropped when its `path_dir()` matches any entry. + pub path_excludes_lower: Option>, /// File type/category filter (e.g. `"code"`, `"document"`, `"picture"`). pub type_filter: Option, /// Minimum bulkiness in **per-million** scale. @@ -184,6 +192,9 @@ pub struct SearchFilterParams<'a> { pub exclude: Option<&'a str>, /// Directory-path pattern (glob, matched against dir portion only). pub path_contains: Option<&'a str>, + /// Directory-path exclude globs, comma-separated (matched against the dir + /// portion only; a record is dropped if its directory matches **any**). + pub path_excludes: Option<&'a str>, /// File type/category filter (e.g. `"code"`, `"document"`). pub type_filter: Option<&'a str>, /// Minimum bulkiness percentage (e.g. `200` = allocated ≥ 2× size). @@ -271,6 +282,9 @@ impl SearchFilters { let lowered = pat.to_ascii_lowercase(); normalize_path_separators(&lowered) }); + // Comma-list of dir globs (`*appdata*,*.cargo*,…`), normalized like + // `path_contains`; see [`path_normalize::parse_path_excludes`]. + let path_excludes_lower = path_normalize::parse_path_excludes(params.path_excludes); // ── Promote type_filter → extensions for early filtering ───── // @@ -344,6 +358,7 @@ impl SearchFilters { resolved_ext_ids: Vec::new(), exclude_lower, path_contains_lower, + path_excludes_lower, type_filter, // CLI bulkiness is a user-facing percentage (200 = 200%). // Internal scale is per-million (1_000_000 = 100%). @@ -503,6 +518,7 @@ impl SearchFilters { && self.max_descendants.is_none() && self.exclude_lower.is_none() && self.path_contains_lower.is_none() + && self.path_excludes_lower.is_none() && self.type_filter.is_none() && self.min_bulkiness.is_none() && self.max_bulkiness.is_none() @@ -768,6 +784,7 @@ impl SearchFilters { && self.extensions.is_empty() && self.exclude_lower.is_none() && self.path_contains_lower.is_none() + && self.path_excludes_lower.is_none() && self.type_filter.is_none() && self.min_bulkiness.is_none() && self.max_bulkiness.is_none() diff --git a/crates/uffs-core/src/search/filters/path_normalize.rs b/crates/uffs-core/src/search/filters/path_normalize.rs index 200eb076c..bd74ca972 100644 --- a/crates/uffs-core/src/search/filters/path_normalize.rs +++ b/crates/uffs-core/src/search/filters/path_normalize.rs @@ -34,3 +34,17 @@ pub(in crate::search) fn normalize_path_separators(input: &str) -> String { } result } + +/// Parse a comma-separated `path_excludes` spec into normalized directory +/// globs. Splits on `,`, trims, drops blanks, ASCII-lowercases, and normalizes +/// separators so each entry matches `path_dir()` exactly as `path_contains` +/// does. `None` when the spec is absent or entirely blank. +pub(in crate::search) fn parse_path_excludes(spec: Option<&str>) -> Option> { + let entries: Vec = spec? + .split(',') + .map(str::trim) + .filter(|entry| !entry.is_empty()) + .map(|entry| normalize_path_separators(&entry.to_ascii_lowercase())) + .collect(); + (!entries.is_empty()).then_some(entries) +} diff --git a/crates/uffs-core/src/search/filters/tests.rs b/crates/uffs-core/src/search/filters/tests.rs index dc9807375..29c592a3f 100644 --- a/crates/uffs-core/src/search/filters/tests.rs +++ b/crates/uffs-core/src/search/filters/tests.rs @@ -224,6 +224,45 @@ fn from_params_normalizes_extensions_to_lowercase_without_dot() { assert_eq!(filters.extensions, ["rs", "jpg", "png"]); } +#[test] +fn from_params_path_excludes_splits_lowercases_and_normalizes() { + let filters = SearchFilters::from_params(&SearchFilterParams { + // Mixed case, forward slashes, blank entries, and surrounding spaces. + path_excludes: Some(" *AppData* , */.Cargo/* , , *Downloads* "), + ..Default::default() + }); + let got: Vec<&str> = filters + .path_excludes_lower + .as_deref() + .unwrap_or_default() + .iter() + .map(String::as_str) + .collect(); + assert_eq!( + got, + ["*appdata*", "*\\.cargo\\*", "*downloads*"], + "comma-split, ASCII-lowered, separator-normalized, blanks dropped" + ); +} + +#[test] +fn from_params_path_excludes_none_when_absent_or_all_blank() { + assert!( + SearchFilters::from_params(&SearchFilterParams::default()) + .path_excludes_lower + .is_none() + ); + assert!( + SearchFilters::from_params(&SearchFilterParams { + path_excludes: Some(" , ,"), + ..Default::default() + }) + .path_excludes_lower + .is_none(), + "all-blank spec collapses to None" + ); +} + #[test] fn resolve_ext_ids_for_drive_accepts_mixed_case_extensions() { let drive = test_drive_with_rs_file(); diff --git a/crates/uffs-daemon/src/index/search.rs b/crates/uffs-daemon/src/index/search.rs index 64b66d945..dd31a646e 100644 --- a/crates/uffs-daemon/src/index/search.rs +++ b/crates/uffs-daemon/src/index/search.rs @@ -125,6 +125,7 @@ impl IndexManager { ext_filter: ep.ext.as_deref(), exclude: ep.exclude.as_deref(), path_contains: ep.path_contains.as_deref(), + path_excludes: ep.path_excludes.as_deref(), type_filter: ep.type_filter.as_deref(), min_bulkiness: ep.min_bulkiness, max_bulkiness: ep.max_bulkiness, diff --git a/crates/uffs-mcp/src/handler/instructions.rs b/crates/uffs-mcp/src/handler/instructions.rs index f5c28f09f..44dacdcba 100644 --- a/crates/uffs-mcp/src/handler/instructions.rs +++ b/crates/uffs-mcp/src/handler/instructions.rs @@ -47,15 +47,23 @@ QUERY STRATEGY (minimize round-trips): KEY PARAMETERS for uffs_search: • pattern: '*' (match-all), '*.ext' (glob), 'word' (substring), '>regex' + KEYWORD-OR — for a topic that could be any of several words, use ONE \ + regex, NEVER N separate searches: '>(solar|energy|utility|pge|sunrun)'. \ + Regex is CASE-INSENSITIVE by default. +• match_path: true → match `pattern` against the full path, not just the name • filter: 'files', 'dirs', or 'all' • ext: 'pdf' or collection aliases: pictures, documents, videos, music, \ - archives, code + archives, code (documents = pdf, doc/docx, xls/xlsx, ppt/pptx, csv, txt, …) • type_filter: semantic category: picture, document, archive, code, video, \ audio, executable, database, config, log, system • min_size / max_size: bytes (1073741824 = 1 GB) • newer / older: '7d', '24h', '2w', '2026-01-15', 'today', 'last_30d' • newer_created / older_created / newer_accessed / older_accessed • path_contains: scope to a subtree ('Users\\\\name' or 'Users/name') +• path_excludes: drop noise DIRS — comma-separated dir globs matched against \ + the path, record dropped if it matches ANY: \ + '*appdata*,*.cargo*,*.rustup*,*node_modules*,*downloads*' +• exclude: drop by FILENAME glob (not path) — e.g. '~$*' for Office temp files • drives: ['C'] or ['C','D'] to scope to specific drives • sort: 'modified', '-size', 'name', '-treesize', '-descendants', '-bulkiness' • limit: max results (default 50, cap 500) @@ -119,8 +127,14 @@ COMMON USER REQUESTS (natural language -> tool call): • Recent executables -> uffs_search type_filter='executable' newer='7d' • Old large files -> uffs_search min_size=104857600 older='365d' sort='-size' • Inventory a drive -> uffs_aggregate preset='overview' drives=['X'] +• Topic files in a folder, minus dev noise (ONE call, not many): + My solar/energy spreadsheets under my home dir -> + uffs_search pattern='>(solar|energy|utility|electric|pge|sunrun|sunpower|tesla)' \ + ext='xls,xlsx' path_contains='Users\\\\name' \ + path_excludes='*appdata*,*.cargo*,*.rustup*,*downloads*' sort='-modified' NOTE: UFFS does NOT search inside file contents — it searches file names, \ -paths, and metadata. For content search, suggest ripgrep or similar. +paths, and metadata (the keyword regex matches the FILENAME — e.g. an invoice \ +named after the installer/utility). For content search, suggest ripgrep. PROMPTS (guided multi-step workflows): find_large_files, find_by_extension, disk_usage_report, cleanup_report, \ diff --git a/crates/uffs-mcp/src/tools/search.rs b/crates/uffs-mcp/src/tools/search.rs index c846a686d..f95def850 100644 --- a/crates/uffs-mcp/src/tools/search.rs +++ b/crates/uffs-mcp/src/tools/search.rs @@ -97,9 +97,14 @@ pub(crate) struct SearchArgs { /// Exclude files matching this glob pattern (e.g. `"*.tmp"`). #[serde(default)] pub exclude: Option, - /// Only include results whose path contains this substring. + /// Only include results whose directory path matches this glob. #[serde(default)] pub path_contains: Option, + /// Exclude results whose directory path matches ANY of these globs + /// (comma-separated, e.g. `"*appdata*,*.cargo*,*.rustup*"`). The one-call + /// way to strip noise dirs — inverse of `path_contains`. + #[serde(default)] + pub path_excludes: Option, // ── Size filters ────────────────────────────────────────────── /// Minimum file size in bytes. @@ -312,6 +317,7 @@ pub(crate) async fn run( ext: args.ext, exclude: args.exclude, path_contains: args.path_contains, + path_excludes: args.path_excludes, hide_system: args.hide_system, // Size bounds. min_size: args.min_size, diff --git a/scripts/ci/file_size_exceptions.txt b/scripts/ci/file_size_exceptions.txt index ccd25ed0b..b6d8908d4 100644 --- a/scripts/ci/file_size_exceptions.txt +++ b/scripts/ci/file_size_exceptions.txt @@ -5,6 +5,7 @@ # --- Permanent exceptions (documented justification) --- crates/uffs-core/src/search/field/field_metadata.rs|PERMANENT: Single const fn match table — one FieldMeta per FieldId variant; cannot be split without breaking the match crates/uffs-core/src/search/filters/tests.rs|PERMANENT: Integration test suite for filter pipeline; splitting further would scatter related test fixtures +crates/uffs-core/src/search/filters/mod.rs|PERMANENT: Cohesive SearchFilters/SearchFilterParams definitions + from_params construction; kept together so the full per-field filter contract is auditable in one place crates/uffs-client/src/schema/field_metadata.rs|PERMANENT: Single const fn match table — one FieldMeta per FieldId variant; mirrors uffs-core version crates/uffs-mft/src/io/parser/index.rs|PERMANENT: Performance-critical single-pass MFT record parser; monolithic loop for cache locality crates/uffs-mft/src/io/parser/index_extension.rs|PERMANENT: Extension record parser mirroring index.rs structure; must stay parallel for maintenance parity