From 8165119f29c87d59485d5091bc743d44727eb36e Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Tue, 16 Jun 2026 18:53:51 -0500 Subject: [PATCH] search feature and pre-install script --- Cargo.lock | 6 + crates/librarian-app/Cargo.toml | 4 + crates/librarian-app/src/main.rs | 387 +++++++++++++++++++++--- crates/librarian-app/src/rows.rs | 59 ++++ crates/librarian-app/src/search.rs | 430 +++++++++++++++++++++++++++ crates/librarian-core/src/lib.rs | 2 + crates/librarian-core/src/matcher.rs | 319 ++++++++++++++++++++ scripts/README.md | 29 ++ scripts/install-ripgrep.ps1 | 199 +++++++++++++ 9 files changed, 1397 insertions(+), 38 deletions(-) create mode 100644 crates/librarian-app/src/search.rs create mode 100644 crates/librarian-core/src/matcher.rs create mode 100644 scripts/README.md create mode 100644 scripts/install-ripgrep.ps1 diff --git a/Cargo.lock b/Cargo.lock index 7d90fbe..33cb842 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1934,6 +1934,7 @@ dependencies = [ "librarian-core", "librarian-win", "notify-debouncer-mini", + "tokio", "winresource", ] @@ -3774,7 +3775,12 @@ version = "1.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ + "bytes", + "libc", + "mio", "pin-project-lite", + "signal-hook-registry", + "windows-sys 0.61.2", ] [[package]] diff --git a/crates/librarian-app/Cargo.toml b/crates/librarian-app/Cargo.toml index 2de0661..745a89c 100644 --- a/crates/librarian-app/Cargo.toml +++ b/crates/librarian-app/Cargo.toml @@ -15,6 +15,10 @@ librarian-win.workspace = true iced.workspace = true chrono = "0.4" notify-debouncer-mini = "0.7.0" +# Async child-process I/O for streaming ripgrep search results. The runtime +# itself is provided by iced's "tokio" feature; we only need process spawning +# and async buffered reads here. +tokio = { version = "1", features = ["process", "io-util", "rt", "time"] } # Embeds the application icon as a Win32 resource (so the .exe shows it in # Explorer / on shortcuts). Windows-only; see build.rs. diff --git a/crates/librarian-app/src/main.rs b/crates/librarian-app/src/main.rs index dc573a5..b9a85e6 100644 --- a/crates/librarian-app/src/main.rs +++ b/crates/librarian-app/src/main.rs @@ -5,6 +5,7 @@ mod config; mod ellipsis; mod icons; mod rows; +mod search; mod selection; mod thumbs; mod tree; @@ -32,6 +33,7 @@ use librarian_win::{ use ellipsis::ellipsized; use icons::{IconCache, IconKey, extract_icons}; use rows::{Row, format_time, human_size}; +use search::{SearchEvent, SearchHit, SearchMode, SearchSpec}; use selection::Selection; use thumbs::{CacheSweep, ThumbCache, ThumbKey, extract_cached, extract_full}; use tree::{Reveal, Tree, TreeChild, TreeRow}; @@ -99,6 +101,9 @@ const MAX_BACKGROUND_PRECACHE: usize = 4000; const SPINNER_FRAMES: [&str; 4] = ["◐", "◓", "◑", "◒"]; /// How often the loading spinner advances a frame. const SPINNER_TICK: Duration = Duration::from_millis(120); +/// Idle time after the last search keystroke before the live search runs, so we +/// don't spawn a ripgrep process for every intermediate character. +const SEARCH_DEBOUNCE: Duration = Duration::from_millis(200); fn main() -> iced::Result { let start = startup_location(); @@ -243,6 +248,18 @@ enum Content { entries: Vec, loading: bool, }, + /// Recursive search results rooted at `root`, streamed in from ripgrep. The + /// browsed folder (in `history`) is unchanged underneath, so leaving the + /// search (clearing it, or navigating) restores the normal listing. + Search { + root: PathBuf, + query: String, + mode: SearchMode, + hits: Vec, + /// True once ripgrep has finished (so an empty `hits` means "no results" + /// rather than "still searching"). + done: bool, + }, Error(String), } @@ -272,7 +289,6 @@ struct Librarian { /// Details list vs. one of the icon-grid sizes. view_mode: ViewMode, show_hidden: bool, - filter: String, address: String, selection: Selection, icons: IconCache, @@ -327,6 +343,23 @@ struct Librarian { /// the current size while the user stays in the folder. Drained in chunks; a /// new session releases and replaces it. bg_queue: Vec, + // --- search -------------------------------------------------------------- + /// Current text in the search box (per tab). + search_query: String, + /// Whether the search box matches names or contents (per tab). + search_mode: SearchMode, + /// The running search, if any. Set as the search subscription's identity, so + /// assigning a new spec supersedes the previous one (killing its `rg` child); + /// cleared when the search finishes or is dismissed. Not parked across tab + /// switches — leaving a tab abandons its in-flight search. + search_active: Option, + /// Monotonic id for searches, stamped onto each spec so streamed results from + /// a superseded search are recognized as stale and dropped. + search_token: u64, + /// Monotonic id for search *input*, bumped on each keystroke (and on submit / + /// tab switch). A debounced live-search check only runs if it still matches, + /// so intermediate keystrokes don't each launch a search. + search_seq: u64, // --- tabs ---------------------------------------------------------------- /// All open tabs. The entry at `active` is `None` — that tab's data lives in /// the flat fields above; every other entry parks its state in `Some(..)`. @@ -346,7 +379,6 @@ struct TabState { content: Content, rows: Vec, selection: Selection, - filter: String, address: String, status: String, scroll_y: f32, @@ -354,6 +386,8 @@ struct TabState { renaming: Option, pending_rename: Option<(PathBuf, String)>, load_token: u64, + search_query: String, + search_mode: SearchMode, } /// Which lane a finished full-extraction belongs to, so its completion is @@ -374,9 +408,35 @@ enum Message { Refresh, AddressChanged(String), AddressSubmit, - FilterChanged(String), SetHidden(bool), SortBy(SortKey), + // --- search --- + /// The search box text changed; schedules a debounced live search. + SearchChanged(String), + /// A debounced live-search check fired, tagged with the input sequence it was + /// scheduled for (so superseded keystrokes are ignored). + SearchDebounced(u64), + /// Run a recursive search for the current box text in the current folder. + SearchSubmit, + /// Switch between name and contents matching. + SearchModeChanged(SearchMode), + /// Dismiss search results and restore the folder listing. + SearchClear, + /// A streamed batch of results from search `token`. + SearchBatch { + token: u64, + hits: Vec, + }, + /// Search `token` finished (`capped` if it hit the result cap). + SearchFinished { + token: u64, + capped: bool, + }, + /// Search `token` could not run. + SearchFailed { + token: u64, + error: String, + }, RowClicked(usize), RowRightClicked(usize), BackgroundRightClicked, @@ -472,7 +532,6 @@ impl Librarian { sort: settings.sort, view_mode: settings.view_mode, show_hidden: settings.show_hidden, - filter: String::new(), address: "This PC".to_string(), selection: Selection::default(), icons: IconCache::default(), @@ -494,6 +553,11 @@ impl Librarian { overlay_loading: false, spinner_frame: 0, bg_queue: Vec::new(), + search_query: String::new(), + search_mode: SearchMode::default(), + search_active: None, + search_token: 0, + search_seq: 0, // Start with a single tab that owns the flat state above. tabs: vec![None], active: 0, @@ -535,12 +599,19 @@ impl Librarian { /// leaving the flat fields as cheap placeholders (about to be overwritten by /// the incoming tab). Moves, never clones, so big row/entry vecs stay cheap. fn snapshot_flat(&mut self) -> TabState { + // Abandon any in-flight search: dropping its spec stops the subscription + // (and kills rg). Mark the parked results "done" so returning to the tab + // shows the partial results statically instead of looking unfinished. + if self.search_active.take().is_some() + && let Content::Search { done, .. } = &mut self.content + { + *done = true; + } TabState { history: std::mem::replace(&mut self.history, History::new(Location::ThisPc)), content: std::mem::replace(&mut self.content, Content::ThisPc { drives: Vec::new() }), rows: std::mem::take(&mut self.rows), selection: std::mem::take(&mut self.selection), - filter: std::mem::take(&mut self.filter), address: std::mem::take(&mut self.address), status: std::mem::take(&mut self.status), scroll_y: self.scroll_y, @@ -548,6 +619,8 @@ impl Librarian { renaming: self.renaming.take(), pending_rename: self.pending_rename.take(), load_token: self.load_token, + search_query: std::mem::take(&mut self.search_query), + search_mode: self.search_mode, } } @@ -557,7 +630,6 @@ impl Librarian { self.content = state.content; self.rows = state.rows; self.selection = state.selection; - self.filter = state.filter; self.address = state.address; self.status = state.status; self.scroll_y = state.scroll_y; @@ -565,6 +637,13 @@ impl Librarian { self.renaming = state.renaming; self.pending_rename = state.pending_rename; self.load_token = state.load_token; + self.search_query = state.search_query; + self.search_mode = state.search_mode; + // The incoming tab carries no live search (in-flight ones were abandoned + // when it was parked); any results it has are already in `content`. + self.search_active = None; + // Invalidate any debounce scheduled by the tab we just left. + self.search_seq = self.search_seq.wrapping_add(1); } /// Reset the flat fields to a brand-new, empty tab at `location` (its content @@ -575,7 +654,6 @@ impl Librarian { self.content = Content::ThisPc { drives: Vec::new() }; self.rows.clear(); self.selection = Selection::default(); - self.filter.clear(); self.status.clear(); self.scroll_y = 0.0; self.last_click = None; @@ -583,6 +661,10 @@ impl Librarian { self.pending_rename = None; self.load_token = 0; self.menu = None; + self.search_query.clear(); + self.search_mode = SearchMode::default(); + self.search_active = None; + self.search_seq = self.search_seq.wrapping_add(1); } /// Bring the just-restored active tab on screen: finish a load that was @@ -704,7 +786,15 @@ impl Librarian { Subscription::none() }; - Subscription::batch([events, watch, spinner]) + // The active search, keyed by its full spec: starting/changing a search + // (new token) tears down the old stream — killing its rg child — and + // starts a fresh one. + let search = match &self.search_active { + Some(spec) => Subscription::run_with(spec.clone(), search_stream), + None => Subscription::none(), + }; + + Subscription::batch([events, watch, spinner, search]) } fn update(&mut self, message: Message) -> Task { @@ -737,11 +827,6 @@ impl Librarian { Message::AddressSubmit => { return self.navigate(Location::parse(&self.address)); } - Message::FilterChanged(value) => { - self.filter = value; - self.recompute_rows(); - return self.prefetch_thumbs(false); - } Message::SetHidden(value) => { self.show_hidden = value; self.recompute_rows(); @@ -756,6 +841,88 @@ impl Librarian { config::save(&self.settings()); return self.prefetch_thumbs(false); } + Message::SearchChanged(value) => { + // Search live as the user types or pastes, but debounce: schedule + // a check tagged with the latest input sequence, and only the + // check that's still current (no newer keystroke) actually runs. + self.search_query = value; + self.search_seq = self.search_seq.wrapping_add(1); + let seq = self.search_seq; + return Task::perform( + async { tokio::time::sleep(SEARCH_DEBOUNCE).await }, + move |_| Message::SearchDebounced(seq), + ); + } + Message::SearchDebounced(seq) => { + // Ignore if a newer keystroke (or an Enter/tab switch) arrived. + if seq == self.search_seq { + return self.run_search_if_changed(); + } + } + Message::SearchModeChanged(mode) => { + self.search_mode = mode; + // Re-run immediately if results are already showing, so toggling + // the mode updates them without a second Enter. + if matches!(self.content, Content::Search { .. }) { + return self.run_search_if_changed(); + } + } + Message::SearchSubmit => { + // Enter is the explicit fallback: cancel any pending debounce and + // run now — but `run_search_if_changed` makes it a no-op if the + // query+mode already match what's shown. + self.search_seq = self.search_seq.wrapping_add(1); + return self.run_search_if_changed(); + } + Message::SearchClear => return self.clear_search(), + Message::SearchBatch { token, hits } => { + if token != self.search_token { + return Task::none(); // a newer search superseded this one + } + if let Content::Search { + hits: existing, + done, + .. + } = &mut self.content + { + existing.extend(hits); + let found = existing.len(); + *done = false; + self.recompute_rows(); + self.status = format!("Searching… {found} found"); + return Task::batch([self.request_icons(), self.prefetch_thumbs(false)]); + } + } + Message::SearchFinished { token, capped } => { + if token != self.search_token { + return Task::none(); + } + // The search is over: drop the subscription so it doesn't restart. + self.search_active = None; + if let Content::Search { hits, done, .. } = &mut self.content { + *done = true; + let found = hits.len(); + self.status = match (found, capped) { + (0, _) => "No results".to_string(), + (n, true) => { + format!("{n} results (showing the first {n}; refine to narrow)") + } + (n, false) => format!("{n} result{}", plural(n)), + }; + // Warm thumbnails for the full result set now that it's settled. + return Task::batch([self.request_icons(), self.seed_background()]); + } + } + Message::SearchFailed { token, error } => { + if token != self.search_token { + return Task::none(); + } + self.search_active = None; + if let Content::Search { done, .. } = &mut self.content { + *done = true; + } + self.status = error; + } Message::RowClicked(index) => return self.on_click(index), Message::ThisPcLoaded(drives) => { self.content = Content::ThisPc { drives }; @@ -1142,11 +1309,91 @@ impl Librarian { self.load_current() } + // --- search --------------------------------------------------------------- + + /// Launch a search for the current box text only if it differs from what's + /// already shown — the shared entry point for live (debounced), submit, and + /// mode-change triggers. An empty box leaves search; an unchanged query+mode + /// is a no-op (so an Enter that matches the current results does nothing). + fn run_search_if_changed(&mut self) -> Task { + let query = self.search_query.trim().to_string(); + if query.is_empty() { + return if matches!(self.content, Content::Search { .. }) { + self.clear_search() + } else { + Task::none() + }; + } + let unchanged = matches!( + &self.content, + Content::Search { query: shown, mode, .. } + if shown == &query && *mode == self.search_mode + ); + if unchanged { + return Task::none(); + } + self.begin_search() + } + + /// Start a recursive ripgrep search for the current box text, rooted at the + /// current folder. Switches `content` to [`Content::Search`] (the browsed + /// location underneath is untouched, so leaving search restores it) and arms + /// the search subscription. A no-op at the "This PC" root; a blank query just + /// clears any active search. + fn begin_search(&mut self) -> Task { + let query = self.search_query.trim().to_string(); + let Some(root) = self.current_dir() else { + // Search needs a real folder root, not the "This PC" landing page. + return Task::none(); + }; + if query.is_empty() { + return self.clear_search(); + } + self.menu = None; + self.selection.clear(); + self.last_click = None; + self.search_token = self.search_token.wrapping_add(1); + self.search_active = Some(SearchSpec { + token: self.search_token, + root: root.clone(), + query: query.clone(), + mode: self.search_mode, + }); + self.content = Content::Search { + root, + query, + mode: self.search_mode, + hits: Vec::new(), + done: false, + }; + self.recompute_rows(); + self.status = "Searching…".to_string(); + // Open a fresh (empty) thumbnail session and snap to the top; results + // populate it as batches stream in. + Task::batch([self.scroll_to(0.0), self.begin_grid_session(false)]) + } + + /// Dismiss search results and restore the folder listing, stopping any + /// running search. + fn clear_search(&mut self) -> Task { + self.search_active = None; + self.search_query.clear(); + if matches!(self.content, Content::Search { .. }) { + // Reloading the current location rebuilds the normal listing. + return self.load_current(); + } + Task::none() + } + /// (Re)load whatever the history currently points at. fn load_current(&mut self) -> Task { self.selection.clear(); self.last_click = None; - self.filter.clear(); + // Navigating leaves any search behind (and stops it, if running), and + // invalidates any debounce a last-moment keystroke may have scheduled. + self.search_active = None; + self.search_query.clear(); + self.search_seq = self.search_seq.wrapping_add(1); let location = self.history.current().clone(); self.address = address_text(&location); @@ -1449,29 +1696,37 @@ impl Librarian { } } - /// Rebuild `rows` from `content`, applying the current filter and sort. + /// Rebuild `rows` from `content`, applying the current sort. fn recompute_rows(&mut self) { self.rows = match &self.content { - Content::ThisPc { drives } => { - let mut rows: Vec = drives.iter().map(rows::row_from_drive).collect(); - if !self.filter.is_empty() { - let needle = self.filter.to_lowercase(); - rows.retain(|r| r.label.to_lowercase().contains(&needle)); - } - rows - } + Content::ThisPc { drives } => drives.iter().map(rows::row_from_drive).collect(), Content::Folder { entries, .. } => { let mut visible: Vec = entries .iter() - .filter(|e| is_visible(e, self.show_hidden, &self.filter)) + .filter(|e| is_visible(e, self.show_hidden, "")) .cloned() .collect(); sort_entries(&mut visible, &self.sort); visible.iter().map(rows::row_from_entry).collect() } + Content::Search { root, hits, .. } => { + let mut rows: Vec = hits + .iter() + .map(|hit| rows::row_from_hit(hit, root)) + .collect(); + // Results stream in roughly in walk order; present folders first + // (Explorer-style), then by the displayed root-relative path, so + // the list stays stable and readable as it grows. + rows.sort_by(|a, b| { + b.is_container + .cmp(&a.is_container) + .then_with(|| a.label.to_lowercase().cmp(&b.label.to_lowercase())) + }); + rows + } Content::Error(_) => Vec::new(), }; - // Drop any selection indices that no longer exist after filtering/sort. + // Drop any selection indices that no longer exist after the rebuild. self.selection.retain_below(self.rows.len()); } @@ -1806,14 +2061,23 @@ impl Librarian { /// The placeholder shown in the file area when there are no rows. fn empty_list_message(&self) -> Element<'_, Message> { let msg = match &self.content { - Content::Folder { loading: true, .. } => "Loading…", - Content::Error(e) => e.as_str(), - _ => "Empty", + Content::Folder { loading: true, .. } => "Loading…".to_string(), + Content::Search { + done: false, query, .. + } => format!("Searching for “{query}”…"), + Content::Search { + done: true, + query, + mode, + .. + } => match mode { + SearchMode::Name => format!("No file names match “{query}”"), + SearchMode::Contents => format!("No files contain “{query}”"), + }, + Content::Error(e) => e.clone(), + _ => "Empty".to_string(), }; - container(text(msg.to_string())) - .padding(16) - .width(Fill) - .into() + container(text(msg)).padding(16).width(Fill).into() } /// The tab strip: one chip per open tab (title + close), plus a "+" button. @@ -1865,7 +2129,31 @@ impl Librarian { .on_press_maybe(msg) .padding([4, 10]) }; - row![ + // Search needs a real folder to recurse from; disabled at "This PC". + let can_search = self.current_dir().is_some(); + let searching = matches!(self.content, Content::Search { .. }); + let mut search_box = text_input("Search this folder", &self.search_query) + .on_input(Message::SearchChanged) + .width(200.0); + if can_search { + search_box = search_box.on_submit(Message::SearchSubmit); + } + let mode = pick_list( + SearchMode::ALL.to_vec(), + Some(self.search_mode), + Message::SearchModeChanged, + ) + .text_size(13) + .padding([4, 6]); + // A clear button appears while results are showing, to drop back to the + // folder listing. + let clear = searching.then(|| { + button(text("✕").size(13)) + .on_press(Message::SearchClear) + .padding([4, 8]) + }); + + let mut bar = row![ nav("←", self.history.can_go_back().then_some(Message::GoBack)), nav( "→", @@ -1877,13 +2165,17 @@ impl Librarian { .on_input(Message::AddressChanged) .on_submit(Message::AddressSubmit) .width(Fill), - text_input("Filter", &self.filter) - .on_input(Message::FilterChanged) - .width(180.0), + search_box, + mode, + ]; + if let Some(clear) = clear { + bar = bar.push(clear); + } + bar.push( checkbox(self.show_hidden) .label("Hidden") .on_toggle(Message::SetHidden), - ] + ) .spacing(6) .padding(8) .align_y(Center) @@ -2605,9 +2897,12 @@ fn is_text_edit(message: &Message) -> bool { message, Message::AddressChanged(_) | Message::AddressSubmit - | Message::FilterChanged(_) | Message::RenameChanged(_) | Message::RenameCommit + | Message::SearchChanged(_) + | Message::SearchSubmit + | Message::SearchModeChanged(_) + | Message::SearchClear ) } @@ -2662,6 +2957,22 @@ fn plural(count: usize) -> &'static str { if count == 1 { "" } else { "s" } } +/// Build the message stream for a running search: drive ripgrep (see +/// [`search::run`]) and tag every event with the spec's token so the update loop +/// can drop results from a search that's since been superseded. +/// +/// `+ use<>` keeps the stream `'static` (it captures no borrow of `spec`), as +/// [`Subscription::run_with`] requires. +fn search_stream(spec: &SearchSpec) -> impl iced::futures::Stream + use<> { + use iced::futures::StreamExt; + let token = spec.token; + search::run(spec.clone()).map(move |event| match event { + SearchEvent::Batch(hits) => Message::SearchBatch { token, hits }, + SearchEvent::Done { capped } => Message::SearchFinished { token, capped }, + SearchEvent::Failed(error) => Message::SearchFailed { token, error }, + }) +} + /// A stream that emits [`Message::DirChanged`] whenever `path`'s direct /// contents change on disk. Bridges the `notify` debouncer (whose callback runs /// on its own thread) into the async world via a channel; the debouncer is diff --git a/crates/librarian-app/src/rows.rs b/crates/librarian-app/src/rows.rs index 8015423..d92cd73 100644 --- a/crates/librarian-app/src/rows.rs +++ b/crates/librarian-app/src/rows.rs @@ -1,6 +1,7 @@ //! The display-row model: a uniform view over directory entries, drives, and //! known folders, plus human-friendly size/time formatting. +use std::path::Path; use std::time::SystemTime; use chrono::{DateTime, Local, Utc}; @@ -8,6 +9,7 @@ use librarian_core::{Entry, Location}; use librarian_win::{DriveInfo, DriveKind, KnownFolder}; use crate::icons::IconKey; +use crate::search::SearchHit; /// One row in the file list, independent of where it came from. #[derive(Debug, Clone)] @@ -73,6 +75,63 @@ pub fn row_from_known(folder: &KnownFolder) -> Row { } } +/// A row for one search result. The label is the path *relative to the search +/// root* (so the result's location is visible at a glance, like an editor's +/// search panel). A directory hit navigates when activated; a file hit opens. +/// For contents searches `matches` is the hit count, surfaced in the Type column. +pub fn row_from_hit(hit: &SearchHit, root: &Path) -> Row { + let label = hit + .path + .strip_prefix(root) + .unwrap_or(&hit.path) + .to_string_lossy() + .into_owned(); + + if hit.is_dir { + return Row { + label, + icon: IconKey::Folder, + is_container: true, + target: Location::Path(hit.path.clone()), + size: None, + modified: None, + type_label: "File folder".to_string(), + }; + } + + let ext = file_extension(&hit.path); + let type_label = match hit.matches { + Some(n) => format!("{} match{}", n, if n == 1 { "" } else { "es" }), + None => ext_type_label(&ext), + }; + Row { + label, + icon: IconKey::Ext(ext), + is_container: false, + target: Location::Path(hit.path.clone()), + size: None, + modified: None, + type_label, + } +} + +/// Lowercase extension without the dot, or `""` for none. +fn file_extension(path: &Path) -> String { + path.extension() + .and_then(|e| e.to_str()) + .map(|e| e.to_ascii_lowercase()) + .unwrap_or_default() +} + +/// Human "type" for a file with the given (lowercase, dotless) extension. +fn ext_type_label(ext: &str) -> String { + if ext.is_empty() { + "File".to_string() + } else { + format!("{} File", ext.to_uppercase()) + } +} + fn type_label(entry: &Entry) -> String { if entry.is_dir() { return "File folder".to_string(); diff --git a/crates/librarian-app/src/search.rs b/crates/librarian-app/src/search.rs new file mode 100644 index 0000000..98913d9 --- /dev/null +++ b/crates/librarian-app/src/search.rs @@ -0,0 +1,430 @@ +//! Recursive search, powered by an external [ripgrep] (`rg`) process. +//! +//! ripgrep is a fast, parallel directory walker and content matcher, so rather +//! than reinventing one we shell out to it and stream its stdout. Two modes: +//! +//! * **Name** — list files whose *name* contains the query, via +//! `rg --files --iglob "*query*"`. ripgrep's walker does the recursion (and +//! hidden/ignore handling); each output line is a matching file path. +//! * **Contents** — files that *contain* the query, via `rg --count-matches`, +//! so each output line is `path:count` for one file with at least one hit. +//! +//! The search is exposed as a [`Stream`] of [`SearchEvent`]s built with +//! [`iced::stream::channel`], so the app can drive it as a subscription keyed by +//! [`SearchSpec`]: changing any field tears the stream down, and because the +//! child is spawned with `kill_on_drop`, dropping the stream kills `rg` — giving +//! free cancellation when the user navigates away or starts a new search. +//! +//! `rg --files` lists *files* only. In **Name** mode, once ripgrep finishes we +//! append matching **directories** found by our own [`librarian_core::NameMatcher`] +//! automaton (same query, same case-insensitive substring semantics) — see +//! [`librarian_core::find_matching_dirs`]. **Contents** mode stays files-only: a +//! directory has no contents to match. + +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; + +use iced::futures::channel::mpsc::Sender; +use iced::futures::{SinkExt, Stream}; +use librarian_core::{NameMatcher, find_matching_dirs}; + +/// Stop after this many results and report the search as capped, so a query +/// matching an entire drive can't flood the UI or run unbounded. +const RESULT_CAP: usize = 5000; +/// Results accumulated before a batch is pushed to the UI. +const BATCH: usize = 128; + +/// What the query matches against. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub enum SearchMode { + /// Match against file names (default), like Explorer's search box. + #[default] + Name, + /// Match against file contents. + Contents, +} + +impl SearchMode { + /// Every mode, in the order the picker lists them. + pub const ALL: [SearchMode; 2] = [SearchMode::Name, SearchMode::Contents]; +} + +impl std::fmt::Display for SearchMode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + SearchMode::Name => "Name", + SearchMode::Contents => "Contents", + }) + } +} + +/// One search result: a file from ripgrep, or a directory from our own name +/// walk, plus (for a [`SearchMode::Contents`] file) how many lines matched. +#[derive(Debug, Clone)] +pub struct SearchHit { + pub path: PathBuf, + /// Matching-line count for a contents search; `None` otherwise. + pub matches: Option, + /// True for a directory hit (appended after ripgrep), false for a file. + pub is_dir: bool, +} + +/// The full identity of a search. Used as a subscription key, so any change +/// supersedes the running search (and kills its `rg` child). +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct SearchSpec { + /// Monotonic id, so even an identical query re-run is treated as new. + pub token: u64, + pub root: PathBuf, + pub query: String, + pub mode: SearchMode, +} + +/// Streamed output of a running search. +#[derive(Debug, Clone)] +pub enum SearchEvent { + /// A chunk of newly found results. + Batch(Vec), + /// The search finished; `capped` if it stopped early at [`RESULT_CAP`]. + Done { capped: bool }, + /// The search could not run (rg missing, failed to launch, …). + Failed(String), +} + +/// Locate the ripgrep executable. Prefers a copy bundled beside our own +/// executable (portable installs), then `PATH`, then the common per-user and +/// machine install locations. `None` means rg isn't installed. +pub fn ripgrep_path() -> Option { + const EXE: &str = "rg.exe"; + + // 1. Beside our own executable — a bundled/portable copy. + if let Ok(exe) = std::env::current_exe() + && let Some(dir) = exe.parent() + { + let cand = dir.join(EXE); + if cand.is_file() { + return Some(cand); + } + } + + // 2. Anywhere on PATH. + if let Some(found) = find_on_path(EXE) { + return Some(found); + } + + // 3. Common install locations (winget, scoop, chocolatey, and the dir our + // install-ripgrep.ps1 uses). + install_candidates() + .into_iter() + .map(|dir| dir.join(EXE)) + .find(|cand| cand.is_file()) +} + +fn find_on_path(exe: &str) -> Option { + let path = std::env::var_os("PATH")?; + std::env::split_paths(&path) + .map(|dir| dir.join(exe)) + .find(|cand| cand.is_file()) +} + +fn install_candidates() -> Vec { + let mut dirs = Vec::new(); + let mut from_env = |var: &str, tail: &[&str]| { + if let Some(base) = std::env::var_os(var) { + let mut p = PathBuf::from(base); + p.extend(tail); + dirs.push(p); + } + }; + // Where install-ripgrep.ps1 places rg.exe. + from_env("LOCALAPPDATA", &["Programs", "ripgrep"]); + // winget's shim links. + from_env("LOCALAPPDATA", &["Microsoft", "WinGet", "Links"]); + // scoop shims. + from_env("USERPROFILE", &["scoop", "shims"]); + // chocolatey. + dirs.push(PathBuf::from(r"C:\ProgramData\chocolatey\bin")); + dirs +} + +/// Build the [`SearchEvent`] stream for `spec`: spawn `rg`, read its stdout, and +/// emit batched [`SearchHit`]s, then [`SearchEvent::Done`]. Spawned with +/// `kill_on_drop`, so dropping the returned stream cancels the search. +/// +/// `+ use<>` keeps the stream `'static` (it owns `spec`), as the subscription +/// runtime requires. +pub fn run(spec: SearchSpec) -> impl Stream + use<> { + iced::stream::channel( + 8, + move |mut output: iced::futures::channel::mpsc::Sender| async move { + let Some(rg) = ripgrep_path() else { + let _ = output + .send(SearchEvent::Failed( + "ripgrep (rg.exe) was not found. Install it (e.g. with the \ + bundled install-ripgrep.ps1) and try again." + .to_string(), + )) + .await; + return; + }; + + let query = spec.query.trim(); + if query.is_empty() { + let _ = output.send(SearchEvent::Done { capped: false }).await; + return; + } + + let mut command = build_command(&rg, query, spec.mode, &spec.root); + let mut child = match command.spawn() { + Ok(child) => child, + Err(error) => { + let _ = output + .send(SearchEvent::Failed(format!( + "Failed to launch ripgrep: {error}" + ))) + .await; + return; + } + }; + let Some(stdout) = child.stdout.take() else { + let _ = output.send(SearchEvent::Done { capped: false }).await; + return; + }; + + use tokio::io::AsyncBufReadExt; + let mut lines = tokio::io::BufReader::new(stdout).lines(); + let mut batch: Vec = Vec::with_capacity(BATCH); + let mut total = 0usize; + let mut capped = false; + + // Stops on EOF or a read error (`next_line` yields `Ok(None)`/`Err`). + while let Ok(Some(line)) = lines.next_line().await { + let Some(hit) = parse_line(&line, spec.mode) else { + continue; + }; + batch.push(hit); + total += 1; + if batch.len() >= BATCH + && output + .send(SearchEvent::Batch(std::mem::take(&mut batch))) + .await + .is_err() + { + return; // receiver gone; kill_on_drop reaps rg + } + if total >= RESULT_CAP { + capped = true; + break; + } + } + + if !batch.is_empty() { + let _ = output.send(SearchEvent::Batch(batch)).await; + } + // Reap rg promptly when we stopped early; harmless if it already exited. + let _ = child.start_kill(); + + // ripgrep covered files. In Name mode, append matching *directories* + // via our own name automaton (using whatever cap rg's files left). + // Contents mode is files-only — a directory has nothing to grep. + if spec.mode == SearchMode::Name { + let remaining = RESULT_CAP.saturating_sub(total); + if stream_dir_matches(&mut output, &spec.root, query, remaining).await { + capped = true; + } + } + let _ = output.send(SearchEvent::Done { capped }).await; + }, + ) +} + +/// Sets a flag on drop, so a directory walk handed to `spawn_blocking` is asked +/// to stop when the search future is torn down (the walk checks the flag once +/// per directory). +struct CancelOnDrop(Arc); + +impl Drop for CancelOnDrop { + fn drop(&mut self) { + self.0.store(true, Ordering::Relaxed); + } +} + +/// Walk `root` for directories whose name matches `query` and stream them as +/// directory [`SearchHit`]s. Returns whether the walk hit `cap`. The walk runs on +/// a blocking thread (it's synchronous filesystem work) and is cancelled if the +/// caller's future is dropped. A no-op for an empty query or a zero cap. +async fn stream_dir_matches( + output: &mut Sender, + root: &Path, + query: &str, + cap: usize, +) -> bool { + if cap == 0 || query.is_empty() { + return false; + } + let cancel = Arc::new(AtomicBool::new(false)); + // Dropped when this future is, signalling the blocking walk to stop. + let _guard = CancelOnDrop(Arc::clone(&cancel)); + + let root = root.to_path_buf(); + let query = query.to_string(); + let dirs = tokio::task::spawn_blocking(move || { + let matcher = NameMatcher::new(&query); + find_matching_dirs(&root, &matcher, cap, &cancel) + }) + .await + .unwrap_or_default(); + + let capped = dirs.len() >= cap; + for chunk in dirs.chunks(BATCH) { + let hits: Vec = chunk + .iter() + .map(|path| SearchHit { + path: path.clone(), + matches: None, + is_dir: true, + }) + .collect(); + if output.send(SearchEvent::Batch(hits)).await.is_err() { + break; // receiver gone + } + } + capped +} + +/// Assemble the `rg` invocation for a mode, with a hidden console window and +/// `kill_on_drop` so cancellation reaps the process. +fn build_command( + rg: &PathBuf, + query: &str, + mode: SearchMode, + root: &PathBuf, +) -> tokio::process::Command { + let mut std_cmd = std::process::Command::new(rg); + match mode { + SearchMode::Name => { + // `--files` lists every file; `--iglob` filters to those whose name + // (a path component, since the glob has no `/`) contains the query, + // case-insensitively. + std_cmd.args(["--files", "--no-ignore", "--hidden", "--iglob"]); + std_cmd.arg(format!("*{}*", escape_glob(query))); + } + SearchMode::Contents => { + // One `path:count` line per file containing the (literal) query. + std_cmd.args([ + "--count-matches", + "--no-ignore", + "--hidden", + "--smart-case", + "--fixed-strings", + "-e", + ]); + std_cmd.arg(query); + } + } + std_cmd.arg("--").arg(root); + std_cmd + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()); + + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + // CREATE_NO_WINDOW: don't flash a console for the child rg process. + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + std_cmd.creation_flags(CREATE_NO_WINDOW); + } + + let mut cmd = tokio::process::Command::from(std_cmd); + cmd.kill_on_drop(true); + cmd +} + +/// Parse one line of `rg` output into a hit, or `None` to skip it. +fn parse_line(line: &str, mode: SearchMode) -> Option { + // tokio's line reader strips the trailing newline (and a preceding CR). + if line.is_empty() { + return None; + } + match mode { + SearchMode::Name => Some(SearchHit { + path: PathBuf::from(line), + matches: None, + is_dir: false, + }), + SearchMode::Contents => { + // `path:count`. Split on the *last* colon: a Windows path's drive + // colon (`C:`) is earlier, and the count field has none. + let (path, count) = line.rsplit_once(':')?; + if path.is_empty() { + return None; + } + Some(SearchHit { + path: PathBuf::from(path), + matches: count.trim().parse::().ok(), + is_dir: false, + }) + } + } +} + +/// Escape glob metacharacters so a name query is matched literally. Each special +/// character becomes a single-member character class (`*` → `[*]`), which avoids +/// backslash escaping — awkward on Windows, where `\` is the path separator. +fn escape_glob(query: &str) -> String { + let mut out = String::with_capacity(query.len() + 8); + for ch in query.chars() { + match ch { + '*' => out.push_str("[*]"), + '?' => out.push_str("[?]"), + '[' => out.push_str("[[]"), + ']' => out.push_str("[]]"), + '{' => out.push_str("[{]"), + '}' => out.push_str("[}]"), + // A backslash in the query is almost certainly a path separator; + // the glob engine wants forward slashes. + '\\' => out.push('/'), + _ => out.push(ch), + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_a_name_result() { + let hit = parse_line(r"C:\Users\me\photo.png", SearchMode::Name).unwrap(); + assert_eq!(hit.path, PathBuf::from(r"C:\Users\me\photo.png")); + assert_eq!(hit.matches, None); + } + + #[test] + fn parses_a_contents_result_keeping_the_drive_colon() { + let hit = parse_line(r"C:\Users\me\notes.txt:7", SearchMode::Contents).unwrap(); + assert_eq!(hit.path, PathBuf::from(r"C:\Users\me\notes.txt")); + assert_eq!(hit.matches, Some(7)); + } + + #[test] + fn blank_lines_are_skipped() { + assert!(parse_line("", SearchMode::Name).is_none()); + assert!(parse_line("", SearchMode::Contents).is_none()); + } + + #[test] + fn escapes_glob_metacharacters_literally() { + // A query of literal metacharacters becomes character classes, never + // wildcards. + assert_eq!(escape_glob("a*b?c"), "a[*]b[?]c"); + assert_eq!(escape_glob("x[y]z"), "x[[]y[]]z"); + assert_eq!(escape_glob("plain"), "plain"); + // Backslash (a path separator) normalizes to a forward slash. + assert_eq!(escape_glob(r"a\b"), "a/b"); + } +} diff --git a/crates/librarian-core/src/lib.rs b/crates/librarian-core/src/lib.rs index afcf393..56fdb24 100644 --- a/crates/librarian-core/src/lib.rs +++ b/crates/librarian-core/src/lib.rs @@ -8,10 +8,12 @@ pub mod enumerate; pub mod history; +pub mod matcher; pub mod model; pub mod sort; pub use enumerate::{DEFAULT_BATCH, read_dir_all, read_dir_batched, read_subdirs}; pub use history::History; +pub use matcher::{NameMatcher, find_matching_dirs}; pub use model::{Attributes, Entry, EntryKind, Location}; pub use sort::{Sort, SortKey, SortOrder, is_visible, sort_entries}; diff --git a/crates/librarian-core/src/matcher.rs b/crates/librarian-core/src/matcher.rs new file mode 100644 index 0000000..e88aad6 --- /dev/null +++ b/crates/librarian-core/src/matcher.rs @@ -0,0 +1,319 @@ +//! Case-insensitive substring matching via a hand-built finite automaton, plus a +//! directory-only tree walk that uses it. +//! +//! ripgrep (Librarian's file-search engine) lists *files* only — it can't report +//! matching directories. To cover folders we run our own matcher: a +//! Knuth–Morris–Pratt automaton over the (lowercased) query that scans each +//! directory name in a single pass with no backtracking, giving the same +//! case-insensitive substring semantics as ripgrep's name search. The result set +//! is appended to ripgrep's file hits by the app layer. + +use std::fs; +use std::os::windows::fs::MetadataExt; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Condvar, Mutex}; +use std::thread; + +// Reparse-point bit (junctions / symlinks); declared locally to keep this crate +// free of the `windows` dependency, matching `model.rs`. +const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; + +/// Upper bound on directory-walk worker threads. The walk is I/O-bound, so a +/// handful of threads saturates the disk; more just adds lock contention. +const MAX_WALK_THREADS: usize = 8; + +/// A compiled case-insensitive substring matcher. +/// +/// Built as a KMP automaton: the query is lowercased and turned into a failure +/// table, after which [`is_match`](Self::is_match) tests a candidate in `O(n)` +/// over its characters (no re-scanning), regardless of how many partial matches +/// occur. Folding is done per-character with [`char::to_lowercase`], so it works +/// for non-ASCII names too. +pub struct NameMatcher { + /// The lowercased query, as `char`s (case folding can change length, and + /// indexing chars keeps multi-byte handling correct). + needle: Vec, + /// KMP failure function: `fail[i]` is the length of the longest proper + /// prefix of `needle[..=i]` that is also a suffix of it. + fail: Vec, +} + +impl NameMatcher { + /// Compile `query` into a matcher. An empty (or all-whitespace-trimmed-away) + /// query matches nothing. + pub fn new(query: &str) -> Self { + let needle: Vec = query.to_lowercase().chars().collect(); + let fail = build_failure(&needle); + Self { needle, fail } + } + + /// Whether `haystack` contains the query as a case-insensitive substring. + pub fn is_match(&self, haystack: &str) -> bool { + let m = self.needle.len(); + if m == 0 { + return false; + } + let mut state = 0usize; // count of leading needle chars matched so far + for ch in haystack.chars().flat_map(char::to_lowercase) { + while state > 0 && self.needle[state] != ch { + state = self.fail[state - 1]; + } + if self.needle[state] == ch { + state += 1; + if state == m { + return true; + } + } + } + false + } +} + +/// Build the KMP failure function for `needle`. +fn build_failure(needle: &[char]) -> Vec { + let mut fail = vec![0usize; needle.len()]; + let mut k = 0usize; + for i in 1..needle.len() { + while k > 0 && needle[k] != needle[i] { + k = fail[k - 1]; + } + if needle[k] == needle[i] { + k += 1; + } + fail[i] = k; + } + fail +} + +/// Recursively walk `root` and return the paths of directories whose name +/// matches `matcher`, up to `cap` results. +/// +/// The walk runs in parallel across a small pool of scoped threads: directories +/// live on a shared stack; each worker pops one, reads it *off-lock* (the part +/// that actually parallelizes), and pushes back any real subdirectories. The +/// walk is finished once the stack is empty and no worker is mid-read. It stops +/// early when `cap` is reached or `cancel` is set (checked as each worker claims +/// its next directory, so a torn-down search ends promptly). Reparse points +/// (junctions/symlinks) are matched by name but never descended into, so the +/// walk can't cycle or escape the subtree. Unreadable directories are skipped. +pub fn find_matching_dirs( + root: &Path, + matcher: &NameMatcher, + cap: usize, + cancel: &AtomicBool, +) -> Vec { + if cap == 0 { + return Vec::new(); + } + + /// Work queue + results shared across the walker threads. + struct Shared { + /// Directories discovered but not yet read. + stack: Vec, + /// Workers currently reading a directory (so possibly about to push + /// more): the walk ends only when this is 0 *and* `stack` is empty. + active: usize, + results: Vec, + /// Set once `cap` is hit or the walk drains, to release every worker. + done: bool, + } + + /// One worker: claim directories and process them until the walk finishes. + fn run_worker( + shared: &Mutex, + idle: &Condvar, + matcher: &NameMatcher, + cap: usize, + cancel: &AtomicBool, + ) { + loop { + // Claim the next directory, or exit when the walk is finished. + let dir = { + let mut state = shared.lock().unwrap(); + loop { + if state.done || cancel.load(Ordering::Relaxed) { + return; + } + if let Some(dir) = state.stack.pop() { + state.active += 1; + break dir; + } + if state.active == 0 { + // Nothing queued and no worker can produce more. + state.done = true; + idle.notify_all(); + return; + } + // Others are still reading; wait for work or for the end. + state = idle.wait(state).unwrap(); + } + }; + + // Read the directory off-lock — the actual parallel work. + let mut subdirs = Vec::new(); + let mut matches = Vec::new(); + if let Ok(entries) = fs::read_dir(&dir) { + for dirent in entries.flatten() { + // Cheap on Windows (no extra syscall); doesn't follow links. + let Ok(meta) = dirent.metadata() else { + continue; + }; + if !meta.is_dir() { + continue; + } + if matcher.is_match(&dirent.file_name().to_string_lossy()) { + matches.push(dirent.path()); + } + // Descend into real directories only — not junctions/symlinks. + if meta.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT == 0 { + subdirs.push(dirent.path()); + } + } + } + + // Merge findings back and release this directory. + let mut state = shared.lock().unwrap(); + if !state.done { + for path in matches { + if state.results.len() >= cap { + break; + } + state.results.push(path); + } + if state.results.len() >= cap { + state.done = true; + } else { + state.stack.extend(subdirs); + } + } + state.active -= 1; + idle.notify_all(); + } + } + + let shared = Mutex::new(Shared { + stack: vec![root.to_path_buf()], + active: 0, + results: Vec::new(), + done: false, + }); + let idle = Condvar::new(); + + let workers = thread::available_parallelism() + .map(|n| n.get()) + .unwrap_or(1) + .min(MAX_WALK_THREADS); + + thread::scope(|scope| { + for _ in 0..workers { + scope.spawn(|| run_worker(&shared, &idle, matcher, cap, cancel)); + } + }); + + shared.into_inner().unwrap().results +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs::File; + + #[test] + fn matches_substring_case_insensitively() { + let m = NameMatcher::new("Report"); + assert!(m.is_match("Quarterly Report 2026")); + assert!(m.is_match("annual-report.txt")); + assert!(!m.is_match("summary")); + } + + #[test] + fn handles_overlapping_prefixes() { + // A pattern whose prefixes overlap exercises the failure function: the + // needle nearly matches, resets, then matches. + let m = NameMatcher::new("aabaa"); + assert!(m.is_match("xaabaabaay")); + assert!(!m.is_match("aabab")); + } + + #[test] + fn empty_query_matches_nothing() { + assert!(!NameMatcher::new("").is_match("anything")); + } + + #[test] + fn finds_matching_directories_recursively() { + let tmp = std::env::temp_dir().join(format!("librarian_match_{}", std::process::id())); + let _ = fs::remove_dir_all(&tmp); + fs::create_dir_all(tmp.join("projects").join("report-archive")).unwrap(); + fs::create_dir_all(tmp.join("Reports")).unwrap(); + fs::create_dir_all(tmp.join("misc")).unwrap(); + File::create(tmp.join("report.txt")).unwrap(); // a *file*, must be ignored + + let matcher = NameMatcher::new("report"); + let cancel = AtomicBool::new(false); + let mut found = find_matching_dirs(&tmp, &matcher, 100, &cancel); + found.sort(); + + let names: Vec = found + .iter() + .map(|p| p.file_name().unwrap().to_string_lossy().into_owned()) + .collect(); + assert_eq!(names, vec!["Reports", "report-archive"]); + + fs::remove_dir_all(&tmp).unwrap(); + } + + #[test] + fn finds_matches_deep_in_a_wide_tree() { + // A wide, several-levels-deep tree exercises the parallel workers' + // push/pop and termination: a match is buried at the bottom of one branch. + let tmp = std::env::temp_dir().join(format!("librarian_deep_{}", std::process::id())); + let _ = fs::remove_dir_all(&tmp); + for a in 0..6 { + for b in 0..6 { + fs::create_dir_all(tmp.join(format!("a{a}")).join(format!("b{b}")).join("leaf")) + .unwrap(); + } + } + // One uniquely-named directory hidden deep in the tree. + fs::create_dir_all(tmp.join("a3").join("b4").join("treasure-chest")).unwrap(); + + let matcher = NameMatcher::new("treasure"); + let cancel = AtomicBool::new(false); + let found = find_matching_dirs(&tmp, &matcher, 100, &cancel); + assert_eq!(found.len(), 1); + assert!(found[0].ends_with("treasure-chest")); + + fs::remove_dir_all(&tmp).unwrap(); + } + + #[test] + fn cancelled_walk_returns_promptly() { + let tmp = std::env::temp_dir().join(format!("librarian_cancel_{}", std::process::id())); + let _ = fs::remove_dir_all(&tmp); + fs::create_dir_all(tmp.join("alpha")).unwrap(); + // Pre-cancelled: the walk should bail out without collecting anything. + let cancel = AtomicBool::new(true); + let matcher = NameMatcher::new("alpha"); + let found = find_matching_dirs(&tmp, &matcher, 100, &cancel); + assert!(found.is_empty()); + + fs::remove_dir_all(&tmp).unwrap(); + } + + #[test] + fn respects_the_cap() { + let tmp = std::env::temp_dir().join(format!("librarian_cap_{}", std::process::id())); + let _ = fs::remove_dir_all(&tmp); + for i in 0..5 { + fs::create_dir_all(tmp.join(format!("data{i}"))).unwrap(); + } + let matcher = NameMatcher::new("data"); + let cancel = AtomicBool::new(false); + let found = find_matching_dirs(&tmp, &matcher, 3, &cancel); + assert_eq!(found.len(), 3); + + fs::remove_dir_all(&tmp).unwrap(); + } +} diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..dce4456 --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,29 @@ +# scripts/ + +Standalone operational helpers. These are **not** part of the application build, +and the Librarian executable never invokes them — they exist to be picked up by +the separate, user-maintained installer/packaging project. + +## `install-ripgrep.ps1` + +Provisions [ripgrep](https://github.com/BurntSushi/ripgrep) (`rg.exe`), which +Librarian uses as its recursive-search engine. Intended to run as a **pre-install +step** in the installer for users who don't already have ripgrep. + +It resolves the latest `x86_64-pc-windows-msvc` release from GitHub (with a pinned +fallback if GitHub is unreachable), installs `rg.exe` to +`%LOCALAPPDATA%\Programs\ripgrep`, and adds it to the user PATH. It is idempotent: +if ripgrep is already discoverable it does nothing. + +```powershell +# Default per-user install (no admin needed): +powershell -ExecutionPolicy Bypass -File .\install-ripgrep.ps1 + +# Pin a version / install next to the app instead of touching PATH: +powershell -ExecutionPolicy Bypass -File .\install-ripgrep.ps1 -Version 15.1.0 -InstallDir "C:\Program Files\Librarian" -NoPath +``` + +At runtime Librarian locates `rg.exe` in this order: next to its own executable, +then on `PATH`, then the common winget/scoop/chocolatey locations and the +install dir above. So the installer can either run this script or simply drop +`rg.exe` beside `librarian.exe` for a fully portable install. diff --git a/scripts/install-ripgrep.ps1 b/scripts/install-ripgrep.ps1 new file mode 100644 index 0000000..77d96fe --- /dev/null +++ b/scripts/install-ripgrep.ps1 @@ -0,0 +1,199 @@ +<# +.SYNOPSIS + Ensures ripgrep (rg.exe) is installed, for Librarian's recursive search. + +.DESCRIPTION + Librarian uses ripgrep as its search engine. This script provisions it on + machines that don't already have it. It is a standalone helper intended to be + bundled and run by the SEPARATE Librarian installer project as a pre-install + step; it is NOT part of the application build and the app does not invoke it. + + Behaviour: + 1. If rg.exe is already discoverable (on PATH, or in the install dir, or in + a known winget/scoop/choco location), it does nothing unless -Force. + 2. Otherwise it resolves the latest ripgrep release from GitHub (falling + back to a pinned version if the GitHub API is unreachable), downloads the + x86_64-pc-windows-msvc archive, extracts rg.exe into -InstallDir, and + (unless -NoPath) adds that directory to the current user's PATH. + + The default install directory matches the first location Librarian probes at + runtime (%LOCALAPPDATA%\Programs\ripgrep), so search works immediately. + +.PARAMETER InstallDir + Directory to install rg.exe into. Default: %LOCALAPPDATA%\Programs\ripgrep. + +.PARAMETER Force + Reinstall even if ripgrep is already present. + +.PARAMETER NoPath + Don't modify the user PATH (e.g. when the installer adds it itself, or when + placing rg.exe next to the Librarian executable instead). + +.PARAMETER Version + Install a specific ripgrep version (e.g. "15.1.0") instead of the latest. + +.EXAMPLE + powershell -ExecutionPolicy Bypass -File .\install-ripgrep.ps1 + +.NOTES + Requires Windows PowerShell 5.1+ or PowerShell 7+. No admin rights needed for + the default per-user install location. +#> +[CmdletBinding()] +param( + [string]$InstallDir = (Join-Path $env:LOCALAPPDATA 'Programs\ripgrep'), + [switch]$Force, + [switch]$NoPath, + [string]$Version +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +# ripgrep target triple this script provisions, and a pinned fallback used only +# if the GitHub "latest release" lookup fails (offline, rate-limited, etc.). +$Triple = 'x86_64-pc-windows-msvc' +$FallbackVersion = '15.1.0' +$Repo = 'BurntSushi/ripgrep' + +function Write-Step([string]$Message) { Write-Host "==> $Message" -ForegroundColor Cyan } +function Write-Ok([string]$Message) { Write-Host " $Message" -ForegroundColor Green } +function Write-Warn2([string]$Message) { Write-Host " $Message" -ForegroundColor Yellow } + +# Return the path to an existing rg.exe if one is discoverable, else $null. Mirrors +# the locations Librarian itself probes at runtime. +function Find-Ripgrep { + param([string]$InstallDir) + + $onPath = Get-Command rg.exe -ErrorAction SilentlyContinue + if ($onPath) { return $onPath.Source } + + $candidates = @( + (Join-Path $InstallDir 'rg.exe'), + (Join-Path $env:LOCALAPPDATA 'Microsoft\WinGet\Links\rg.exe'), + (Join-Path $env:USERPROFILE 'scoop\shims\rg.exe'), + 'C:\ProgramData\chocolatey\bin\rg.exe' + ) + foreach ($c in $candidates) { + if ($c -and (Test-Path -LiteralPath $c)) { return $c } + } + return $null +} + +# Resolve the download URL + version for the requested (or latest) release. +function Resolve-Release { + param([string]$Version) + + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + + if ($Version) { + $tag = $Version + } + else { + try { + Write-Step "Querying GitHub for the latest ripgrep release..." + $headers = @{ 'User-Agent' = 'librarian-install-ripgrep' } + $rel = Invoke-RestMethod -Headers $headers -Uri "https://api.github.com/repos/$Repo/releases/latest" + $tag = $rel.tag_name + + # Prefer the asset URL straight from the release metadata when present. + $asset = $rel.assets | Where-Object { $_.name -like "*$Triple.zip" } | Select-Object -First 1 + if ($asset) { + return [pscustomobject]@{ Version = $tag; Url = $asset.browser_download_url } + } + } + catch { + Write-Warn2 "GitHub lookup failed ($($_.Exception.Message)); using pinned v$FallbackVersion." + $tag = $FallbackVersion + } + } + + $name = "ripgrep-$tag-$Triple.zip" + $url = "https://github.com/$Repo/releases/download/$tag/$name" + return [pscustomobject]@{ Version = $tag; Url = $url } +} + +# Append $Dir to the current user's PATH (persisted + this session), if missing. +function Add-ToUserPath { + param([string]$Dir) + + $userPath = [Environment]::GetEnvironmentVariable('Path', 'User') + $parts = @() + if ($userPath) { $parts = $userPath -split ';' | Where-Object { $_ -ne '' } } + + if ($parts -contains $Dir) { + Write-Ok "Already on the user PATH." + return + } + + $newPath = (($parts + $Dir) -join ';') + [Environment]::SetEnvironmentVariable('Path', $newPath, 'User') + # Reflect it in the current session too, so a follow-on step sees rg. + $env:Path = "$env:Path;$Dir" + Write-Ok "Added to the user PATH (open a new terminal to pick it up globally)." +} + +# --- main --------------------------------------------------------------------- + +$existing = Find-Ripgrep -InstallDir $InstallDir +if ($existing -and -not $Force) { + $rgVersion = (& $existing --version | Select-Object -First 1) + Write-Step "ripgrep is already installed: $existing" + Write-Ok $rgVersion + Write-Ok "Nothing to do. Re-run with -Force to reinstall." + return +} + +$release = Resolve-Release -Version $Version +Write-Step "Installing ripgrep $($release.Version) ($Triple)" + +$tempDir = Join-Path ([IO.Path]::GetTempPath()) ("librarian-rg-" + [Guid]::NewGuid().ToString('N')) +New-Item -ItemType Directory -Path $tempDir -Force | Out-Null +$zipPath = Join-Path $tempDir 'ripgrep.zip' + +try { + Write-Step "Downloading $($release.Url)" + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + # Progress rendering makes Invoke-WebRequest dramatically slower; suppress it. + $oldProgress = $ProgressPreference + $ProgressPreference = 'SilentlyContinue' + try { + Invoke-WebRequest -Uri $release.Url -OutFile $zipPath -UseBasicParsing + } + finally { + $ProgressPreference = $oldProgress + } + + Write-Step "Extracting..." + Expand-Archive -Path $zipPath -DestinationPath $tempDir -Force + + $rg = Get-ChildItem -Path $tempDir -Recurse -Filter 'rg.exe' | Select-Object -First 1 + if (-not $rg) { + throw "rg.exe not found in the downloaded archive." + } + + if (-not (Test-Path -LiteralPath $InstallDir)) { + New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null + } + $destExe = Join-Path $InstallDir 'rg.exe' + Copy-Item -LiteralPath $rg.FullName -Destination $destExe -Force + + # Bring along the license/readme that ship alongside, where present. + foreach ($extra in @('LICENSE-MIT', 'UNLICENSE', 'COPYING', 'README.md')) { + $src = Get-ChildItem -Path $tempDir -Recurse -Filter $extra -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($src) { Copy-Item -LiteralPath $src.FullName -Destination $InstallDir -Force } + } + + Write-Ok "Installed: $destExe" + $rgVersion = (& $destExe --version | Select-Object -First 1) + Write-Ok $rgVersion + + if (-not $NoPath) { + Add-ToUserPath -Dir $InstallDir + } + + Write-Step "ripgrep is ready for Librarian search." +} +finally { + Remove-Item -LiteralPath $tempDir -Recurse -Force -ErrorAction SilentlyContinue +}