quick_search/crates/quicksearch-core/src/config/diff.rs

278 lines
12 KiB
Rust
Raw Normal View History

//! Config diffing: [`diff_actions`] turns two [`Config`]s into the
//! [`IndexWork`] reconciliation plan and its restart/rebuild flags.
use std::collections::BTreeSet;
use super::*;
/// 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 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 union is the
/// only thing that is certainly enough, and every part is idempotent.
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.
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() {
let duplicate = child == parent && i > j;
let properly_nested = child != parent && child.starts_with(parent);
if duplicate || properly_nested {
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,
}
}