1use std::collections::{HashMap, HashSet};
4use std::path::{Path, PathBuf};
5use std::sync::{
6 Arc, Condvar, Mutex, MutexGuard,
7 atomic::{AtomicBool, Ordering},
8};
9use std::time::Instant;
10
11use serde::{Deserialize, Serialize};
12
13use crate::cache::{
14 CacheProfile, CachedSegment, EmbeddingCache, MemoryCache, ResourceWrite, SegmentWrite,
15};
16use crate::discovery::{DiscoveryOptions, ResourceKind};
17use crate::embedder::{Embedder, EmbeddingRequest, EmbeddingTask, ResourceInput};
18use crate::extractor::{
19 ExtractedContent, Extractor, ResourceBytes, TextExtractor, extract_resource, text_range,
20 unsupported,
21};
22use crate::index::{BruteForceIndex, Scored, VectorIndex};
23use crate::lexical::LexicalQuery;
24use crate::progress::Completion;
25use crate::segment::{TextFormat, TextSegmenter};
26use crate::{Embedding, Error, ResourceId, ResourceLocator, ResourceMetadata, Result};
27use crate::{FsProvider, ProvidedResource, ResourceProvider, ResourceScope, ResourceVersion};
28
29mod image_scan;
30pub use image_scan::ImageScan;
31
32const INPUT_FORMAT_VERSION: u32 = 2;
34
35#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
37#[serde(rename_all = "snake_case")]
38pub enum SearchMode {
39 #[default]
41 Hybrid,
42 Semantic,
44 Lexical,
46}
47
48impl std::str::FromStr for SearchMode {
49 type Err = Error;
50 fn from_str(s: &str) -> Result<Self> {
51 match s {
52 "hybrid" => Ok(Self::Hybrid),
53 "semantic" => Ok(Self::Semantic),
54 "lexical" | "keyword" => Ok(Self::Lexical),
55 other => Err(Error::Config(format!(
56 "unknown search mode {other:?}: use hybrid, semantic or lexical"
57 ))),
58 }
59 }
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
64#[serde(rename_all = "snake_case")]
65pub enum DegradedReason {
66 ModelLoading,
67 NotYetEmbedded,
68 NoModel,
69}
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
73pub struct SearchDegradation {
74 pub mode: DegradedMode,
75 pub reason: DegradedReason,
76}
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
79#[serde(rename_all = "snake_case")]
80pub enum DegradedMode {
81 LexicalOnly,
82}
83
84impl SearchDegradation {
85 pub fn lexical(reason: DegradedReason) -> Self {
86 Self {
87 mode: DegradedMode::LexicalOnly,
88 reason,
89 }
90 }
91}
92
93#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
95pub struct MatchSignals {
96 pub semantic: Option<f32>,
98 pub lexical: Option<f32>,
100 pub filename: bool,
102}
103
104#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
108pub struct MatchReason {
109 pub semantic_rank: Option<usize>,
110 pub keyword_rank: Option<usize>,
112 pub filename_rank: Option<usize>,
113 pub matched_terms: Vec<String>,
116}
117
118#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
120pub struct SearchHit {
121 pub resource: ResourceId,
122 pub locator: ResourceLocator,
123 pub score: f32,
127 pub signals: MatchSignals,
129 #[serde(default)]
131 pub r#match: MatchReason,
132 pub metadata: ResourceMetadata,
133 pub heading: Option<String>,
135 pub preview: Option<String>,
137 pub query_index: usize,
140}
141
142#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
144#[serde(rename_all = "snake_case")]
145pub enum SkipCode {
146 #[default]
147 Other,
148 PdfParseFailed,
149 PdfParserPanicked,
150}
151
152#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
154pub struct SkippedCount {
155 pub code: SkipCode,
156 pub reason: String,
157 pub count: usize,
158}
159
160#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
162pub struct SkippedResource {
163 #[serde(default)]
164 pub code: SkipCode,
165 pub resource: ResourceId,
166 #[serde(
167 default,
168 skip_serializing_if = "Option::is_none",
169 serialize_with = "crate::resource::serialize_optional_path"
170 )]
171 pub path: Option<PathBuf>,
172 pub reason: String,
173}
174
175#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
177pub struct PageDiagnostic {
178 #[serde(default)]
179 pub code: SkipCode,
180 pub resource: ResourceId,
181 pub page: u32,
182 pub reason: String,
183}
184
185#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
187pub struct SyncStats {
188 #[serde(default, skip_serializing_if = "Option::is_none")]
190 pub progress: Option<crate::IndexProgress>,
191 pub resources_discovered: usize,
192 pub resources_unchanged: usize,
194 pub resources_updated: usize,
196 pub skipped: Vec<SkippedResource>,
197 #[serde(default)]
198 pub skipped_counts: Vec<SkippedCount>,
199 #[serde(default, skip_serializing_if = "Vec::is_empty")]
200 pub page_diagnostics: Vec<PageDiagnostic>,
201 pub segments_embedded: usize,
203 pub embed_ms: u64,
204}
205
206#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
207pub struct SearchStats {
208 pub sync: SyncStats,
209 pub segments_searched: usize,
210 pub total_ms: u64,
211}
212
213#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
214pub struct SearchResults {
215 pub queries: Vec<String>,
217 pub mode: SearchMode,
218 pub hits: Vec<SearchHit>,
219 #[serde(default, skip_serializing_if = "Option::is_none")]
221 pub degraded: Option<SearchDegradation>,
222 pub stats: SearchStats,
223 #[serde(default, skip_serializing_if = "Option::is_none")]
225 pub kind_counts: Option<std::collections::BTreeMap<String, usize>>,
226}
227
228#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
229pub struct IndexReport {
230 pub sync: SyncStats,
231 pub resources_removed: usize,
233 pub total_ms: u64,
234}
235
236#[derive(Debug, Clone, PartialEq, Eq)]
238#[non_exhaustive]
239pub enum ProgressEvent {
240 IndexProgress {
242 progress: crate::IndexProgress,
243 },
244 Walked {
249 roots: Vec<ResourceId>,
250 },
251 Discovered {
252 resources: usize,
253 },
254 Embedding {
256 done: usize,
257 total: usize,
258 },
259}
260
261pub type ProgressFn<'a> = &'a (dyn Fn(ProgressEvent) + Sync);
267
268fn report_walked(progress: Option<ProgressFn<'_>>, roots: Option<Vec<ResourceId>>) {
269 if let (Some(p), Some(roots)) = (progress, roots) {
270 p(ProgressEvent::Walked { roots });
271 }
272}
273
274pub struct SearchEngineBuilder {
275 provider: Arc<dyn ResourceProvider>,
276 extractors: Vec<Arc<dyn Extractor>>,
277 #[cfg(feature = "audio")]
278 audio_extractor: Option<crate::audio::AudioExtractor>,
279 embedder: Option<Arc<dyn Embedder>>,
280 cache: Option<Box<dyn EmbeddingCache>>,
281 segmenter: TextSegmenter,
282 batch_size: usize,
283 max_audio_duration_secs: u64,
284}
285
286impl SearchEngineBuilder {
287 #[cfg(feature = "audio")]
289 pub fn audio_extractor(mut self, extractor: crate::audio::AudioExtractor) -> Self {
290 self.audio_extractor = Some(extractor);
291 self
292 }
293
294 pub fn extractor(mut self, extractor: impl Extractor + 'static) -> Self {
297 self.extractors.push(Arc::new(extractor));
298 self
299 }
300
301 pub fn provider(mut self, provider: impl ResourceProvider + 'static) -> Self {
303 self.provider = Arc::new(provider);
304 self
305 }
306
307 pub fn shared_provider(mut self, provider: Arc<dyn ResourceProvider>) -> Self {
308 self.provider = provider;
309 self
310 }
311
312 pub fn embedder(mut self, embedder: impl Embedder + 'static) -> Self {
313 self.embedder = Some(Arc::new(embedder));
314 self
315 }
316
317 pub fn shared_embedder(mut self, embedder: Arc<dyn Embedder>) -> Self {
318 self.embedder = Some(embedder);
319 self
320 }
321
322 pub fn cache(mut self, cache: impl EmbeddingCache + 'static) -> Self {
324 self.cache = Some(Box::new(cache));
325 self
326 }
327
328 pub fn segmenter(mut self, segmenter: TextSegmenter) -> Self {
329 self.segmenter = segmenter;
330 self
331 }
332
333 pub fn batch_size(mut self, n: usize) -> Self {
335 self.batch_size = n.max(1);
336 self
337 }
338
339 pub fn max_audio_duration_secs(mut self, seconds: u64) -> Self {
341 self.max_audio_duration_secs = seconds;
342 self
343 }
344
345 pub fn cache_profile(&self, model: crate::embedder::ModelIdentity) -> CacheProfile {
347 CacheProfile {
348 model,
349 segmenter_version: format!(
350 "{}+input-v{INPUT_FORMAT_VERSION}",
351 self.segmenter.version()
352 ),
353 }
354 }
355
356 pub fn build(mut self) -> Result<SearchEngine> {
357 crate::discovery::audio_byte_limit(self.max_audio_duration_secs)?;
358 #[cfg(feature = "pdf")]
359 self.extractors.push(Arc::new(crate::PdfTextExtractor));
360 self.extractors.push(Arc::new(TextExtractor));
361 let embedder = self
362 .embedder
363 .take()
364 .ok_or_else(|| Error::Config("SearchEngine needs an embedder".into()))?;
365 let profile = self.cache_profile(embedder.identity().clone());
366 let profile_id = profile.id();
367 let mut cache = self.cache.unwrap_or_else(|| Box::new(MemoryCache::new()));
368 cache.register_profile(&profile_id, &profile)?;
369 Ok(SearchEngine {
370 provider: self.provider,
371 extractors: self.extractors,
372 #[cfg(feature = "audio")]
373 audio_extractor: self.audio_extractor,
374 embedder,
375 lexical_only: false,
376 skipped_versions: Mutex::new(HashMap::new()),
377 cache: Arc::new(Mutex::new(cache)),
378 indexing: Arc::new(Mutex::new(())),
379 pdf_diagnostics: Mutex::new(HashMap::new()),
380 scrubber_started: AtomicBool::new(false),
381 #[cfg(all(feature = "test-support", debug_assertions))]
382 lease_observer: Mutex::new(None),
383 scrubber_stop: Arc::new(ScrubStop::default()),
384 segmenter: self.segmenter,
385 profile,
386 profile_id,
387 batch_size: self.batch_size,
388 max_audio_duration_secs: self.max_audio_duration_secs,
389 })
390 }
391}
392
393pub struct SearchEngine {
400 provider: Arc<dyn ResourceProvider>,
401 extractors: Vec<Arc<dyn Extractor>>,
402 #[cfg(feature = "audio")]
403 audio_extractor: Option<crate::audio::AudioExtractor>,
404 embedder: Arc<dyn Embedder>,
405 lexical_only: bool,
406 skipped_versions: Mutex<HashMap<ResourceId, (ResourceVersion, String)>>,
410 cache: Arc<Mutex<Box<dyn EmbeddingCache>>>,
411 indexing: Arc<Mutex<()>>,
414 pdf_diagnostics: Mutex<HashMap<ResourceId, PdfDiagnostics>>,
415 scrubber_started: AtomicBool,
416 #[cfg(all(feature = "test-support", debug_assertions))]
417 lease_observer: Mutex<Option<LeaseObserver>>,
418 scrubber_stop: Arc<ScrubStop>,
419 segmenter: TextSegmenter,
420 profile: CacheProfile,
421 profile_id: String,
422 batch_size: usize,
423 max_audio_duration_secs: u64,
424}
425
426#[cfg(all(feature = "test-support", debug_assertions))]
428#[derive(Clone, Copy, Debug, PartialEq, Eq)]
429pub enum LeaseEvent {
430 Contended,
432 Acquired,
433}
434
435#[cfg(all(feature = "test-support", debug_assertions))]
436type LeaseObserver = Arc<dyn Fn(LeaseEvent) + Send + Sync>;
437#[derive(Default)]
439struct ScrubStop {
440 state: Mutex<ScrubStopState>,
441 wake: Condvar,
442}
443
444#[derive(Default)]
445struct ScrubStopState {
446 stopped: bool,
447 #[cfg(test)]
448 waiting: bool,
449}
450
451impl ScrubStop {
452 fn wait(&self, delay: std::time::Duration) -> bool {
453 let Ok(state) = self.state.lock() else {
454 return false;
455 };
456 #[cfg(test)]
457 let state = {
458 let mut state = state;
459 state.waiting = true;
460 self.wake.notify_all();
461 state
462 };
463 let Ok((state, _)) = self.wake.wait_timeout_while(state, delay, |s| !s.stopped) else {
464 return false;
465 };
466 #[cfg(test)]
467 let state = {
468 let mut state = state;
469 state.waiting = false;
470 state
471 };
472 !state.stopped
473 }
474
475 fn is_stopped(&self) -> bool {
476 self.state.lock().map_or(true, |state| state.stopped)
477 }
478
479 fn stop(&self) {
480 let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner());
481 state.stopped = true;
482 self.wake.notify_all();
483 }
484}
485
486impl Drop for SearchEngine {
487 fn drop(&mut self) {
488 self.scrubber_stop.stop();
489 }
490}
491
492impl SearchEngine {
493 #[cfg(all(feature = "test-support", debug_assertions))]
495 pub fn observe_indexing_lease(&self, observer: LeaseObserver) {
496 *self
497 .lease_observer
498 .lock()
499 .unwrap_or_else(|e| e.into_inner()) = Some(observer);
500 }
501
502 #[cfg(all(feature = "test-support", debug_assertions))]
503 fn observe_lease(&self, event: LeaseEvent) {
504 let observer = self
505 .lease_observer
506 .lock()
507 .unwrap_or_else(|e| e.into_inner())
508 .clone();
509 if let Some(observer) = observer {
510 observer(event);
511 }
512 }
513
514 fn acquire_indexing_lease(&self) -> Result<MutexGuard<'_, ()>> {
515 let guard = match self.indexing.try_lock() {
517 Ok(guard) => guard,
518 Err(std::sync::TryLockError::WouldBlock) => {
519 #[cfg(all(feature = "test-support", debug_assertions))]
520 self.observe_lease(LeaseEvent::Contended);
521 self.indexing
522 .lock()
523 .map_err(|_| Error::Config("indexing lock poisoned".into()))?
524 }
525 Err(std::sync::TryLockError::Poisoned(_)) => {
526 return Err(Error::Config("indexing lock poisoned".into()));
527 }
528 };
529 #[cfg(all(feature = "test-support", debug_assertions))]
530 self.observe_lease(LeaseEvent::Acquired);
531 Ok(guard)
532 }
533
534 pub fn lexical() -> Result<Self> {
537 Self::lexical_with_provider(Arc::new(FsProvider))
538 }
539
540 pub fn lexical_with_provider(provider: Arc<dyn ResourceProvider>) -> Result<Self> {
542 let mut engine = Self::builder()
543 .shared_provider(provider)
544 .embedder(crate::lexical_engine::LexicalIdentity::default())
545 .build()?;
546 engine.lexical_only = true;
547 Ok(engine)
548 }
549
550 fn lexical_fallback(&self) -> Result<Self> {
551 let mut engine = Self::lexical_with_provider(self.provider.clone())?;
552 engine.segmenter = self.segmenter.clone();
553 engine.max_audio_duration_secs = self.max_audio_duration_secs;
554 Ok(engine)
555 }
556
557 pub fn builder() -> SearchEngineBuilder {
558 SearchEngineBuilder {
559 provider: Arc::new(FsProvider),
560 extractors: Vec::new(),
561 #[cfg(feature = "audio")]
562 audio_extractor: None,
563 embedder: None,
564 cache: None,
565 segmenter: TextSegmenter::default(),
566 batch_size: 16,
567 max_audio_duration_secs: 7200,
568 }
569 }
570
571 pub fn extract(&self, hit: &SearchHit) -> Result<ExtractedContent> {
574 #[cfg(feature = "audio")]
575 if hit.metadata.modality == crate::Modality::Audio {
576 if let Some(extractor) = &self.audio_extractor {
577 return extractor.extract(
578 self.provider.as_ref(),
579 &hit.resource,
580 &hit.locator,
581 &hit.metadata,
582 self.max_audio_duration_secs,
583 );
584 }
585 self.extract_audio_samples(hit)?;
586 return Err(unsupported(&hit.resource, &hit.locator, &hit.metadata));
587 }
588 let extractor = self
589 .extractors
590 .iter()
591 .find(|e| e.supports(&hit.resource, &hit.locator, &hit.metadata))
592 .ok_or_else(|| unsupported(&hit.resource, &hit.locator, &hit.metadata))?;
593 extract_resource(
594 self.provider.as_ref(),
595 extractor.as_ref(),
596 &hit.resource,
597 &hit.locator,
598 &hit.metadata,
599 )
600 }
601
602 #[cfg(feature = "audio")]
604 pub fn extract_audio_samples(&self, hit: &SearchHit) -> Result<Vec<f32>> {
605 crate::audio::extract_samples(
606 self.provider.as_ref(),
607 &hit.resource,
608 &hit.locator,
609 &hit.metadata,
610 self.max_audio_duration_secs,
611 )
612 }
613
614 fn audio_descriptor_hash(&self, resource: &ProvidedResource) -> Result<String> {
616 #[cfg(feature = "audio")]
617 {
618 Ok(input_hash(&[
619 &resource.fingerprint(),
620 crate::audio::PREPARATION,
621 self.embedder.input_identity(crate::Modality::Audio)?,
622 ]))
623 }
624 #[cfg(not(feature = "audio"))]
625 {
626 Ok(resource.fingerprint())
627 }
628 }
629
630 fn audio_eligible(&self, id: &ResourceId) -> Result<bool> {
631 let limit = self.max_audio_duration_secs * 1000;
632 self.with_cache(|c| {
633 let Some(r) = c.resource(&self.profile_id, id)? else {return Ok(false);};
634 Ok(r.metadata.size_bytes <= crate::discovery::audio_byte_limit(self.max_audio_duration_secs)?
635 && !r.segments.is_empty()
636 && r.segments.iter().all(|s| matches!(s.locator, ResourceLocator::Time {start_ms,end_ms} if start_ms<end_ms && end_ms<=limit)))
637 })
638 }
639
640 pub fn profile(&self) -> &CacheProfile {
641 &self.profile
642 }
643
644 pub fn profile_id(&self) -> &str {
645 &self.profile_id
646 }
647
648 pub fn embedder(&self) -> &dyn Embedder {
649 self.embedder.as_ref()
650 }
651
652 pub fn search<'e>(&'e self, query: impl Into<String>) -> SearchRequest<'e> {
655 SearchRequest {
656 engine: self,
657 queries: vec![query.into()],
658 paths: Vec::new(),
659 roots: Vec::new(),
660 limit: 10,
661 min_score: None,
662 discovery: DiscoveryOptions::default(),
663 preview_chars: Some(240),
664 progress: None,
665 sync: true,
666 mode: SearchMode::default(),
667 image_scan: ImageScan::Exact,
668 lexical_weight: None,
669 lexical_fallback: false,
670 kind_counts: false,
671 listed: None,
672 }
673 }
674
675 pub fn index<'e>(&'e self) -> IndexRequest<'e> {
677 IndexRequest {
678 engine: self,
679 paths: Vec::new(),
680 roots: Vec::new(),
681 discovery: DiscoveryOptions::default(),
682 progress: None,
683 }
684 }
685
686 pub fn with_cache<T>(&self, f: impl FnOnce(&mut dyn EmbeddingCache) -> Result<T>) -> Result<T> {
690 let mut guard = self
691 .cache
692 .lock()
693 .map_err(|_| Error::Config("cache lock poisoned".into()))?;
694 f(guard.as_mut())
695 }
696
697 pub fn scrub_cache(&self) -> Result<crate::cache::CacheCleanup> {
702 let _lease = self
703 .indexing
704 .lock()
705 .map_err(|_| Error::Config("indexing lock poisoned".into()))?;
706 self.with_cache(|cache| cache.scrub())
707 }
708
709 pub fn start_cache_scrubber(&self) {
714 self.spawn_cache_scrubber();
715 }
716
717 fn spawn_cache_scrubber(&self) -> Option<std::thread::JoinHandle<()>> {
718 let cache = Arc::downgrade(&self.cache);
719 let stop = self.scrubber_stop.clone();
720 self.start_cache_scrubber_with(
721 move || {
722 let delay = {
723 let Some(cache) = cache.upgrade() else {
724 return false;
725 };
726 let Ok(cache) = cache.lock() else {
727 return false;
728 };
729 cache.scrub_delay(std::time::Duration::from_secs(60))
730 }; stop.wait(delay)
732 },
733 |cache| match cache.scrub_if_idle(std::time::Duration::from_secs(60)) {
734 Ok(Some(report)) if report.pending => eprintln!(
735 "warning: cache scrub pending; run seyk cache scrub once other readers close"
736 ),
737 Err(error) => eprintln!("warning: cache scrub pending: {error}"),
738 _ => {}
739 },
740 )
741 }
742
743 fn start_cache_scrubber_with(
746 &self,
747 mut tick: impl FnMut() -> bool + Send + 'static,
748 mut scrub: impl FnMut(&mut dyn EmbeddingCache) + Send + 'static,
749 ) -> Option<std::thread::JoinHandle<()>> {
750 if self.scrubber_started.swap(true, Ordering::Relaxed) {
751 return None;
752 }
753 let cache = Arc::downgrade(&self.cache);
754 let indexing = Arc::downgrade(&self.indexing);
755 let stop = self.scrubber_stop.clone();
756 Some(std::thread::spawn(move || {
757 while !stop.is_stopped() && tick() {
758 if stop.is_stopped() {
759 break;
760 }
761 let (Some(cache), Some(indexing)) = (cache.upgrade(), indexing.upgrade()) else {
762 break;
763 };
764 let Ok(_lease) = indexing.try_lock() else {
766 continue;
767 };
768 let Ok(mut cache) = cache.lock() else { break };
769 scrub(cache.as_mut());
770 }
771 }))
772 }
773
774 fn sync(
777 &self,
778 _indexing: &MutexGuard<'_, ()>,
779 resources: &[ProvidedResource],
780 progress: Option<ProgressFn>,
781 ) -> Result<SyncStats> {
782 let _phase = seyk_embed::diagnostics::Phase::new("index_pass");
783 let stats = self.sync_resources(resources, progress)?;
784 let skipped: HashSet<_> = stats.skipped.iter().map(|s| &s.resource).collect();
785 let outcomes: Vec<_> = resources
789 .iter()
790 .map(|r| {
791 let outcome = if skipped.contains(&r.id) {
792 self.preparation_descriptor(r)
793 .ok()
794 .map(|descriptor| (r.version.clone(), descriptor))
795 } else {
796 None
797 };
798 (r.id.clone(), outcome)
799 })
800 .collect();
801 let mut remembered = self
802 .skipped_versions
803 .lock()
804 .map_err(|_| Error::Config("skip outcomes lock poisoned".into()))?;
805 for (id, outcome) in outcomes {
806 if let Some(outcome) = outcome {
807 remembered.insert(id, outcome);
808 } else {
809 remembered.remove(&id);
810 }
811 }
812 Ok(stats)
813 }
814
815 fn skipped_at_current_version(&self, resource: &ProvidedResource) -> Result<bool> {
816 let outcome = self
817 .skipped_versions
818 .lock()
819 .map_err(|_| Error::Config("skip outcomes lock poisoned".into()))?
820 .get(&resource.id)
821 .cloned();
822 Ok(outcome.is_some_and(|(version, descriptor)| {
823 version.matches(&resource.version)
824 && self
825 .preparation_descriptor(resource)
826 .is_ok_and(|current| current == descriptor)
827 }))
828 }
829
830 fn preparation_descriptor(&self, r: &ProvidedResource) -> Result<String> {
831 if r.kind == ResourceKind::Audio {
832 return self.audio_descriptor_hash(r);
833 }
834 #[cfg(feature = "pdf")]
835 if r.kind == ResourceKind::Pdf {
836 return Ok(input_hash(&[
837 &r.fingerprint(),
838 crate::pdf::TEXT_VERSION,
839 crate::pdf::PREPARATION_VERSION,
840 &self.pdf_capabilities(),
841 ]));
842 }
843 Ok(r.fingerprint())
844 }
845
846 fn sync_resources(
847 &self,
848 resources: &[ProvidedResource],
849 progress: Option<ProgressFn>,
850 ) -> Result<SyncStats> {
851 let mut stats = SyncStats {
852 resources_discovered: resources.len(),
853 ..Default::default()
854 };
855 let pid = self.profile_id.as_str();
856 let known = self.with_cache(|c| c.resource_index(pid))?;
857
858 let telemetry = crate::progress::PassProgress::new(resources, progress);
859 let mut pending: Vec<Pending> = Vec::new();
860 let mut reported_skips = 0;
861 for r in resources {
862 for skipped in &stats.skipped[reported_skips..] {
863 telemetry.complete(&skipped.resource, Completion::Skipped);
864 }
865 reported_skips = stats.skipped.len();
866 let modality = r.kind.modality();
867 if !self.supports_kind(r.kind) {
868 stats.skipped.push(skip(
869 &r.id,
870 if r.kind == ResourceKind::Pdf && !cfg!(feature = "pdf") {
871 "PDF support is not compiled in (feature `pdf`)".into()
872 } else if r.kind == ResourceKind::Audio && !cfg!(feature = "audio") {
873 "audio support is not compiled in (feature `audio`)".into()
874 } else {
875 self.embedder
876 .unavailable_reason(modality)
877 .unwrap_or_else(|| {
878 format!("{modality} search is not supported by this embedder")
879 })
880 },
881 ));
882 continue;
883 }
884 let descriptor_hash = match self.preparation_descriptor(r) {
885 Ok(hash) => hash,
886 Err(error) => {
887 stats.skipped.push(skip(&r.id, error.to_string()));
888 continue;
889 }
890 };
891 let cached = known.get(&r.id);
892 if cached.is_some_and(|m| {
894 m.version.matches(&r.version)
895 && m.descriptor_hash.as_ref() == Some(&descriptor_hash)
896 }) {
897 if r.kind == ResourceKind::Audio && !self.audio_eligible(&r.id)? {
898 stats.skipped.push(skip(
899 &r.id,
900 "audio exceeds configured duration limit or is not validated",
901 ));
902 } else {
903 stats.resources_unchanged += 1;
904 self.replay_pdf_diagnostics(&r.id, &descriptor_hash, &mut stats)?;
905 telemetry.complete(
906 &r.id,
907 if stats.skipped.len() == reported_skips {
908 Completion::Reused
909 } else {
910 Completion::Skipped
911 },
912 );
913 }
914 continue;
915 }
916 let version = match self.provider.version(&r.id) {
917 Ok(v) => v,
918 Err(e) => {
919 stats.skipped.push(skip(&r.id, e.to_string()));
920 continue;
921 }
922 };
923 if r.version.is_known() && !r.version.matches(&version) {
925 stats
926 .skipped
927 .push(skip(&r.id, "resource changed after discovery"));
928 continue;
929 }
930 #[cfg(feature = "audio")]
931 if r.kind == ResourceKind::Audio {
932 let source = self.provider.open(&r.id).and_then(|source| {
933 crate::audio::VerifiedReader::new(
934 source,
935 &r.id,
936 crate::discovery::audio_byte_limit(self.max_audio_duration_secs)?,
937 )
938 });
939 let mut source = match source {
940 Ok(s) => s,
941 Err(e) => {
942 stats.skipped.push(skip(&r.id, e.to_string()));
943 continue;
944 }
945 };
946 let after = match self.provider.version(&r.id) {
947 Ok(v) => v,
948 Err(e) => {
949 stats.skipped.push(skip(&r.id, e.to_string()));
950 continue;
951 }
952 };
953 if version.is_known() && !version.matches(&after) {
954 stats
955 .skipped
956 .push(skip(&r.id, "resource changed while reading"));
957 continue;
958 }
959 let content_hash = source.content_hash().to_owned();
960 if let Some(m) = cached.filter(|m| {
961 m.metadata.content_hash == content_hash
962 && m.descriptor_hash.as_ref() == Some(&descriptor_hash)
963 }) {
964 if !self.audio_eligible(&r.id)? {
965 stats.skipped.push(skip(
966 &r.id,
967 "audio exceeds configured duration limit or is not validated",
968 ));
969 continue;
970 }
971 let mut metadata = m.metadata.clone();
972 metadata.size_bytes = source.len();
973 metadata.modified_ns = r.modified_ns;
974 self.with_cache(|c| c.touch_resource(pid, &r.id, &metadata, &version))?;
975 stats.resources_unchanged += 1;
976 telemetry.complete(&r.id, Completion::Reused);
977 continue;
978 }
979 use std::io::{Read, Seek};
980 let mut header = [0; 12];
981 let header_result = source.read(&mut header).and_then(|n| {
982 source.rewind()?;
983 Ok(n)
984 });
985 let n = match header_result {
986 Ok(n) => n,
987 Err(_) => {
988 stats
989 .skipped
990 .push(skip(&r.id, "audio changed while reading"));
991 continue;
992 }
993 };
994 let target = Pending {
995 resource: r.id.clone(),
996 metadata: ResourceMetadata {
997 media_type: Some(crate::audio::media_type(&header[..n]).into()),
998 modality,
999 size_bytes: source.len(),
1000 modified_ns: r.modified_ns,
1001 content_hash,
1002 },
1003 version,
1004 descriptor_hash,
1005 jobs: Vec::new(),
1006 page_diagnostics: Vec::new(),
1007 };
1008 if let Some(reason) = self.sync_audio(r, source, target, &mut stats, progress)? {
1009 stats.skipped.push(skip(&r.id, reason));
1010 } else {
1011 telemetry.complete(&r.id, Completion::NewlyIndexed);
1012 }
1013 continue;
1014 }
1015 let bytes = match self.provider.read(&r.id) {
1016 Ok(b) => b,
1017 Err(e) => {
1018 stats.skipped.push(skip(&r.id, e.to_string()));
1019 continue;
1020 }
1021 };
1022 let after = match self.provider.version(&r.id) {
1023 Ok(v) => v,
1024 Err(e) => {
1025 stats.skipped.push(skip(&r.id, e.to_string()));
1026 continue;
1027 }
1028 };
1029 if version.is_known() && !version.matches(&after) {
1030 stats
1031 .skipped
1032 .push(skip(&r.id, "resource changed while reading"));
1033 continue;
1034 }
1035 let content_hash = blake3::hash(&bytes).to_hex().to_string();
1036 if let Some(m) = cached.filter(|m| {
1037 m.metadata.content_hash == content_hash
1038 && m.descriptor_hash.as_ref() == Some(&descriptor_hash)
1039 }) {
1040 let mut metadata = m.metadata.clone();
1041 metadata.size_bytes = bytes.len() as u64;
1042 metadata.modified_ns = r.modified_ns;
1043 self.with_cache(|c| c.touch_resource(pid, &r.id, &metadata, &version))?;
1044 stats.resources_unchanged += 1;
1045 self.replay_pdf_diagnostics(&r.id, &descriptor_hash, &mut stats)?;
1046 telemetry.complete(
1047 &r.id,
1048 if stats.skipped.len() == reported_skips {
1049 Completion::Reused
1050 } else {
1051 Completion::Skipped
1052 },
1053 );
1054 continue;
1055 }
1056 let metadata = ResourceMetadata {
1057 media_type: match r.kind {
1058 ResourceKind::Pdf => Some("application/pdf".into()),
1059 #[cfg(feature = "audio")]
1060 ResourceKind::Audio => Some(crate::audio::media_type(&bytes).into()),
1061 _ => None,
1062 },
1063 modality,
1064 size_bytes: bytes.len() as u64,
1065 modified_ns: r.modified_ns,
1066 content_hash,
1067 };
1068 #[cfg(feature = "pdf")]
1069 if r.kind == ResourceKind::Pdf {
1070 let target = Pending {
1071 resource: r.id.clone(),
1072 metadata,
1073 version,
1074 descriptor_hash,
1075 jobs: Vec::new(),
1076 page_diagnostics: Vec::new(),
1077 };
1078 if let Some(entry) = self.sync_pdf(r, bytes, target, &mut stats, progress)? {
1079 stats.skipped.push(entry);
1080 } else {
1081 telemetry.complete(&r.id, Completion::NewlyIndexed);
1082 }
1083 continue;
1084 }
1085 let prepared = self.prepare(r, bytes);
1086 match prepared {
1087 Ok((jobs, page_diagnostics)) => pending.push(Pending {
1088 resource: r.id.clone(),
1089 metadata,
1090 version,
1091 descriptor_hash,
1092 jobs,
1093 page_diagnostics,
1094 }),
1095 Err(reason) => stats.skipped.push(skip(&r.id, reason)),
1096 }
1097 }
1098
1099 for skipped in &stats.skipped[reported_skips..] {
1100 telemetry.complete(&skipped.resource, Completion::Skipped);
1101 }
1102
1103 if self.lexical_only {
1104 self.write_resources(
1105 &pending,
1106 0,
1107 pending.len(),
1108 &HashMap::new(),
1109 &mut stats,
1110 &telemetry,
1111 )?;
1112 stats.progress = Some(telemetry.snapshot());
1113 stats.skipped_counts = skipped_counts(&stats.skipped);
1114 seyk_embed::diagnostics::counts(
1115 "index_work",
1116 &[("missing_inputs", 0), ("resources", pending.len())],
1117 );
1118 return Ok(stats);
1119 }
1120
1121 let all_hashes: Vec<String> = pending
1123 .iter()
1124 .flat_map(|p| p.jobs.iter().map(|j| j.segment.input_hash.clone()))
1125 .collect();
1126 let already = self.with_cache(|c| c.known_inputs(pid, &all_hashes))?;
1127 let mut queued: HashSet<&str> = HashSet::new();
1128 let mut work: Vec<(usize, &Job)> = Vec::new();
1129 for (ri, p) in pending.iter().enumerate() {
1130 for j in &p.jobs {
1131 if !already.contains(&j.segment.input_hash)
1132 && queued.insert(j.segment.input_hash.as_str())
1133 {
1134 work.push((ri, j));
1135 }
1136 }
1137 }
1138
1139 seyk_embed::diagnostics::counts(
1140 "index_work",
1141 &[("missing_inputs", work.len()), ("resources", pending.len())],
1142 );
1143 let total = work.len();
1144 let started = Instant::now();
1145 let mut failed: HashMap<String, String> = HashMap::new();
1148 let mut done = 0;
1149 let mut written = 0;
1150 if let Some(p) = progress {
1151 p(ProgressEvent::Embedding { done: 0, total });
1152 }
1153 for batch in work.chunks(self.batch_size) {
1154 let results = self.embed_batch(batch);
1155 let mut store = Vec::with_capacity(batch.len());
1156 for ((_, job), res) in batch.iter().zip(results) {
1157 match res {
1158 Ok(v) => store.push((job.segment.input_hash.clone(), v)),
1159 Err(e) => {
1160 failed
1161 .entry(job.segment.input_hash.clone())
1162 .or_insert_with(|| e.to_string());
1163 }
1164 }
1165 }
1166 self.with_cache(|c| c.put_embeddings(pid, &store))?;
1167 done += batch.len();
1168 stats.segments_embedded += store.len();
1169 if let Some(p) = progress {
1170 p(ProgressEvent::Embedding { done, total });
1171 }
1172 let ready_upto = batch.last().map(|(ri, _)| *ri).unwrap_or(0);
1175 written = self.write_resources(
1176 &pending, written, ready_upto, &failed, &mut stats, &telemetry,
1177 )?;
1178 }
1179 self.write_resources(
1180 &pending,
1181 written,
1182 pending.len(),
1183 &failed,
1184 &mut stats,
1185 &telemetry,
1186 )?;
1187 stats.embed_ms += started.elapsed().as_millis() as u64;
1188 if stats.resources_updated > 0 {
1189 self.with_cache(|c| c.prune_orphans(pid))?;
1190 }
1191 for skipped in &stats.skipped {
1192 telemetry.complete(&skipped.resource, Completion::Skipped);
1193 }
1194 stats.progress = Some(telemetry.snapshot());
1195 stats.skipped_counts = skipped_counts(&stats.skipped);
1196 Ok(stats)
1197 }
1198
1199 fn write_resources(
1201 &self,
1202 pending: &[Pending],
1203 from: usize,
1204 upto: usize,
1205 failed: &HashMap<String, String>,
1206 stats: &mut SyncStats,
1207 telemetry: &crate::progress::PassProgress<'_>,
1208 ) -> Result<usize> {
1209 let pid = self.profile_id.as_str();
1210 for p in pending.iter().take(upto).skip(from) {
1211 if let Some(reason) = p
1212 .jobs
1213 .iter()
1214 .find_map(|j| failed.get(&j.segment.input_hash))
1215 {
1216 stats.skipped.push(skip(&p.resource, reason.clone()));
1217 telemetry.complete(&p.resource, Completion::Skipped);
1218 continue;
1219 }
1220 let resource = ResourceWrite {
1221 metadata: &p.metadata,
1222 version: &p.version,
1223 descriptor_hash: Some(&p.descriptor_hash),
1224 segments: p
1225 .jobs
1226 .iter()
1227 .map(|j| SegmentWrite {
1228 segment: &j.segment,
1229 lexical_body: &j.lexical_body,
1230 })
1231 .collect(),
1232 };
1233 self.with_cache(|c| c.put_resource(pid, &p.resource, &resource))?;
1234 stats.resources_updated += 1;
1235 telemetry.complete(&p.resource, Completion::NewlyIndexed);
1236 stats.page_diagnostics.extend(p.page_diagnostics.clone());
1237 }
1238 Ok(upto.max(from))
1239 }
1240
1241 fn checked_embed_batch(&self, requests: &[EmbeddingRequest<'_>]) -> Result<Vec<Embedding>> {
1244 let vectors = self.embedder.embed_batch(requests)?;
1245 if vectors.len() != requests.len() {
1246 return Err(Error::Embedding(
1247 "embedder returned the wrong number of vectors".into(),
1248 ));
1249 }
1250 let dimension = self.embedder.dimension().get();
1251 if let Some(vector) = vectors.iter().find(|v| v.dim() != dimension) {
1252 return Err(Error::DimensionMismatch {
1253 expected: dimension,
1254 actual: vector.dim(),
1255 });
1256 }
1257 Ok(vectors)
1258 }
1259
1260 fn embed_batch(&self, batch: &[(usize, &Job)]) -> Vec<Result<Vec<f32>>> {
1262 let inputs: Vec<EmbeddingRequest> = batch
1263 .iter()
1264 .map(|(_, j)| EmbeddingRequest {
1265 input: j.input.as_input(),
1266 task: EmbeddingTask::SearchDocument,
1267 })
1268 .collect();
1269 match self.checked_embed_batch(&inputs) {
1270 Ok(vs) => vs.into_iter().map(|v| Ok(v.into_vec())).collect(),
1271 Err(_) if inputs.len() > 1 => inputs
1272 .iter()
1273 .map(|i| {
1274 self.checked_embed_batch(std::slice::from_ref(i))
1275 .map(|mut v| v.remove(0).into_vec())
1276 })
1277 .collect(),
1278 Err(e) => vec![Err(e)],
1279 }
1280 }
1281
1282 fn supports_kind(&self, kind: ResourceKind) -> bool {
1283 match kind {
1284 ResourceKind::Pdf => cfg!(feature = "pdf"),
1285 ResourceKind::Audio => {
1286 cfg!(feature = "audio") && self.embedder.supports(crate::Modality::Audio)
1287 }
1288 _ => self.embedder.supports(kind.modality()),
1289 }
1290 }
1291
1292 fn supports_locator(&self, metadata: &ResourceMetadata, locator: &ResourceLocator) -> bool {
1293 if metadata.modality == crate::Modality::Audio {
1294 return cfg!(feature = "audio") && self.embedder.supports(crate::Modality::Audio);
1295 }
1296 if metadata.media_type.as_deref() != Some("application/pdf") {
1297 return true;
1298 }
1299 match locator {
1300 ResourceLocator::Page { .. } => {
1301 cfg!(feature = "pdf-render") && self.embedder.supports(crate::Modality::Image)
1302 }
1303 ResourceLocator::PageText { .. } => {
1304 cfg!(feature = "pdf") && self.embedder.supports(crate::Modality::Text)
1305 }
1306 _ => false,
1307 }
1308 }
1309
1310 #[cfg(feature = "pdf")]
1311 fn pdf_capabilities(&self) -> String {
1312 #[cfg(feature = "pdf-render")]
1313 let renderer = crate::pdf::RENDER_VERSION;
1314 #[cfg(not(feature = "pdf-render"))]
1315 let renderer = "no-render";
1316 format!(
1317 "pdf-cap-v1;text={};image={};{renderer}",
1318 self.embedder.supports(crate::Modality::Text),
1319 self.pdf_images()
1320 )
1321 }
1322
1323 #[cfg(feature = "pdf")]
1324 fn pdf_images(&self) -> bool {
1325 cfg!(feature = "pdf-render") && self.embedder.supports(crate::Modality::Image)
1326 }
1327
1328 fn replay_pdf_diagnostics(
1329 &self,
1330 resource: &ResourceId,
1331 descriptor: &str,
1332 stats: &mut SyncStats,
1333 ) -> Result<()> {
1334 let diagnostics = self
1335 .pdf_diagnostics
1336 .lock()
1337 .map_err(|_| Error::Config("PDF diagnostics lock poisoned".into()))?;
1338 if let Some(record) = diagnostics
1339 .get(resource)
1340 .filter(|record| record.descriptor_hash == descriptor)
1341 {
1342 stats.page_diagnostics.extend(record.pages.clone());
1343 if let Some(skipped) = &record.skipped {
1344 stats.skipped.push(skipped.clone());
1345 }
1346 }
1347 Ok(())
1348 }
1349
1350 #[cfg(feature = "pdf")]
1351 fn remember_pdf_diagnostics(
1352 &self,
1353 target: &Pending,
1354 skipped: Option<SkippedResource>,
1355 ) -> Result<()> {
1356 let mut diagnostics = self
1357 .pdf_diagnostics
1358 .lock()
1359 .map_err(|_| Error::Config("PDF diagnostics lock poisoned".into()))?;
1360 if !target.page_diagnostics.is_empty() || skipped.is_some() {
1361 diagnostics.insert(
1362 target.resource.clone(),
1363 PdfDiagnostics {
1364 descriptor_hash: target.descriptor_hash.clone(),
1365 pages: target.page_diagnostics.clone(),
1366 skipped,
1367 },
1368 );
1369 } else {
1370 diagnostics.remove(&target.resource);
1371 }
1372 Ok(())
1373 }
1374
1375 #[cfg(feature = "pdf")]
1376 fn sync_pdf(
1377 &self,
1378 r: &ProvidedResource,
1379 bytes: Vec<u8>,
1380 mut target: Pending,
1381 stats: &mut SyncStats,
1382 progress: Option<ProgressFn>,
1383 ) -> Result<Option<SkippedResource>> {
1384 let pages = match crate::pdf::read_pages(&bytes, &r.id) {
1385 Ok(pages) => pages,
1386 Err(error) => {
1387 let entry = pdf_skip(&r.id, error);
1388 self.commit_media_resource(&target, &[], stats)?;
1389 self.remember_pdf_diagnostics(&target, Some(entry.clone()))?;
1390 return Ok(Some(entry));
1391 }
1392 };
1393 let all_failed = pages.iter().all(|page| page.error.is_some());
1396 let mut first_failure = None;
1397 let mut valid_pages = Vec::new();
1398 for page in pages {
1399 if let Some(error) = page.error {
1400 let entry = pdf_skip(&r.id, error);
1401 let reason = entry.reason.clone();
1402 target.page_diagnostics.push(PageDiagnostic {
1403 code: entry.code,
1404 resource: r.id.clone(),
1405 page: page.page,
1406 reason: reason.clone(),
1407 });
1408 first_failure.get_or_insert(entry);
1409 } else {
1410 valid_pages.push(page);
1411 }
1412 }
1413 if all_failed {
1414 self.commit_media_resource(&target, &[], stats)?;
1415 self.remember_pdf_diagnostics(&target, first_failure.clone())?;
1416 stats.page_diagnostics.extend(target.page_diagnostics);
1417 return Ok(first_failure);
1418 }
1419 #[cfg(feature = "pdf-render")]
1420 let mut renderer = None;
1421 let capacity = self.batch_size.min(4);
1422 let mut batch = Vec::with_capacity(capacity);
1423 let mut records = Vec::new();
1424 for page in valid_pages {
1425 if crate::pdf::usable(&page.text) {
1426 if !self.embedder.supports(crate::Modality::Text) {
1427 target.page_diagnostics.push(PageDiagnostic {
1428 code: SkipCode::Other,
1429 resource: r.id.clone(),
1430 page: page.page,
1431 reason: "embedded text is not supported by this embedder".into(),
1432 });
1433 continue;
1434 }
1435 let digest = blake3::hash(page.text.as_bytes()).to_hex().to_string();
1436 for segment in self.segmenter.segment(&page.text, TextFormat::Plain) {
1437 batch.push(Job::text(
1438 ResourceLocator::PageText {
1439 page: page.page,
1440 byte_start: segment.byte_range.start as u64,
1441 byte_end: segment.byte_range.end as u64,
1442 text_version: crate::pdf::TEXT_VERSION.into(),
1443 page_text_hash: digest.clone(),
1444 },
1445 None,
1446 segment.text.clone(),
1447 segment_title(r.title.as_deref(), None),
1448 segment.text,
1449 ));
1450 if batch.len() == capacity
1451 && let Some(reason) =
1452 self.flush_media_batch(&mut batch, &mut records, stats, progress)?
1453 {
1454 return Ok(Some(skip(&r.id, reason)));
1455 }
1456 }
1457 } else if self.pdf_images() {
1458 #[cfg(feature = "pdf-render")]
1459 {
1460 if renderer.is_none() {
1461 renderer = match crate::pdf::render::PageRenderer::new(&bytes, &r.id) {
1462 Ok(renderer) => Some(renderer),
1463 Err(error) => return Ok(Some(pdf_skip(&r.id, error))),
1464 };
1465 }
1466 match renderer
1467 .as_ref()
1468 .expect("renderer initialized")
1469 .page(page.page)
1470 {
1471 Ok(png) => {
1472 batch.push(Job::image(ResourceLocator::Page { page: page.page }, png))
1473 }
1474 Err(Error::PdfRender { reason, .. }) => {
1475 target.page_diagnostics.push(PageDiagnostic {
1476 code: SkipCode::Other,
1477 resource: r.id.clone(),
1478 page: page.page,
1479 reason: reason.into(),
1480 });
1481 continue;
1482 }
1483 Err(error) => return Ok(Some(pdf_skip(&r.id, error))),
1484 }
1485 }
1486 } else {
1487 target.page_diagnostics.push(PageDiagnostic {
1488 code: SkipCode::Other,
1489 resource: r.id.clone(),
1490 page: page.page,
1491 reason: if cfg!(feature = "pdf-render") {
1492 self.embedder.unavailable_reason(crate::Modality::Image).unwrap_or_else(||
1493 "no usable embedded text; image embedding is disabled".into())
1494 } else {
1495 "no usable embedded text; PDF rendering is not compiled in (feature `pdf-render`)".into()
1496 },
1497 });
1498 }
1499 if batch.len() == capacity
1500 && let Some(reason) =
1501 self.flush_media_batch(&mut batch, &mut records, stats, progress)?
1502 {
1503 return Ok(Some(skip(&r.id, reason)));
1504 }
1505 }
1506 if let Some(reason) = self.flush_media_batch(&mut batch, &mut records, stats, progress)? {
1507 return Ok(Some(skip(&r.id, reason)));
1508 }
1509 self.commit_media_resource(&target, &records, stats)?;
1510 self.remember_pdf_diagnostics(&target, None)?;
1511 stats.page_diagnostics.extend(target.page_diagnostics);
1512 Ok(None)
1513 }
1514
1515 #[cfg(any(feature = "pdf", feature = "audio"))]
1518 fn commit_media_resource(
1519 &self,
1520 target: &Pending,
1521 records: &[(CachedSegment, String)],
1522 stats: &mut SyncStats,
1523 ) -> Result<()> {
1524 let write = ResourceWrite {
1525 metadata: &target.metadata,
1526 version: &target.version,
1527 descriptor_hash: Some(&target.descriptor_hash),
1528 segments: records
1529 .iter()
1530 .map(|(segment, body)| SegmentWrite {
1531 segment,
1532 lexical_body: body,
1533 })
1534 .collect(),
1535 };
1536 self.with_cache(|c| c.put_resource(&self.profile_id, &target.resource, &write))?;
1537 stats.resources_updated += 1;
1538 Ok(())
1539 }
1540
1541 #[cfg(any(feature = "pdf", feature = "audio"))]
1542 fn flush_media_batch(
1543 &self,
1544 batch: &mut Vec<Job>,
1545 records: &mut Vec<(CachedSegment, String)>,
1546 stats: &mut SyncStats,
1547 progress: Option<ProgressFn>,
1548 ) -> Result<Option<String>> {
1549 if batch.is_empty() {
1550 return Ok(None);
1551 }
1552 if self.lexical_only {
1553 records.extend(batch.drain(..).map(|j| (j.segment, j.lexical_body)));
1554 return Ok(None);
1555 }
1556 let hashes: Vec<_> = batch.iter().map(|j| j.segment.input_hash.clone()).collect();
1557 let already = self.with_cache(|c| c.known_inputs(&self.profile_id, &hashes))?;
1558 let mut queued = HashSet::new();
1559 let work: Vec<_> = batch
1560 .iter()
1561 .filter(|j| {
1562 !already.contains(&j.segment.input_hash) && queued.insert(&j.segment.input_hash)
1563 })
1564 .map(|j| (0, j))
1565 .collect();
1566 let mut store = Vec::new();
1567 let mut failure = None;
1568 if !work.is_empty() {
1569 if let Some(p) = progress {
1570 p(ProgressEvent::Embedding {
1571 done: 0,
1572 total: work.len(),
1573 });
1574 }
1575 let started = Instant::now();
1576 for ((_, job), result) in work.iter().zip(self.embed_batch(&work)) {
1577 match result {
1578 Ok(vector) => store.push((job.segment.input_hash.clone(), vector)),
1579 Err(error) => {
1580 failure.get_or_insert_with(|| error.to_string());
1581 }
1582 }
1583 }
1584 stats.embed_ms += started.elapsed().as_millis() as u64;
1585 self.with_cache(|c| c.put_embeddings(&self.profile_id, &store))?;
1586 stats.segments_embedded += store.len();
1587 if let Some(p) = progress {
1588 p(ProgressEvent::Embedding {
1589 done: work.len(),
1590 total: work.len(),
1591 });
1592 }
1593 }
1594 records.extend(batch.drain(..).map(|j| (j.segment, j.lexical_body)));
1597 Ok(failure)
1598 }
1599
1600 #[cfg(feature = "audio")]
1601 fn sync_audio(
1602 &self,
1603 r: &ProvidedResource,
1604 source: crate::audio::VerifiedReader,
1605 target: Pending,
1606 stats: &mut SyncStats,
1607 progress: Option<ProgressFn>,
1608 ) -> Result<Option<String>> {
1609 let mut batch = Vec::new();
1610 let mut records = Vec::new();
1611 let capacity = self.batch_size.min(4);
1612 let identity = self.embedder.input_identity(crate::Modality::Audio)?;
1613 let result = crate::audio::windows_verified(
1614 source,
1615 &r.id,
1616 self.max_audio_duration_secs,
1617 |locator, samples| {
1618 let mut hash = blake3::Hasher::new();
1619 hash.update(b"audio-input-v1");
1620 hash.update(crate::audio::PREPARATION.as_bytes());
1621 hash.update(&(identity.len() as u64).to_le_bytes());
1622 hash.update(identity.as_bytes());
1623 hash.update(&(samples.len() as u64).to_le_bytes());
1624 for sample in &samples {
1625 hash.update(&sample.to_le_bytes());
1626 }
1627 batch.push(Job {
1628 segment: CachedSegment {
1629 locator,
1630 heading: None,
1631 input_hash: hash.finalize().to_hex().to_string(),
1632 },
1633 lexical_body: String::new(),
1634 input: JobInput::Audio { samples },
1635 });
1636 if batch.len() == capacity
1637 && let Some(reason) =
1638 self.flush_media_batch(&mut batch, &mut records, stats, progress)?
1639 {
1640 return Err(Error::Embedding(reason));
1641 }
1642 Ok(())
1643 },
1644 );
1645 if let Err(error) = result {
1646 return match error {
1647 Error::ChangedWhileReading { .. }
1648 | Error::UnsupportedAudio { .. }
1649 | Error::InvalidLocator { .. }
1650 | Error::Embedding(_)
1651 | Error::ModelNotFound(_) => Ok(Some(error.to_string())),
1652 other => Err(other),
1653 };
1654 }
1655 if let Some(reason) = self.flush_media_batch(&mut batch, &mut records, stats, progress)? {
1656 return Ok(Some(reason));
1657 }
1658 let after = match self.provider.version(&r.id) {
1659 Ok(v) => v,
1660 Err(e) => return Ok(Some(e.to_string())),
1661 };
1662 if target.version.is_known() && !target.version.matches(&after) {
1663 return Ok(Some("resource changed while reading".into()));
1664 }
1665 self.commit_media_resource(&target, &records, stats)?;
1666 Ok(None)
1667 }
1668
1669 fn prepare(
1671 &self,
1672 r: &ProvidedResource,
1673 bytes: Vec<u8>,
1674 ) -> std::result::Result<(Vec<Job>, Vec<PageDiagnostic>), String> {
1675 match r.kind {
1676 ResourceKind::Audio => {
1677 Err("audio requires the streaming preparation path (feature `audio`)".into())
1678 }
1679 ResourceKind::Pdf => {
1680 #[cfg(feature = "pdf")]
1681 {
1682 Err("PDF resources require the streaming preparation path".into())
1683 }
1684 #[cfg(not(feature = "pdf"))]
1685 {
1686 Err("PDF feature is disabled".into())
1687 }
1688 }
1689 ResourceKind::Text(format) => {
1690 if bytes.iter().take(8192).any(|&b| b == 0) {
1691 return Err("looks like a binary file".into());
1692 }
1693 let text = String::from_utf8(bytes).map_err(|_| "not valid UTF-8".to_string())?;
1694 let stem = &r.title;
1695 Ok((
1696 self.segmenter
1697 .segment(&text, format)
1698 .into_iter()
1699 .map(|s| {
1700 let title = segment_title(stem.as_deref(), s.heading.as_deref());
1701 let model_text = match format {
1702 TextFormat::Markdown => strip_link_targets(&s.text),
1703 TextFormat::Plain | TextFormat::Code => s.text.clone(),
1704 };
1705 Job::text(
1706 ResourceLocator::Text {
1707 byte_start: s.byte_range.start as u64,
1708 byte_end: s.byte_range.end as u64,
1709 line_start: Some(s.line_start),
1710 line_end: Some(s.line_end),
1711 },
1712 s.heading,
1713 s.text,
1715 title,
1716 model_text,
1717 )
1718 })
1719 .collect(),
1720 Vec::new(),
1721 ))
1722 }
1723 ResourceKind::Image => Ok((
1724 vec![Job::image(ResourceLocator::WholeResource, bytes)],
1725 Vec::new(),
1726 )),
1727 }
1728 }
1729}
1730
1731#[cfg(feature = "pdf")]
1732fn pdf_skip(resource: &ResourceId, error: Error) -> SkippedResource {
1733 let code = match &error {
1734 Error::ParserPanicked { .. } => SkipCode::PdfParserPanicked,
1735 Error::PdfParse { .. } | Error::PdfPageDecode { .. } => SkipCode::PdfParseFailed,
1736 _ => SkipCode::Other,
1737 };
1738 let reason = match error {
1739 Error::PdfParse { reason, .. } | Error::PdfPageDecode { reason, .. } => {
1740 format!("PDF parse failed: {reason}")
1741 }
1742 Error::PdfRender { reason, .. } => format!("PDF rendering failed: {reason}"),
1743 Error::ParserPanicked { reason, .. } => format!("PDF parser panicked: {reason}"),
1744 other => other.to_string(),
1745 };
1746 SkippedResource {
1747 code,
1748 resource: resource.clone(),
1749 path: resource.to_file_path(),
1750 reason,
1751 }
1752}
1753
1754struct PdfDiagnostics {
1755 descriptor_hash: String,
1756 pages: Vec<PageDiagnostic>,
1757 skipped: Option<SkippedResource>,
1758}
1759
1760struct Pending {
1761 descriptor_hash: String,
1762 resource: ResourceId,
1763 version: ResourceVersion,
1764 metadata: ResourceMetadata,
1765 jobs: Vec<Job>,
1766 page_diagnostics: Vec<PageDiagnostic>,
1767}
1768
1769struct Job {
1770 segment: CachedSegment,
1773 lexical_body: String,
1774 input: JobInput,
1775}
1776
1777impl Job {
1778 fn image(locator: ResourceLocator, bytes: Vec<u8>) -> Self {
1779 Self {
1780 segment: CachedSegment {
1781 locator,
1782 heading: None,
1783 input_hash: input_hash(&["image", &blake3::hash(&bytes).to_hex()]),
1784 },
1785 lexical_body: String::new(),
1786 input: JobInput::Image { bytes },
1787 }
1788 }
1789
1790 fn text(
1793 locator: ResourceLocator,
1794 heading: Option<String>,
1795 lexical_body: String,
1796 title: Option<String>,
1797 text: String,
1798 ) -> Self {
1799 Self {
1800 segment: CachedSegment {
1801 locator,
1802 heading,
1803 input_hash: input_hash(&["text", title.as_deref().unwrap_or(""), &text]),
1804 },
1805 lexical_body,
1806 input: JobInput::Text { title, text },
1807 }
1808 }
1809}
1810
1811enum JobInput {
1812 Text {
1813 title: Option<String>,
1814 text: String,
1815 },
1816 Image {
1817 bytes: Vec<u8>,
1818 },
1819 #[cfg(feature = "audio")]
1820 Audio {
1821 samples: Vec<f32>,
1822 },
1823}
1824
1825impl JobInput {
1826 fn as_input(&self) -> ResourceInput<'_> {
1827 match self {
1828 JobInput::Text { title, text } => ResourceInput::Text {
1829 title: title.as_deref(),
1830 text,
1831 },
1832 JobInput::Image { bytes } => ResourceInput::Image { bytes },
1833 #[cfg(feature = "audio")]
1834 JobInput::Audio { samples } => ResourceInput::Audio {
1835 samples,
1836 sample_rate: 16000,
1837 },
1838 }
1839 }
1840}
1841
1842fn strip_link_targets(text: &str) -> String {
1849 let mut out = String::with_capacity(text.len());
1850 let mut rest = text;
1851 while let Some(i) = rest.find('[') {
1852 out.push_str(&rest[..i]);
1853 let after = &rest[i..];
1854 if let Some(inner) = after.strip_prefix("[[")
1855 && let Some(end) = inner.find("]]")
1856 && !inner[..end].contains('\n')
1857 {
1858 let target = &inner[..end];
1859 out.push_str(target.rsplit('|').next().unwrap_or(target));
1860 rest = &inner[end + 2..];
1861 continue;
1862 }
1863 if let Some(close) = after.find("](")
1864 && !after[..close].contains('\n')
1865 && let Some(end) = after[close + 2..].find(')')
1866 && !after[close + 2..close + 2 + end].contains(char::is_whitespace)
1867 {
1868 out.push_str(&after[1..close]);
1869 rest = &after[close + 2 + end + 1..];
1870 continue;
1871 }
1872 out.push('[');
1873 rest = &after[1..];
1874 }
1875 out.push_str(rest);
1876 out
1877}
1878
1879fn skip(resource: &ResourceId, reason: impl Into<String>) -> SkippedResource {
1880 let reason = reason.into();
1881 SkippedResource {
1882 code: SkipCode::Other,
1883 resource: resource.clone(),
1884 path: resource.to_file_path(),
1885 reason,
1886 }
1887}
1888
1889fn skipped_counts(skipped: &[SkippedResource]) -> Vec<SkippedCount> {
1890 let mut counts = std::collections::BTreeMap::new();
1891 for entry in skipped {
1892 *counts
1893 .entry((entry.code, entry.reason.clone()))
1894 .or_insert(0) += 1;
1895 }
1896 counts
1897 .into_iter()
1898 .map(|((code, reason), count)| SkippedCount {
1899 code,
1900 reason,
1901 count,
1902 })
1903 .collect()
1904}
1905
1906fn input_hash(parts: &[&str]) -> String {
1907 let mut h = blake3::Hasher::new();
1908 for p in parts {
1909 h.update(&(p.len() as u64).to_le_bytes());
1910 h.update(p.as_bytes());
1911 }
1912 h.finalize().to_hex().to_string()
1913}
1914
1915fn segment_title(stem: Option<&str>, heading: Option<&str>) -> Option<String> {
1918 match (stem, heading) {
1919 (Some(s), Some(h)) if h.to_lowercase().starts_with(&s.to_lowercase()) => {
1920 Some(h.to_string())
1921 }
1922 (Some(s), Some(h)) => Some(format!("{s} > {h}")),
1923 (Some(s), None) => Some(s.to_string()),
1924 (None, h) => h.map(str::to_string),
1925 }
1926}
1927
1928pub struct SearchRequest<'e> {
1929 engine: &'e SearchEngine,
1930 queries: Vec<String>,
1931 paths: Vec<PathBuf>,
1932 roots: Vec<ResourceId>,
1933 limit: usize,
1934 min_score: Option<f32>,
1935 discovery: DiscoveryOptions,
1936 preview_chars: Option<usize>,
1937 progress: Option<ProgressFn<'e>>,
1938 sync: bool,
1939 mode: SearchMode,
1940 image_scan: ImageScan,
1941 lexical_weight: Option<f32>,
1942 lexical_fallback: bool,
1943 kind_counts: bool,
1944 listed: Option<Vec<ProvidedResource>>,
1947}
1948
1949impl<'e> SearchRequest<'e> {
1950 pub fn image_scan(mut self, scan: ImageScan) -> Self {
1954 self.image_scan = scan;
1955 self
1956 }
1957
1958 pub fn kind_counts(mut self, enabled: bool) -> Self {
1960 self.kind_counts = enabled;
1961 self
1962 }
1963
1964 pub fn roots(mut self, roots: impl IntoIterator<Item = ResourceId>) -> Self {
1966 self.roots.extend(roots);
1967 self
1968 }
1969
1970 pub fn query(mut self, query: impl Into<String>) -> Self {
1973 self.queries.push(query.into());
1974 self
1975 }
1976
1977 pub fn paths<I, P>(mut self, paths: I) -> Self
1980 where
1981 I: IntoIterator<Item = P>,
1982 P: AsRef<Path>,
1983 {
1984 self.paths
1985 .extend(paths.into_iter().map(|p| expand_tilde(p.as_ref())));
1986 self
1987 }
1988
1989 pub fn limit(mut self, n: usize) -> Self {
1990 self.limit = n;
1991 self
1992 }
1993
1994 pub fn lexical_weight(mut self, w: f32) -> Self {
1999 self.lexical_weight = Some(w);
2000 self
2001 }
2002
2003 pub fn mode(mut self, mode: SearchMode) -> Self {
2004 self.mode = mode;
2005 self
2006 }
2007
2008 pub fn min_score(mut self, s: f32) -> Self {
2011 self.min_score = Some(s);
2012 self
2013 }
2014
2015 pub fn include(mut self, glob: impl Into<String>) -> Self {
2016 self.discovery.include.push(glob.into());
2017 self
2018 }
2019
2020 pub fn exclude(mut self, glob: impl Into<String>) -> Self {
2021 self.discovery.exclude.push(glob.into());
2022 self
2023 }
2024
2025 pub fn discovery(mut self, opts: DiscoveryOptions) -> Self {
2026 self.discovery = opts;
2027 self
2028 }
2029
2030 pub fn previews(mut self, chars: Option<usize>) -> Self {
2032 self.preview_chars = chars;
2033 self
2034 }
2035
2036 pub fn progress(mut self, f: ProgressFn<'e>) -> Self {
2037 self.progress = Some(f);
2038 self
2039 }
2040
2041 pub fn sync(mut self, sync: bool) -> Self {
2045 self.sync = sync;
2046 self
2047 }
2048
2049 pub fn lexical_fallback(mut self, enabled: bool) -> Self {
2055 self.lexical_fallback = enabled;
2056 self
2057 }
2058
2059 pub fn execute(mut self) -> Result<SearchResults> {
2060 let started = Instant::now();
2061 let engine = self.engine;
2062 if engine.lexical_only {
2063 self.sync = true;
2064 self.mode = SearchMode::Lexical;
2065 }
2066 if self.image_scan == ImageScan::PhotoAnchorQ4 {
2067 if self.mode == SearchMode::Lexical {
2068 return Err(Error::Config(
2069 "q4 image scan is unavailable in lexical-only mode".into(),
2070 ));
2071 }
2072 if !engine.with_cache(|c| Ok(c.supports_image_codes()))? {
2073 return Err(Error::Config(
2074 "q4 image scan requires SQLite; --no-cache is unsupported".into(),
2075 ));
2076 }
2077 crate::cache::image_codes::check_profile(&engine.profile)?;
2078 }
2079 if self
2080 .lexical_weight
2081 .is_some_and(|w| !w.is_finite() || w < 0.0)
2082 {
2083 return Err(Error::Config(
2084 "lexical weight must be finite and non-negative".into(),
2085 ));
2086 }
2087 if self.paths.is_empty() && self.roots.is_empty() {
2088 return Err(Error::Config("no paths to search".into()));
2089 }
2090 let mut discovery = self.discovery.clone();
2091 discovery.max_audio_duration_secs = engine.max_audio_duration_secs;
2092 let scope = ResourceScope::from_paths(&self.paths, self.roots.clone(), discovery)?;
2093 let pid = engine.profile_id.as_str();
2094 let listed = self.listed.take();
2097 let cached_metadata =
2098 if listed.is_none() && !self.sync && engine.provider.supports_cached_listing() {
2099 Some(engine.with_cache(|c| c.resource_index(pid))?)
2100 } else {
2101 None
2102 };
2103 let resources = if let Some(listed) = listed {
2104 listed
2106 } else {
2107 let resources = if let Some(metadata) = &cached_metadata {
2108 let ids: Vec<_> = metadata.keys().cloned().collect();
2109 engine.provider.list_cached(&scope, &ids)?
2110 } else {
2111 let listing = engine.provider.list_walked(&scope)?;
2112 report_walked(self.progress, listing.walked_roots);
2113 listing.resources
2114 };
2115 if let Some(p) = self.progress {
2116 p(ProgressEvent::Discovered {
2117 resources: resources.len(),
2118 });
2119 }
2120 resources
2121 };
2122 if self.lexical_fallback && !self.sync && !engine.lexical_only {
2123 let cached = engine.with_cache(|c| c.resource_index(&engine.profile_id))?;
2124 let mut pending = false;
2125 for r in &resources {
2126 if engine.supports_kind(r.kind)
2127 && !engine.skipped_at_current_version(r)?
2128 && !cached
2129 .get(&r.id)
2130 .is_some_and(|m| !r.version.is_known() || m.version.matches(&r.version))
2131 {
2132 pending = true;
2133 break;
2134 }
2135 }
2136 if pending {
2137 let lexical = engine.lexical_fallback()?;
2138 let mut results = SearchRequest {
2139 engine: &lexical,
2140 sync: true,
2141 image_scan: ImageScan::Exact,
2142 listed: Some(resources),
2143 ..self
2144 }
2145 .execute()?;
2146 results.degraded = Some(SearchDegradation::lexical(DegradedReason::NotYetEmbedded));
2147 results.stats.total_ms = started.elapsed().as_millis() as u64;
2148 return Ok(results);
2149 }
2150 }
2151 let mut sync = if self.sync {
2152 let indexing = engine.acquire_indexing_lease()?;
2153 let report = engine.sync(&indexing, &resources, self.progress)?;
2154 if self.image_scan == ImageScan::PhotoAnchorQ4 {
2155 let skipped: HashSet<_> = report.skipped.iter().map(|s| &s.resource).collect();
2156 let paths = resources
2157 .iter()
2158 .filter(|r| r.kind == ResourceKind::Image && !skipped.contains(&r.id))
2159 .map(|r| r.id.clone())
2160 .collect();
2161 engine.with_cache(|c| c.sync_image_codes(pid, &paths))?;
2162 }
2163 report
2164 } else {
2165 SyncStats {
2166 resources_discovered: resources.len(),
2167 ..Default::default()
2168 }
2169 };
2170
2171 let mut wanted: HashSet<ResourceId> = resources
2172 .iter()
2173 .filter(|r| engine.supports_kind(r.kind))
2174 .map(|r| r.id.clone())
2175 .collect();
2176 let metadata = match cached_metadata {
2177 Some(metadata) => metadata,
2178 None => engine.with_cache(|c| c.resource_index(pid))?,
2179 };
2180 let skipped: HashSet<_> = sync.skipped.iter().map(|s| s.resource.clone()).collect();
2184 for r in &resources {
2185 if skipped.contains(&r.id)
2186 || engine.skipped_at_current_version(r)?
2187 || (self.lexical_fallback
2188 && r.version.is_known()
2189 && !metadata
2190 .get(&r.id)
2191 .is_some_and(|m| m.version.matches(&r.version)))
2192 {
2193 wanted.remove(&r.id);
2194 }
2195 }
2196 for r in resources.iter().filter(|r| r.kind == ResourceKind::Audio) {
2197 let failed = skipped.contains(&r.id);
2198 if failed {
2199 wanted.remove(&r.id);
2200 continue;
2201 }
2202 let descriptor = match engine.audio_descriptor_hash(r) {
2203 Ok(hash) => hash,
2204 Err(error) => {
2205 sync.skipped.push(skip(&r.id, error.to_string()));
2206 wanted.remove(&r.id);
2207 continue;
2208 }
2209 };
2210 let current = metadata.get(&r.id).is_some_and(|m| {
2211 (self.sync || m.version.matches(&r.version))
2213 && m.metadata.size_bytes == r.size_bytes
2214 && m.descriptor_hash.as_ref() == Some(&descriptor)
2215 });
2216 if !current || !engine.audio_eligible(&r.id)? {
2217 wanted.remove(&r.id);
2218 }
2219 }
2220
2221 sync.skipped_counts = skipped_counts(&sync.skipped);
2222
2223 let depth = self.limit.saturating_mul(4).max(50);
2226
2227 let mut segments = SegmentTable::default();
2228 let mut semantic: Vec<(SegmentKey, f32, usize)> = Vec::new();
2229 let mut searched = 0;
2230 if self.mode != SearchMode::Lexical {
2231 let dim = engine.embedder.dimension().get();
2232 let mut query_vectors = Vec::with_capacity(self.queries.len());
2233 for q in &self.queries {
2234 let v = engine.embedder.embed_text(q, EmbeddingTask::SearchQuery)?;
2235 if v.dim() != dim {
2236 return Err(Error::DimensionMismatch {
2237 expected: dim,
2238 actual: v.dim(),
2239 });
2240 }
2241 query_vectors.push(v);
2242 }
2243 let min_score = self.min_score.unwrap_or(f32::MIN);
2244 let take = if self.mode == SearchMode::Semantic {
2245 self.limit
2246 } else {
2247 depth
2248 };
2249 if self.image_scan == ImageScan::PhotoAnchorQ4 {
2250 let mut rows = engine.with_cache(|c| c.image_scan_rows(pid, &wanted))?;
2251 rows.retain(|v| {
2252 metadata
2253 .get(&v.resource)
2254 .is_some_and(|m| engine.supports_locator(&m.metadata, &v.segment.locator))
2255 });
2256 rows.sort_by_cached_key(|v| (crate::cache::resource_key(&v.resource), v.ordinal));
2257 searched = rows.len();
2258 let ranked = image_scan::search(engine, &rows, &query_vectors, take, min_score)?;
2259 let keys: Vec<_> = rows
2260 .into_iter()
2261 .map(|v| segments.insert(v.resource, v.ordinal, v.segment))
2262 .collect();
2263 semantic = fuse_max(ranked.into_iter(), take)
2264 .into_iter()
2265 .map(|f| (keys[f.scored.id].clone(), f.scored.score, f.query_index))
2266 .collect();
2267 } else {
2268 let mut stored = engine.with_cache(|c| c.vectors_for(pid, &wanted))?;
2269 stored.sort_by_cached_key(|v| (crate::cache::resource_key(&v.resource), v.ordinal));
2271 let mut index = BruteForceIndex::with_capacity(dim, stored.len())?;
2272 let mut keys = Vec::with_capacity(stored.len());
2273 for v in stored {
2274 if v.vector.len() == dim
2276 && metadata.get(&v.resource).is_some_and(|m| {
2277 engine.supports_locator(&m.metadata, &v.segment.locator)
2278 })
2279 {
2280 index.add(&v.vector);
2281 keys.push(segments.insert(v.resource, v.ordinal, v.segment));
2282 }
2283 }
2284 searched = keys.len();
2285 semantic = fuse_max(
2286 query_vectors
2287 .iter()
2288 .map(|q| index.search(q.as_slice(), take, min_score)),
2289 take,
2290 )
2291 .into_iter()
2292 .map(|f| (keys[f.scored.id].clone(), f.scored.score, f.query_index))
2293 .collect();
2294 }
2295 }
2296
2297 let mut lexical: Vec<(SegmentKey, f32)> = Vec::new();
2298 let mut named: Vec<SegmentKey> = Vec::new();
2299 if self.mode != SearchMode::Semantic {
2300 let mut unsupported = HashSet::new();
2306 let all_pdf_locators_supported = cfg!(feature = "pdf-render")
2310 && engine.embedder.supports(crate::Modality::Image)
2311 && cfg!(feature = "pdf")
2312 && engine.embedder.supports(crate::Modality::Text);
2313 if !all_pdf_locators_supported {
2314 for resource in &wanted {
2315 if let Some(state) = metadata.get(resource)
2316 && state.metadata.media_type.as_deref() == Some("application/pdf")
2317 {
2318 for &ordinal in &segments.load_resource(engine, pid, resource)?.unsupported
2319 {
2320 unsupported.insert((resource.clone(), ordinal));
2321 }
2322 }
2323 }
2324 }
2325 let query = LexicalQuery::new(self.queries.iter().map(String::as_str));
2326 for hit in engine.with_cache(|c| {
2327 c.lexical_search(
2328 pid,
2329 &query,
2330 &wanted,
2331 depth.saturating_add(unsupported.len()),
2332 )
2333 })? {
2334 let key = (hit.resource, hit.ordinal);
2335 if unsupported.contains(&key) {
2336 continue;
2337 }
2338 if metadata
2339 .get(&key.0)
2340 .is_some_and(|m| m.metadata.media_type.as_deref() == Some("application/pdf"))
2341 {
2342 let Some(segment) = segments.get_or_load(engine, pid, &key)? else {
2343 continue;
2344 };
2345 if !engine.supports_locator(&metadata[&key.0].metadata, &segment.locator) {
2346 continue;
2347 }
2348 }
2349 lexical.push((key, hit.score));
2350 if lexical.len() == depth {
2351 break;
2352 }
2353 }
2354 for resource in resources.iter().filter(|r| {
2355 wanted.contains(&r.id)
2356 && self
2357 .queries
2358 .iter()
2359 .any(|q| names_file(q, Path::new(&r.id.labels().1)))
2360 }) {
2361 if !metadata.contains_key(&resource.id) {
2362 continue;
2363 }
2364 if let Some(ordinal) = segments
2367 .load_resource(engine, pid, &resource.id)?
2368 .first_supported
2369 {
2370 named.push((resource.id.clone(), ordinal));
2371 }
2372 }
2373 if self.mode == SearchMode::Lexical {
2374 searched = lexical.len();
2375 }
2376 }
2377
2378 let weight = match self.mode {
2379 SearchMode::Hybrid => self
2380 .lexical_weight
2381 .unwrap_or_else(|| keyword_weight(&self.queries)),
2382 _ => keyword_weight(&self.queries),
2383 };
2384 let ranked = rank(self.mode, &semantic, &lexical, weight, &named, self.limit);
2385
2386 let semantic_ranks: HashMap<_, _> = semantic
2389 .iter()
2390 .enumerate()
2391 .map(|(i, (key, _, _))| (key, i + 1))
2392 .collect();
2393 let keyword_ranks: HashMap<_, _> = lexical
2394 .iter()
2395 .enumerate()
2396 .map(|(i, (key, _))| (key, i + 1))
2397 .collect();
2398 let filename_ranks: HashMap<_, _> = named
2399 .iter()
2400 .enumerate()
2401 .map(|(i, key)| (key, i + 1))
2402 .collect();
2403 let mut previews = PreviewReader::default();
2404 let mut hits = Vec::with_capacity(ranked.len());
2405 let mut keyword_keys = Vec::new();
2406 let mut keyword_positions = Vec::new();
2407 for (key, score, signals, query_index) in ranked {
2408 let Some(metadata) = metadata.get(&key.0).map(|s| s.metadata.clone()) else {
2409 continue;
2410 };
2411 let Some(segment) = segments.get_or_load(engine, pid, &key)? else {
2412 continue;
2413 };
2414 if !engine.supports_locator(&metadata, &segment.locator) {
2415 continue;
2416 }
2417 let preview = self.preview_chars.and_then(|n| {
2418 previews.preview(
2419 engine.provider.as_ref(),
2420 &key.0,
2421 &segment.locator,
2422 &metadata,
2423 n,
2424 )
2425 });
2426 let keyword_rank = signals
2427 .lexical
2428 .and_then(|_| keyword_ranks.get(&key).copied());
2429 let reason = MatchReason {
2430 semantic_rank: signals
2431 .semantic
2432 .and_then(|_| semantic_ranks.get(&key).copied()),
2433 keyword_rank,
2434 filename_rank: signals.filename.then(|| filename_ranks[&key]),
2435 matched_terms: Vec::new(),
2436 };
2437 if keyword_rank.is_some() {
2438 keyword_keys.push(key.clone());
2439 keyword_positions.push(hits.len());
2440 }
2441 hits.push(SearchHit {
2442 resource: key.0,
2443 locator: segment.locator.clone(),
2444 score,
2445 signals,
2446 r#match: reason,
2447 metadata,
2448 heading: segment.heading.clone(),
2449 preview,
2450 query_index,
2451 });
2452 }
2453
2454 if !keyword_keys.is_empty() {
2455 let query = LexicalQuery::new(self.queries.iter().map(String::as_str));
2456 let terms =
2457 engine.with_cache(|c| c.lexical_matched_terms(pid, &query, &keyword_keys))?;
2458 for (position, terms) in keyword_positions.into_iter().zip(terms) {
2459 hits[position].r#match.matched_terms = terms;
2460 }
2461 }
2462
2463 let kind_counts = self.kind_counts.then(|| {
2464 let mut counts = std::collections::BTreeMap::new();
2465 for hit in &hits {
2466 *counts.entry(hit.locator.kind().to_owned()).or_insert(0) += 1;
2467 }
2468 counts
2469 });
2470 Ok(SearchResults {
2471 kind_counts,
2472 queries: self.queries,
2473 mode: self.mode,
2474 hits,
2475 degraded: engine
2476 .lexical_only
2477 .then(|| SearchDegradation::lexical(DegradedReason::NoModel)),
2478 stats: SearchStats {
2479 sync,
2480 segments_searched: searched,
2481 total_ms: started.elapsed().as_millis() as u64,
2482 },
2483 })
2484 }
2485}
2486
2487type SegmentKey = (ResourceId, usize);
2489
2490#[derive(Default)]
2493struct SegmentTable {
2494 segments: HashMap<ResourceId, HashMap<usize, CachedSegment>>,
2495 complete: HashMap<ResourceId, CompleteResource>,
2497}
2498
2499#[derive(Default)]
2502struct CompleteResource {
2503 unsupported: Vec<usize>,
2504 first_supported: Option<usize>,
2505}
2506
2507impl SegmentTable {
2508 fn insert(&mut self, path: ResourceId, ordinal: usize, segment: CachedSegment) -> SegmentKey {
2509 self.segments
2510 .entry(path.clone())
2511 .or_default()
2512 .insert(ordinal, segment);
2513 (path, ordinal)
2514 }
2515
2516 fn load_resource(
2519 &mut self,
2520 engine: &SearchEngine,
2521 pid: &str,
2522 resource: &ResourceId,
2523 ) -> Result<&CompleteResource> {
2524 if !self.complete.contains_key(resource) {
2525 let cached = engine.with_cache(|c| c.resource(pid, resource))?;
2526 let mut complete = CompleteResource::default();
2527 let seen = self.segments.entry(resource.clone()).or_default();
2528 if let Some(cached) = cached {
2529 for (ordinal, segment) in cached.segments.into_iter().enumerate() {
2530 if engine.supports_locator(&cached.metadata, &segment.locator) {
2531 complete.first_supported.get_or_insert(ordinal);
2532 } else {
2533 complete.unsupported.push(ordinal);
2534 }
2535 seen.entry(ordinal).or_insert(segment);
2538 }
2539 }
2540 self.complete.insert(resource.clone(), complete);
2541 }
2542 Ok(&self.complete[resource])
2543 }
2544
2545 fn get_or_load(
2546 &mut self,
2547 engine: &SearchEngine,
2548 pid: &str,
2549 key: &SegmentKey,
2550 ) -> Result<Option<&CachedSegment>> {
2551 if !self
2552 .segments
2553 .get(&key.0)
2554 .is_some_and(|s| s.contains_key(&key.1))
2555 {
2556 self.load_resource(engine, pid, &key.0)?;
2557 }
2558 Ok(self.segments.get(&key.0).and_then(|s| s.get(&key.1)))
2559 }
2560}
2561
2562fn names_file(query: &str, path: &Path) -> bool {
2565 let q = query
2566 .trim()
2567 .trim_matches(['"', '\'', '`'])
2568 .trim()
2569 .replace('\\', "/")
2570 .to_lowercase();
2571 if q.is_empty() {
2572 return false;
2573 }
2574 let full = path.to_string_lossy().replace('\\', "/").to_lowercase();
2575 let path = Path::new(&full);
2576 let name = path.file_name().map(|n| n.to_string_lossy().to_lowercase());
2577 let stem = path.file_stem().map(|n| n.to_string_lossy().to_lowercase());
2578 if name.as_deref() == Some(q.as_str()) || stem.as_deref() == Some(q.as_str()) {
2579 return true;
2580 }
2581 q.contains('/') && {
2582 full == q || (full.ends_with(&q) && full[..full.len() - q.len()].ends_with('/'))
2583 }
2584}
2585
2586pub fn keyword_weight(queries: &[String]) -> f32 {
2593 let identifier = |w: &str| {
2594 w.chars().any(|c| c.is_ascii_digit())
2595 || w.trim_matches(|c: char| !c.is_alphanumeric())
2596 .contains(['.', '_', ':', '-', '/', '#'])
2597 };
2598 let keywordish = queries.iter().any(|q| {
2599 let words: Vec<&str> = q.split_whitespace().collect();
2600 words.iter().any(|w| identifier(w)) || LexicalQuery::new([q.as_str()]).groups.len() <= 2
2601 });
2602 if keywordish {
2603 1.0
2604 } else {
2605 PROSE_KEYWORD_WEIGHT
2606 }
2607}
2608
2609pub const PROSE_KEYWORD_WEIGHT: f32 = 0.3;
2611
2612const RRF_K: f32 = 5.0;
2616const FILENAME_WEIGHT: f32 = 3.0;
2618
2619fn rank(
2621 mode: SearchMode,
2622 semantic: &[(SegmentKey, f32, usize)],
2623 lexical: &[(SegmentKey, f32)],
2624 lexical_weight: f32,
2625 named: &[SegmentKey],
2626 limit: usize,
2627) -> Vec<(SegmentKey, f32, MatchSignals, usize)> {
2628 if mode == SearchMode::Semantic {
2629 return semantic
2630 .iter()
2631 .take(limit)
2632 .map(|(k, cos, q)| {
2633 let signals = MatchSignals {
2634 semantic: Some(*cos),
2635 ..Default::default()
2636 };
2637 (k.clone(), *cos, signals, *q)
2638 })
2639 .collect();
2640 }
2641 let rrf = |rank: usize, weight: f32| weight / (RRF_K + rank as f32 + 1.0);
2642 let mut fused: HashMap<SegmentKey, (f32, MatchSignals, usize)> = HashMap::new();
2643 for (rank, (k, cos, q)) in semantic.iter().enumerate() {
2644 let e = fused.entry(k.clone()).or_default();
2645 e.0 += rrf(rank, 1.0);
2646 e.1.semantic = Some(*cos);
2647 e.2 = *q;
2648 }
2649 if lexical_weight > 0.0 {
2650 for (rank, (k, bm25)) in lexical.iter().enumerate() {
2651 let e = fused.entry(k.clone()).or_default();
2652 e.0 += rrf(rank, lexical_weight);
2653 e.1.lexical = Some(*bm25);
2654 }
2655 }
2656 for (rank, k) in named.iter().enumerate() {
2657 let e = fused.entry(k.clone()).or_default();
2658 e.0 += rrf(rank, FILENAME_WEIGHT);
2659 e.1.filename = true;
2660 }
2661 let best = [
2663 (!semantic.is_empty(), 1.0),
2664 (!lexical.is_empty(), lexical_weight),
2665 (!named.is_empty(), FILENAME_WEIGHT),
2666 ]
2667 .iter()
2668 .filter(|(found, w)| *found && *w > 0.0)
2669 .map(|(_, w)| rrf(0, *w))
2670 .sum::<f32>();
2671 if best == 0.0 {
2672 return Vec::new();
2673 }
2674 let mut out: Vec<_> = fused
2675 .into_iter()
2676 .map(|(k, (score, signals, q))| (k, score / best, signals, q))
2677 .collect();
2678 out.sort_by(|a, b| {
2679 b.1.total_cmp(&a.1)
2680 .then_with(|| {
2681 b.2.semantic
2682 .unwrap_or(f32::MIN)
2683 .total_cmp(&a.2.semantic.unwrap_or(f32::MIN))
2684 })
2685 .then_with(|| a.0.cmp(&b.0))
2686 });
2687 out.truncate(limit);
2688 out
2689}
2690
2691struct Fused {
2692 scored: Scored,
2693 query_index: usize,
2694}
2695
2696fn fuse_max(per_query: impl Iterator<Item = Vec<Scored>>, limit: usize) -> Vec<Fused> {
2701 let mut best: HashMap<usize, Fused> = HashMap::new();
2702 for (query_index, hits) in per_query.enumerate() {
2703 for scored in hits {
2704 match best.get(&scored.id) {
2705 Some(b) if b.scored.score >= scored.score => {}
2706 _ => {
2707 best.insert(
2708 scored.id,
2709 Fused {
2710 scored,
2711 query_index,
2712 },
2713 );
2714 }
2715 }
2716 }
2717 }
2718 let mut fused: Vec<Fused> = best.into_values().collect();
2719 fused.sort_by(|a, b| {
2720 b.scored
2721 .score
2722 .total_cmp(&a.scored.score)
2723 .then(a.scored.id.cmp(&b.scored.id))
2724 });
2725 fused.truncate(limit);
2726 fused
2727}
2728
2729pub struct IndexRequest<'e> {
2730 engine: &'e SearchEngine,
2731 paths: Vec<PathBuf>,
2732 roots: Vec<ResourceId>,
2733 discovery: DiscoveryOptions,
2734 progress: Option<ProgressFn<'e>>,
2735}
2736
2737impl<'e> IndexRequest<'e> {
2738 pub fn roots(mut self, roots: impl IntoIterator<Item = ResourceId>) -> Self {
2740 self.roots.extend(roots);
2741 self
2742 }
2743
2744 pub fn paths<I, P>(mut self, paths: I) -> Self
2747 where
2748 I: IntoIterator<Item = P>,
2749 P: AsRef<Path>,
2750 {
2751 self.paths
2752 .extend(paths.into_iter().map(|p| expand_tilde(p.as_ref())));
2753 self
2754 }
2755
2756 pub fn discovery(mut self, opts: DiscoveryOptions) -> Self {
2757 self.discovery = opts;
2758 self
2759 }
2760
2761 pub fn progress(mut self, f: ProgressFn<'e>) -> Self {
2762 self.progress = Some(f);
2763 self
2764 }
2765
2766 pub fn execute(self) -> Result<IndexReport> {
2769 let started = Instant::now();
2770 let engine = self.engine;
2771 if self.paths.is_empty() && self.roots.is_empty() {
2772 return Err(Error::Config("no paths to index".into()));
2773 }
2774 let mut discovery = self.discovery;
2775 discovery.max_audio_duration_secs = engine.max_audio_duration_secs;
2776 let scope = ResourceScope::from_paths(&self.paths, self.roots, discovery)?;
2777 let listing = engine.provider.list_walked(&scope)?;
2778 report_walked(self.progress, listing.walked_roots);
2779 let resources = listing.resources;
2780 if let Some(p) = self.progress {
2781 p(ProgressEvent::Discovered {
2782 resources: resources.len(),
2783 });
2784 }
2785 let indexing = engine.acquire_indexing_lease()?;
2786 let mut sync = engine.sync(&indexing, &resources, self.progress)?;
2787
2788 let pid = engine.profile_id.as_str();
2789 let present: HashSet<&ResourceId> = resources.iter().map(|r| &r.id).collect();
2790 let known = engine.with_cache(|c| c.resource_index(pid))?;
2791 let mut candidates: HashSet<_> = known.keys().cloned().collect();
2794 candidates.extend(
2795 engine
2796 .skipped_versions
2797 .lock()
2798 .map_err(|_| Error::Config("skip outcomes lock poisoned".into()))?
2799 .keys()
2800 .cloned(),
2801 );
2802 let mut gone = Vec::new();
2803 match engine.provider.deletion_scope(&scope) {
2804 Ok(deletion_scope) => {
2805 for id in candidates {
2806 if present.contains(&id) || !deletion_scope.contains(&id) {
2807 continue;
2808 }
2809 match engine.provider.is_deleted(&id, &deletion_scope) {
2810 Ok(true) => gone.push(id),
2811 Ok(false) => {}
2812 Err(e) => sync.skipped.push(skip(&id, format!("deletion check: {e}"))),
2813 }
2814 }
2815 }
2816 Err(e) => {
2817 for root in &scope.roots {
2818 sync.skipped
2819 .push(skip(root, format!("deletion scope: {e}")));
2820 }
2821 }
2822 }
2823 let removed = engine.with_cache(|c| {
2824 c.remove_resources(pid, &gone)?;
2825 if !gone.is_empty() {
2826 c.prune_orphans(pid)?;
2827 }
2828 Ok(gone.iter().filter(|id| known.contains_key(*id)).count())
2829 })?;
2830
2831 if !gone.is_empty() {
2832 let gone: HashSet<_> = gone.iter().collect();
2833 engine
2834 .skipped_versions
2835 .lock()
2836 .map_err(|_| Error::Config("skip outcomes lock poisoned".into()))?
2837 .retain(|resource, _| !gone.contains(resource));
2838 engine
2839 .pdf_diagnostics
2840 .lock()
2841 .map_err(|_| Error::Config("PDF diagnostics lock poisoned".into()))?
2842 .retain(|resource, _| !gone.contains(resource));
2843 }
2844 sync.skipped_counts = skipped_counts(&sync.skipped);
2845 Ok(IndexReport {
2846 sync,
2847 resources_removed: removed,
2848 total_ms: started.elapsed().as_millis() as u64,
2849 })
2850 }
2851}
2852
2853#[derive(Default)]
2855struct PreviewReader {
2856 files: HashMap<ResourceId, Option<ResourceBytes>>,
2858 #[cfg(feature = "pdf")]
2859 pages: HashMap<(ResourceId, u32), Option<String>>,
2860 #[cfg(feature = "pdf")]
2861 page_bytes: usize,
2862 #[cfg(all(test, feature = "pdf"))]
2863 page_decodes: usize,
2864}
2865
2866impl PreviewReader {
2867 fn preview(
2868 &mut self,
2869 provider: &dyn ResourceProvider,
2870 path: &ResourceId,
2871 locator: &ResourceLocator,
2872 meta: &ResourceMetadata,
2873 max_chars: usize,
2874 ) -> Option<String> {
2875 if !matches!(
2876 locator,
2877 ResourceLocator::Text { .. } | ResourceLocator::PageText { .. }
2878 ) {
2879 return None;
2880 }
2881 let read = self
2882 .files
2883 .entry(path.clone())
2884 .or_insert_with(|| ResourceBytes::read(provider, path).ok())
2885 .as_ref()?;
2886 let bytes = read.verify(path, meta).ok()?;
2887 let text = match locator {
2888 ResourceLocator::Text { .. } => text_range(bytes, path, locator).ok()?,
2889 #[cfg(feature = "pdf")]
2890 ResourceLocator::PageText { page, .. }
2891 if meta.media_type.as_deref() == Some("application/pdf") =>
2892 {
2893 let key = (path.clone(), *page);
2894 if !self.pages.contains_key(&key) {
2895 #[cfg(test)]
2896 {
2897 self.page_decodes += 1;
2898 }
2899 let text = crate::pdf::read_page(bytes, path, *page).ok();
2900 if let Some(t) = &text {
2901 if self.page_bytes + t.len() > crate::pdf::MAX_DOCUMENT_TEXT_BYTES {
2902 self.pages.insert(key, None);
2903 return None;
2904 }
2905 self.page_bytes += t.len();
2906 }
2907 self.pages.insert(key.clone(), text);
2908 }
2909 crate::pdf::slice(self.pages.get(&key)?.as_deref()?, path, locator).ok()?
2910 }
2911 _ => return None,
2912 };
2913 Some(make_preview(text, max_chars))
2914 }
2915}
2916
2917pub fn make_preview(text: &str, max_chars: usize) -> String {
2919 let body: Vec<&str> = text
2920 .lines()
2921 .skip_while(|l| l.trim_start().starts_with('#') || l.trim().is_empty())
2922 .collect();
2923 let source = if body.is_empty() {
2924 text.to_string()
2925 } else {
2926 body.join(" ")
2927 };
2928 let collapsed = source.split_whitespace().collect::<Vec<_>>().join(" ");
2929 if collapsed.chars().count() <= max_chars {
2930 return collapsed;
2931 }
2932 let cut: String = collapsed.chars().take(max_chars).collect();
2933 let cut = cut.rsplit_once(' ').map(|(a, _)| a).unwrap_or(&cut);
2934 format!("{cut}…")
2935}
2936
2937pub fn expand_tilde(p: &Path) -> PathBuf {
2939 match p.strip_prefix("~") {
2940 Ok(rest) => dirs::home_dir()
2941 .map(|h| h.join(rest))
2942 .unwrap_or_else(|| p.to_path_buf()),
2943 Err(_) => p.to_path_buf(),
2944 }
2945}
2946
2947#[cfg(test)]
2948mod tests {
2949 use super::*;
2950
2951 #[cfg(feature = "pdf")]
2952 #[test]
2953 fn clean_pdf_replacement_is_reused_without_stale_diagnostic_skips() {
2954 use crate::pdf::fixture::{Kind, pdf};
2955 let dir = tempfile::tempdir().unwrap();
2956 let path = dir.path().join("changing.pdf");
2957 let engine = SearchEngine::lexical().unwrap();
2958 std::fs::write(&path, crate::pdf::fixture::malformed_cmap(false)).unwrap();
2959 let a = engine.index().paths([&path]).execute().unwrap();
2960 assert_eq!(a.sync.skipped.len(), 1);
2961 assert!(!a.sync.page_diagnostics.is_empty());
2962 assert_eq!(a.sync.progress.unwrap().by_kind["pdf"].skipped, 1);
2963 assert_eq!(engine.pdf_diagnostics.lock().unwrap().len(), 1);
2964 std::fs::write(&path, pdf(&["Clean version B with basil."], Kind::Plain)).unwrap();
2965 let b = engine.index().paths([&path]).execute().unwrap();
2966 assert!(b.sync.skipped.is_empty());
2967 assert!(b.sync.page_diagnostics.is_empty());
2968 assert!(engine.pdf_diagnostics.lock().unwrap().is_empty());
2969 let warm = engine.index().paths([&path]).execute().unwrap();
2970 assert_eq!(warm.sync.resources_unchanged, 1);
2971 assert_eq!(warm.sync.segments_embedded, 0);
2972 assert!(warm.sync.skipped.is_empty());
2973 assert!(warm.sync.page_diagnostics.is_empty());
2974 let progress = warm.sync.progress.unwrap();
2975 let counts = &progress.by_kind["pdf"];
2976 assert_eq!((counts.indexed, counts.waiting, counts.skipped), (1, 0, 0));
2977 assert_eq!(progress.eta_seconds, None);
2978 }
2979
2980 #[cfg(feature = "pdf")]
2981 fn diagnostic_target(resource: &ResourceId, descriptor: &str, page: u32) -> Pending {
2982 Pending {
2983 descriptor_hash: descriptor.into(),
2984 resource: resource.clone(),
2985 version: ResourceVersion::unknown(),
2986 metadata: ResourceMetadata {
2987 media_type: Some("application/pdf".into()),
2988 modality: crate::Modality::Document,
2989 size_bytes: 0,
2990 modified_ns: None,
2991 content_hash: String::new(),
2992 },
2993 jobs: Vec::new(),
2994 page_diagnostics: vec![PageDiagnostic {
2995 code: SkipCode::PdfParseFailed,
2996 resource: resource.clone(),
2997 page,
2998 reason: descriptor.into(),
2999 }],
3000 }
3001 }
3002
3003 #[cfg(feature = "pdf")]
3004 #[test]
3005 fn pdf_diagnostics_replace_descriptors_and_clear_empty_results() {
3006 let engine = SearchEngine::lexical().unwrap();
3007 let resource = "mem://pdf/first".parse().unwrap();
3008 let other = "mem://pdf/other".parse().unwrap();
3009 let a = diagnostic_target(&resource, "A", 1);
3010 let mut b = diagnostic_target(&resource, "B", 2);
3011 let unrelated = diagnostic_target(&other, "other", 3);
3012 engine
3013 .remember_pdf_diagnostics(&a, Some(skip(&resource, "A skip")))
3014 .unwrap();
3015 engine.remember_pdf_diagnostics(&unrelated, None).unwrap();
3016 let b_skip = skip(&resource, "B skip");
3017 engine
3018 .remember_pdf_diagnostics(&b, Some(b_skip.clone()))
3019 .unwrap();
3020 let mut stats = SyncStats::default();
3021 engine
3022 .replay_pdf_diagnostics(&resource, "A", &mut stats)
3023 .unwrap();
3024 assert!(stats.page_diagnostics.is_empty());
3025 assert!(stats.skipped.is_empty());
3026 engine
3027 .replay_pdf_diagnostics(&resource, "B", &mut stats)
3028 .unwrap();
3029 assert_eq!(stats.page_diagnostics, b.page_diagnostics);
3030 assert_eq!(stats.skipped, vec![b_skip]);
3031 b.page_diagnostics.clear();
3032 engine.remember_pdf_diagnostics(&b, None).unwrap();
3033 let mut stats = SyncStats::default();
3034 engine
3035 .replay_pdf_diagnostics(&resource, "B", &mut stats)
3036 .unwrap();
3037 assert!(stats.page_diagnostics.is_empty());
3038 assert!(stats.skipped.is_empty());
3039 engine
3040 .replay_pdf_diagnostics(&other, "other", &mut stats)
3041 .unwrap();
3042 assert_eq!(stats.page_diagnostics, unrelated.page_diagnostics);
3043 assert!(stats.skipped.is_empty());
3044 }
3045
3046 #[cfg(feature = "pdf")]
3047 #[test]
3048 fn deletion_clears_pdf_diagnostics_only_for_deleted_resources() {
3049 let root = tempfile::tempdir().unwrap();
3050 let deleted = root.path().join("deleted.pdf");
3051 let kept = root.path().join("kept.pdf");
3052 for path in [&deleted, &kept] {
3053 std::fs::write(path, b"%PDF invalid").unwrap();
3054 }
3055 let engine = SearchEngine::lexical().unwrap();
3056 engine.index().paths([root.path()]).execute().unwrap();
3057 let deleted_id = ResourceId::from_path(deleted.canonicalize().unwrap()).unwrap();
3058 let kept_id = ResourceId::from_path(kept.canonicalize().unwrap()).unwrap();
3059 assert_eq!(engine.pdf_diagnostics.lock().unwrap().len(), 2);
3060 std::fs::remove_file(deleted).unwrap();
3061 engine.index().paths([root.path()]).execute().unwrap();
3062 let diagnostics = engine.pdf_diagnostics.lock().unwrap();
3063 assert!(!diagnostics.contains_key(&deleted_id));
3064 assert!(diagnostics.contains_key(&kept_id));
3065 }
3066
3067 #[test]
3068 fn skipped_versions_stay_bounded_after_delete_cycles() {
3069 let root = tempfile::tempdir().unwrap();
3072 let engine = SearchEngine::lexical().unwrap();
3073 for batch in 0..3 {
3074 for i in 0..1000 {
3075 std::fs::write(root.path().join(format!("bad-{batch}-{i}.txt")), [0xff]).unwrap();
3076 }
3077 let report = engine.index().paths([root.path()]).execute().unwrap();
3078 assert_eq!(report.sync.skipped.len(), 1000);
3079 assert_eq!(engine.skipped_versions.lock().unwrap().len(), 1000);
3080 for i in 0..1000 {
3081 std::fs::remove_file(root.path().join(format!("bad-{batch}-{i}.txt"))).unwrap();
3082 }
3083 let report = engine.index().paths([root.path()]).execute().unwrap();
3084 assert_eq!(report.sync.resources_discovered, 0);
3085 assert_eq!(report.resources_removed, 0, "no cached resources existed");
3086 assert!(engine.skipped_versions.lock().unwrap().is_empty());
3087 }
3088 }
3089
3090 #[test]
3091 fn skipped_versions_prune_only_confirmed_deletions_in_scope() {
3092 let first = tempfile::tempdir().unwrap();
3093 let second = tempfile::tempdir().unwrap();
3094 let deleted = first.path().join("deleted.txt");
3095 let excluded = first.path().join("excluded.txt");
3096 let outside = second.path().join("outside.txt");
3097 for path in [&deleted, &excluded, &outside] {
3098 std::fs::write(path, [0xff]).unwrap();
3099 }
3100 let engine = SearchEngine::lexical().unwrap();
3101 engine
3102 .index()
3103 .paths([first.path(), second.path()])
3104 .execute()
3105 .unwrap();
3106 let excluded_id = ResourceId::from_path(excluded.canonicalize().unwrap()).unwrap();
3107 let outside_id = ResourceId::from_path(outside.canonicalize().unwrap()).unwrap();
3108 std::fs::remove_file(deleted).unwrap();
3109 std::fs::remove_file(outside).unwrap();
3110 let report = engine
3111 .index()
3112 .paths([first.path()])
3113 .discovery(DiscoveryOptions {
3114 exclude: vec!["excluded.txt".into()],
3115 ..Default::default()
3116 })
3117 .execute()
3118 .unwrap();
3119 assert_eq!(report.sync.resources_discovered, 0);
3120 {
3121 let skipped = engine.skipped_versions.lock().unwrap();
3122 assert_eq!(skipped.len(), 2);
3123 assert!(
3124 skipped.contains_key(&excluded_id),
3125 "excluded is not deleted"
3126 );
3127 assert!(
3128 skipped.contains_key(&outside_id),
3129 "other scopes must survive"
3130 );
3131 }
3132 engine.index().paths([second.path()]).execute().unwrap();
3133 let skipped = engine.skipped_versions.lock().unwrap();
3134 assert_eq!(skipped.len(), 1);
3135 assert!(skipped.contains_key(&excluded_id));
3136 }
3137
3138 fn worker_engine() -> SearchEngine {
3139 struct NoInference(crate::ModelIdentity);
3140 impl Embedder for NoInference {
3141 fn identity(&self) -> &crate::ModelIdentity {
3142 &self.0
3143 }
3144 fn supports(&self, _: crate::Modality) -> bool {
3145 true
3146 }
3147 fn embed_batch(
3148 &self,
3149 _: &[EmbeddingRequest<'_>],
3150 ) -> seyk_embed::Result<Vec<Embedding>> {
3151 panic!("worker must not embed")
3152 }
3153 }
3154 SearchEngine::builder()
3155 .embedder(NoInference(crate::ModelIdentity {
3156 model_id: "test/worker".into(),
3157 revision: "1".into(),
3158 variant: "test".into(),
3159 dimension: crate::Dimension::D128,
3160 normalization: "l2".into(),
3161 query_prompt: String::new(),
3162 document_prompt: String::new(),
3163 max_tokens: 512,
3164 }))
3165 .build()
3166 .unwrap()
3167 }
3168
3169 fn worker_ticks() -> (
3172 std::sync::mpsc::Sender<()>,
3173 std::sync::mpsc::Receiver<()>,
3174 impl FnMut() -> bool + Send,
3175 ) {
3176 let (ticks, receive) = std::sync::mpsc::channel();
3177 let (waiting, waits) = std::sync::mpsc::channel();
3178 (ticks, waits, move || {
3179 waiting.send(()).unwrap();
3180 receive.recv().is_ok()
3181 })
3182 }
3183
3184 fn worker_wait(waits: &std::sync::mpsc::Receiver<()>) {
3185 waits
3186 .recv_timeout(std::time::Duration::from_secs(5))
3187 .unwrap();
3188 }
3189
3190 #[cfg(feature = "pdf")]
3191 #[test]
3192 fn pdf_skip_codes_come_from_variants_not_display_text() {
3193 let resource = "mem://typed".parse().unwrap();
3194 assert_eq!(
3195 pdf_skip(
3196 &resource,
3197 Error::ParserPanicked {
3198 resource: resource.clone(),
3199 reason: "arbitrary sanitized reason"
3200 }
3201 )
3202 .code,
3203 SkipCode::PdfParserPanicked
3204 );
3205 assert_eq!(
3206 pdf_skip(
3207 &resource,
3208 Error::PdfPageDecode {
3209 resource: resource.clone(),
3210 reason: "arbitrary sanitized reason"
3211 }
3212 )
3213 .code,
3214 SkipCode::PdfParseFailed
3215 );
3216 assert_eq!(
3217 skip(&resource, "PDF parser panicked: forged string").code,
3218 SkipCode::Other
3219 );
3220 }
3221
3222 #[test]
3223 fn cache_worker_duplicate_start_runs_only_one_worker() {
3224 use std::sync::atomic::AtomicUsize;
3225 let engine = worker_engine();
3226 let (ticks, waits, clock) = worker_ticks();
3227 let runs = Arc::new(AtomicUsize::new(0));
3228 let observed = runs.clone();
3229 let worker = engine
3230 .start_cache_scrubber_with(clock, move |_| {
3231 observed.fetch_add(1, Ordering::SeqCst);
3232 })
3233 .unwrap();
3234 worker_wait(&waits);
3235 assert!(
3236 engine
3237 .start_cache_scrubber_with(
3238 || panic!("duplicate worker started"),
3239 |_| panic!("duplicate scrub")
3240 )
3241 .is_none()
3242 );
3243 engine.start_cache_scrubber();
3244 ticks.send(()).unwrap();
3245 worker_wait(&waits);
3246 assert_eq!(runs.load(Ordering::SeqCst), 1);
3247 drop(ticks);
3248 worker.join().unwrap();
3249 }
3250
3251 #[test]
3252 fn cache_worker_excludes_active_indexing() {
3253 use std::sync::atomic::AtomicUsize;
3254 let engine = worker_engine();
3255 let (ticks, waits, clock) = worker_ticks();
3256 let runs = Arc::new(AtomicUsize::new(0));
3257 let observed = runs.clone();
3258 let worker = engine
3259 .start_cache_scrubber_with(clock, move |_| {
3260 observed.fetch_add(1, Ordering::SeqCst);
3261 })
3262 .unwrap();
3263 worker_wait(&waits);
3264 let indexing = engine.indexing.lock().unwrap();
3265 ticks.send(()).unwrap();
3266 worker_wait(&waits);
3267 assert_eq!(runs.load(Ordering::SeqCst), 0);
3268 drop(indexing);
3269 ticks.send(()).unwrap();
3270 worker_wait(&waits);
3271 assert_eq!(runs.load(Ordering::SeqCst), 1);
3272 drop(ticks);
3273 worker.join().unwrap();
3274 }
3275
3276 fn join_worker_promptly(worker: std::thread::JoinHandle<()>) {
3277 let (completed, completion) = std::sync::mpsc::channel();
3278 std::thread::spawn(move || {
3279 let _ = completed.send(worker.join());
3280 });
3281 completion
3282 .recv_timeout(std::time::Duration::from_secs(2))
3283 .expect("worker did not exit within two seconds")
3284 .unwrap();
3285 }
3286
3287 #[test]
3288 fn cache_worker_drop_interrupts_real_production_deadline_wait() {
3289 let engine = worker_engine();
3290 let weak_cache = Arc::downgrade(&engine.cache);
3291 let weak_indexing = Arc::downgrade(&engine.indexing);
3292 let stop = engine.scrubber_stop.clone();
3293 let worker = engine.spawn_cache_scrubber().unwrap();
3294 assert_eq!(
3298 MemoryCache::new().scrub_delay(std::time::Duration::from_secs(60)),
3299 std::time::Duration::from_secs(60)
3300 );
3301 let state = stop.state.lock().unwrap();
3302 let (state, _) = stop
3303 .wake
3304 .wait_timeout_while(state, std::time::Duration::from_secs(5), |state| {
3305 !state.waiting
3306 })
3307 .unwrap();
3308 assert!(
3309 state.waiting,
3310 "production worker did not enter its deadline wait"
3311 );
3312 assert!(!state.stopped);
3313 drop(state);
3314 drop(engine);
3315 assert!(weak_cache.upgrade().is_none());
3316 assert!(weak_indexing.upgrade().is_none());
3317 join_worker_promptly(worker);
3318 }
3319
3320 #[test]
3321 fn cache_worker_drop_while_active_finishes_only_current_iteration() {
3322 let engine = worker_engine();
3323 let weak_cache = Arc::downgrade(&engine.cache);
3324 let weak_indexing = Arc::downgrade(&engine.indexing);
3325 let (ticks, waits, clock) = worker_ticks();
3326 let (entered, active) = std::sync::mpsc::channel();
3327 let (finish, release) = std::sync::mpsc::channel();
3328 let worker = engine
3329 .start_cache_scrubber_with(clock, move |_| {
3330 entered.send(()).unwrap();
3331 release
3332 .recv_timeout(std::time::Duration::from_secs(5))
3333 .unwrap();
3334 })
3335 .unwrap();
3336 worker_wait(&waits);
3337 ticks.send(()).unwrap();
3338 worker_wait(&active);
3339 drop(engine);
3340 assert!(
3341 weak_cache.upgrade().is_some(),
3342 "in-flight iteration retains its cache"
3343 );
3344 assert!(weak_indexing.upgrade().is_some());
3345 finish.send(()).unwrap();
3346 join_worker_promptly(worker);
3347 assert!(
3348 weak_cache.upgrade().is_none(),
3349 "released after current iteration"
3350 );
3351 assert!(weak_indexing.upgrade().is_none());
3352 assert!(
3353 waits.try_recv().is_err(),
3354 "must not start another wait after drop"
3355 );
3356 }
3357
3358 #[cfg(any(feature = "pdf", feature = "audio"))]
3359 #[test]
3360 fn media_commit_failure_preserves_binding_and_update_count() {
3361 struct NoInference(crate::ModelIdentity);
3362 impl Embedder for NoInference {
3363 fn identity(&self) -> &crate::ModelIdentity {
3364 &self.0
3365 }
3366 fn supports(&self, _: crate::Modality) -> bool {
3367 true
3368 }
3369 fn embed_batch(
3370 &self,
3371 _: &[EmbeddingRequest<'_>],
3372 ) -> seyk_embed::Result<Vec<Embedding>> {
3373 panic!("committing prepared records must not run inference")
3374 }
3375 }
3376 let dir = tempfile::tempdir().unwrap();
3377 let path = dir.path().join("cache.db");
3378 let engine = SearchEngine::builder()
3379 .embedder(NoInference(crate::ModelIdentity {
3380 model_id: "test/media-commit".into(),
3381 revision: "1".into(),
3382 variant: "test".into(),
3383 dimension: crate::Dimension::D128,
3384 normalization: "l2".into(),
3385 query_prompt: String::new(),
3386 document_prompt: String::new(),
3387 max_tokens: 512,
3388 }))
3389 .cache(crate::SqliteCache::open(&path).unwrap())
3390 .build()
3391 .unwrap();
3392 let mut target = Pending {
3393 resource: "test://media/document".parse().unwrap(),
3394 metadata: ResourceMetadata {
3395 media_type: Some("application/pdf".into()),
3396 modality: crate::Modality::Document,
3397 size_bytes: 10,
3398 modified_ns: None,
3399 content_hash: "old-content".into(),
3400 },
3401 version: ResourceVersion::known("old-version"),
3402 descriptor_hash: "old-descriptor".into(),
3403 jobs: Vec::new(),
3404 page_diagnostics: Vec::new(),
3405 };
3406 let records = vec![(
3407 CachedSegment {
3408 locator: ResourceLocator::Page { page: 1 },
3409 heading: None,
3410 input_hash: "old-input".into(),
3411 },
3412 String::new(),
3413 )];
3414 let mut stats = SyncStats::default();
3415 engine
3416 .commit_media_resource(&target, &records, &mut stats)
3417 .unwrap();
3418 assert_eq!(stats.resources_updated, 1);
3419 let binding = || {
3420 engine
3421 .with_cache(|cache| cache.resource(engine.profile_id(), &target.resource))
3422 .unwrap()
3423 .unwrap()
3424 };
3425 let old = binding();
3426 target.metadata.content_hash = "new-content".into();
3427 target.version = ResourceVersion::known("new-version");
3428 target.descriptor_hash = "new-descriptor".into();
3429 let conn = rusqlite::Connection::open(&path).unwrap();
3430 conn.execute_batch(
3432 "CREATE TRIGGER fail_media_commit BEFORE INSERT ON resources
3433 BEGIN SELECT RAISE(ABORT, 'injected media commit failure'); END;",
3434 )
3435 .unwrap();
3436 let error = engine
3437 .commit_media_resource(&target, &[], &mut stats)
3438 .unwrap_err();
3439 assert!(matches!(error, Error::Cache(_)));
3440 assert!(error.to_string().contains("injected media commit failure"));
3441 assert_eq!(stats.resources_updated, 1);
3442 assert_eq!(binding(), old);
3443
3444 conn.execute_batch("DROP TRIGGER fail_media_commit;")
3445 .unwrap();
3446 engine
3447 .commit_media_resource(&target, &[], &mut stats)
3448 .unwrap();
3449 assert_eq!(stats.resources_updated, 2);
3450 let new = binding();
3451 assert_eq!(new.metadata, target.metadata);
3452 assert_eq!(new.version, target.version);
3453 assert_eq!(new.descriptor_hash.as_deref(), Some("new-descriptor"));
3454 assert!(new.segments.is_empty());
3455 }
3456
3457 #[test]
3458 fn keywords_count_fully_for_names_and_identifiers_only() {
3459 let w = |q: &str| keyword_weight(&[q.to_string()]);
3460 for q in [
3461 "garden.md",
3462 "Example Person",
3463 "INC-18372",
3464 "where is Session::restore() called",
3465 "invoice_38192",
3466 "pricing page",
3467 ] {
3468 assert_eq!(w(q), 1.0, "{q}");
3469 }
3470 for q in [
3471 "how can I make tomato soup for dinner",
3472 "how to grow vegetables in a small garden",
3473 ] {
3474 assert_eq!(w(q), PROSE_KEYWORD_WEIGHT, "{q}");
3475 }
3476 }
3477
3478 #[test]
3479 fn names_file_matches_name_stem_and_path_suffix_only() {
3480 let p = Path::new("/book/plants/garden.md");
3481 for q in ["garden.md", "Garden", "plants/garden.md", " `garden.md` "] {
3482 assert!(names_file(q, p), "{q}");
3483 }
3484 for q in ["arden.md", "lants/garden.md", "my garden", "garden.txt", ""] {
3485 assert!(!names_file(q, p), "{q}");
3486 }
3487 }
3488
3489 #[test]
3490 fn names_file_normalizes_both_paths_and_queries_and_preserves_boundaries() {
3491 for path in [
3492 "/book/my people/My Notes.md",
3493 r"C:\book\my people\My Notes.md",
3494 ] {
3495 let path = Path::new(path);
3496 for query in [
3497 "MY NOTES.MD",
3498 "my notes",
3499 " \"My Notes.md\" ",
3500 "`my notes`",
3501 "my people/My Notes.md",
3502 r"my people\My Notes.md",
3503 ] {
3504 assert!(names_file(query, path), "{query}: {path:?}");
3505 }
3506 for query in [
3507 "notes.md",
3508 "my notes today",
3509 "people/My Notes.md",
3510 r"people\My Notes.md",
3511 " `` ",
3512 ] {
3513 assert!(!names_file(query, path), "{query}: {path:?}");
3514 }
3515 }
3516 assert!(names_file(
3517 r"plants\garden.md",
3518 Path::new("/book/plants/garden.md")
3519 ));
3520 assert!(names_file(
3521 "plants/garden.md",
3522 Path::new(r"C:\book\plants\garden.md")
3523 ));
3524 assert!(!names_file("my garden", Path::new(r"C:\book\garden.md")));
3525 }
3526
3527 #[test]
3528 fn hybrid_rrf_k5_preserves_a_strong_semantic_lead() {
3529 assert_eq!(RRF_K, 5.0);
3530 let semantic: Vec<_> = (0..10)
3531 .map(|i| {
3532 let key = (format!("mem://rrf/{i}").parse().unwrap(), 0);
3533 (key, 1.0 - i as f32 * 0.05, 0)
3534 })
3535 .collect();
3536 let lexical = vec![(semantic[9].0.clone(), 1.0)];
3539 let hits = rank(SearchMode::Hybrid, &semantic, &lexical, 0.3, &[], 10);
3540 assert_eq!(hits[0].0, semantic[0].0);
3541 assert_eq!(hits[3].0, semantic[9].0);
3542 assert!((hits[0].1 - 10.0 / 13.0).abs() < 1e-6);
3543 assert!((hits[3].1 - 7.0 / 13.0).abs() < 1e-6);
3544 }
3545
3546 #[test]
3547 fn fusion_keeps_best_score_and_its_query() {
3548 let s = |id, score| Scored { id, score };
3549 let fused = fuse_max(
3550 [vec![s(1, 0.9), s(2, 0.5)], vec![s(2, 0.8), s(3, 0.7)]].into_iter(),
3551 2,
3552 );
3553 let got: Vec<_> = fused
3554 .iter()
3555 .map(|f| (f.scored.id, f.scored.score, f.query_index))
3556 .collect();
3557 assert_eq!(got, [(1, 0.9, 0), (2, 0.8, 1)]);
3558 }
3559
3560 #[test]
3561 fn titles() {
3562 assert_eq!(
3563 segment_title(Some("garden"), None).as_deref(),
3564 Some("garden")
3565 );
3566 assert_eq!(
3567 segment_title(Some("garden"), Some("Garden > Spring")).as_deref(),
3568 Some("Garden > Spring")
3569 );
3570 assert_eq!(
3571 segment_title(Some("notes"), Some("Kitchen")).as_deref(),
3572 Some("notes > Kitchen")
3573 );
3574 }
3575
3576 #[test]
3577 fn previews() {
3578 assert_eq!(
3579 make_preview("## Head\n\nSome text\nhere.", 100),
3580 "Some text here."
3581 );
3582 assert_eq!(make_preview("alpha beta gamma delta", 12), "alpha beta…");
3583 }
3584
3585 #[test]
3586 fn link_targets_are_not_embedded() {
3587 assert_eq!(
3588 strip_link_targets("Sources: [main dossier](../people/Ada.md), ."),
3589 "Sources: main dossier, !map."
3590 );
3591 assert_eq!(
3592 strip_link_targets("see [[Ada - old]] and [[Marlow|him]]"),
3593 "see Ada - old and him"
3594 );
3595 assert_eq!(
3596 strip_link_targets("an [unclosed bracket and [x] plain"),
3597 "an [unclosed bracket and [x] plain"
3598 );
3599 assert_eq!(
3600 strip_link_targets("[spaced](not a url)"),
3601 "[spaced](not a url)"
3602 );
3603 }
3604
3605 #[test]
3606 fn input_hash_is_unambiguous() {
3607 assert_ne!(
3608 input_hash(&["text", "ab", "c"]),
3609 input_hash(&["text", "a", "bc"])
3610 );
3611 }
3612}
3613
3614#[cfg(test)]
3615mod pre_split_golden {
3616 use super::*;
3617
3618 struct IdentityOnly(crate::ModelIdentity);
3619 impl Embedder for IdentityOnly {
3620 fn identity(&self) -> &crate::ModelIdentity {
3621 &self.0
3622 }
3623 fn supports(&self, _: crate::Modality) -> bool {
3624 true
3625 }
3626 fn embed_batch(
3627 &self,
3628 _: &[EmbeddingRequest<'_>],
3629 ) -> seyk_embed::Result<Vec<crate::Embedding>> {
3630 panic!("preparation must not run inference")
3631 }
3632 }
3633
3634 #[test]
3635 fn production_profile_and_prepared_input_hashes_match_pre_split() {
3636 let golden: serde_json::Value =
3639 serde_json::from_str(include_str!("../tests/fixtures/pre_split_preparation.json"))
3640 .unwrap();
3641 let identity = serde_json::from_value(golden["profile"]["model"].clone()).unwrap();
3642 let engine = SearchEngine::builder()
3643 .embedder(IdentityOnly(identity))
3644 .build()
3645 .unwrap();
3646 assert_eq!(
3647 serde_json::to_value(engine.profile()).unwrap(),
3648 golden["profile"]
3649 );
3650 assert_eq!(engine.profile_id(), golden["profile_id"].as_str().unwrap());
3651 for (name, title, kind, bytes) in [
3652 (
3653 "titled",
3654 Some("Guide"),
3655 ResourceKind::Text(TextFormat::Markdown),
3656 b"# Setup\n\nRead the [manual](https://example.com/manual) before installing.\n"
3657 .as_slice(),
3658 ),
3659 (
3660 "untitled",
3661 None,
3662 ResourceKind::Text(TextFormat::Plain),
3663 b"A short note with no document title.\n".as_slice(),
3664 ),
3665 (
3666 "image",
3667 Some("diagram"),
3668 ResourceKind::Image,
3669 b"\x89PNG\r\n\x1a\nsynthetic golden bytes".as_slice(),
3670 ),
3671 ] {
3672 let resource = ProvidedResource {
3673 id: format!("test://golden/{name}").parse().unwrap(),
3674 kind,
3675 size_bytes: bytes.len() as u64,
3676 modified_ns: None,
3677 version: ResourceVersion::unknown(),
3678 title: title.map(String::from),
3679 };
3680 let (jobs, _) = engine.prepare(&resource, bytes.to_vec()).unwrap();
3681 assert_eq!(jobs.len(), 1);
3682 assert_eq!(
3683 jobs[0].segment.input_hash,
3684 golden["input_hashes"][name].as_str().unwrap(),
3685 "{name}"
3686 );
3687 }
3688 }
3689 #[cfg(feature = "pdf")]
3690 #[test]
3691 fn preview_decodes_a_budget_rejected_page_once_and_keeps_smaller_pages_available() {
3692 use crate::pdf::fixture::{Kind, pdf};
3693
3694 let dir = tempfile::tempdir().unwrap();
3695 let root = dir.path().canonicalize().unwrap();
3697 let fixture = |name: &str, texts: &[&str]| {
3698 let path = root.join(name);
3699 let source = ResourceId::from_path(&path).unwrap();
3700 let bytes = pdf(texts, Kind::Plain);
3701 std::fs::write(&path, &bytes).unwrap();
3702 let meta = ResourceMetadata {
3703 media_type: Some("application/pdf".into()),
3704 modality: crate::Modality::Document,
3705 size_bytes: bytes.len() as u64,
3706 modified_ns: None,
3707 content_hash: blake3::hash(&bytes).to_hex().to_string(),
3708 };
3709 let pages = crate::pdf::read_pages(&bytes, &source).unwrap();
3710 (source, meta, pages)
3711 };
3712 let (large, large_meta, large_pages) = fixture(
3713 "large.pdf",
3714 &["This page exceeds the remaining preview budget.", "basil"],
3715 );
3716 let (small, small_meta, small_pages) = fixture("small.pdf", &["mint"]);
3717 let locator = |page: &crate::pdf::PageText| ResourceLocator::PageText {
3718 page: page.page,
3719 byte_start: 0,
3720 byte_end: page.text.len() as u64,
3721 text_version: crate::pdf::TEXT_VERSION.into(),
3722 page_text_hash: blake3::hash(page.text.as_bytes()).to_hex().to_string(),
3723 };
3724 let remaining = large_pages[1].text.len() + small_pages[0].text.len();
3726 assert!(large_pages[0].text.len() > remaining);
3727 let initial_bytes = crate::pdf::MAX_DOCUMENT_TEXT_BYTES - remaining;
3728 let mut reader = PreviewReader {
3729 page_bytes: initial_bytes,
3730 ..Default::default()
3731 };
3732 for _ in 0..2 {
3733 assert!(
3734 reader
3735 .preview(
3736 &crate::FsProvider,
3737 &large,
3738 &locator(&large_pages[0]),
3739 &large_meta,
3740 200
3741 )
3742 .is_none()
3743 );
3744 }
3745 assert_eq!(reader.page_decodes, 1);
3746 assert_eq!(reader.page_bytes, initial_bytes);
3747
3748 for (source, meta, page, expected) in [
3750 (&large, &large_meta, &large_pages[1], "basil"),
3751 (&small, &small_meta, &small_pages[0], "mint"),
3752 ] {
3753 let valid = locator(page);
3754 let mut invalid = valid.clone();
3755 if let ResourceLocator::PageText { byte_end, .. } = &mut invalid {
3756 *byte_end += 1;
3757 }
3758 assert!(
3759 reader
3760 .preview(&crate::FsProvider, source, &invalid, meta, 200)
3761 .is_none()
3762 );
3763 assert_eq!(
3764 reader
3765 .preview(&crate::FsProvider, source, &valid, meta, 200)
3766 .as_deref(),
3767 Some(expected)
3768 );
3769 }
3770 assert_eq!(reader.page_decodes, 3);
3771 assert_eq!(reader.page_bytes, crate::pdf::MAX_DOCUMENT_TEXT_BYTES);
3772 assert!(
3773 reader
3774 .preview(
3775 &crate::FsProvider,
3776 &large,
3777 &locator(&large_pages[0]),
3778 &large_meta,
3779 200
3780 )
3781 .is_none()
3782 );
3783 assert_eq!(reader.page_decodes, 3);
3784 }
3785
3786 #[cfg(feature = "pdf")]
3787 #[test]
3788 fn preview_rejects_a_valid_page_in_a_corrupt_resource() {
3789 use crate::pdf::fixture::{Kind, pdf};
3790 let dir = tempfile::tempdir().unwrap();
3791 let path = dir.path().canonicalize().unwrap().join("broken.pdf");
3792 let source = ResourceId::from_path(&path).unwrap();
3793 let text =
3794 crate::pdf::read_page(&pdf(&["garden basil", "second"], Kind::Plain), &source, 1)
3795 .unwrap();
3796 let locator = ResourceLocator::PageText {
3797 page: 1,
3798 byte_start: 0,
3799 byte_end: text.len() as u64,
3800 text_version: crate::pdf::TEXT_VERSION.into(),
3801 page_text_hash: blake3::hash(text.as_bytes()).to_hex().to_string(),
3802 };
3803 for kind in [Kind::MissingContents, Kind::TruncatedContents] {
3804 let bytes = pdf(&["garden basil", "second"], kind);
3805 std::fs::write(&path, &bytes).unwrap();
3806 let meta = ResourceMetadata {
3807 media_type: Some("application/pdf".into()),
3808 modality: crate::Modality::Document,
3809 size_bytes: bytes.len() as u64,
3810 modified_ns: None,
3811 content_hash: blake3::hash(&bytes).to_hex().to_string(),
3812 };
3813 assert!(
3814 PreviewReader::default()
3815 .preview(&crate::FsProvider, &source, &locator, &meta, 200)
3816 .is_none()
3817 );
3818 }
3819 }
3820}