1use std::collections::{BTreeSet, BinaryHeap, HashSet};
34use std::fs;
35use std::io::{BufRead, BufReader};
36use std::path::{Path, PathBuf};
37
38use serde::{Deserialize, Serialize};
39
40use super::store::{namespace_file, MemoryError, MemoryRecord, ProjectScope};
41use super::store::{DEFAULT_SEARCH_LIMIT, MAX_SEARCH_LIMIT};
42
43pub const SEGMENT_MAX_DOCS: usize = 1024;
45pub const MAX_SEGMENTS: usize = 32;
49pub const MAX_TERMS_PER_DOC: usize = 512;
51
52const MANIFEST_VERSION: u32 = 2;
53
54pub fn index_dir_in(base: &Path, scope: &ProjectScope) -> PathBuf {
56 super::store::memory_dir_in(base)
57 .join("index")
58 .join(scope.namespace())
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize)]
62struct SegmentMeta {
63 file: String,
64 docs: usize,
65 min_ts: u64,
66 max_ts: u64,
67 bytes: u64,
69 sha256: String,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize)]
74struct Manifest {
75 version: u32,
76 indexed_bytes: u64,
78 segments: Vec<SegmentMeta>,
79 tombstones: Vec<String>,
80}
81
82impl Manifest {
83 fn empty() -> Self {
84 Self {
85 version: MANIFEST_VERSION,
86 indexed_bytes: 0,
87 segments: Vec::new(),
88 tombstones: Vec::new(),
89 }
90 }
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize)]
95struct SegmentDoc {
96 id: String,
97 ts: u64,
98 #[serde(default)]
99 tags: Vec<String>,
100 #[serde(default)]
101 terms: Vec<String>,
102}
103
104#[derive(Debug, Clone, PartialEq, Eq)]
107pub struct PageCursor {
108 pub before_ts: u64,
109 pub before_id: String,
110}
111
112#[derive(Debug, Clone, Default)]
115pub struct IndexQuery {
116 pub terms: Vec<String>,
117 pub tag_prefix: Option<String>,
118 pub since_ms: Option<u64>,
119 pub until_ms: Option<u64>,
120 pub limit: Option<usize>,
122 pub cursor: Option<PageCursor>,
123}
124
125#[derive(Debug, Clone, PartialEq, Eq)]
127pub struct IndexHit {
128 pub id: String,
129 pub timestamp_ms: u64,
130 pub tags: Vec<String>,
131}
132
133#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
135pub struct IndexSearchStats {
136 pub docs_scanned: usize,
139 pub max_resident_hits: usize,
141 pub segments_open: usize,
143}
144
145#[derive(Debug, Clone)]
147pub struct IndexPage {
148 pub hits: Vec<IndexHit>,
149 pub next: Option<PageCursor>,
150 pub stats: IndexSearchStats,
151}
152
153#[derive(Debug, Clone, Copy, PartialEq, Eq)]
155pub struct IndexStatus {
156 pub segments: usize,
157 pub indexed_docs: usize,
158 pub tombstones: usize,
159}
160
161fn terms_of(content: &str) -> Vec<String> {
163 let mut set = BTreeSet::new();
164 for token in content
165 .split(|c: char| !c.is_alphanumeric())
166 .filter(|t| t.len() >= 2)
167 {
168 set.insert(token.to_lowercase());
169 if set.len() >= MAX_TERMS_PER_DOC {
170 break;
171 }
172 }
173 set.into_iter().collect()
174}
175
176fn manifest_path(dir: &Path) -> PathBuf {
177 dir.join("manifest.json")
178}
179
180fn load_manifest(dir: &Path) -> Option<Manifest> {
181 let raw = fs::read_to_string(manifest_path(dir)).ok()?;
182 let manifest: Manifest = serde_json::from_str(&raw).ok()?;
183 if manifest.version != MANIFEST_VERSION {
184 return None;
185 }
186 Some(manifest)
187}
188
189fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), MemoryError> {
190 crate::core::private_fs::write_atomic_private(path, bytes)
191 .map_err(std::io::Error::from)
192 .map_err(MemoryError::from)
193}
194
195fn sha256_hex(bytes: &[u8]) -> String {
196 use sha2::{Digest, Sha256};
197 let digest = Sha256::digest(bytes);
198 let mut out = String::with_capacity(64);
199 for byte in digest {
200 use std::fmt::Write;
201 let _ = write!(out, "{byte:02x}");
202 }
203 out
204}
205
206fn segments_verified(dir: &Path, manifest: &Manifest) -> bool {
209 for seg in &manifest.segments {
210 let path = dir.join(&seg.file);
211 let Ok(bytes) = fs::read(&path) else {
212 return false; };
214 if bytes.len() as u64 != seg.bytes {
215 return false; }
217 if sha256_hex(&bytes) != seg.sha256 {
218 return false; }
220 }
221 true
222}
223
224fn reset_index_dir(dir: &Path) -> Result<(), MemoryError> {
226 if dir.exists() {
227 fs::remove_dir_all(dir)?;
228 }
229 Ok(())
230}
231
232pub fn ensure_index_in(base: &Path, scope: &ProjectScope) -> Result<IndexStatus, MemoryError> {
237 let dir = index_dir_in(base, scope);
238 let store_path = namespace_file(base, &scope.namespace());
239 let store_len = fs::metadata(&store_path).map(|m| m.len()).unwrap_or(0);
240
241 let mut manifest = match load_manifest(&dir) {
242 Some(m) if m.indexed_bytes <= store_len && segments_verified(&dir, &m) => m,
243 Some(_) | None => {
244 reset_index_dir(&dir)?;
247 Manifest::empty()
248 }
249 };
250
251 if store_len > manifest.indexed_bytes {
252 crate::core::private_fs::ensure_private_dir(&dir).map_err(std::io::Error::from)?;
253 let mut tombstones: HashSet<String> = manifest.tombstones.iter().cloned().collect();
254 let mut pending: Vec<SegmentDoc> = Vec::new();
255
256 let file = fs::File::open(&store_path)?;
257 let mut reader = BufReader::new(file);
258 {
260 use std::io::Read;
261 std::io::copy(
262 &mut Read::take(&mut reader, manifest.indexed_bytes),
263 &mut std::io::sink(),
264 )?;
265 }
266 let mut consumed = manifest.indexed_bytes;
267 let mut line = String::new();
268 loop {
269 line.clear();
270 let n = reader.read_line(&mut line)?;
271 if n == 0 {
272 break;
273 }
274 if !line.ends_with('\n') {
275 break;
278 }
279 consumed += n as u64;
280 let trimmed = line.trim();
281 if trimmed.is_empty() {
282 continue;
283 }
284 if let Ok(value) = serde_json::from_str::<serde_json::Value>(trimmed) {
285 if let Some(id) = value.get("tombstone").and_then(|v| v.as_str()) {
286 tombstones.insert(id.to_string());
287 pending.retain(|doc| doc.id != id);
288 continue;
289 }
290 }
291 let record: MemoryRecord = match serde_json::from_str(trimmed) {
292 Ok(r) => r,
293 Err(_) => continue,
294 };
295 if record.project.as_deref() != Some(scope.key()) {
296 continue;
297 }
298 let Some(id) = record.id.clone() else {
299 continue;
300 };
301 if tombstones.contains(&id) {
302 continue;
303 }
304 let terms = if record.sensitivity == Some(super::store::MemorySensitivity::Secret) {
307 Vec::new()
308 } else {
309 terms_of(&record.content)
310 };
311 pending.push(SegmentDoc {
312 id,
313 ts: record.timestamp_ms,
314 tags: record.tags,
315 terms,
316 });
317 }
318
319 let mut next_seg = manifest
320 .segments
321 .iter()
322 .filter_map(|s| {
323 s.file
324 .strip_prefix("seg-")
325 .and_then(|rest| rest.strip_suffix(".jsonl"))
326 .and_then(|n| n.parse::<u64>().ok())
327 })
328 .max()
329 .map(|n| n + 1)
330 .unwrap_or(0);
331 for chunk in pending.chunks(SEGMENT_MAX_DOCS) {
332 let mut docs = chunk.to_vec();
333 docs.sort_by(|a, b| b.ts.cmp(&a.ts).then_with(|| b.id.cmp(&a.id)));
334 manifest
335 .segments
336 .push(write_segment(&dir, next_seg, &docs)?);
337 next_seg += 1;
338 }
339
340 manifest.indexed_bytes = consumed;
341 manifest.tombstones = tombstones.into_iter().collect();
342 manifest.tombstones.sort();
343
344 if manifest.segments.len() > MAX_SEGMENTS {
350 let tombstoned: HashSet<String> = manifest.tombstones.iter().cloned().collect();
351 let mut all_docs: Vec<SegmentDoc> = Vec::new();
352 for seg in &manifest.segments {
353 let mut stream = SegmentStream::open(&dir.join(&seg.file))?;
354 while let Some(doc) = stream.current.take() {
355 if !tombstoned.contains(&doc.id) {
356 all_docs.push(doc);
357 }
358 stream.advance()?;
359 }
360 }
361 all_docs.sort_by(|a, b| b.ts.cmp(&a.ts).then_with(|| b.id.cmp(&a.id)));
362
363 let old_files: Vec<String> = manifest.segments.iter().map(|s| s.file.clone()).collect();
364 let target_docs = all_docs
365 .len()
366 .div_ceil(MAX_SEGMENTS / 2)
367 .max(SEGMENT_MAX_DOCS);
368 let mut compacted: Vec<SegmentMeta> = Vec::new();
369 for chunk in all_docs.chunks(target_docs) {
370 compacted.push(write_segment(&dir, next_seg, chunk)?);
371 next_seg += 1;
372 }
373 manifest.segments = compacted;
374 manifest.tombstones.clear();
375 write_atomic(
379 &manifest_path(&dir),
380 serde_json::to_string_pretty(&manifest)?.as_bytes(),
381 )?;
382 for file in old_files {
383 let _ = fs::remove_file(dir.join(file));
384 }
385 } else {
386 write_atomic(
387 &manifest_path(&dir),
388 serde_json::to_string_pretty(&manifest)?.as_bytes(),
389 )?;
390 }
391 }
392
393 Ok(IndexStatus {
394 segments: manifest.segments.len(),
395 indexed_docs: manifest.segments.iter().map(|s| s.docs).sum(),
396 tombstones: manifest.tombstones.len(),
397 })
398}
399
400fn write_segment(dir: &Path, seq: u64, docs: &[SegmentDoc]) -> Result<SegmentMeta, MemoryError> {
403 let mut body = String::new();
404 for doc in docs {
405 body.push_str(&serde_json::to_string(doc)?);
406 body.push('\n');
407 }
408 let file = format!("seg-{seq:06}.jsonl");
409 write_atomic(&dir.join(&file), body.as_bytes())?;
410 Ok(SegmentMeta {
411 file,
412 docs: docs.len(),
413 min_ts: docs.iter().map(|d| d.ts).min().unwrap_or(0),
414 max_ts: docs.iter().map(|d| d.ts).max().unwrap_or(0),
415 bytes: body.len() as u64,
416 sha256: sha256_hex(body.as_bytes()),
417 })
418}
419
420struct SegmentStream {
422 reader: BufReader<fs::File>,
423 current: Option<SegmentDoc>,
424}
425
426impl SegmentStream {
427 fn open(path: &Path) -> Result<Self, MemoryError> {
428 let reader = BufReader::new(fs::File::open(path)?);
429 let mut stream = Self {
430 reader,
431 current: None,
432 };
433 stream.advance()?;
434 Ok(stream)
435 }
436
437 fn advance(&mut self) -> Result<(), MemoryError> {
438 let mut line = String::new();
439 loop {
440 line.clear();
441 if self.reader.read_line(&mut line)? == 0 {
442 self.current = None;
443 return Ok(());
444 }
445 if line.trim().is_empty() {
446 continue;
447 }
448 match serde_json::from_str::<SegmentDoc>(line.trim()) {
449 Ok(doc) => {
450 self.current = Some(doc);
451 return Ok(());
452 }
453 Err(_) => continue, }
455 }
456 }
457}
458
459pub fn search_index_in(
462 base: &Path,
463 scope: &ProjectScope,
464 query: &IndexQuery,
465) -> Result<IndexPage, MemoryError> {
466 ensure_index_in(base, scope)?;
467 let dir = index_dir_in(base, scope);
468 let manifest = load_manifest(&dir).unwrap_or_else(Manifest::empty);
469 let tombstones: HashSet<&String> = manifest.tombstones.iter().collect();
470 let limit = query
471 .limit
472 .unwrap_or(DEFAULT_SEARCH_LIMIT)
473 .clamp(1, MAX_SEARCH_LIMIT);
474 let wanted_terms: Vec<String> = query.terms.iter().flat_map(|t| terms_of(t)).collect();
475
476 let mut streams: Vec<SegmentStream> = Vec::new();
478 for seg in &manifest.segments {
479 if let Some(since) = query.since_ms {
480 if seg.max_ts < since {
481 continue;
482 }
483 }
484 if let Some(until) = query.until_ms {
485 if seg.min_ts > until {
486 continue;
487 }
488 }
489 if let Some(cursor) = &query.cursor {
490 if seg.min_ts > cursor.before_ts {
491 continue; }
493 }
494 streams.push(SegmentStream::open(&dir.join(&seg.file))?);
495 }
496
497 #[derive(PartialEq, Eq)]
499 struct HeapKey {
500 ts: u64,
501 id: String,
502 stream: usize,
503 }
504 impl Ord for HeapKey {
505 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
506 self.ts.cmp(&other.ts).then_with(|| self.id.cmp(&other.id))
507 }
508 }
509 impl PartialOrd for HeapKey {
510 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
511 Some(self.cmp(other))
512 }
513 }
514 let mut heap: BinaryHeap<HeapKey> = BinaryHeap::new();
515 for (i, stream) in streams.iter().enumerate() {
516 if let Some(doc) = &stream.current {
517 heap.push(HeapKey {
518 ts: doc.ts,
519 id: doc.id.clone(),
520 stream: i,
521 });
522 }
523 }
524
525 let mut hits: Vec<IndexHit> = Vec::new();
526 let mut stats = IndexSearchStats {
527 segments_open: streams.len(),
528 ..Default::default()
529 };
530 let mut next: Option<PageCursor> = None;
531
532 while let Some(key) = heap.pop() {
533 let stream = &mut streams[key.stream];
534 let doc = stream
535 .current
536 .take()
537 .expect("heap keys always reference a buffered doc");
538 stream.advance()?;
539 if let Some(following) = &stream.current {
540 heap.push(HeapKey {
541 ts: following.ts,
542 id: following.id.clone(),
543 stream: key.stream,
544 });
545 }
546 stats.docs_scanned += 1;
547
548 if let Some(cursor) = &query.cursor {
550 let after = doc.ts < cursor.before_ts
551 || (doc.ts == cursor.before_ts && doc.id < cursor.before_id);
552 if !after {
553 continue;
554 }
555 }
556 if tombstones.contains(&doc.id) {
557 continue;
558 }
559 if let Some(since) = query.since_ms {
560 if doc.ts < since {
561 continue;
562 }
563 }
564 if let Some(until) = query.until_ms {
565 if doc.ts > until {
566 continue;
567 }
568 }
569 if let Some(prefix) = &query.tag_prefix {
570 if !doc.tags.iter().any(|t| t.starts_with(prefix)) {
571 continue;
572 }
573 }
574 if !wanted_terms.is_empty() {
575 let have: HashSet<&String> = doc.terms.iter().collect();
576 if !wanted_terms.iter().all(|t| have.contains(t)) {
577 continue;
578 }
579 }
580
581 if hits.len() == limit {
582 let last = hits.last().expect("limit >= 1");
584 next = Some(PageCursor {
585 before_ts: last.timestamp_ms,
586 before_id: last.id.clone(),
587 });
588 break;
589 }
590 hits.push(IndexHit {
591 id: doc.id,
592 timestamp_ms: doc.ts,
593 tags: doc.tags,
594 });
595 stats.max_resident_hits = stats.max_resident_hits.max(hits.len());
596 }
597
598 Ok(IndexPage { hits, next, stats })
599}