quick_search/crates/quicksearch-core/src/config.rs
= e8057e596c
All checks were successful
CI / linux (push) Successful in 3m2s
CI / windows-cross (push) Successful in 2m7s
CI / release (push) Successful in 13s
Improved Windows handling of Cloud folders. Improved indexing status reporting. Cleaned up some comments. Added support for old M$ word documents. Improved idle memory consumption.
2026-08-05 18:05:04 -04:00

1981 lines
80 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Configuration: TOML file, resolution rules, filter sets, and live-update
//! classification.
//!
//! File resolution (see [`Config::config_path`]): a `config.toml` sitting
//! next to the executable wins ("portable mode"); otherwise the per-user
//! XDG location is used and auto-created on first run. Relative paths
//! *inside* a config resolve against the config file's own directory, so a
//! portable folder can be moved wholesale.
//!
//! The GUI is the only writer of the file at runtime; external edits take
//! effect on next start. After editing, callers run [`diff_actions`] to
//! learn which running services must react (rebuild the index, restart the
//! watcher, repoint search).
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(default)]
#[derive(Default)]
pub struct Config {
pub paths: PathConfig,
pub indexing: IndexingConfig,
pub processing: ProcessingConfig,
pub search: SearchConfig,
pub ui: UiConfig,
pub security: SecurityConfig,
/// File this config was loaded from; `save()` writes back to it.
/// `None` for hand-built configs (tests), which save to the default
/// location.
#[serde(skip)]
pub source: Option<PathBuf>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct PathConfig {
/// One or more directory roots to index. Indexing walks each root
/// independently; duplicates and nested roots are de-duplicated by the
/// indexer at run time.
pub indexing_paths: Vec<String>,
/// SQLite index location. Relative values resolve against the config
/// file's directory; `~` expands to the home directory.
pub database_path: String,
}
/// What to index and when — the knobs the coordinator and walker consume.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct IndexingConfig {
/// The indexing mode, written down: `true` is automatic — filesystem
/// watchers apply changes as they happen and a full reindex runs every
/// `reindex_interval_minutes` — and `false` is manual, where nothing
/// runs until the user asks. The GUI's Stop / Return to Automatic
/// controls save it as they switch, so the mode survives a restart.
pub auto_index: bool,
pub reindex_interval_minutes: u64,
pub follow_symlinks: bool,
pub include_hidden: bool,
/// Empty = extract content from everything the extractor registry
/// supports. Non-empty = only files with these extensions get content
/// extraction/FTS; everything else is still listed for filename search
/// (`content_state = NA`). Entries are case-insensitive, with or
/// without a leading dot. The reserved entry [`EXTENSIONLESS`] whitelists
/// files that have no extension at all (`Makefile`, `README`, `.bashrc`),
/// which are otherwise excluded by any non-empty filter. `#` starts a
/// comment — whole-entry or trailing — see [`content_filter_entries`].
/// Applied at walk time, when the row is written — which is why changing
/// what it matches forces a rebuild (see [`diff_actions`]).
pub content_extensions: Vec<String>,
/// Excluded from the index entirely — never even listed. A pattern
/// without `/` matches any single path component (so `.git` prunes
/// whole subtrees); a pattern containing `/` or resembling a path is
/// matched against the full path. Glob syntax (`*`, `?`, `[..]`).
pub ignore_patterns: Vec<String>,
/// Per-root walker thread override, keyed by the root string exactly
/// as it appears in `indexing_paths` — the indexer resolves both sides
/// to the same canonical path before matching, so a key that spells its
/// root as `~/docs`, `/docs/` or a symlink still applies. Absent or
/// 0 = auto-detect (4 for local storage, 16 for network mounts). Read
/// at run start; a change applies to the next run.
pub root_workers: std::collections::HashMap<String, usize>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct ProcessingConfig {
/// Bytes read from the head of each new or changed file. Those bytes do
/// four jobs, so this one number sets more than the hash:
///
/// 1. with the size, they identify the file (see `get_file_hash`);
/// 2. they are the magic-byte window for MIME detection — `infer` reads
/// 8 KiB from a path and its longest matcher needs 262 bytes, so the
/// default is exactly as good as opening the file, and a value under
/// 262 makes some formats undetectable except by extension;
/// 3. they are the text-sniff window (`textenc::looks_like_text`) that
/// decides whether an unknown-extension or extensionless file is
/// valid UTF-8 and so worth extracting — small values judge files on
/// less evidence, and a binary whose first bytes happen to be valid
/// UTF-8 is likelier to slip through a short window than a long one;
/// 4. any plaintext file no larger than this is extracted during the
/// walk, sparing the content pass an open/read/close.
///
/// Changing it invalidates stored hashes and forces a rebuild.
pub hash_length: usize,
pub maximum_text_size: usize,
pub maximum_text_file_size: u64,
pub batch_size: usize,
pub fts_update_batch_size: usize,
/// How large the write-ahead log may grow during a run before the indexer
/// forces a checkpoint, in bytes. `0` disables forced checkpoints;
/// anything else is raised to [`MINIMUM_WAL_SIZE`] at the use site.
///
/// SQLite's own autocheckpoint copies the log into the index continuously
/// but can only *reset* it when no reader is mid-query, and a run keeps a
/// reader per root busy from start to finish — so left alone the log grows
/// for the whole run and can end up larger than the index itself. This is
/// a stall-frequency knob, not a throughput one: by the time a forced
/// checkpoint fires there is almost nothing left to copy, so it costs lock
/// acquisition and little else.
pub maximum_wal_size: u64,
pub tokenize: String,
/// When `true` (default), extracted text is stored zstd-compressed in
/// `documents_text` so search results can render snippet/highlight
/// previews without re-reading the source file. When `false` the
/// inverted FTS5 index still gets the tokens (so queries return the
/// same hits) but nothing is stored alongside; full-text results carry
/// no snippets, can't be case-verified or occurrence-ranked, and fuzzy
/// full-text search is unavailable. This mode drops the on-disk
/// footprint to roughly what stock Baloo uses.
pub store_text_for_snippets: bool,
}
/// Floor on a non-zero [`ProcessingConfig::maximum_wal_size`]. A checkpoint
/// costs a lock acquisition that can wait on a running search, so a cap set
/// low enough to fire every round would trade a large log for a stalled
/// writer. Zero still means "never force one".
pub const MINIMUM_WAL_SIZE: u64 = 1024 * 1024 * 16;
/// Fuzzy edit distances above this are allowed but warned about: matches
/// become dominated by coincidence and every fuzzy pass slows down.
pub const FUZZY_EDITS_WARN_ABOVE: usize = 3;
/// Search-side preferences, shared by the GUI and the CLI mode.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct SearchConfig {
/// Whether the fuzzy stages start enabled.
pub fuzzy_default: bool,
/// Ceiling on the fuzzy stages' Levenshtein budget. The budget grows
/// with the term (one edit per three characters) up to this cap, so 2
/// means "1 edit for 3–5 character terms, 2 for anything longer".
/// 0 disables the fuzzy stages entirely; above
/// [`FUZZY_EDITS_WARN_ABOVE`] the GUI and CLI warn.
pub fuzzy_max_edits: usize,
/// Hard cap on buffered/displayed results per search.
pub display_limit: usize,
/// Streaming batch size — how many hits per update event.
pub results_per_page: usize,
/// How long the GUI waits after the last keystroke before searching.
pub debounce_ms: u64,
}
impl SearchConfig {
/// The caution to show next to `fuzzy_max_edits`, or `None` when the
/// value is sane. One wording, shared by the Options window and the
/// terminal mode.
pub fn fuzzy_edits_warning(&self) -> Option<String> {
if self.fuzzy_max_edits <= FUZZY_EDITS_WARN_ABOVE {
return None;
}
Some(format!(
"Fuzzy edit distance {} is above the recommended maximum of {}; \
results will be dominated by false matches and every fuzzy pass \
gets slower.",
self.fuzzy_max_edits, FUZZY_EDITS_WARN_ABOVE
))
}
}
impl Default for PathConfig {
fn default() -> Self {
PathConfig {
indexing_paths: vec![default_home_path()],
database_path: default_db_path().to_string_lossy().into_owned(),
}
}
}
impl Default for IndexingConfig {
fn default() -> Self {
IndexingConfig {
auto_index: true,
reindex_interval_minutes: 60,
follow_symlinks: false,
include_hidden: false,
content_extensions: Vec::new(),
ignore_patterns: default_ignore_patterns(),
root_workers: std::collections::HashMap::new(),
}
}
}
impl Default for ProcessingConfig {
fn default() -> Self {
ProcessingConfig {
hash_length: 1024 * 8,
maximum_text_size: 1024 * 256,
maximum_text_file_size: 1024 * 1024 * 2,
batch_size: 500,
fts_update_batch_size: 1000,
maximum_wal_size: 1024 * 1024 * 512,
tokenize: "trigram".to_string(),
store_text_for_snippets: true,
}
}
}
impl Default for SearchConfig {
fn default() -> Self {
SearchConfig {
fuzzy_default: false,
fuzzy_max_edits: 2,
display_limit: 1000,
results_per_page: 100,
debounce_ms: 150,
}
}
}
/// Index encryption. The password itself is never stored anywhere — only
/// the KDF salt lives here, and it is not a secret (it makes the derivation
/// unique per install, nothing more).
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct SecurityConfig {
/// Encrypt the index (SQLCipher) with a password asked for at startup.
/// Turning this on or off requires deleting and rebuilding the index.
pub password_protected: bool,
/// KDF salt, exactly 32 lowercase hex digits (16 bytes). Written by the
/// app at the moment a password is set — never generated as a default,
/// never edited by hand, and never shown in the GUI. Absent until a
/// password exists.
#[serde(skip_serializing_if = "Option::is_none")]
pub salt: Option<String>,
/// Store the derived key in the OS keychain (Secret Service / Windows
/// Credential Manager) and skip the startup prompt on this machine.
pub use_keychain: bool,
}
impl SecurityConfig {
/// The decoded salt. An error means the config is unusable for
/// unlocking: protection is on but no salt was ever written, or the
/// value was tampered with — both surfaced to the user, never guessed
/// around.
pub fn salt_bytes(&self) -> Result<[u8; crate::security::SALT_LEN], String> {
match &self.salt {
None => Err(
"password protection is enabled but the config has no salt; \
disable protection or set the password again"
.to_string(),
),
Some(hex) => crate::security::salt_from_hex(hex)
.map_err(|e| format!("invalid salt in config: {}", e)),
}
}
}
/// Interface preferences.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct UiConfig {
/// Zoom factor for the whole GUI — fonts, spacing, and widgets scale
/// together. Applied live; also adjustable with Ctrl +/-/0 at runtime
/// (keyboard zoom isn't persisted).
pub scale: f32,
/// Roots the "live updates are disabled" warning has already been shown
/// for, as they appear in `indexing_paths`.
///
/// Keyed by root rather than a single flag so that adding a folder warns
/// again — the trade-off changed — while restarting the app does not.
/// Pruned to the current root set whenever the folder list is applied.
pub watch_cap_warned_roots: Vec<String>,
}
impl Default for UiConfig {
fn default() -> Self {
UiConfig {
scale: 1.1,
watch_cap_warned_roots: Vec::new(),
}
}
}
/// Directories and files excluded from a fresh index.
///
/// Build artefacts everywhere, plus the things a Windows home directory or
/// drive root contains that are actively harmful to index:
///
/// - `$RECYCLE.BIN` holds *deleted* files. Indexing it puts their contents
/// back into search results, which is a privacy problem rather than mere
/// noise. It is Hidden+System, so the walk already skips it by default —
/// this covers the user who legitimately turns `include_hidden` on. (`$` is
/// not a glob metacharacter, so the name is matched literally.)
/// - `System Volume Information` is ACL-denied even to Administrators, so
/// without it every run logs an unreadable-directory warning per drive.
/// - The kernel's paging and hibernation files are multi-gigabyte and
/// permanently locked.
/// - `Thumbs.db` and `desktop.ini` occur in thousands of folders and carry no
/// searchable content.
///
/// Deliberately absent: any exclusion for `C:\Windows`. The default root is
/// the user's profile, so it would never apply, and a pattern general enough
/// to catch it would also catch a user folder named `Windows`.
/// `config_example.toml` documents it for people who add a drive root.
fn default_ignore_patterns() -> Vec<String> {
let mut patterns = vec![".git", "node_modules", "*.tmp", ".venv", "venv"];
if cfg!(windows) {
patterns.extend([
"$RECYCLE.BIN",
"System Volume Information",
"pagefile.sys",
"hiberfil.sys",
"swapfile.sys",
"Thumbs.db",
"desktop.ini",
]);
}
patterns.into_iter().map(str::to_string).collect()
}
/// Platform-sensible default for the first indexing root when no config
/// exists. `$HOME` on Unix, `%USERPROFILE%` on Windows; falls back to the
/// current directory.
fn default_home_path() -> String {
if let Some(home) = crate::platform::home_dir() {
return home.to_string_lossy().into_owned();
}
".".to_string()
}
/// `config.toml` beside the running executable, if the executable's
/// location is known. Existence is checked by the caller.
fn portable_config_path() -> Option<PathBuf> {
let exe = std::env::current_exe().ok()?;
Some(exe.parent()?.join("config.toml"))
}
/// Per-user config directory: `$XDG_CONFIG_HOME`/`~/.config` on Unix,
/// `%APPDATA%` on Windows.
fn config_base_dir() -> PathBuf {
#[cfg(windows)]
{
if let Some(d) = std::env::var_os("APPDATA") {
return PathBuf::from(d);
}
}
if let Some(d) = std::env::var_os("XDG_CONFIG_HOME") {
let p = PathBuf::from(d);
if p.is_absolute() {
return p;
}
}
if let Some(home) = crate::platform::home_dir() {
return PathBuf::from(home).join(".config");
}
PathBuf::from(".")
}
/// Per-user data directory: `$XDG_DATA_HOME`/`~/.local/share` on Unix,
/// `%LOCALAPPDATA%` on Windows.
fn data_base_dir() -> PathBuf {
#[cfg(windows)]
{
if let Some(d) = std::env::var_os("LOCALAPPDATA") {
return PathBuf::from(d);
}
}
if let Some(d) = std::env::var_os("XDG_DATA_HOME") {
let p = PathBuf::from(d);
if p.is_absolute() {
return p;
}
}
if let Some(home) = crate::platform::home_dir() {
return PathBuf::from(home).join(".local").join("share");
}
PathBuf::from(".")
}
/// Default index location when the config doesn't name one.
pub fn default_db_path() -> PathBuf {
data_base_dir().join("quicksearch").join("index.sqlite")
}
/// Expand a leading `~`/`~/` to the home directory. Other `~user` forms are
/// left untouched.
fn expand_tilde(path: &str) -> PathBuf {
if path == "~" || path.starts_with("~/") || path.starts_with("~\\") {
if let Some(home) = crate::platform::home_dir() {
let mut p = PathBuf::from(home);
if path.len() > 2 {
p.push(&path[2..]);
}
return p;
}
}
PathBuf::from(path)
}
impl Config {
/// The config file this process should use: the portable override next
/// to the binary when present, else the XDG location.
pub fn config_path() -> PathBuf {
if let Some(p) = portable_config_path() {
if p.exists() {
return p;
}
}
config_base_dir().join("quicksearch").join("config.toml")
}
pub fn load() -> Result<Self, String> {
Self::load_from(&Self::config_path())
}
/// Load from an explicit path. A missing file is created with defaults
/// (directories included). The loaded config remembers `path` and
/// `save()` writes back to it.
pub fn load_from(path: &Path) -> Result<Self, String> {
if path.exists() {
let content = fs::read_to_string(path)
.map_err(|e| format!("Failed to read config file {}: {}", path.display(), e))?;
let mut cfg: Config = toml::from_str(&content)
.map_err(|e| format!("Failed to parse config file {}: {}", path.display(), e))?;
cfg.source = Some(path.to_path_buf());
Ok(cfg)
} else {
let cfg = Config {
source: Some(path.to_path_buf()),
..Config::default()
};
cfg.save()?;
Ok(cfg)
}
}
/// Write back to the file this config was loaded from (or the default
/// location), creating parent directories as needed. Raw values are
/// written verbatim — relative paths in a portable config stay relative.
pub fn save(&self) -> Result<(), String> {
let path = self.source.clone().unwrap_or_else(Self::config_path);
if let Some(dir) = path.parent() {
fs::create_dir_all(dir)
.map_err(|e| format!("Failed to create config dir {}: {}", dir.display(), e))?;
}
let content = toml::to_string_pretty(self)
.map_err(|e| format!("Failed to serialize config: {}", e))?;
fs::write(&path, content)
.map_err(|e| format!("Failed to write config file {}: {}", path.display(), e))?;
Ok(())
}
/// Directory that relative in-config paths resolve against: the config
/// file's own directory, falling back to the CWD for sourceless configs.
fn base_dir(&self) -> PathBuf {
self.source
.as_deref()
.and_then(Path::parent)
.map(Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from("."))
}
/// `database_path` with `~` expanded and relative values resolved
/// against the config file's directory.
pub fn resolved_database_path(&self) -> PathBuf {
let p = expand_tilde(&self.paths.database_path);
if p.is_absolute() {
p
} else {
self.base_dir().join(p)
}
}
/// `resolved_indexing_paths` canonicalized and spelled the way
/// `files.path` prefixes them.
///
/// The form roots must be compared in: `~/docs`, `docs` in a portable
/// config and `/home/me/docs` are one root under three spellings, and a
/// re-spelling is not a configuration change. Duplicates collapse, order
/// is not preserved — a caller that needs one uses
/// [`Config::resolved_indexing_paths`].
pub fn normalized_indexing_paths(&self) -> BTreeSet<String> {
self.resolved_indexing_paths()
.iter()
.map(|p| crate::file_handling::normalize_root_string(&p.to_string_lossy()))
.collect()
}
/// `indexing_paths` with the same resolution rules as
/// [`Config::resolved_database_path`].
pub fn resolved_indexing_paths(&self) -> Vec<PathBuf> {
self.paths
.indexing_paths
.iter()
.map(|raw| {
let p = expand_tilde(raw);
if p.is_absolute() {
p
} else {
self.base_dir().join(p)
}
})
.collect()
}
}
/// Reserved `content_extensions` entry standing for "files with no
/// extension". Matched case-insensitively, and it cannot collide with a real
/// extension because the parentheses are not part of one.
pub const EXTENSIONLESS: &str = "(none)";
/// The `content_extensions` entries that actually filter: `#` starts a
/// comment and runs to the end of the entry, so a whole-line comment drops
/// out entirely and `md # notes` filters on `md`. Surrounding space is
/// trimmed; what is left of a `#` never is.
pub fn content_filter_entries(list: &[String]) -> impl Iterator<Item = &str> {
list.iter().filter_map(|raw| {
let entry = raw.split('#').next().unwrap_or_default().trim();
(!entry.is_empty()).then_some(entry)
})
}
/// Whether a file's content (text extraction + FTS) should be indexed under
/// the `content_extensions` filter. Files that fail this are still listed
/// for filename search. No entries (empty, or nothing but comments) =
/// everything allowed.
pub fn content_allowed(path: &Path, cfg: &Config) -> bool {
let list = &cfg.indexing.content_extensions;
if content_filter_entries(list).next().is_none() {
return true;
}
// `Path::extension` is None for `Makefile` and for dot-only names like
// `.bashrc`, so without the sentinel a non-empty filter always skips them.
match path.extension().and_then(|e| e.to_str()) {
// The sentinel is reserved: it never doubles as an extension, so a
// file named `x.(none)` is not whitelisted by it.
Some(ext) => content_filter_entries(list)
.filter(|allowed| !allowed.eq_ignore_ascii_case(EXTENSIONLESS))
.any(|allowed| allowed.trim_start_matches('.').eq_ignore_ascii_case(ext)),
None => {
content_filter_entries(list).any(|allowed| allowed.eq_ignore_ascii_case(EXTENSIONLESS))
}
}
}
/// Longest name folded without allocating. Comfortably above `NAME_MAX` on the
/// filesystems that matter (255 bytes), so the heap path is effectively dead
/// code kept for correctness rather than for use.
const FOLD_BUF: usize = 256;
/// Whether `pat` is a plain name with no glob syntax in it.
///
/// `\` is included even though it is not glob syntax everywhere: a pattern
/// containing one is routed to the path set rather than the component set, so
/// treating it as literal here would put it in the wrong place.
fn is_literal_name(pat: &str) -> bool {
!pat.contains(['*', '?', '[', ']', '{', '}', '/', '\\'])
}
/// Compiled ignore patterns, split by matching scope: patterns without a
/// path separator match any single path component; the rest match the full
/// path. Both use glob syntax.
#[derive(Debug)]
pub struct IgnoreSet {
/// Component patterns that are plain ASCII names — `.git`, `node_modules`,
/// `System Volume Information`. Held apart from `component` purely for
/// speed, and it is a large difference on Windows.
///
/// globset compiles a case-insensitive glob by giving up every fast path it
/// has: `Glob::literal`, `ext`, `prefix`, `suffix` and `basename_tokens` all
/// return `None` the moment `case_insensitive` is set, so every pattern
/// falls through to a `RegexSet` scan. Case-sensitively, `.git` and
/// `node_modules` compile to a hash lookup and `*.tmp` to an extension
/// lookup. That left Windows — which is case-insensitive *and* carries seven
/// extra default patterns — running a 12-pattern regex DFA over every single
/// directory entry, where Linux did five hash lookups.
///
/// Folded and compared as **ASCII**, matching [`PATH_COLLATION`]'s reasoning
/// exactly: SQLite's `NOCASE` and `LIKE` fold ASCII only, so the glob layer
/// agreeing with them is what keeps a path filter from disagreeing with the
/// ignore rules. Patterns that are not ASCII stay in `component` and keep
/// globset's Unicode folding, so nothing that matched before stops matching.
literal_components: std::collections::HashSet<String>,
component: globset::GlobSet,
path: globset::GlobSet,
empty: bool,
}
impl IgnoreSet {
pub fn compile(patterns: &[String]) -> Result<IgnoreSet, String> {
let mut literal_components = std::collections::HashSet::new();
let mut component = globset::GlobSetBuilder::new();
let mut path = globset::GlobSetBuilder::new();
for pat in patterns {
// Trailing separators are how people naturally write directory
// patterns ("/tmp/"); paths compare without them, so strip —
// except a drive root ("D:\" or "D:/"), where the separator is
// the whole point: trimmed to "D:" it would become a component
// pattern that can never match. Drive roots keep a normalized
// "D:/" spelling, which the ancestor walk in
// `matches_path_pattern` does reach.
let raw = pat.trim();
let trimmed = raw.trim_end_matches(['/', '\\']);
let is_drive_root = raw.len() > trimmed.len()
&& trimmed.len() == 2
&& trimmed.as_bytes()[0].is_ascii_alphabetic()
&& trimmed.as_bytes()[1] == b':';
let drive_root;
let pat: &str = if is_drive_root {
drive_root = format!("{}/", trimmed);
&drive_root
} else {
trimmed
};
if pat.is_empty() {
continue;
}
// A plain ASCII name needs no glob machinery at all — see
// `literal_components`. Everything else, including every non-ASCII
// pattern, goes on to globset unchanged.
if pat.is_ascii() && is_literal_name(pat) {
literal_components.insert(if crate::platform::PATHS_ARE_CASE_INSENSITIVE {
pat.to_ascii_lowercase()
} else {
pat.to_string()
});
continue;
}
let glob = globset::GlobBuilder::new(pat)
.literal_separator(false)
// Match the filesystem's own rules, or `node_modules` fails to
// exclude `Node_Modules`. globset already handles the other
// half of Windows compatibility on its own: `Candidate` folds
// `\` to `/` when matching, and backslash-as-escape is off
// wherever `\` is a separator.
.case_insensitive(crate::platform::PATHS_ARE_CASE_INSENSITIVE)
.build()
.map_err(|e| format!("invalid ignore pattern {:?}: {}", pat, e))?;
if pat.contains('/') || pat.contains('\\') {
path.add(glob);
} else {
component.add(glob);
}
}
let component = component
.build()
.map_err(|e| format!("compile ignore patterns: {}", e))?;
let path = path
.build()
.map_err(|e| format!("compile ignore patterns: {}", e))?;
let empty = literal_components.is_empty() && component.is_empty() && path.is_empty();
Ok(IgnoreSet {
literal_components,
component,
path,
empty,
})
}
/// Whether `name` is one of the plain-name patterns, folded per platform.
///
/// Allocation-free for any name that fits [`FOLD_BUF`], which is every name
/// a real filesystem can produce. This runs on every directory entry the
/// walker sees, so it is the one place in the ignore path worth keeping off
/// the heap.
fn matches_literal(&self, name: &str) -> bool {
if self.literal_components.is_empty() {
return false;
}
if !crate::platform::PATHS_ARE_CASE_INSENSITIVE {
return self.literal_components.contains(name);
}
// Every stored literal is ASCII, and ASCII case folding maps ASCII to
// ASCII, so a name containing any non-ASCII byte cannot equal one.
if !name.is_ascii() {
return false;
}
if name.len() <= FOLD_BUF {
let mut buf = [0u8; FOLD_BUF];
let buf = &mut buf[..name.len()];
buf.copy_from_slice(name.as_bytes());
buf.make_ascii_lowercase();
// Lowercasing ASCII yields ASCII, which is always valid UTF-8.
let folded = std::str::from_utf8(buf).expect("ascii stays utf-8");
return self.literal_components.contains(folded);
}
self.literal_components.contains(&name.to_ascii_lowercase())
}
/// Match a single file/directory name. Used by the walker to prune
/// subtrees before descending.
pub fn matches_component(&self, name: &str) -> bool {
if self.empty {
return false;
}
// The literal set answers almost every call — the defaults are all
// plain names — and answers it without touching the regex engine.
self.matches_literal(name) || self.component.is_match(name)
}
/// Match a path against the full-path patterns only. The path *and its
/// ancestors* are tested, so a pattern matching a directory ignores
/// everything beneath it — the same semantics the walker gets by
/// pruning that directory before descending.
pub fn matches_path_pattern(&self, path: &Path) -> bool {
if self.path.is_empty() {
return false;
}
let mut cur = Some(path);
while let Some(p) = cur {
if self.path.is_match(p) {
return true;
}
cur = p.parent();
}
false
}
/// Match a full path: either a full-path pattern hits it (or an
/// ancestor), or any single component matches a component pattern.
/// Used for watcher events, where walk-time pruning never saw the path.
pub fn matches_path(&self, path: &Path) -> bool {
if self.empty {
return false;
}
if self.matches_path_pattern(path) {
return true;
}
path.components().any(|c| match c {
// Routed through `matches_component` rather than `self.component`
// directly, or the literal patterns would be invisible here and the
// watcher would index what the walker prunes.
std::path::Component::Normal(name) => self.matches_component(&name.to_string_lossy()),
_ => false,
})
}
pub fn is_empty(&self) -> bool {
self.empty
}
}
/// What must happen to the *stored index* to bring it back in line with the
/// configuration, short of deleting and rebuilding it.
///
/// Every field is independently satisfiable and the whole thing is
/// idempotent: applying it twice does nothing the second time, which is what
/// lets the same plan be produced from a live config edit and from the
/// `config_validation` fingerprint of a config that was hand-edited while the
/// app was closed. See [`crate::scope`] for the pass that applies it.
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct IndexWork {
/// Roots that are no longer configured, in `files.path` spelling. Every
/// row beneath one is deleted; no filesystem access is involved, so a
/// root whose folder is gone is handled the same as one that still
/// exists.
pub drop_roots: Vec<String>,
/// The ignore/hidden rules narrowed. Stored rows under the surviving
/// roots are re-tested against them and the ones the walker would no
/// longer emit are deleted.
pub prune_scope: bool,
/// Symlink following was turned off. A followed target is stored under
/// its own canonical path, which can be outside every root; with links
/// off no walk can produce such a row, and no root's range would ever
/// visit it again, so every row outside the roots goes.
pub drop_aliases: bool,
/// The `content_extensions` filter changed. Kept rows are re-tested
/// against it in both directions: newly-included files go back to
/// pending, newly-excluded ones give up their text, properties and FTS
/// row but keep the name/path row that filename search needs.
pub reconcile_content: bool,
/// `store_text_for_snippets` turned on. Rows that finished extraction
/// under the old setting kept no text, so they must run again.
pub restore_text: bool,
/// `store_text_for_snippets` turned off. The stored text is dead weight
/// now; dropping it leaves full-text search working and only costs
/// snippets.
pub drop_text: bool,
/// Files that are newly in scope exist only on disk — nothing in the
/// index points at them, so a full walk has to go and find them.
pub reindex: bool,
}
impl IndexWork {
/// Whether there is nothing to do at all.
pub fn is_empty(&self) -> bool {
*self == IndexWork::default()
}
/// Fold `other` in, so one pass satisfies both.
///
/// For a second config edit arriving while the first is still being
/// applied: the plans are computed against different configurations and
/// neither knows what the other left undone, so the union of the two is
/// the only thing that is certainly enough. Every part is idempotent, so
/// re-doing the finished half of the first costs time and nothing else.
pub fn merge_from(&mut self, other: &IndexWork) {
for root in &other.drop_roots {
if !self.drop_roots.contains(root) {
self.drop_roots.push(root.clone());
}
}
self.drop_aliases |= other.drop_aliases;
self.prune_scope |= other.prune_scope;
self.reconcile_content |= other.reconcile_content;
self.restore_text |= other.restore_text;
self.drop_text |= other.drop_text;
self.reindex |= other.reindex;
}
/// Whether any part of this touches stored rows, as opposed to only
/// asking for another walk.
pub fn touches_index(&self) -> bool {
!self.drop_roots.is_empty()
|| self.drop_aliases
|| self.prune_scope
|| self.reconcile_content
|| self.restore_text
|| self.drop_text
}
/// Whether applying this means scanning the rows under each surviving
/// root, rather than just deleting whole ranges.
pub fn scans_rows(&self) -> bool {
self.prune_scope || self.reconcile_content || self.restore_text || self.drop_text
}
/// The plan in one line, for the log entry that announces the scan.
///
/// Names what changed rather than what will happen to the rows: the
/// reader is someone asking why a run has not started walking yet, and
/// the answer they need is which edit of theirs caused it.
pub fn summary(&self) -> String {
let mut parts: Vec<String> = Vec::new();
if !self.drop_roots.is_empty() {
parts.push(format!(
"{} root(s) no longer indexed",
self.drop_roots.len()
));
}
if self.prune_scope {
parts.push("narrowed ignore or hidden-file rules".into());
}
if self.drop_aliases {
parts.push("symlinks no longer followed".into());
}
if self.reconcile_content {
parts.push("changed content extensions".into());
}
if self.restore_text {
parts.push("snippet text turned on".into());
}
if self.drop_text {
parts.push("snippet text turned off".into());
}
if parts.is_empty() {
// `touches_index` is false here, so no caller logs this; a
// placeholder beats an empty pair of parentheses if one ever does.
return "no stored rows affected".into();
}
parts.join("; ")
}
}
/// What running services must do after a config edit. Computed by the GUI
/// (the only runtime editor) after saving, and by the coordinator from the
/// config it was already holding.
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct ConfigActions {
/// The stored file cannot be read or compared under the new
/// configuration and must be deleted and rebuilt from scratch. Reserved
/// for the three settings that leave no other option: the FTS tokenizer
/// (baked into the table definition), the hash length (stored hashes
/// become incomparable) and the encryption key.
pub requires_rebuild: bool,
/// Reconciliation the index can do in place. Empty when
/// `requires_rebuild` is set — a wipe subsumes all of it.
pub work: IndexWork,
/// Searches must reopen against a different database file.
pub search_db_changed: bool,
}
/// Roots that live inside other roots, as `(child, parent)` pairs (exact
/// duplicates are reported once). Nested roots are disallowed: with one
/// walker per root they would race for the same files and split progress
/// attribution. Comparison is on best-effort canonicalized paths (an
/// unresolvable root is compared as spelled), component-wise per
/// [`crate::file_handling::UnreadableDirs::covers`].
pub fn nested_roots(roots: &[String]) -> Vec<(String, String)> {
let resolved: Vec<PathBuf> = roots
.iter()
.map(|r| {
let p = expand_tilde(r);
fs::canonicalize(&p).unwrap_or(p)
})
.collect();
let mut out = Vec::new();
for (i, child) in resolved.iter().enumerate() {
for (j, parent) in resolved.iter().enumerate() {
if i == j {
continue;
}
if child == parent {
if i > j {
out.push((roots[i].clone(), roots[j].clone()));
}
} else if child.starts_with(parent) {
out.push((roots[i].clone(), roots[j].clone()));
}
}
}
out
}
/// The `content_extensions` entries that decide what a file is matched
/// against, normalized the way [`content_allowed`] compares them: comments
/// stripped, a leading dot optional, case-insensitive. Two lists with the
/// same set here filter identically, however they are spelled or ordered.
fn content_filter_set(list: &[String]) -> BTreeSet<String> {
content_filter_entries(list)
.map(|e| e.trim_start_matches('.').to_ascii_lowercase())
.collect()
}
/// Whether `new` accepts everything `old` did and more.
///
/// An empty list means "no filter, everything allowed", so it is a superset
/// of every other list rather than the empty set — the one case plain set
/// arithmetic gets backwards.
fn filter_widened(old: &BTreeSet<String>, new: &BTreeSet<String>) -> bool {
match (old.is_empty(), new.is_empty()) {
(_, true) => !old.is_empty(),
(true, false) => false,
(false, false) => new.difference(old).next().is_some(),
}
}
/// What an edit means for the running services and the stored index.
///
/// The guiding rule: a wipe is only for data that cannot be read or compared
/// any more. Everything else is a difference between what the index holds and
/// what the configuration would produce, and a difference can be reconciled —
/// rows that fell out of scope are deleted ([`IndexWork::prune_scope`],
/// [`IndexWork::drop_roots`]), rows whose content scope moved are re-tested
/// ([`IndexWork::reconcile_content`]), and files that came *into* scope are
/// found by another walk ([`IndexWork::reindex`]).
///
/// Roots, ignore patterns and content extensions are compared as **sets**
/// after normalization, so reordering a list or re-spelling a root is not a
/// change at all.
pub fn diff_actions(old: &Config, new: &Config) -> ConfigActions {
let old_roots = old.normalized_indexing_paths();
let new_roots = new.normalized_indexing_paths();
// Encryption on↔off or a different salt (⇒ a different key) makes the
// on-disk file unreadable to the new configuration. The tokenizer is part
// of the FTS table's definition, and `hash_length` decides what bytes a
// stored hash covers, so old and new hashes cannot be compared. Those
// three are the whole of it; the GUI's security flows drive their own
// explicit dialog, and this covers hand-edited configs applied through
// the generic path. `use_keychain` only changes where the key is
// remembered, not the file.
let requires_rebuild = old.processing.hash_length != new.processing.hash_length
|| old.processing.tokenize != new.processing.tokenize
|| old.security.password_protected != new.security.password_protected
|| old.security.salt != new.security.salt;
let mut work = IndexWork::default();
if !requires_rebuild {
work.drop_roots = old_roots.difference(&new_roots).cloned().collect();
let old_ignores: BTreeSet<&str> = old
.indexing
.ignore_patterns
.iter()
.map(|s| s.trim())
.collect();
let new_ignores: BTreeSet<&str> = new
.indexing
.ignore_patterns
.iter()
.map(|s| s.trim())
.collect();
// Hidden files narrow the walk exactly the way an added ignore pattern
// does, so they take the same route.
work.prune_scope = new_ignores.difference(&old_ignores).next().is_some()
|| (old.indexing.include_hidden && !new.indexing.include_hidden);
// Symlinks do not: a followed target is stored under its own canonical
// path, which is either inside a root — where a direct walk produces
// exactly the same row, so nothing changes — or outside every root,
// where turning links off strands it somewhere no walk and no
// per-root scan will ever look again.
work.drop_aliases = old.indexing.follow_symlinks && !new.indexing.follow_symlinks;
let old_content = content_filter_set(&old.indexing.content_extensions);
let new_content = content_filter_set(&new.indexing.content_extensions);
let content_widened = filter_widened(&old_content, &new_content);
work.reconcile_content = old_content != new_content;
let old_store = old.processing.store_text_for_snippets;
let new_store = new.processing.store_text_for_snippets;
work.restore_text = !old_store && new_store;
work.drop_text = old_store && !new_store;
// Widening only ever *adds* files, and a file that is not in the index
// is not findable from it: only a walk can produce those rows. Text
// that has to be extracted again needs a run for the same reason —
// the content pass runs as part of one.
work.reindex = new_roots.difference(&old_roots).next().is_some()
|| old_ignores.difference(&new_ignores).next().is_some()
|| (!old.indexing.include_hidden && new.indexing.include_hidden)
|| (!old.indexing.follow_symlinks && new.indexing.follow_symlinks)
|| content_widened
|| work.restore_text;
}
ConfigActions {
requires_rebuild,
work,
search_db_changed: old.paths.database_path != new.paths.database_path,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn tmp_dir() -> PathBuf {
crate::testutil::scratch_dir("config")
}
#[test]
fn fresh_install_defaults_to_home_as_only_root() {
let dir = tmp_dir();
let path = dir.join("config.toml");
assert!(!path.exists(), "fresh install: no config yet");
let cfg = Config::load_from(&path).unwrap();
let home = std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.expect("test environment has a home dir");
assert_eq!(
cfg.paths.indexing_paths,
vec![home],
"the user's home folder must be the only default index root"
);
// The auto-created file round-trips identically.
let reloaded = Config::load_from(&path).unwrap();
assert_eq!(reloaded.paths.indexing_paths, cfg.paths.indexing_paths);
// A [paths] section that omits indexing_paths also falls back to
// home, not to an empty list.
fs::write(&path, "[paths]\ndatabase_path = \"x.sqlite\"\n").unwrap();
let partial = Config::load_from(&path).unwrap();
assert_eq!(partial.paths.indexing_paths, cfg.paths.indexing_paths);
fs::remove_dir_all(&dir).ok();
}
#[test]
fn missing_file_created_with_defaults() {
let dir = tmp_dir();
let path = dir.join("config.toml");
let cfg = Config::load_from(&path).unwrap();
assert!(path.exists(), "default config file should be written");
assert_eq!(cfg.search.display_limit, 1000);
assert_eq!(cfg.search.results_per_page, 100);
assert!(cfg.indexing.auto_index);
assert_eq!(cfg.source.as_deref(), Some(path.as_path()));
fs::remove_dir_all(&dir).ok();
}
#[test]
fn partial_file_gets_section_defaults() {
let dir = tmp_dir();
let path = dir.join("config.toml");
fs::write(
&path,
"[paths]\nindexing_paths=[\"/x\"]\ndatabase_path=\"db.sqlite\"\n",
)
.unwrap();
let cfg = Config::load_from(&path).unwrap();
assert_eq!(cfg.paths.indexing_paths, vec!["/x".to_string()]);
assert_eq!(cfg.processing.batch_size, 500, "missing sections default");
assert_eq!(cfg.search.debounce_ms, 150);
assert!((cfg.ui.scale - 1.1).abs() < f32::EPSILON);
fs::remove_dir_all(&dir).ok();
}
#[test]
fn relative_paths_resolve_against_config_dir() {
let dir = tmp_dir();
let path = dir.join("config.toml");
fs::write(
&path,
"[paths]\nindexing_paths=[\"data\"]\ndatabase_path=\"index.sqlite\"\n",
)
.unwrap();
let cfg = Config::load_from(&path).unwrap();
assert_eq!(cfg.resolved_database_path(), dir.join("index.sqlite"));
assert_eq!(cfg.resolved_indexing_paths(), vec![dir.join("data")]);
fs::remove_dir_all(&dir).ok();
}
#[test]
fn save_keeps_relative_paths_portable() {
let dir = tmp_dir();
let path = dir.join("config.toml");
fs::write(
&path,
"[paths]\nindexing_paths=[\"data\"]\ndatabase_path=\"index.sqlite\"\n",
)
.unwrap();
let cfg = Config::load_from(&path).unwrap();
cfg.save().unwrap();
let text = fs::read_to_string(&path).unwrap();
assert!(
text.contains("database_path = \"index.sqlite\""),
"relative path must survive a save round-trip: {}",
text
);
fs::remove_dir_all(&dir).ok();
}
#[test]
fn tilde_expansion() {
if std::env::var_os("HOME").is_none() {
return; // nothing to assert without a home dir
}
let mut cfg = Config::default();
cfg.paths.database_path = "~/qs/index.sqlite".to_string();
let resolved = cfg.resolved_database_path();
assert!(resolved.is_absolute());
assert!(!resolved.to_string_lossy().contains('~'));
}
#[test]
fn content_allowed_semantics() {
let mut cfg = Config::default();
assert!(content_allowed(Path::new("/a/b.xyz"), &cfg), "empty = all");
cfg.indexing.content_extensions = vec!["txt".into(), ".MD".into()];
assert!(content_allowed(Path::new("/a/b.txt"), &cfg));
assert!(content_allowed(Path::new("/a/B.TXT"), &cfg));
assert!(
content_allowed(Path::new("/a/readme.md"), &cfg),
"leading dot + case in filter"
);
assert!(!content_allowed(Path::new("/a/b.pdf"), &cfg));
assert!(!content_allowed(Path::new("/a/noext"), &cfg));
assert!(
!content_allowed(Path::new("/a/.bashrc"), &cfg),
"dot-only name has no ext"
);
}
#[test]
fn content_allowed_extensionless_sentinel() {
let mut cfg = Config::default();
cfg.indexing.content_extensions = vec!["txt".into(), " (NonE) ".into()];
assert!(content_allowed(Path::new("/a/Makefile"), &cfg));
assert!(
content_allowed(Path::new("/a/.bashrc"), &cfg),
"dot-only name"
);
assert!(
content_allowed(Path::new("/a/b.txt"), &cfg),
"real extensions still work"
);
assert!(
!content_allowed(Path::new("/a/b.pdf"), &cfg),
"sentinel is not a wildcard"
);
// The sentinel is not itself an extension: a file literally named
// `x.none` is not whitelisted by it.
assert!(!content_allowed(Path::new("/a/x.none"), &cfg));
assert!(!content_allowed(Path::new("/a/x.(none)"), &cfg));
// Every capitalisation of the word means the same thing.
for spelling in ["(none)", "(NONE)", "(NonE)", "(nOnE)"] {
let mut c = Config::default();
c.indexing.content_extensions = vec![spelling.to_string()];
assert!(content_allowed(Path::new("/a/README"), &c), "{spelling}");
}
// A leading dot is stripped for extensions but must not turn some
// other entry into the sentinel.
let mut only_txt = Config::default();
only_txt.indexing.content_extensions = vec!["txt".into()];
assert!(!content_allowed(Path::new("/a/Makefile"), &only_txt));
}
#[test]
fn content_allowed_comments() {
let mut cfg = Config::default();
cfg.indexing.content_extensions = vec![
"# source files only".into(),
"rs # rust".into(),
" .MD\t# docs ".into(),
" # indented whole-line comment".into(),
"(none) # Makefile, LICENSE, ...".into(),
];
assert!(content_allowed(Path::new("/a/b.rs"), &cfg));
assert!(
content_allowed(Path::new("/a/b.md"), &cfg),
"dot + trailing comment"
);
assert!(
content_allowed(Path::new("/a/Makefile"), &cfg),
"sentinel + comment"
);
assert!(!content_allowed(Path::new("/a/b.pdf"), &cfg));
// Comment text is not itself a filter entry.
assert!(!content_allowed(Path::new("/a/b.rust"), &cfg));
assert!(!content_allowed(Path::new("/a/b.only"), &cfg));
assert!(!content_allowed(Path::new("/a/b.docs"), &cfg));
// Nothing but comments filters nothing — same as an empty list.
let mut all_comments = Config::default();
all_comments.indexing.content_extensions =
vec!["# nothing enabled yet".into(), " ".into(), "#".into()];
assert!(content_allowed(Path::new("/a/b.pdf"), &all_comments));
assert!(content_allowed(Path::new("/a/Makefile"), &all_comments));
}
/// Comments, spelling and order are not part of the content filter, so
/// editing them is no work at all — and a real change to it is never a
/// rebuild, only a re-decision of the text already stored.
#[test]
fn comment_only_edit_is_no_work_at_all() {
let mut old = Config::default();
old.indexing.content_extensions = vec!["txt".into(), "md".into()];
for cosmetic in [
vec!["# my notes".into(), "txt".into(), "md # markdown".into()],
vec!["md".into(), "txt".into()],
vec![".TXT".into(), ".Md".into()],
] {
let mut new = old.clone();
new.indexing.content_extensions = cosmetic;
let a = diff_actions(&old, &new);
assert_eq!(a, ConfigActions::default(), "cosmetic edit is not a change");
}
// Adding an extension widens the filter: files already indexed by
// name need their text extracted, which takes a run.
let mut widened = old.clone();
widened.indexing.content_extensions = vec!["txt".into(), "md".into(), "(none)".into()];
let a = diff_actions(&old, &widened);
assert!(!a.requires_rebuild);
assert!(a.work.reconcile_content && a.work.reindex);
// Commenting one out narrows it: the stored text goes, and nothing
// needs walking to make that true.
let mut narrowed = old.clone();
narrowed.indexing.content_extensions = vec!["txt".into(), "# md".into()];
let a = diff_actions(&old, &narrowed);
assert!(!a.requires_rebuild);
assert!(a.work.reconcile_content && !a.work.reindex);
}
/// An empty list means "everything allowed", so it is a superset of every
/// other list — the case plain set arithmetic reads backwards.
#[test]
fn an_empty_content_filter_is_the_widest_one() {
let mut listed = Config::default();
listed.indexing.content_extensions = vec!["txt".into()];
let mut unfiltered = listed.clone();
unfiltered.indexing.content_extensions = vec![];
let widening = diff_actions(&listed, &unfiltered).work;
assert!(widening.reconcile_content && widening.reindex);
let narrowing = diff_actions(&unfiltered, &listed).work;
assert!(narrowing.reconcile_content && !narrowing.reindex);
}
#[test]
fn ignore_set_component_vs_path() {
let set = IgnoreSet::compile(&[
".git".to_string(),
"*.tmp".to_string(),
"/home/*/secret".to_string(),
"".to_string(), // blank lines ignored
])
.unwrap();
assert!(set.matches_component(".git"));
assert!(set.matches_component("junk.tmp"));
assert!(!set.matches_component("git"));
// Full-path checks catch both kinds.
assert!(set.matches_path(Path::new("/repo/.git/config")));
assert!(set.matches_path(Path::new("/x/y/file.tmp")));
assert!(set.matches_path(Path::new("/home/bob/secret")));
// A dir-matching path pattern ignores everything beneath it, same
// as the walker pruning that directory.
assert!(set.matches_path(Path::new("/home/bob/secret/inner/deep.txt")));
assert!(!set.matches_path(Path::new("/home/bob/public")));
assert!(!set.matches_path(Path::new("/repo/src/main.rs")));
}
#[test]
fn directory_patterns_with_trailing_slash() {
let set = IgnoreSet::compile(&[
"/tmp/".to_string(), // absolute dir, natural spelling
"cache/".to_string(), // becomes a component pattern
"*/target/".to_string(), // dir anywhere by suffix
"/".to_string(), // degenerate: trims to nothing, skipped
])
.unwrap();
// The directory itself and everything beneath it.
assert!(set.matches_path(Path::new("/tmp")));
assert!(set.matches_path(Path::new("/tmp/a/b/c.txt")));
assert!(!set.matches_path(Path::new("/tmpfoo/file.txt")));
// "cache/" behaves like the component pattern "cache".
assert!(set.matches_path(Path::new("/home/x/cache/obj.bin")));
// Suffix form matches the dir at any depth.
assert!(set.matches_path(Path::new("/repo/sub/target/debug/app")));
// A bare "/" must not ignore the universe.
assert!(!set.matches_path(Path::new("/etc/passwd")));
}
/// A drive-root pattern must survive the trailing-separator trim as a
/// path pattern — trimmed to "D:" it would land in the component set,
/// where nothing is ever named "D:".
#[test]
fn drive_root_patterns_are_not_component_patterns() {
let set = IgnoreSet::compile(&[r"D:\".to_string(), "E:/".to_string()]).unwrap();
assert!(!set.matches_component("D:"));
assert!(!set.matches_component(r"D:\"));
assert!(!set.matches_component("E:"));
}
/// The full drive-root behavior needs Windows path semantics:
/// `Path::parent` only walks up to `D:\` there, and globset only folds
/// `\` to `/` where `\` is a separator.
#[cfg(windows)]
#[test]
fn drive_root_pattern_ignores_the_whole_drive() {
let set = IgnoreSet::compile(&[r"D:\".to_string()]).unwrap();
assert!(set.matches_path(Path::new(r"D:\")));
assert!(set.matches_path(Path::new(r"D:\Users\x\file.txt")));
assert!(set.matches_path(Path::new(r"d:\case\folded.txt")));
assert!(!set.matches_path(Path::new(r"E:\file.txt")));
}
/// A bare "D:" (no separator) compiles but can only match a component
/// literally named "D:", which no file ever is. The GUI warns about
/// this shape; the compiler intentionally leaves it alone.
#[test]
fn bare_drive_letter_stays_a_component_pattern() {
let set = IgnoreSet::compile(&["D:".to_string()]).unwrap();
assert!(set.matches_component("D:"));
#[cfg(windows)]
assert!(!set.matches_path(Path::new(r"D:\file.txt")));
}
#[test]
fn ignore_set_invalid_pattern_errors() {
let err = IgnoreSet::compile(&["[".to_string()]).unwrap_err();
assert!(err.contains("invalid ignore pattern"), "{}", err);
}
#[test]
fn empty_ignore_set_matches_nothing() {
let set = IgnoreSet::compile(&[]).unwrap();
assert!(set.is_empty());
assert!(!set.matches_path(Path::new("/any/thing")));
assert!(!set.matches_component("anything"));
}
/// Pattern matching must follow the filesystem's own case rules, or
/// `node_modules` silently fails to exclude `Node_Modules` on Windows.
#[test]
fn ignore_matching_follows_platform_case_rules() {
let set = IgnoreSet::compile(&["node_modules".to_string()]).unwrap();
assert!(
set.matches_component("node_modules"),
"exact always matches"
);
let folded = cfg!(any(windows, target_os = "macos"));
assert_eq!(
set.matches_component("Node_Modules"),
folded,
"case folding must track the platform's filesystem semantics"
);
}
/// Which patterns take the fast path, and that taking it changes nothing
/// observable. A plain name is matched whole — never as a prefix, a
/// substring or a wildcard — and only its case is allowed to vary.
#[test]
fn the_literal_fast_path_matches_whole_names_only() {
let literal = IgnoreSet::compile(&["node_modules".to_string()]).unwrap();
assert!(
!literal.literal_components.is_empty(),
"a plain name belongs on the fast path"
);
for globby in ["node_module?", "node_*", "*.tmp", "a[bc]d", "x{1,2}"] {
assert!(
IgnoreSet::compile(&[globby.to_string()])
.unwrap()
.literal_components
.is_empty(),
"{} has glob syntax and must stay with globset",
globby
);
}
assert!(literal.matches_component("node_modules"));
for cased in ["Node_Modules", "NODE_MODULES", "node_moduleS"] {
assert_eq!(
literal.matches_component(cased),
cfg!(any(windows, target_os = "macos")),
"only case may vary, and only where the filesystem says so: {}",
cased
);
}
for name in [
"node_modules_",
"_node_modules",
"nodemodules",
"node_module",
"src",
"",
] {
assert!(
!literal.matches_component(name),
"{} is not the ignored name",
name
);
}
}
/// A non-ASCII pattern keeps globset's Unicode folding rather than being
/// silently downgraded to the ASCII fast path.
#[test]
fn non_ascii_patterns_stay_on_the_glob_path() {
let set = IgnoreSet::compile(&["café".to_string()]).unwrap();
assert!(
set.literal_components.is_empty(),
"a non-ASCII name must not join the ASCII-folded set"
);
assert!(set.matches_component("café"));
assert!(!set.matches_component("cafe"));
}
/// Names longer than the stack fold buffer take the heap path, and must
/// come back with the same answer.
#[test]
fn overlong_names_still_fold_correctly() {
let long = "a".repeat(FOLD_BUF + 10);
let set = IgnoreSet::compile(std::slice::from_ref(&long)).unwrap();
assert!(set.matches_component(&long));
assert_eq!(
set.matches_component(&long.to_uppercase()),
cfg!(any(windows, target_os = "macos"))
);
assert!(!set.matches_component(&"a".repeat(FOLD_BUF + 9)));
}
/// Watcher events are matched by whole path, and the literal patterns have
/// to be visible on that route too — otherwise the watcher indexes exactly
/// what the walker prunes and the index churns every cycle.
#[test]
fn full_path_matching_sees_literal_component_patterns() {
let set = IgnoreSet::compile(&["node_modules".to_string()]).unwrap();
assert!(set.matches_path(Path::new("/home/me/proj/node_modules/pkg/index.js")));
assert!(!set.matches_path(Path::new("/home/me/proj/src/index.js")));
}
#[test]
fn default_ignore_patterns_cover_the_platform() {
let d = IndexingConfig::default().ignore_patterns;
for shared in [".git", "node_modules", "*.tmp", ".venv", "venv"] {
assert!(d.iter().any(|p| p == shared), "missing {}", shared);
}
// `$RECYCLE.BIN` holds deleted files; indexing it would surface their
// contents in search results.
let recycle = d.iter().any(|p| p == "$RECYCLE.BIN");
assert_eq!(recycle, cfg!(windows), "Windows-only exclusions");
// Whatever the platform, the defaults must actually compile — a
// pattern like `$RECYCLE.BIN` going through globset is the risk.
let set = IgnoreSet::compile(&d).expect("default patterns compile");
assert!(!set.is_empty());
if cfg!(windows) {
assert!(
set.matches_component("$RECYCLE.BIN"),
"`$` must be matched literally, not as a metacharacter"
);
}
}
#[test]
fn nested_roots_matrix() {
// Straight nesting (paths don't exist → compared as spelled).
assert_eq!(
nested_roots(&["/qs-x/b".into(), "/qs-x/b/c".into()]),
vec![("/qs-x/b/c".to_string(), "/qs-x/b".to_string())]
);
// Component boundary: /a/bc is NOT under /a/b.
assert!(nested_roots(&["/qs-x/b".into(), "/qs-x/bc".into()]).is_empty());
// Disjoint roots.
assert!(nested_roots(&["/qs-x/b".into(), "/qs-x/c".into()]).is_empty());
// Exact duplicates flag once.
assert_eq!(nested_roots(&["/qs-x".into(), "/qs-x".into()]).len(), 1);
// Empty and singleton lists are fine.
assert!(nested_roots(&[]).is_empty());
assert!(nested_roots(&["/qs-x".into()]).is_empty());
// Symlinked spellings of the same real directory are caught via
// canonicalization.
#[cfg(unix)]
{
let dir = tmp_dir();
let real = dir.join("real");
fs::create_dir_all(&real).unwrap();
let link = dir.join("alias");
std::os::unix::fs::symlink(&real, &link).unwrap();
let pairs = nested_roots(&[
real.to_string_lossy().into_owned(),
link.to_string_lossy().into_owned(),
]);
assert_eq!(pairs.len(), 1, "alias of the same dir counts as duplicate");
fs::remove_dir_all(&dir).ok();
}
}
#[test]
fn removed_precount_key_still_parses() {
let dir = tmp_dir();
let path = dir.join("config.toml");
fs::write(
&path,
"[processing]\nprecount_files_for_progress = true\nbatch_size = 42\n",
)
.unwrap();
let cfg = Config::load_from(&path).unwrap();
assert_eq!(cfg.processing.batch_size, 42, "known keys still load");
fs::remove_dir_all(&dir).ok();
}
#[test]
fn root_workers_round_trip() {
let dir = tmp_dir();
let path = dir.join("config.toml");
let mut cfg = Config {
source: Some(path.clone()),
..Config::default()
};
cfg.paths.indexing_paths = vec!["/data".into(), "/share".into()];
cfg.indexing.root_workers.insert("/share".into(), 24);
cfg.save().unwrap();
let loaded = Config::load_from(&path).unwrap();
assert_eq!(loaded.indexing.root_workers.get("/share"), Some(&24));
assert_eq!(
loaded.indexing.root_workers.get("/data"),
None,
"absent = auto"
);
fs::remove_dir_all(&dir).ok();
}
/// Only three settings may wipe the index: the FTS tokenizer, the hash
/// length and the encryption key. Anything else that reaches
/// `requires_rebuild` is a bug — it costs the user everything the index
/// took hours to learn.
#[test]
fn only_unreadable_data_forces_a_rebuild() {
let base = Config::default();
let mut tokenizer = base.clone();
tokenizer.processing.tokenize = "unicode61".into();
let mut hash = base.clone();
hash.processing.hash_length = base.processing.hash_length + 1;
let mut protect = base.clone();
protect.security.password_protected = true;
let mut salt = base.clone();
salt.security.salt = Some("00".repeat(16));
for c in [&tokenizer, &hash, &protect, &salt] {
let a = diff_actions(&base, c);
assert!(a.requires_rebuild, "must wipe");
assert!(
a.work.is_empty(),
"a wipe subsumes reconciliation; leaving work behind would run it \
against a file that is about to be deleted"
);
}
// The keychain only decides where the key is remembered, not what the
// file was written with.
let mut keychain = base.clone();
keychain.security.use_keychain = true;
assert_eq!(diff_actions(&base, &keychain), ConfigActions::default());
}
/// Narrowing deletes; widening walks. Nothing here may wipe.
#[test]
fn diff_actions_matrix() {
let dir = tmp_dir();
let kept = dir.join("kept");
let dropped = dir.join("dropped");
fs::create_dir_all(&kept).unwrap();
fs::create_dir_all(&dropped).unwrap();
let (kept, dropped) = (
kept.to_string_lossy().into_owned(),
dropped.to_string_lossy().into_owned(),
);
let mut base = Config::default();
base.paths.indexing_paths = vec![kept.clone(), dropped.clone()];
base.indexing.ignore_patterns = vec!["node_modules".into()];
base.indexing.include_hidden = true;
base.indexing.follow_symlinks = true;
assert_eq!(diff_actions(&base, &base.clone()), ConfigActions::default());
// Removing a root: its rows are deleted by range, and no walk is
// needed to establish that they should go.
let mut c = base.clone();
c.paths.indexing_paths = vec![kept.clone()];
let a = diff_actions(&base, &c);
assert!(!a.requires_rebuild);
assert_eq!(a.work.drop_roots, vec![dropped.clone()]);
assert!(!a.work.reindex && !a.work.prune_scope);
// Adding one: nothing stored is wrong, there is just more to find.
let a = diff_actions(&c, &base);
assert!(!a.requires_rebuild);
assert!(a.work.drop_roots.is_empty() && a.work.reindex && !a.work.prune_scope);
for (narrow, widen, what) in [
(
{
let mut c = base.clone();
c.indexing.ignore_patterns.push("*.log".into());
c
},
{
let mut c = base.clone();
c.indexing.ignore_patterns.clear();
c
},
"ignore patterns",
),
(
{
let mut c = base.clone();
c.indexing.include_hidden = false;
c
},
base.clone(),
"hidden files",
),
] {
let a = diff_actions(&base, &narrow);
assert!(!a.requires_rebuild, "{} must not wipe", what);
assert!(
a.work.prune_scope && !a.work.reindex,
"narrowing {} prunes and needs no walk",
what
);
let a = diff_actions(&narrow, &widen);
assert!(!a.requires_rebuild, "{} must not wipe", what);
assert!(
a.work.reindex && !a.work.prune_scope,
"widening {} walks and deletes nothing",
what
);
}
// Symlinks take their own route: with links on, a target inside a root
// is stored under exactly the path a direct walk would produce, so
// nothing in scope changes. What turning them off strands is the rows
// *outside* every root, which no per-root scan would ever revisit.
let mut no_links = base.clone();
no_links.indexing.follow_symlinks = false;
let a = diff_actions(&base, &no_links);
assert!(!a.requires_rebuild);
assert!(
a.work.drop_aliases && !a.work.prune_scope && !a.work.reindex,
"turning links off sweeps outside the roots and nothing else"
);
let a = diff_actions(&no_links, &base);
assert!(
a.work.reindex && !a.work.drop_aliases && !a.work.prune_scope,
"turning links on only adds"
);
// Stored text: turning it on means re-extracting, turning it off means
// throwing the blobs away — never a rebuild either way.
let mut off = base.clone();
off.processing.store_text_for_snippets = false;
let mut on = base.clone();
on.processing.store_text_for_snippets = true;
let a = diff_actions(&on, &off);
assert!(a.work.drop_text && !a.work.restore_text && !a.work.reindex);
let a = diff_actions(&off, &on);
assert!(a.work.restore_text && !a.work.drop_text && a.work.reindex);
let mut c = base.clone();
c.paths.database_path = "/elsewhere.sqlite".into();
let a = diff_actions(&base, &c);
assert!(a.search_db_changed && !a.requires_rebuild && a.work.is_empty());
let mut c = base.clone();
c.search.display_limit = 5000;
c.processing.batch_size = 999;
c.processing.maximum_wal_size = 0;
c.indexing.auto_index = false;
c.indexing.reindex_interval_minutes = 5;
assert_eq!(
diff_actions(&base, &c),
ConfigActions::default(),
"soft knobs are not index work"
);
fs::remove_dir_all(&dir).ok();
}
/// A second edit landing while the first is still being applied must not
/// lose the first's work: the two plans are computed against different
/// configurations, so neither knows what the other left undone.
#[test]
fn merging_two_plans_loses_nothing() {
let first = IndexWork {
drop_roots: vec!["/gone".into(), "/shared".into()],
prune_scope: true,
drop_text: true,
..IndexWork::default()
};
let second = IndexWork {
drop_roots: vec!["/shared".into(), "/also-gone".into()],
reconcile_content: true,
reindex: true,
..IndexWork::default()
};
let mut merged = second.clone();
merged.merge_from(&first);
assert_eq!(
merged.drop_roots,
vec!["/shared", "/also-gone", "/gone"],
"every root from both, each once"
);
assert!(merged.prune_scope && merged.drop_text);
assert!(merged.reconcile_content && merged.reindex);
// Merging an empty plan changes nothing, and merging a plan into
// itself is the identity — both are what make a restart safe.
let mut untouched = first.clone();
untouched.merge_from(&IndexWork::default());
assert_eq!(untouched, first);
untouched.merge_from(&first);
assert_eq!(untouched, first);
}
/// Order and spelling are not configuration. Reordering the folder list,
/// reordering the ignore patterns, or writing a root a different way used
/// to wipe a multi-million-file index for nothing.
#[test]
fn respelling_a_list_is_not_a_change() {
let dir = tmp_dir();
let a_dir = dir.join("alpha");
let b_dir = dir.join("beta");
fs::create_dir_all(&a_dir).unwrap();
fs::create_dir_all(&b_dir).unwrap();
let mut base = Config::default();
base.paths.indexing_paths = vec![
a_dir.to_string_lossy().into_owned(),
b_dir.to_string_lossy().into_owned(),
];
base.indexing.ignore_patterns = vec!["node_modules".into(), "*.tmp".into()];
let mut reordered = base.clone();
reordered.paths.indexing_paths.reverse();
reordered.indexing.ignore_patterns.reverse();
assert_eq!(diff_actions(&base, &reordered), ConfigActions::default());
// A trailing separator, a `.` hop and a duplicate entry all name the
// same two roots.
let mut respelled = base.clone();
respelled.paths.indexing_paths = vec![
format!("{}{}", a_dir.to_string_lossy(), std::path::MAIN_SEPARATOR),
b_dir.join(".").to_string_lossy().into_owned(),
a_dir.to_string_lossy().into_owned(),
];
assert_eq!(diff_actions(&base, &respelled), ConfigActions::default());
// Whitespace around an ignore pattern is trimmed before it compiles,
// so it cannot be a change either.
let mut padded = base.clone();
padded.indexing.ignore_patterns = vec![" node_modules ".into(), "*.tmp".into()];
assert_eq!(diff_actions(&base, &padded), ConfigActions::default());
fs::remove_dir_all(&dir).ok();
}
#[test]
fn security_config_round_trips_and_salt_is_omitted_when_none() {
let dir = tmp_dir();
let path = dir.join("config.toml");
// Defaults: protection off, no salt — and crucially the file must
// not contain an invented salt value.
let cfg = Config::load_from(&path).unwrap();
assert!(!cfg.security.password_protected);
assert_eq!(cfg.security.salt, None);
let text = fs::read_to_string(&path).unwrap();
assert!(!text.contains("salt"), "no default salt may be written");
// With a salt set, it round-trips exactly.
let mut cfg = cfg;
cfg.security.password_protected = true;
cfg.security.salt = Some("0f1e2d3c4b5a69788796a5b4c3d2e1f0".to_string());
cfg.security.use_keychain = true;
cfg.save().unwrap();
let reloaded = Config::load_from(&path).unwrap();
assert_eq!(reloaded.security, cfg.security);
fs::remove_dir_all(&dir).ok();
}
#[test]
fn absent_security_section_is_default() {
let dir = tmp_dir();
let path = dir.join("config.toml");
fs::write(&path, "[paths]\ndatabase_path = \"x.sqlite\"\n").unwrap();
let cfg = Config::load_from(&path).unwrap();
assert_eq!(cfg.security, SecurityConfig::default());
fs::remove_dir_all(&dir).ok();
}
#[test]
fn salt_bytes_validates_hostile_configs() {
// Protected but no salt: hard error, nothing invented.
let mut sec = SecurityConfig {
password_protected: true,
salt: None,
use_keychain: false,
};
assert!(sec.salt_bytes().is_err());
// Hand-crafted hostile values: truncated, oversized, non-hex,
// embedded whitespace/quotes. All rejected.
for bad in [
"",
"abcd",
&"ab".repeat(17),
&"ab".repeat(4096),
"0g1e2d3c4b5a69788796a5b4c3d2e1f0",
"0f1e2d3c4b5a6978 796a5b4c3d2e1f0",
"0f1e2d3c4b5a69788796a5b4c3d2e1f'",
] {
sec.salt = Some(bad.to_string());
assert!(sec.salt_bytes().is_err(), "must reject salt {:?}", bad);
}
// A valid salt decodes, upper- or lowercase.
sec.salt = Some("0F1E2D3C4B5A69788796A5B4C3D2E1F0".to_string());
assert!(sec.salt_bytes().is_ok());
}
/// Dismissing the watch-cap warning must not trigger a rebuild or a
/// watcher restart — it is pure UI bookkeeping, and restarting the
/// watcher would re-trip the very warning being dismissed.
#[test]
fn watch_cap_warned_roots_is_a_soft_knob() {
let base = Config::default();
let mut c = base.clone();
c.ui.watch_cap_warned_roots = vec!["/media/ApolloStore".to_string()];
assert_eq!(diff_actions(&base, &c), ConfigActions::default());
}
#[test]
fn watch_cap_warned_roots_round_trips() {
let dir = tmp_dir();
let path = dir.join("config.toml");
let mut cfg = Config {
source: Some(path.clone()),
..Config::default()
};
cfg.ui.watch_cap_warned_roots =
vec!["/media/ApolloStore".to_string(), "/media/GSSD".to_string()];
cfg.save().unwrap();
let loaded = Config::load_from(&path).unwrap();
assert_eq!(
loaded.ui.watch_cap_warned_roots,
vec!["/media/ApolloStore".to_string(), "/media/GSSD".to_string()]
);
fs::remove_dir_all(&dir).ok();
}
/// Configs written before this field existed must still load.
#[test]
fn config_without_watch_cap_warned_roots_parses() {
let dir = tmp_dir();
let path = dir.join("config.toml");
fs::write(
&path,
"[paths]\nindexing_paths=[\"/x\"]\ndatabase_path=\"db.sqlite\"\n[ui]\nscale=1.25\n",
)
.unwrap();
let cfg = Config::load_from(&path).unwrap();
assert!(cfg.ui.watch_cap_warned_roots.is_empty());
assert_eq!(cfg.ui.scale, 1.25, "existing ui keys still parse");
fs::remove_dir_all(&dir).ok();
}
#[test]
fn fuzzy_max_edits_round_trips() {
let dir = tmp_dir();
let path = dir.join("config.toml");
let mut cfg = Config {
source: Some(path.clone()),
..Config::default()
};
cfg.search.fuzzy_max_edits = 4;
cfg.save().unwrap();
let loaded = Config::load_from(&path).unwrap();
assert_eq!(loaded.search.fuzzy_max_edits, 4);
fs::remove_dir_all(&dir).ok();
}
/// Configs written before this field existed keep the historic budget.
#[test]
fn config_without_fuzzy_max_edits_defaults_to_two() {
let dir = tmp_dir();
let path = dir.join("config.toml");
fs::write(
&path,
"[paths]\nindexing_paths=[\"/x\"]\ndatabase_path=\"db.sqlite\"\n\
[search]\nfuzzy_default=true\ndisplay_limit=250\n",
)
.unwrap();
let cfg = Config::load_from(&path).unwrap();
assert_eq!(cfg.search.fuzzy_max_edits, 2);
assert!(cfg.search.fuzzy_default, "existing search keys still parse");
assert_eq!(cfg.search.display_limit, 250);
fs::remove_dir_all(&dir).ok();
}
#[test]
fn fuzzy_edits_warning_only_above_the_threshold() {
let mut cfg = SearchConfig::default();
for quiet in 0..=FUZZY_EDITS_WARN_ABOVE {
cfg.fuzzy_max_edits = quiet;
assert!(
cfg.fuzzy_edits_warning().is_none(),
"{} should be quiet",
quiet
);
}
for loud in [FUZZY_EDITS_WARN_ABOVE + 1, 8, usize::MAX] {
cfg.fuzzy_max_edits = loud;
let msg = cfg
.fuzzy_edits_warning()
.expect("warns above the threshold");
assert!(msg.contains(&loud.to_string()));
assert!(msg.contains(&FUZZY_EDITS_WARN_ABOVE.to_string()));
}
}
}