mirror of
https://github.com/openai/codex.git
synced 2026-11-10 21:00:47 +00:00
## Why Matches in parent directory names can crowd matching filenames out of limited search results. For example, searching for `ViewModel` should keep `ChatViewModel.kt` visible even when many directories are named `viewmodel`. ## What changed - Rank basename matches before applying the result limit for single positive fuzzy queries without path separators. - Prefer exact filenames, then the strongest exact directory match, then other matching filenames. Leave additional directory matches ranked by their paths. - Compute highlight indices from the basename for promoted results and return scores that reflect the new ranking. - Rank snapshots in resumable time slices so new queries and cancellation can interrupt the work. ## Testing Add regression coverage for filename matches surviving the result limit, exact file and directory priority, path queries, and resuming partial rankings. Update app-server score expectations while preserving highlight indices. GitOrigin-RevId: 41b3420581b4f6490859e809245370b737889544
653 lines
20 KiB
Rust
653 lines
20 KiB
Rust
use crossbeam_channel::Receiver;
|
||
use crossbeam_channel::Sender;
|
||
use crossbeam_channel::after;
|
||
use crossbeam_channel::never;
|
||
use crossbeam_channel::select;
|
||
use crossbeam_channel::unbounded;
|
||
use ignore::WalkBuilder;
|
||
use ignore::overrides::OverrideBuilder;
|
||
use nucleo::Config;
|
||
use nucleo::Injector;
|
||
use nucleo::Matcher;
|
||
use nucleo::Nucleo;
|
||
use nucleo::Utf32String;
|
||
use nucleo::pattern::CaseMatching;
|
||
use nucleo::pattern::Normalization;
|
||
use serde::Serialize;
|
||
use std::num::NonZero;
|
||
use std::path::Path;
|
||
use std::path::PathBuf;
|
||
use std::sync::Arc;
|
||
use std::sync::Condvar;
|
||
use std::sync::Mutex;
|
||
use std::sync::RwLock;
|
||
use std::sync::atomic::AtomicBool;
|
||
use std::sync::atomic::Ordering;
|
||
use std::thread;
|
||
use std::time::Duration;
|
||
use tokio::process::Command;
|
||
|
||
#[cfg(test)]
|
||
use nucleo::Utf32Str;
|
||
#[cfg(test)]
|
||
use nucleo::pattern::AtomKind;
|
||
#[cfg(test)]
|
||
use nucleo::pattern::Pattern;
|
||
|
||
mod cli;
|
||
mod snapshot;
|
||
|
||
pub use cli::Cli;
|
||
|
||
/// A single match result returned from the search.
|
||
///
|
||
/// * `score` – Relevance score used to select and order this result.
|
||
/// * `path` – Path to the matched entry (file or directory), relative to the
|
||
/// search directory.
|
||
/// * `match_type` – Whether this match is a file or directory.
|
||
/// * `indices` – Optional list of character indices that matched the query.
|
||
/// These are only filled when the caller of [`run`] sets
|
||
/// `options.compute_indices` to `true`. The indices vector follows the
|
||
/// guidance from `nucleo::pattern::Pattern::indices`: they are
|
||
/// unique and sorted in ascending order so that callers can use
|
||
/// them directly for highlighting.
|
||
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||
pub struct FileMatch {
|
||
pub score: u32,
|
||
pub path: PathBuf,
|
||
pub match_type: MatchType,
|
||
pub root: PathBuf,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub indices: Option<Vec<u32>>, // Sorted & deduplicated when present
|
||
}
|
||
|
||
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
|
||
#[serde(rename_all = "lowercase")]
|
||
pub enum MatchType {
|
||
File,
|
||
Directory,
|
||
}
|
||
|
||
/// Carries the selected root and entry type without duplicating ASCII matcher paths.
|
||
enum IndexedEntry {
|
||
Ascii {
|
||
root_idx: usize,
|
||
match_type: MatchType,
|
||
},
|
||
Unicode(Box<UnicodeEntry>),
|
||
}
|
||
|
||
// Unicode matcher columns retain only one codepoint per grapheme.
|
||
struct UnicodeEntry {
|
||
root_idx: usize,
|
||
relative_path: Box<str>,
|
||
match_type: MatchType,
|
||
}
|
||
|
||
impl FileMatch {
|
||
pub fn full_path(&self) -> PathBuf {
|
||
self.root.join(&self.path)
|
||
}
|
||
}
|
||
|
||
/// Returns the final path component for a matched path, falling back to the full path.
|
||
pub fn file_name_from_path(path: &str) -> String {
|
||
Path::new(path)
|
||
.file_name()
|
||
.map(|name| name.to_string_lossy().into_owned())
|
||
.unwrap_or_else(|| path.to_string())
|
||
}
|
||
|
||
#[derive(Debug)]
|
||
pub struct FileSearchResults {
|
||
pub matches: Vec<FileMatch>,
|
||
pub total_match_count: usize,
|
||
}
|
||
|
||
#[derive(Debug, Clone, Serialize, PartialEq, Eq, Default)]
|
||
pub struct FileSearchSnapshot {
|
||
pub query: String,
|
||
pub matches: Vec<FileMatch>,
|
||
pub total_match_count: usize,
|
||
pub scanned_file_count: usize,
|
||
pub walk_complete: bool,
|
||
}
|
||
|
||
#[derive(Debug, Clone)]
|
||
pub struct FileSearchOptions {
|
||
pub limit: NonZero<usize>,
|
||
pub exclude: Vec<String>,
|
||
pub threads: NonZero<usize>,
|
||
pub compute_indices: bool,
|
||
/// Toggle ignore-file processing in the walker.
|
||
///
|
||
/// When enabled, `.gitignore` files are scoped by
|
||
/// `WalkBuilder::require_git(true)`, so they are honored only when the
|
||
/// traversed path is inside a git repository. When disabled, the walker
|
||
/// turns off `.gitignore`, git-global/exclude rules, `.ignore`, and
|
||
/// parent-directory ignore scanning.
|
||
pub respect_gitignore: bool,
|
||
}
|
||
|
||
impl Default for FileSearchOptions {
|
||
fn default() -> Self {
|
||
Self {
|
||
#[expect(clippy::unwrap_used)]
|
||
limit: NonZero::new(20).unwrap(),
|
||
exclude: Vec::new(),
|
||
#[expect(clippy::unwrap_used)]
|
||
threads: NonZero::new(2).unwrap(),
|
||
compute_indices: false,
|
||
respect_gitignore: true,
|
||
}
|
||
}
|
||
}
|
||
|
||
pub trait SessionReporter: Send + Sync + 'static {
|
||
/// Called when the debounced top-N changes.
|
||
fn on_update(&self, snapshot: &FileSearchSnapshot);
|
||
|
||
/// Called when the session becomes idle or is cancelled. Guaranteed to be called at least once per update_query.
|
||
fn on_complete(&self);
|
||
}
|
||
|
||
pub struct FileSearchSession {
|
||
inner: Arc<SessionInner>,
|
||
}
|
||
|
||
impl FileSearchSession {
|
||
/// Update the query. This should be cheap relative to re-walking.
|
||
pub fn update_query(&self, pattern_text: &str) {
|
||
let _ = self
|
||
.inner
|
||
.work_tx
|
||
.send(WorkSignal::QueryUpdated(pattern_text.to_string()));
|
||
}
|
||
}
|
||
|
||
impl Drop for FileSearchSession {
|
||
fn drop(&mut self) {
|
||
self.inner.shutdown.store(true, Ordering::Relaxed);
|
||
let _ = self.inner.work_tx.send(WorkSignal::Shutdown);
|
||
}
|
||
}
|
||
|
||
pub fn create_session(
|
||
search_directories: Vec<PathBuf>,
|
||
options: FileSearchOptions,
|
||
reporter: Arc<dyn SessionReporter>,
|
||
cancel_flag: Option<Arc<AtomicBool>>,
|
||
) -> anyhow::Result<FileSearchSession> {
|
||
let FileSearchOptions {
|
||
limit,
|
||
exclude,
|
||
threads,
|
||
compute_indices,
|
||
respect_gitignore,
|
||
} = options;
|
||
|
||
let Some(primary_search_directory) = search_directories.first() else {
|
||
anyhow::bail!("at least one search directory is required");
|
||
};
|
||
let override_matcher = build_override_matcher(primary_search_directory, &exclude)?;
|
||
let (work_tx, work_rx) = unbounded();
|
||
|
||
let notify_tx = work_tx.clone();
|
||
let notify = Arc::new(move || {
|
||
let _ = notify_tx.send(WorkSignal::NucleoNotify);
|
||
});
|
||
let nucleo = Nucleo::new(
|
||
Config::DEFAULT.match_paths(),
|
||
notify,
|
||
Some(threads.get()),
|
||
1,
|
||
);
|
||
let injector = nucleo.injector();
|
||
|
||
let cancelled = cancel_flag.unwrap_or_else(|| Arc::new(AtomicBool::new(false)));
|
||
|
||
let inner = Arc::new(SessionInner {
|
||
search_directories,
|
||
limit: limit.get(),
|
||
threads: threads.get(),
|
||
compute_indices,
|
||
respect_gitignore,
|
||
cancelled,
|
||
shutdown: Arc::new(AtomicBool::new(false)),
|
||
reporter,
|
||
work_tx,
|
||
});
|
||
|
||
let matcher_inner = inner.clone();
|
||
thread::spawn(move || matcher_worker(matcher_inner, work_rx, nucleo));
|
||
|
||
let walker_inner = inner.clone();
|
||
thread::spawn(move || walker_worker(walker_inner, override_matcher, injector));
|
||
|
||
Ok(FileSearchSession { inner })
|
||
}
|
||
|
||
pub trait Reporter {
|
||
fn report_match(&self, file_match: &FileMatch);
|
||
fn warn_matches_truncated(&self, total_match_count: usize, shown_match_count: usize);
|
||
fn warn_no_search_pattern(&self, search_directory: &Path);
|
||
}
|
||
|
||
pub async fn run_main<T: Reporter>(
|
||
Cli {
|
||
pattern,
|
||
limit,
|
||
cwd,
|
||
compute_indices,
|
||
json: _,
|
||
exclude,
|
||
threads,
|
||
}: Cli,
|
||
reporter: T,
|
||
) -> anyhow::Result<()> {
|
||
let search_directory = match cwd {
|
||
Some(dir) => dir,
|
||
None => std::env::current_dir()?,
|
||
};
|
||
let pattern_text = match pattern {
|
||
Some(pattern) => pattern,
|
||
None => {
|
||
reporter.warn_no_search_pattern(&search_directory);
|
||
#[cfg(unix)]
|
||
Command::new("ls")
|
||
.arg("-al")
|
||
.current_dir(search_directory)
|
||
.stdout(std::process::Stdio::inherit())
|
||
.stderr(std::process::Stdio::inherit())
|
||
.status()
|
||
.await?;
|
||
#[cfg(windows)]
|
||
{
|
||
Command::new("cmd")
|
||
.arg("/c")
|
||
.arg(search_directory)
|
||
.stdout(std::process::Stdio::inherit())
|
||
.stderr(std::process::Stdio::inherit())
|
||
.status()
|
||
.await?;
|
||
}
|
||
return Ok(());
|
||
}
|
||
};
|
||
|
||
let FileSearchResults {
|
||
total_match_count,
|
||
matches,
|
||
} = run(
|
||
&pattern_text,
|
||
vec![search_directory.to_path_buf()],
|
||
FileSearchOptions {
|
||
limit,
|
||
exclude,
|
||
threads,
|
||
compute_indices,
|
||
respect_gitignore: true,
|
||
},
|
||
/*cancel_flag*/ None,
|
||
)?;
|
||
let match_count = matches.len();
|
||
let matches_truncated = total_match_count > match_count;
|
||
|
||
for file_match in matches {
|
||
reporter.report_match(&file_match);
|
||
}
|
||
if matches_truncated {
|
||
reporter.warn_matches_truncated(total_match_count, match_count);
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// The worker threads will periodically check `cancel_flag` to see if they
|
||
/// should stop processing files.
|
||
pub fn run(
|
||
pattern_text: &str,
|
||
roots: Vec<PathBuf>,
|
||
options: FileSearchOptions,
|
||
cancel_flag: Option<Arc<AtomicBool>>,
|
||
) -> anyhow::Result<FileSearchResults> {
|
||
let reporter = Arc::new(RunReporter::default());
|
||
let session = create_session(roots, options, reporter.clone(), cancel_flag)?;
|
||
|
||
session.update_query(pattern_text);
|
||
|
||
let snapshot = reporter.wait_for_complete();
|
||
Ok(FileSearchResults {
|
||
matches: snapshot.matches,
|
||
total_match_count: snapshot.total_match_count,
|
||
})
|
||
}
|
||
|
||
/// Sort matches in-place by descending score, then ascending path.
|
||
#[cfg(test)]
|
||
fn sort_matches(matches: &mut [(u32, String)]) {
|
||
matches.sort_by(cmp_by_score_desc_then_path_asc::<(u32, String), _, _>(
|
||
|t| t.0,
|
||
|t| t.1.as_str(),
|
||
));
|
||
}
|
||
|
||
/// Returns a comparator closure suitable for `slice.sort_by(...)` that orders
|
||
/// items by descending score and then ascending path using the provided accessors.
|
||
pub fn cmp_by_score_desc_then_path_asc<T, FScore, FPath>(
|
||
score_of: FScore,
|
||
path_of: FPath,
|
||
) -> impl FnMut(&T, &T) -> std::cmp::Ordering
|
||
where
|
||
FScore: Fn(&T) -> u32,
|
||
FPath: Fn(&T) -> &str,
|
||
{
|
||
use std::cmp::Ordering;
|
||
move |a, b| match score_of(b).cmp(&score_of(a)) {
|
||
Ordering::Equal => path_of(a).cmp(path_of(b)),
|
||
other => other,
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
fn create_pattern(pattern: &str) -> Pattern {
|
||
Pattern::new(
|
||
pattern,
|
||
CaseMatching::Ignore,
|
||
Normalization::Smart,
|
||
AtomKind::Fuzzy,
|
||
)
|
||
}
|
||
|
||
struct SessionInner {
|
||
search_directories: Vec<PathBuf>,
|
||
limit: usize,
|
||
threads: usize,
|
||
compute_indices: bool,
|
||
respect_gitignore: bool,
|
||
cancelled: Arc<AtomicBool>,
|
||
shutdown: Arc<AtomicBool>,
|
||
reporter: Arc<dyn SessionReporter>,
|
||
work_tx: Sender<WorkSignal>,
|
||
}
|
||
|
||
enum WorkSignal {
|
||
QueryUpdated(String),
|
||
NucleoNotify,
|
||
WalkComplete,
|
||
Shutdown,
|
||
}
|
||
|
||
fn build_override_matcher(
|
||
search_directory: &Path,
|
||
exclude: &[String],
|
||
) -> anyhow::Result<Option<ignore::overrides::Override>> {
|
||
if exclude.is_empty() {
|
||
return Ok(None);
|
||
}
|
||
let mut override_builder = OverrideBuilder::new(search_directory);
|
||
for exclude in exclude {
|
||
let exclude_pattern = format!("!{exclude}");
|
||
override_builder.add(&exclude_pattern)?;
|
||
}
|
||
let matcher = override_builder.build()?;
|
||
Ok(Some(matcher))
|
||
}
|
||
|
||
fn get_file_path<'a>(path: &'a Path, search_directories: &[PathBuf]) -> Option<(usize, &'a str)> {
|
||
let mut best_match: Option<(usize, &Path)> = None;
|
||
for (idx, root) in search_directories.iter().enumerate() {
|
||
if let Ok(rel_path) = path.strip_prefix(root) {
|
||
let root_depth = root.components().count();
|
||
match best_match {
|
||
Some((best_idx, _))
|
||
if search_directories[best_idx].components().count() >= root_depth => {}
|
||
_ => {
|
||
best_match = Some((idx, rel_path));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
let (root_idx, rel_path) = best_match?;
|
||
rel_path.to_str().map(|p| (root_idx, p))
|
||
}
|
||
|
||
/// Walks the search directories and feeds discovered paths into `nucleo`
|
||
/// via the injector.
|
||
///
|
||
/// The walker uses `require_git(true)` to match git's own ignore semantics:
|
||
/// git never reads `.gitignore` files from directories above the repository
|
||
/// root. Without this flag, the `ignore` crate reads `.gitignore` files from
|
||
/// *all* ancestor directories—a deliberate divergence from git intended for
|
||
/// non-git use cases—allowing a broad parent ignore (e.g. `~/.gitignore`
|
||
/// containing `*`) to silently suppress every file in the walk.
|
||
///
|
||
/// When `respect_gitignore` is `false`, all git-related ignore processing is
|
||
/// disabled regardless of this flag.
|
||
fn walker_worker(
|
||
inner: Arc<SessionInner>,
|
||
override_matcher: Option<ignore::overrides::Override>,
|
||
injector: Injector<IndexedEntry>,
|
||
) {
|
||
let Some(first_root) = inner.search_directories.first() else {
|
||
let _ = inner.work_tx.send(WorkSignal::WalkComplete);
|
||
return;
|
||
};
|
||
|
||
let mut walk_builder = WalkBuilder::new(first_root);
|
||
for root in inner.search_directories.iter().skip(1) {
|
||
walk_builder.add(root);
|
||
}
|
||
walk_builder
|
||
.threads(inner.threads)
|
||
// Allow hidden entries.
|
||
.hidden(false)
|
||
// Follow symlinks to search their contents.
|
||
.follow_links(true)
|
||
// Keep ignore behavior aligned with git repositories: only apply
|
||
// gitignore rules when a git context exists.
|
||
.require_git(true);
|
||
if !inner.respect_gitignore {
|
||
walk_builder
|
||
.git_ignore(false)
|
||
.git_global(false)
|
||
.git_exclude(false)
|
||
.ignore(false)
|
||
.parents(false);
|
||
}
|
||
if let Some(override_matcher) = override_matcher {
|
||
walk_builder.overrides(override_matcher);
|
||
}
|
||
|
||
let walker = walk_builder.build_parallel();
|
||
|
||
walker.run(|| {
|
||
const CHECK_INTERVAL: usize = 1024;
|
||
let mut n = 0;
|
||
let search_directories = inner.search_directories.clone();
|
||
let injector = injector.clone();
|
||
let cancelled = inner.cancelled.clone();
|
||
let shutdown = inner.shutdown.clone();
|
||
|
||
Box::new(move |entry| {
|
||
let entry = match entry {
|
||
Ok(entry) => entry,
|
||
Err(_) => return ignore::WalkState::Continue,
|
||
};
|
||
let path = entry.path();
|
||
if path.to_str().is_none() {
|
||
return ignore::WalkState::Continue;
|
||
}
|
||
if let Some((root_idx, relative_path)) = get_file_path(path, &search_directories) {
|
||
let match_type = match entry.file_type() {
|
||
Some(file_type) if file_type.is_dir() => MatchType::Directory,
|
||
_ => MatchType::File,
|
||
};
|
||
let column = Utf32String::from(relative_path);
|
||
let indexed_entry = match &column {
|
||
Utf32String::Ascii(_) => IndexedEntry::Ascii {
|
||
root_idx,
|
||
match_type,
|
||
},
|
||
Utf32String::Unicode(_) => IndexedEntry::Unicode(Box::new(UnicodeEntry {
|
||
root_idx,
|
||
relative_path: Box::from(relative_path),
|
||
match_type,
|
||
})),
|
||
};
|
||
injector.push(indexed_entry, |_, cols| {
|
||
cols[0] = column;
|
||
});
|
||
}
|
||
n += 1;
|
||
if n >= CHECK_INTERVAL {
|
||
if cancelled.load(Ordering::Relaxed) || shutdown.load(Ordering::Relaxed) {
|
||
return ignore::WalkState::Quit;
|
||
}
|
||
n = 0;
|
||
}
|
||
ignore::WalkState::Continue
|
||
})
|
||
});
|
||
let _ = inner.work_tx.send(WorkSignal::WalkComplete);
|
||
}
|
||
|
||
fn matcher_worker(
|
||
inner: Arc<SessionInner>,
|
||
work_rx: Receiver<WorkSignal>,
|
||
mut nucleo: Nucleo<IndexedEntry>,
|
||
) -> anyhow::Result<()> {
|
||
const TICK_TIMEOUT_MS: u64 = 10;
|
||
let config = Config::DEFAULT.match_paths();
|
||
let mut matcher = Matcher::new(config);
|
||
let cancel_requested = || inner.cancelled.load(Ordering::Relaxed);
|
||
let shutdown_requested = || inner.shutdown.load(Ordering::Relaxed);
|
||
|
||
let mut last_query = String::new();
|
||
let mut next_notify = never();
|
||
let mut will_notify = false;
|
||
let mut walk_complete = false;
|
||
let mut ranking = None;
|
||
|
||
loop {
|
||
select! {
|
||
recv(work_rx) -> signal => {
|
||
let Ok(signal) = signal else {
|
||
break;
|
||
};
|
||
match signal {
|
||
WorkSignal::QueryUpdated(query) => {
|
||
ranking = None;
|
||
let append = query.starts_with(&last_query);
|
||
nucleo.pattern.reparse(
|
||
0,
|
||
&query,
|
||
CaseMatching::Ignore,
|
||
Normalization::Smart,
|
||
append,
|
||
);
|
||
last_query = query;
|
||
will_notify = true;
|
||
next_notify = after(Duration::from_millis(0));
|
||
}
|
||
WorkSignal::NucleoNotify => {
|
||
if !will_notify {
|
||
will_notify = true;
|
||
next_notify = after(Duration::from_millis(TICK_TIMEOUT_MS));
|
||
}
|
||
}
|
||
WorkSignal::WalkComplete => {
|
||
walk_complete = true;
|
||
if !will_notify {
|
||
will_notify = true;
|
||
next_notify = after(Duration::from_millis(0));
|
||
}
|
||
}
|
||
WorkSignal::Shutdown => {
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
recv(next_notify) -> _ => {
|
||
will_notify = false;
|
||
if ranking.is_none() {
|
||
let status = nucleo.tick(TICK_TIMEOUT_MS);
|
||
if status.changed {
|
||
ranking = snapshot::Ranking::start(&nucleo, &last_query, walk_complete);
|
||
}
|
||
if ranking.is_none() && !status.running && walk_complete {
|
||
inner.reporter.on_complete();
|
||
}
|
||
}
|
||
if let Some(active) = &mut ranking {
|
||
if let Some(snapshot) = active.advance(
|
||
&inner, &nucleo, &last_query, &mut matcher,
|
||
Duration::from_millis(TICK_TIMEOUT_MS),
|
||
) {
|
||
inner.reporter.on_update(&snapshot);
|
||
ranking = None;
|
||
}
|
||
// Resume this snapshot or collect work that arrived while ranking it.
|
||
will_notify = true;
|
||
next_notify = after(Duration::ZERO);
|
||
}
|
||
}
|
||
default(Duration::from_millis(100)) => {
|
||
// Occasionally check the cancel flag.
|
||
}
|
||
}
|
||
|
||
if cancel_requested() || shutdown_requested() {
|
||
break;
|
||
}
|
||
}
|
||
|
||
// If we cancelled or otherwise exited the loop, make sure the reporter is notified.
|
||
inner.reporter.on_complete();
|
||
|
||
Ok(())
|
||
}
|
||
|
||
#[derive(Default)]
|
||
struct RunReporter {
|
||
snapshot: RwLock<FileSearchSnapshot>,
|
||
completed: (Condvar, Mutex<bool>),
|
||
}
|
||
|
||
impl SessionReporter for RunReporter {
|
||
fn on_update(&self, snapshot: &FileSearchSnapshot) {
|
||
#[allow(clippy::unwrap_used)]
|
||
let mut guard = self.snapshot.write().unwrap();
|
||
*guard = snapshot.clone();
|
||
}
|
||
|
||
fn on_complete(&self) {
|
||
let (cv, mutex) = &self.completed;
|
||
#[allow(clippy::unwrap_used)]
|
||
let mut completed = mutex.lock().unwrap();
|
||
*completed = true;
|
||
cv.notify_all();
|
||
}
|
||
}
|
||
|
||
impl RunReporter {
|
||
fn wait_for_complete(&self) -> FileSearchSnapshot {
|
||
let (cv, mutex) = &self.completed;
|
||
#[allow(clippy::unwrap_used)]
|
||
let mut completed = mutex.lock().unwrap();
|
||
while !*completed {
|
||
#[allow(clippy::unwrap_used)]
|
||
{
|
||
completed = cv.wait(completed).unwrap();
|
||
}
|
||
}
|
||
#[allow(clippy::unwrap_used)]
|
||
self.snapshot.read().unwrap().clone()
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
#[path = "lib_tests.rs"]
|
||
mod tests;
|