//! 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)] 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, } #[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, /// 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, /// 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, /// 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, } #[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 { 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: 24 * 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, /// 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, } impl Default for UiConfig { fn default() -> Self { UiConfig { scale: 1.1, watch_cap_warned_roots: Vec::new(), } } } impl Default for Config { fn default() -> Self { Config { paths: PathConfig::default(), indexing: IndexingConfig::default(), processing: ProcessingConfig::default(), search: SearchConfig::default(), ui: UiConfig::default(), security: SecurityConfig::default(), source: None, } } } /// 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 { 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 { 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::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 { 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 mut cfg = Config::default(); cfg.source = Some(path.to_path_buf()); 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 { 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 /// [`resolved_database_path`]. pub fn resolved_indexing_paths(&self) -> Vec { 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 { 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)) } } } /// 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: globset::GlobSet, path: globset::GlobSet, empty: bool, } impl IgnoreSet { pub fn compile(patterns: &[String]) -> Result { 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; } 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(cfg!(any(windows, target_os = "macos"))) .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 = component.is_empty() && path.is_empty(); Ok(IgnoreSet { component, path, empty, }) } /// Match a single file/directory name. Used by the walker to prune /// subtrees before descending. pub fn matches_component(&self, name: &str) -> bool { !self.empty && 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| { matches!(c, std::path::Component::Normal(name) if self.component.is_match(Path::new(name))) }) } 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, /// 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 } } /// 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) and is component-boundary /// aware — `/a/bc` is not under `/a/b`. pub fn nested_roots(roots: &[String]) -> Vec<(String, String)> { let resolved: Vec = 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 { 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, new: &BTreeSet) -> 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 { let mut p = std::env::temp_dir(); p.push(format!( "quicksearch-config-{}-{}", std::process::id(), std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_nanos() )); fs::create_dir_all(&p).unwrap(); p } #[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" ); } #[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::default(); cfg.source = Some(path.clone()); 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::default(); cfg.source = Some(path.clone()); 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::default(); cfg.source = Some(path.clone()); 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())); } } }