Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions crates/uffs-client/src/protocol/cli_args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)?),
Expand Down Expand Up @@ -331,6 +334,7 @@ struct RawCliArgs {
older_accessed: Option<String>,
exclude: Option<String>,
in_path: Option<String>,
path_excludes: Option<String>,
type_filter: Option<String>,
month: Option<String>,
between: Option<String>,
Expand Down Expand Up @@ -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,
Expand Down
7 changes: 7 additions & 0 deletions crates/uffs-client/src/protocol/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// 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<String>,
/// File type/category filter (e.g. `"code"`, `"document"`, `"picture"`).
#[serde(skip_serializing_if = "Option::is_none")]
pub type_filter: Option<String>,
Expand Down Expand Up @@ -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,
Expand Down
11 changes: 10 additions & 1 deletion crates/uffs-core/src/search/filters/apply.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}

Expand Down Expand Up @@ -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()
Expand Down
17 changes: 17 additions & 0 deletions crates/uffs-core/src/search/filters/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -89,6 +94,9 @@ pub struct SearchFilters {
/// Directory-path pattern (glob, lowered). Matches against `path_dir()`
/// only.
pub path_contains_lower: Option<String>,
/// Directory-path **exclude** globs (lowered, separator-normalized). A
/// record is dropped when its `path_dir()` matches any entry.
pub path_excludes_lower: Option<Vec<String>>,
/// File type/category filter (e.g. `"code"`, `"document"`, `"picture"`).
pub type_filter: Option<String>,
/// Minimum bulkiness in **per-million** scale.
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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 ─────
//
Expand Down Expand Up @@ -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%).
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down
14 changes: 14 additions & 0 deletions crates/uffs-core/src/search/filters/path_normalize.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<String>> {
let entries: Vec<String> = 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)
}
39 changes: 39 additions & 0 deletions crates/uffs-core/src/search/filters/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
1 change: 1 addition & 0 deletions crates/uffs-daemon/src/index/search.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
18 changes: 16 additions & 2 deletions crates/uffs-mcp/src/handler/instructions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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, \
Expand Down
8 changes: 7 additions & 1 deletion crates/uffs-mcp/src/tools/search.rs
Original file line number Diff line number Diff line change
Expand Up @@ -97,9 +97,14 @@ pub(crate) struct SearchArgs {
/// Exclude files matching this glob pattern (e.g. `"*.tmp"`).
#[serde(default)]
pub exclude: Option<String>,
/// Only include results whose path contains this substring.
/// Only include results whose directory path matches this glob.
#[serde(default)]
pub path_contains: Option<String>,
/// 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<String>,

// ── Size filters ──────────────────────────────────────────────
/// Minimum file size in bytes.
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions scripts/ci/file_size_exceptions.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading