277 lines
12 KiB
Rust
277 lines
12 KiB
Rust
//! 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,
|
|
}
|
|
}
|