Skip to main content

ipfrs_storage/
block_index.rs

1//! Secondary index over stored blocks for accelerated queries.
2//!
3//! Provides [`StorageBlockIndex`] which maintains an in-memory inverted index
4//! keyed by content type, size bucket (MB granularity), day bucket, and custom
5//! tags. Queries filter across any combination of those dimensions without
6//! scanning every stored entry.
7
8use std::collections::HashMap;
9
10// ──────────────────────────────────────────────────────────────────────────────
11// Constants
12// ──────────────────────────────────────────────────────────────────────────────
13
14/// Bytes per MB (1 MiB = 1_048_576 bytes).
15const BYTES_PER_MB: u64 = 1_048_576;
16
17/// Seconds per day.
18const SECS_PER_DAY: u64 = 86_400;
19
20// ──────────────────────────────────────────────────────────────────────────────
21// IndexKey
22// ──────────────────────────────────────────────────────────────────────────────
23
24/// Discriminated key used to address a single bucket in the inverted index.
25#[derive(Clone, Debug, PartialEq, Eq, Hash)]
26pub enum IndexKey {
27    /// Exact content-type string (e.g. `"image/png"`).
28    ContentType(String),
29    /// 1-MiB size bucket: `bucket = size_bytes / 1_048_576`.
30    SizeBucket(u64),
31    /// Day bucket: `bucket = created_at_secs / 86400`.
32    DayBucket(u64),
33    /// Arbitrary tag string.
34    Tag(String),
35}
36
37// ──────────────────────────────────────────────────────────────────────────────
38// IndexEntry
39// ──────────────────────────────────────────────────────────────────────────────
40
41/// Metadata record stored for each block in the index.
42#[derive(Clone, Debug, PartialEq, Eq)]
43pub struct IndexEntry {
44    /// Content identifier (CID) string.
45    pub cid: String,
46    /// Serialised size of the block in bytes.
47    pub size_bytes: u64,
48    /// MIME-style content type string.
49    pub content_type: String,
50    /// UNIX timestamp (seconds) at which the block was created.
51    pub created_at_secs: u64,
52    /// Arbitrary user-supplied tags.
53    pub tags: Vec<String>,
54}
55
56impl IndexEntry {
57    /// Derive the [`IndexKey::SizeBucket`] for this entry.
58    #[inline]
59    pub fn size_bucket(&self) -> IndexKey {
60        IndexKey::SizeBucket(self.size_bytes / BYTES_PER_MB)
61    }
62
63    /// Derive the [`IndexKey::DayBucket`] for this entry.
64    #[inline]
65    pub fn day_bucket(&self) -> IndexKey {
66        IndexKey::DayBucket(self.created_at_secs / SECS_PER_DAY)
67    }
68}
69
70// ──────────────────────────────────────────────────────────────────────────────
71// IndexQuery
72// ──────────────────────────────────────────────────────────────────────────────
73
74/// Filter specification for [`StorageBlockIndex::query`].
75///
76/// All fields are optional; only the `Some` variants are applied. Multiple
77/// active filters are ANDed together.
78#[derive(Clone, Debug, Default)]
79pub struct IndexQuery {
80    /// Exact content-type match.
81    pub content_type: Option<String>,
82    /// Lower bound on `size_bytes` (inclusive).
83    pub min_size_bytes: Option<u64>,
84    /// Upper bound on `size_bytes` (inclusive).
85    pub max_size_bytes: Option<u64>,
86    /// Require `created_at_secs` strictly greater than this value.
87    pub created_after_secs: Option<u64>,
88    /// Require the entry's tag list to contain this exact tag.
89    pub tag: Option<String>,
90}
91
92// ──────────────────────────────────────────────────────────────────────────────
93// IndexStats
94// ──────────────────────────────────────────────────────────────────────────────
95
96/// Aggregate statistics about the current index state.
97#[derive(Clone, Debug, PartialEq, Eq)]
98pub struct IndexStats {
99    /// Total number of indexed entries.
100    pub total_entries: usize,
101    /// Number of distinct content-type strings observed.
102    pub unique_content_types: usize,
103    /// Number of distinct tag strings observed.
104    pub unique_tags: usize,
105    /// Sum of `size_bytes` across all entries.
106    pub total_size_bytes: u64,
107}
108
109// ──────────────────────────────────────────────────────────────────────────────
110// StorageBlockIndex
111// ──────────────────────────────────────────────────────────────────────────────
112
113/// Secondary index over stored blocks that accelerates queries by content type,
114/// size range, creation time, and custom tags without scanning all blocks.
115///
116/// # Indexing strategy
117///
118/// Every [`IndexEntry`] inserted into the index is registered under four
119/// categories of [`IndexKey`]:
120///
121/// - `ContentType(content_type)` — one key per entry.
122/// - `SizeBucket(size_bytes / 1_MiB)` — coarse size bucket (1 MiB granularity).
123/// - `DayBucket(created_at_secs / 86_400)` — coarse day bucket.
124/// - `Tag(tag)` for each tag string in `entry.tags`.
125///
126/// The inverted index maps each [`IndexKey`] to the list of CID strings that
127/// carry that key, enabling fast set-based retrieval without a full scan.
128pub struct StorageBlockIndex {
129    /// Primary storage: maps CID to its full [`IndexEntry`].
130    pub entries: HashMap<String, IndexEntry>,
131    /// Inverted index: maps an [`IndexKey`] to the CIDs that belong to it.
132    pub index: HashMap<IndexKey, Vec<String>>,
133}
134
135impl StorageBlockIndex {
136    /// Create a new, empty [`StorageBlockIndex`].
137    pub fn new() -> Self {
138        Self {
139            entries: HashMap::new(),
140            index: HashMap::new(),
141        }
142    }
143
144    // ── insert ────────────────────────────────────────────────────────────────
145
146    /// Insert (or replace) an entry in the index.
147    ///
148    /// If a previous entry exists for the same CID it is removed first so the
149    /// inverted index does not accumulate stale pointers.
150    pub fn insert(&mut self, entry: IndexEntry) {
151        // Remove any existing entry for this CID to keep the index consistent.
152        self.remove(&entry.cid.clone());
153
154        let cid = entry.cid.clone();
155
156        // Build the set of index keys this entry should appear under.
157        let keys = Self::keys_for_entry(&entry);
158
159        // Store primary record.
160        self.entries.insert(cid.clone(), entry);
161
162        // Update inverted index.
163        for key in keys {
164            self.index.entry(key).or_default().push(cid.clone());
165        }
166    }
167
168    // ── remove ────────────────────────────────────────────────────────────────
169
170    /// Remove an entry by CID from both the primary store and all index buckets.
171    ///
172    /// Returns `true` when the entry existed and was removed, `false` otherwise.
173    pub fn remove(&mut self, cid: &str) -> bool {
174        let entry = match self.entries.remove(cid) {
175            Some(e) => e,
176            None => return false,
177        };
178
179        let keys = Self::keys_for_entry(&entry);
180
181        for key in keys {
182            if let Some(cids) = self.index.get_mut(&key) {
183                cids.retain(|c| c != cid);
184                // Leave empty vecs in place; they are harmless and avoid
185                // repeated re-allocation on future inserts.
186            }
187        }
188
189        true
190    }
191
192    // ── query ─────────────────────────────────────────────────────────────────
193
194    /// Query the index with an [`IndexQuery`] filter.
195    ///
196    /// The implementation performs a full scan of the primary entry map and
197    /// applies each active filter in turn.  Results are sorted by
198    /// `created_at_secs` descending (newest first).
199    pub fn query<'a>(&'a self, q: &IndexQuery) -> Vec<&'a IndexEntry> {
200        let mut results: Vec<&IndexEntry> = self
201            .entries
202            .values()
203            .filter(|e| {
204                if let Some(ref ct) = q.content_type {
205                    if &e.content_type != ct {
206                        return false;
207                    }
208                }
209                if let Some(min) = q.min_size_bytes {
210                    if e.size_bytes < min {
211                        return false;
212                    }
213                }
214                if let Some(max) = q.max_size_bytes {
215                    if e.size_bytes > max {
216                        return false;
217                    }
218                }
219                if let Some(after) = q.created_after_secs {
220                    if e.created_at_secs <= after {
221                        return false;
222                    }
223                }
224                if let Some(ref tag) = q.tag {
225                    if !e.tags.contains(tag) {
226                        return false;
227                    }
228                }
229                true
230            })
231            .collect();
232
233        // Sort newest first, then by CID for determinism among ties.
234        results.sort_by(|a, b| {
235            b.created_at_secs
236                .cmp(&a.created_at_secs)
237                .then_with(|| a.cid.cmp(&b.cid))
238        });
239
240        results
241    }
242
243    // ── entries_for_key ───────────────────────────────────────────────────────
244
245    /// Look up all entries that are indexed under the given [`IndexKey`].
246    ///
247    /// Returns entries sorted by CID string for deterministic ordering.
248    pub fn entries_for_key<'a>(&'a self, key: &IndexKey) -> Vec<&'a IndexEntry> {
249        let cids = match self.index.get(key) {
250            Some(v) => v,
251            None => return Vec::new(),
252        };
253
254        let mut entries: Vec<&IndexEntry> = cids
255            .iter()
256            .filter_map(|cid| self.entries.get(cid.as_str()))
257            .collect();
258
259        entries.sort_by(|a, b| a.cid.cmp(&b.cid));
260        entries
261    }
262
263    // ── stats ─────────────────────────────────────────────────────────────────
264
265    /// Compute aggregate statistics over all indexed entries.
266    pub fn stats(&self) -> IndexStats {
267        let total_entries = self.entries.len();
268
269        let mut content_types = std::collections::HashSet::new();
270        let mut tags = std::collections::HashSet::new();
271        let mut total_size_bytes: u64 = 0;
272
273        for entry in self.entries.values() {
274            content_types.insert(entry.content_type.as_str());
275            for tag in &entry.tags {
276                tags.insert(tag.as_str());
277            }
278            total_size_bytes = total_size_bytes.saturating_add(entry.size_bytes);
279        }
280
281        IndexStats {
282            total_entries,
283            unique_content_types: content_types.len(),
284            unique_tags: tags.len(),
285            total_size_bytes,
286        }
287    }
288
289    // ── internal helpers ──────────────────────────────────────────────────────
290
291    /// Derive all [`IndexKey`]s that an entry should be registered under.
292    fn keys_for_entry(entry: &IndexEntry) -> Vec<IndexKey> {
293        let mut keys = Vec::with_capacity(3 + entry.tags.len());
294        keys.push(IndexKey::ContentType(entry.content_type.clone()));
295        keys.push(entry.size_bucket());
296        keys.push(entry.day_bucket());
297        for tag in &entry.tags {
298            keys.push(IndexKey::Tag(tag.clone()));
299        }
300        keys
301    }
302}
303
304impl Default for StorageBlockIndex {
305    fn default() -> Self {
306        Self::new()
307    }
308}
309
310// ──────────────────────────────────────────────────────────────────────────────
311// BlockIndexEntry
312// ──────────────────────────────────────────────────────────────────────────────
313
314/// A block entry for the secondary block index, keyed by codec, tags, size, and
315/// creation tick.
316#[derive(Debug, Clone, PartialEq, Eq)]
317pub struct BlockIndexEntry {
318    /// Content identifier (CID) string.
319    pub cid: String,
320    /// Size of the block in bytes.
321    pub size_bytes: u64,
322    /// Codec name, e.g. `"dag-cbor"`, `"raw"`.
323    pub codec: String,
324    /// Monotonic tick at which the block was created (logical clock).
325    pub created_tick: u64,
326    /// Arbitrary tags attached to this block.
327    pub tags: Vec<String>,
328}
329
330// ──────────────────────────────────────────────────────────────────────────────
331// BlockIndexStats
332// ──────────────────────────────────────────────────────────────────────────────
333
334/// Aggregate statistics for a [`SecondaryBlockIndex`].
335#[derive(Debug, Clone, PartialEq, Eq)]
336pub struct BlockIndexStats {
337    /// Number of entries currently in the index.
338    pub entry_count: usize,
339    /// Sum of `size_bytes` across all entries.
340    pub total_bytes: u64,
341    /// Number of distinct codec strings.
342    pub unique_codecs: usize,
343    /// Number of distinct tag strings.
344    pub unique_tags: usize,
345}
346
347// ──────────────────────────────────────────────────────────────────────────────
348// SecondaryBlockIndex
349// ──────────────────────────────────────────────────────────────────────────────
350
351/// Secondary index for fast block lookups by codec, tag, size range, and
352/// creation-tick range.
353///
354/// Maintains inverted indices from codec and tag to CID lists so that
355/// lookups by those dimensions are O(bucket-size) instead of O(n).
356/// Size-range and tick-range queries use linear filtering over the primary
357/// entry map.
358pub struct SecondaryBlockIndex {
359    /// Primary storage: CID → entry.
360    entries: HashMap<String, BlockIndexEntry>,
361    /// Inverted index: codec → \[CID\].
362    by_codec: HashMap<String, Vec<String>>,
363    /// Inverted index: tag → \[CID\].
364    by_tag: HashMap<String, Vec<String>>,
365    /// Running total of `size_bytes` across all entries.
366    total_bytes: u64,
367}
368
369impl SecondaryBlockIndex {
370    /// Create a new, empty [`SecondaryBlockIndex`].
371    pub fn new() -> Self {
372        Self {
373            entries: HashMap::new(),
374            by_codec: HashMap::new(),
375            by_tag: HashMap::new(),
376            total_bytes: 0,
377        }
378    }
379
380    // ── insert ────────────────────────────────────────────────────────────────
381
382    /// Insert a [`BlockIndexEntry`] into the index.
383    ///
384    /// If an entry with the same CID already exists it is removed first so
385    /// that all inverted indices stay consistent.
386    pub fn insert(&mut self, entry: BlockIndexEntry) {
387        // Remove stale entry if present.
388        let cid_clone = entry.cid.clone();
389        self.remove(&cid_clone);
390
391        // Update running total.
392        self.total_bytes = self.total_bytes.saturating_add(entry.size_bytes);
393
394        // Update codec index.
395        self.by_codec
396            .entry(entry.codec.clone())
397            .or_default()
398            .push(entry.cid.clone());
399
400        // Update tag index.
401        for tag in &entry.tags {
402            self.by_tag
403                .entry(tag.clone())
404                .or_default()
405                .push(entry.cid.clone());
406        }
407
408        // Store primary record.
409        self.entries.insert(entry.cid.clone(), entry);
410    }
411
412    // ── remove ────────────────────────────────────────────────────────────────
413
414    /// Remove an entry by CID from all indices.
415    ///
416    /// Returns `Some(entry)` if found, `None` otherwise.
417    pub fn remove(&mut self, cid: &str) -> Option<BlockIndexEntry> {
418        let entry = self.entries.remove(cid)?;
419
420        // Subtract from running total.
421        self.total_bytes = self.total_bytes.saturating_sub(entry.size_bytes);
422
423        // Clean codec index.
424        if let Some(cids) = self.by_codec.get_mut(&entry.codec) {
425            cids.retain(|c| c != cid);
426        }
427
428        // Clean tag index.
429        for tag in &entry.tags {
430            if let Some(cids) = self.by_tag.get_mut(tag) {
431                cids.retain(|c| c != cid);
432            }
433        }
434
435        Some(entry)
436    }
437
438    // ── get ───────────────────────────────────────────────────────────────────
439
440    /// Look up an entry by CID.
441    pub fn get(&self, cid: &str) -> Option<&BlockIndexEntry> {
442        self.entries.get(cid)
443    }
444
445    // ── find_by_codec ─────────────────────────────────────────────────────────
446
447    /// Return all entries that use the given codec, sorted by CID for
448    /// deterministic ordering.
449    pub fn find_by_codec(&self, codec: &str) -> Vec<&BlockIndexEntry> {
450        let cids = match self.by_codec.get(codec) {
451            Some(v) => v,
452            None => return Vec::new(),
453        };
454        let mut results: Vec<&BlockIndexEntry> = cids
455            .iter()
456            .filter_map(|c| self.entries.get(c.as_str()))
457            .collect();
458        results.sort_by(|a, b| a.cid.cmp(&b.cid));
459        results
460    }
461
462    // ── find_by_tag ───────────────────────────────────────────────────────────
463
464    /// Return all entries that carry the given tag, sorted by CID.
465    pub fn find_by_tag(&self, tag: &str) -> Vec<&BlockIndexEntry> {
466        let cids = match self.by_tag.get(tag) {
467            Some(v) => v,
468            None => return Vec::new(),
469        };
470        let mut results: Vec<&BlockIndexEntry> = cids
471            .iter()
472            .filter_map(|c| self.entries.get(c.as_str()))
473            .collect();
474        results.sort_by(|a, b| a.cid.cmp(&b.cid));
475        results
476    }
477
478    // ── find_by_size_range ────────────────────────────────────────────────────
479
480    /// Return all entries whose `size_bytes` is within `[min, max]`
481    /// (inclusive), sorted by CID.
482    pub fn find_by_size_range(&self, min: u64, max: u64) -> Vec<&BlockIndexEntry> {
483        let mut results: Vec<&BlockIndexEntry> = self
484            .entries
485            .values()
486            .filter(|e| e.size_bytes >= min && e.size_bytes <= max)
487            .collect();
488        results.sort_by(|a, b| a.cid.cmp(&b.cid));
489        results
490    }
491
492    // ── find_by_created_range ─────────────────────────────────────────────────
493
494    /// Return all entries whose `created_tick` is within `[min_tick, max_tick]`
495    /// (inclusive), sorted by CID.
496    pub fn find_by_created_range(&self, min_tick: u64, max_tick: u64) -> Vec<&BlockIndexEntry> {
497        let mut results: Vec<&BlockIndexEntry> = self
498            .entries
499            .values()
500            .filter(|e| e.created_tick >= min_tick && e.created_tick <= max_tick)
501            .collect();
502        results.sort_by(|a, b| a.cid.cmp(&b.cid));
503        results
504    }
505
506    // ── entry_count ───────────────────────────────────────────────────────────
507
508    /// Number of entries currently in the index.
509    #[inline]
510    pub fn entry_count(&self) -> usize {
511        self.entries.len()
512    }
513
514    // ── total_bytes ───────────────────────────────────────────────────────────
515
516    /// Cumulative size in bytes of all indexed blocks.
517    #[inline]
518    pub fn total_bytes(&self) -> u64 {
519        self.total_bytes
520    }
521
522    // ── unique_codecs ─────────────────────────────────────────────────────────
523
524    /// Return all distinct codec strings, sorted alphabetically.
525    pub fn unique_codecs(&self) -> Vec<String> {
526        let mut codecs: Vec<String> = self
527            .by_codec
528            .iter()
529            .filter(|(_, cids)| !cids.is_empty())
530            .map(|(codec, _)| codec.clone())
531            .collect();
532        codecs.sort();
533        codecs
534    }
535
536    // ── unique_tags ───────────────────────────────────────────────────────────
537
538    /// Return all distinct tag strings, sorted alphabetically.
539    pub fn unique_tags(&self) -> Vec<String> {
540        let mut tags: Vec<String> = self
541            .by_tag
542            .iter()
543            .filter(|(_, cids)| !cids.is_empty())
544            .map(|(tag, _)| tag.clone())
545            .collect();
546        tags.sort();
547        tags
548    }
549
550    // ── stats ─────────────────────────────────────────────────────────────────
551
552    /// Compute aggregate statistics.
553    pub fn stats(&self) -> BlockIndexStats {
554        BlockIndexStats {
555            entry_count: self.entry_count(),
556            total_bytes: self.total_bytes,
557            unique_codecs: self.unique_codecs().len(),
558            unique_tags: self.unique_tags().len(),
559        }
560    }
561}
562
563impl Default for SecondaryBlockIndex {
564    fn default() -> Self {
565        Self::new()
566    }
567}
568
569// ──────────────────────────────────────────────────────────────────────────────
570// Tests
571// ──────────────────────────────────────────────────────────────────────────────
572
573#[cfg(test)]
574mod tests {
575    use super::*;
576
577    // ── helpers ───────────────────────────────────────────────────────────────
578
579    fn make_entry(
580        cid: &str,
581        content_type: &str,
582        size_bytes: u64,
583        created_at_secs: u64,
584        tags: &[&str],
585    ) -> IndexEntry {
586        IndexEntry {
587            cid: cid.to_owned(),
588            content_type: content_type.to_owned(),
589            size_bytes,
590            created_at_secs,
591            tags: tags.iter().map(|t| t.to_string()).collect(),
592        }
593    }
594
595    /// Populate an index with a small, varied set of entries.
596    fn populated_index() -> StorageBlockIndex {
597        let mut idx = StorageBlockIndex::new();
598        // entry A: image, 2 MiB, day 0, tags: ["public"]
599        idx.insert(make_entry(
600            "cid-a",
601            "image/png",
602            2 * BYTES_PER_MB,
603            0,
604            &["public"],
605        ));
606        // entry B: video, 10 MiB, day 1, tags: ["public", "featured"]
607        idx.insert(make_entry(
608            "cid-b",
609            "video/mp4",
610            10 * BYTES_PER_MB,
611            SECS_PER_DAY,
612            &["public", "featured"],
613        ));
614        // entry C: image, 500 KiB, day 2, tags: ["private"]
615        idx.insert(make_entry(
616            "cid-c",
617            "image/png",
618            512_000,
619            2 * SECS_PER_DAY,
620            &["private"],
621        ));
622        // entry D: text, 1 byte, day 3, no tags
623        idx.insert(make_entry("cid-d", "text/plain", 1, 3 * SECS_PER_DAY, &[]));
624        idx
625    }
626
627    // ── 1. new() starts empty ─────────────────────────────────────────────────
628
629    #[test]
630    fn test_new_is_empty() {
631        let idx = StorageBlockIndex::new();
632        assert!(idx.entries.is_empty());
633        assert!(idx.index.is_empty());
634    }
635
636    // ── 2. insert stores entry ────────────────────────────────────────────────
637
638    #[test]
639    fn test_insert_stores_entry() {
640        let mut idx = StorageBlockIndex::new();
641        let e = make_entry("cid-1", "text/plain", 100, 1000, &[]);
642        idx.insert(e.clone());
643        assert_eq!(idx.entries.get("cid-1"), Some(&e));
644    }
645
646    // ── 3. insert updates all index buckets ──────────────────────────────────
647
648    #[test]
649    fn test_insert_updates_content_type_index() {
650        let mut idx = StorageBlockIndex::new();
651        idx.insert(make_entry("cid-1", "text/plain", 100, 0, &[]));
652        let key = IndexKey::ContentType("text/plain".to_owned());
653        assert!(idx
654            .index
655            .get(&key)
656            .is_some_and(|v| v.contains(&"cid-1".to_owned())));
657    }
658
659    #[test]
660    fn test_insert_updates_size_bucket_index() {
661        let mut idx = StorageBlockIndex::new();
662        // 3 MiB → bucket 3
663        idx.insert(make_entry("cid-1", "text/plain", 3 * BYTES_PER_MB, 0, &[]));
664        let key = IndexKey::SizeBucket(3);
665        assert!(idx
666            .index
667            .get(&key)
668            .is_some_and(|v| v.contains(&"cid-1".to_owned())));
669    }
670
671    #[test]
672    fn test_insert_updates_day_bucket_index() {
673        let mut idx = StorageBlockIndex::new();
674        // day 5
675        idx.insert(make_entry(
676            "cid-1",
677            "text/plain",
678            1,
679            5 * SECS_PER_DAY + 3600,
680            &[],
681        ));
682        let key = IndexKey::DayBucket(5);
683        assert!(idx
684            .index
685            .get(&key)
686            .is_some_and(|v| v.contains(&"cid-1".to_owned())));
687    }
688
689    #[test]
690    fn test_insert_updates_tag_indexes() {
691        let mut idx = StorageBlockIndex::new();
692        idx.insert(make_entry("cid-1", "text/plain", 1, 0, &["alpha", "beta"]));
693        assert!(idx
694            .index
695            .get(&IndexKey::Tag("alpha".to_owned()))
696            .is_some_and(|v| v.contains(&"cid-1".to_owned())));
697        assert!(idx
698            .index
699            .get(&IndexKey::Tag("beta".to_owned()))
700            .is_some_and(|v| v.contains(&"cid-1".to_owned())));
701    }
702
703    // ── 4. remove removes from entries ───────────────────────────────────────
704
705    #[test]
706    fn test_remove_removes_from_entries() {
707        let mut idx = populated_index();
708        assert!(idx.remove("cid-a"));
709        assert!(!idx.entries.contains_key("cid-a"));
710    }
711
712    // ── 5. remove cleans all index buckets ───────────────────────────────────
713
714    #[test]
715    fn test_remove_cleans_content_type_bucket() {
716        let mut idx = populated_index();
717        idx.remove("cid-a");
718        let key = IndexKey::ContentType("image/png".to_owned());
719        // cid-c is still an image/png; cid-a should be gone
720        let cids = idx.index.get(&key).cloned().unwrap_or_default();
721        assert!(!cids.contains(&"cid-a".to_owned()));
722    }
723
724    #[test]
725    fn test_remove_cleans_size_bucket() {
726        let mut idx = populated_index();
727        // cid-a is 2 MiB → bucket 2
728        idx.remove("cid-a");
729        let key = IndexKey::SizeBucket(2);
730        let cids = idx.index.get(&key).cloned().unwrap_or_default();
731        assert!(!cids.contains(&"cid-a".to_owned()));
732    }
733
734    #[test]
735    fn test_remove_cleans_tag_bucket() {
736        let mut idx = populated_index();
737        idx.remove("cid-a");
738        let key = IndexKey::Tag("public".to_owned());
739        let cids = idx.index.get(&key).cloned().unwrap_or_default();
740        assert!(!cids.contains(&"cid-a".to_owned()));
741        // cid-b also has "public"
742        assert!(cids.contains(&"cid-b".to_owned()));
743    }
744
745    // ── 6. remove returns false for unknown cid ───────────────────────────────
746
747    #[test]
748    fn test_remove_unknown_cid_returns_false() {
749        let mut idx = StorageBlockIndex::new();
750        assert!(!idx.remove("nonexistent"));
751    }
752
753    // ── 7. query content_type filter ─────────────────────────────────────────
754
755    #[test]
756    fn test_query_content_type_filter() {
757        let idx = populated_index();
758        let q = IndexQuery {
759            content_type: Some("image/png".to_owned()),
760            ..Default::default()
761        };
762        let results = idx.query(&q);
763        assert_eq!(results.len(), 2);
764        for e in &results {
765            assert_eq!(e.content_type, "image/png");
766        }
767    }
768
769    // ── 8. query min_size filter ──────────────────────────────────────────────
770
771    #[test]
772    fn test_query_min_size_filter() {
773        let idx = populated_index();
774        let q = IndexQuery {
775            min_size_bytes: Some(5 * BYTES_PER_MB),
776            ..Default::default()
777        };
778        let results = idx.query(&q);
779        // Only cid-b (10 MiB) qualifies
780        assert_eq!(results.len(), 1);
781        assert_eq!(results[0].cid, "cid-b");
782    }
783
784    // ── 9. query max_size filter ──────────────────────────────────────────────
785
786    #[test]
787    fn test_query_max_size_filter() {
788        let idx = populated_index();
789        let q = IndexQuery {
790            max_size_bytes: Some(BYTES_PER_MB),
791            ..Default::default()
792        };
793        let results = idx.query(&q);
794        // cid-c (512 000) and cid-d (1) qualify
795        assert_eq!(results.len(), 2);
796        for e in &results {
797            assert!(e.size_bytes <= BYTES_PER_MB);
798        }
799    }
800
801    // ── 10. query size range (min + max) ──────────────────────────────────────
802
803    #[test]
804    fn test_query_size_range() {
805        let idx = populated_index();
806        let q = IndexQuery {
807            min_size_bytes: Some(BYTES_PER_MB),
808            max_size_bytes: Some(5 * BYTES_PER_MB),
809            ..Default::default()
810        };
811        let results = idx.query(&q);
812        // Only cid-a (2 MiB) falls in [1 MiB, 5 MiB]
813        assert_eq!(results.len(), 1);
814        assert_eq!(results[0].cid, "cid-a");
815    }
816
817    // ── 11. query created_after filter ───────────────────────────────────────
818
819    #[test]
820    fn test_query_created_after_filter() {
821        let idx = populated_index();
822        // strictly after day 1 → days 2 and 3
823        let q = IndexQuery {
824            created_after_secs: Some(SECS_PER_DAY),
825            ..Default::default()
826        };
827        let results = idx.query(&q);
828        assert_eq!(results.len(), 2);
829        for e in &results {
830            assert!(e.created_at_secs > SECS_PER_DAY);
831        }
832    }
833
834    // ── 12. query tag filter ──────────────────────────────────────────────────
835
836    #[test]
837    fn test_query_tag_filter() {
838        let idx = populated_index();
839        let q = IndexQuery {
840            tag: Some("featured".to_owned()),
841            ..Default::default()
842        };
843        let results = idx.query(&q);
844        assert_eq!(results.len(), 1);
845        assert_eq!(results[0].cid, "cid-b");
846    }
847
848    // ── 13. query multiple filters combined ──────────────────────────────────
849
850    #[test]
851    fn test_query_combined_filters() {
852        let idx = populated_index();
853        let q = IndexQuery {
854            content_type: Some("image/png".to_owned()),
855            max_size_bytes: Some(BYTES_PER_MB),
856            ..Default::default()
857        };
858        let results = idx.query(&q);
859        // Only cid-c is image/png AND <= 1 MiB
860        assert_eq!(results.len(), 1);
861        assert_eq!(results[0].cid, "cid-c");
862    }
863
864    // ── 14. query returns sorted by created_at_secs desc ─────────────────────
865
866    #[test]
867    fn test_query_sorted_by_created_at_desc() {
868        let idx = populated_index();
869        let results = idx.query(&IndexQuery::default());
870        assert_eq!(results.len(), 4);
871        let times: Vec<u64> = results.iter().map(|e| e.created_at_secs).collect();
872        // Must be non-increasing
873        for i in 1..times.len() {
874            assert!(
875                times[i - 1] >= times[i],
876                "Expected descending order at index {i}: {times:?}"
877            );
878        }
879    }
880
881    // ── 15. query returns empty for no match ──────────────────────────────────
882
883    #[test]
884    fn test_query_no_match_returns_empty() {
885        let idx = populated_index();
886        let q = IndexQuery {
887            content_type: Some("application/octet-stream".to_owned()),
888            ..Default::default()
889        };
890        assert!(idx.query(&q).is_empty());
891    }
892
893    // ── 16. entries_for_key ContentType ──────────────────────────────────────
894
895    #[test]
896    fn test_entries_for_key_content_type() {
897        let idx = populated_index();
898        let key = IndexKey::ContentType("image/png".to_owned());
899        let entries = idx.entries_for_key(&key);
900        assert_eq!(entries.len(), 2);
901        let cids: Vec<&str> = entries.iter().map(|e| e.cid.as_str()).collect();
902        assert!(cids.contains(&"cid-a"));
903        assert!(cids.contains(&"cid-c"));
904    }
905
906    // ── 17. entries_for_key Tag ───────────────────────────────────────────────
907
908    #[test]
909    fn test_entries_for_key_tag() {
910        let idx = populated_index();
911        let key = IndexKey::Tag("public".to_owned());
912        let entries = idx.entries_for_key(&key);
913        assert_eq!(entries.len(), 2);
914        let cids: Vec<&str> = entries.iter().map(|e| e.cid.as_str()).collect();
915        assert!(cids.contains(&"cid-a"));
916        assert!(cids.contains(&"cid-b"));
917    }
918
919    // ── 18. entries_for_key SizeBucket ───────────────────────────────────────
920
921    #[test]
922    fn test_entries_for_key_size_bucket() {
923        let idx = populated_index();
924        // cid-a is exactly 2 MiB → bucket 2
925        let key = IndexKey::SizeBucket(2);
926        let entries = idx.entries_for_key(&key);
927        assert_eq!(entries.len(), 1);
928        assert_eq!(entries[0].cid, "cid-a");
929    }
930
931    // ── 19. entries_for_key DayBucket ────────────────────────────────────────
932
933    #[test]
934    fn test_entries_for_key_day_bucket() {
935        let idx = populated_index();
936        // cid-b was created at exactly SECS_PER_DAY → day bucket 1
937        let key = IndexKey::DayBucket(1);
938        let entries = idx.entries_for_key(&key);
939        assert_eq!(entries.len(), 1);
940        assert_eq!(entries[0].cid, "cid-b");
941    }
942
943    // ── 20. stats total_entries ───────────────────────────────────────────────
944
945    #[test]
946    fn test_stats_total_entries() {
947        let idx = populated_index();
948        assert_eq!(idx.stats().total_entries, 4);
949    }
950
951    // ── 21. stats unique_content_types ───────────────────────────────────────
952
953    #[test]
954    fn test_stats_unique_content_types() {
955        let idx = populated_index();
956        // image/png, video/mp4, text/plain → 3
957        assert_eq!(idx.stats().unique_content_types, 3);
958    }
959
960    // ── 22. stats total_size_bytes ────────────────────────────────────────────
961
962    #[test]
963    fn test_stats_total_size_bytes() {
964        let idx = populated_index();
965        let expected = 2 * BYTES_PER_MB   // cid-a
966            + 10 * BYTES_PER_MB           // cid-b
967            + 512_000                     // cid-c
968            + 1; // cid-d
969        assert_eq!(idx.stats().total_size_bytes, expected);
970    }
971
972    // ── bonus: stats unique_tags ──────────────────────────────────────────────
973
974    #[test]
975    fn test_stats_unique_tags() {
976        let idx = populated_index();
977        // public, featured, private → 3
978        assert_eq!(idx.stats().unique_tags, 3);
979    }
980
981    // ── bonus: default() is same as new() ────────────────────────────────────
982
983    #[test]
984    fn test_default_is_empty() {
985        let idx = StorageBlockIndex::default();
986        assert!(idx.entries.is_empty());
987    }
988
989    // ── bonus: re-insert same cid replaces entry ──────────────────────────────
990
991    #[test]
992    fn test_reinsert_replaces_entry() {
993        let mut idx = StorageBlockIndex::new();
994        idx.insert(make_entry("cid-1", "text/plain", 100, 1000, &["old"]));
995        idx.insert(make_entry("cid-1", "image/png", 200, 2000, &["new"]));
996
997        assert_eq!(idx.entries.len(), 1);
998        let e = idx.entries.get("cid-1").expect("entry must exist");
999        assert_eq!(e.content_type, "image/png");
1000
1001        // Old content-type bucket must no longer contain cid-1
1002        let old_key = IndexKey::ContentType("text/plain".to_owned());
1003        let old_cids = idx.index.get(&old_key).cloned().unwrap_or_default();
1004        assert!(!old_cids.contains(&"cid-1".to_owned()));
1005
1006        // Old tag bucket must no longer contain cid-1
1007        let old_tag = IndexKey::Tag("old".to_owned());
1008        let old_tag_cids = idx.index.get(&old_tag).cloned().unwrap_or_default();
1009        assert!(!old_tag_cids.contains(&"cid-1".to_owned()));
1010    }
1011}
1012
1013// ──────────────────────────────────────────────────────────────────────────────
1014// SecondaryBlockIndex tests
1015// ──────────────────────────────────────────────────────────────────────────────
1016
1017#[cfg(test)]
1018mod secondary_tests {
1019    use super::*;
1020
1021    fn entry(cid: &str, size: u64, codec: &str, tick: u64, tags: &[&str]) -> BlockIndexEntry {
1022        BlockIndexEntry {
1023            cid: cid.to_owned(),
1024            size_bytes: size,
1025            codec: codec.to_owned(),
1026            created_tick: tick,
1027            tags: tags.iter().map(|t| t.to_string()).collect(),
1028        }
1029    }
1030
1031    fn populated() -> SecondaryBlockIndex {
1032        let mut idx = SecondaryBlockIndex::new();
1033        idx.insert(entry("cid-a", 1024, "raw", 10, &["pin"]));
1034        idx.insert(entry("cid-b", 2048, "dag-cbor", 20, &["pin", "important"]));
1035        idx.insert(entry("cid-c", 512, "raw", 30, &["temp"]));
1036        idx.insert(entry("cid-d", 4096, "dag-pb", 40, &[]));
1037        idx
1038    }
1039
1040    // ── 1. new is empty ──────────────────────────────────────────────────────
1041
1042    #[test]
1043    fn test_secondary_new_is_empty() {
1044        let idx = SecondaryBlockIndex::new();
1045        assert_eq!(idx.entry_count(), 0);
1046        assert_eq!(idx.total_bytes(), 0);
1047    }
1048
1049    // ── 2. default is empty ──────────────────────────────────────────────────
1050
1051    #[test]
1052    fn test_secondary_default_is_empty() {
1053        let idx = SecondaryBlockIndex::default();
1054        assert_eq!(idx.entry_count(), 0);
1055    }
1056
1057    // ── 3. insert and get ────────────────────────────────────────────────────
1058
1059    #[test]
1060    fn test_secondary_insert_and_get() {
1061        let mut idx = SecondaryBlockIndex::new();
1062        let e = entry("cid-1", 100, "raw", 5, &["x"]);
1063        idx.insert(e.clone());
1064        let got = idx.get("cid-1");
1065        assert!(got.is_some());
1066        assert_eq!(got.map(|g| &g.cid), Some(&"cid-1".to_owned()));
1067        assert_eq!(got.map(|g| g.size_bytes), Some(100));
1068    }
1069
1070    // ── 4. get returns None for missing ──────────────────────────────────────
1071
1072    #[test]
1073    fn test_secondary_get_missing() {
1074        let idx = SecondaryBlockIndex::new();
1075        assert!(idx.get("nope").is_none());
1076    }
1077
1078    // ── 5. remove returns entry ──────────────────────────────────────────────
1079
1080    #[test]
1081    fn test_secondary_remove_returns_entry() {
1082        let mut idx = populated();
1083        let removed = idx.remove("cid-a");
1084        assert!(removed.is_some());
1085        assert_eq!(removed.map(|r| r.cid), Some("cid-a".to_owned()));
1086        assert!(idx.get("cid-a").is_none());
1087    }
1088
1089    // ── 6. remove returns None for missing ───────────────────────────────────
1090
1091    #[test]
1092    fn test_secondary_remove_missing() {
1093        let mut idx = SecondaryBlockIndex::new();
1094        assert!(idx.remove("nope").is_none());
1095    }
1096
1097    // ── 7. remove updates total_bytes ────────────────────────────────────────
1098
1099    #[test]
1100    fn test_secondary_remove_updates_total_bytes() {
1101        let mut idx = SecondaryBlockIndex::new();
1102        idx.insert(entry("a", 100, "raw", 1, &[]));
1103        idx.insert(entry("b", 200, "raw", 2, &[]));
1104        assert_eq!(idx.total_bytes(), 300);
1105        idx.remove("a");
1106        assert_eq!(idx.total_bytes(), 200);
1107    }
1108
1109    // ── 8. remove updates codec index ────────────────────────────────────────
1110
1111    #[test]
1112    fn test_secondary_remove_updates_codec_index() {
1113        let mut idx = populated();
1114        idx.remove("cid-a"); // was "raw"
1115        let raw_entries = idx.find_by_codec("raw");
1116        let cids: Vec<&str> = raw_entries.iter().map(|e| e.cid.as_str()).collect();
1117        assert!(!cids.contains(&"cid-a"));
1118        assert!(cids.contains(&"cid-c")); // cid-c is also raw
1119    }
1120
1121    // ── 9. remove updates tag index ──────────────────────────────────────────
1122
1123    #[test]
1124    fn test_secondary_remove_updates_tag_index() {
1125        let mut idx = populated();
1126        idx.remove("cid-a"); // had tag "pin"
1127        let pin_entries = idx.find_by_tag("pin");
1128        let cids: Vec<&str> = pin_entries.iter().map(|e| e.cid.as_str()).collect();
1129        assert!(!cids.contains(&"cid-a"));
1130        assert!(cids.contains(&"cid-b")); // cid-b also has "pin"
1131    }
1132
1133    // ── 10. find_by_codec ────────────────────────────────────────────────────
1134
1135    #[test]
1136    fn test_secondary_find_by_codec() {
1137        let idx = populated();
1138        let raw = idx.find_by_codec("raw");
1139        assert_eq!(raw.len(), 2);
1140        let cids: Vec<&str> = raw.iter().map(|e| e.cid.as_str()).collect();
1141        assert!(cids.contains(&"cid-a"));
1142        assert!(cids.contains(&"cid-c"));
1143    }
1144
1145    // ── 11. find_by_codec empty ──────────────────────────────────────────────
1146
1147    #[test]
1148    fn test_secondary_find_by_codec_empty() {
1149        let idx = populated();
1150        assert!(idx.find_by_codec("dag-json").is_empty());
1151    }
1152
1153    // ── 12. find_by_tag ──────────────────────────────────────────────────────
1154
1155    #[test]
1156    fn test_secondary_find_by_tag() {
1157        let idx = populated();
1158        let pinned = idx.find_by_tag("pin");
1159        assert_eq!(pinned.len(), 2);
1160        let cids: Vec<&str> = pinned.iter().map(|e| e.cid.as_str()).collect();
1161        assert!(cids.contains(&"cid-a"));
1162        assert!(cids.contains(&"cid-b"));
1163    }
1164
1165    // ── 13. find_by_tag empty ────────────────────────────────────────────────
1166
1167    #[test]
1168    fn test_secondary_find_by_tag_empty() {
1169        let idx = populated();
1170        assert!(idx.find_by_tag("nonexistent").is_empty());
1171    }
1172
1173    // ── 14. find_by_tag single match ─────────────────────────────────────────
1174
1175    #[test]
1176    fn test_secondary_find_by_tag_single() {
1177        let idx = populated();
1178        let important = idx.find_by_tag("important");
1179        assert_eq!(important.len(), 1);
1180        assert_eq!(important[0].cid, "cid-b");
1181    }
1182
1183    // ── 15. find_by_size_range ───────────────────────────────────────────────
1184
1185    #[test]
1186    fn test_secondary_find_by_size_range() {
1187        let idx = populated();
1188        let results = idx.find_by_size_range(512, 2048);
1189        assert_eq!(results.len(), 3); // cid-a(1024), cid-b(2048), cid-c(512)
1190        for e in &results {
1191            assert!(e.size_bytes >= 512 && e.size_bytes <= 2048);
1192        }
1193    }
1194
1195    // ── 16. find_by_size_range no match ──────────────────────────────────────
1196
1197    #[test]
1198    fn test_secondary_find_by_size_range_none() {
1199        let idx = populated();
1200        assert!(idx.find_by_size_range(10000, 20000).is_empty());
1201    }
1202
1203    // ── 17. find_by_size_range exact match ───────────────────────────────────
1204
1205    #[test]
1206    fn test_secondary_find_by_size_range_exact() {
1207        let idx = populated();
1208        let results = idx.find_by_size_range(4096, 4096);
1209        assert_eq!(results.len(), 1);
1210        assert_eq!(results[0].cid, "cid-d");
1211    }
1212
1213    // ── 18. find_by_created_range ────────────────────────────────────────────
1214
1215    #[test]
1216    fn test_secondary_find_by_created_range() {
1217        let idx = populated();
1218        let results = idx.find_by_created_range(15, 35);
1219        assert_eq!(results.len(), 2); // cid-b(20), cid-c(30)
1220        for e in &results {
1221            assert!(e.created_tick >= 15 && e.created_tick <= 35);
1222        }
1223    }
1224
1225    // ── 19. find_by_created_range no match ───────────────────────────────────
1226
1227    #[test]
1228    fn test_secondary_find_by_created_range_none() {
1229        let idx = populated();
1230        assert!(idx.find_by_created_range(100, 200).is_empty());
1231    }
1232
1233    // ── 20. entry_count ──────────────────────────────────────────────────────
1234
1235    #[test]
1236    fn test_secondary_entry_count() {
1237        let idx = populated();
1238        assert_eq!(idx.entry_count(), 4);
1239    }
1240
1241    // ── 21. total_bytes ──────────────────────────────────────────────────────
1242
1243    #[test]
1244    fn test_secondary_total_bytes() {
1245        let idx = populated();
1246        assert_eq!(idx.total_bytes(), 1024 + 2048 + 512 + 4096);
1247    }
1248
1249    // ── 22. unique_codecs ────────────────────────────────────────────────────
1250
1251    #[test]
1252    fn test_secondary_unique_codecs() {
1253        let idx = populated();
1254        let codecs = idx.unique_codecs();
1255        assert_eq!(codecs.len(), 3);
1256        assert!(codecs.contains(&"raw".to_owned()));
1257        assert!(codecs.contains(&"dag-cbor".to_owned()));
1258        assert!(codecs.contains(&"dag-pb".to_owned()));
1259    }
1260
1261    // ── 23. unique_tags ──────────────────────────────────────────────────────
1262
1263    #[test]
1264    fn test_secondary_unique_tags() {
1265        let idx = populated();
1266        let tags = idx.unique_tags();
1267        assert_eq!(tags.len(), 3);
1268        assert!(tags.contains(&"pin".to_owned()));
1269        assert!(tags.contains(&"important".to_owned()));
1270        assert!(tags.contains(&"temp".to_owned()));
1271    }
1272
1273    // ── 24. stats accuracy ───────────────────────────────────────────────────
1274
1275    #[test]
1276    fn test_secondary_stats() {
1277        let idx = populated();
1278        let s = idx.stats();
1279        assert_eq!(s.entry_count, 4);
1280        assert_eq!(s.total_bytes, 1024 + 2048 + 512 + 4096);
1281        assert_eq!(s.unique_codecs, 3);
1282        assert_eq!(s.unique_tags, 3);
1283    }
1284
1285    // ── 25. re-insert replaces entry ─────────────────────────────────────────
1286
1287    #[test]
1288    fn test_secondary_reinsert_replaces() {
1289        let mut idx = SecondaryBlockIndex::new();
1290        idx.insert(entry("cid-1", 100, "raw", 1, &["old"]));
1291        idx.insert(entry("cid-1", 200, "dag-cbor", 2, &["new"]));
1292
1293        assert_eq!(idx.entry_count(), 1);
1294        let e = idx.get("cid-1");
1295        assert!(e.is_some());
1296        let e = e.expect("checked above");
1297        assert_eq!(e.codec, "dag-cbor");
1298        assert_eq!(e.size_bytes, 200);
1299        assert_eq!(idx.total_bytes(), 200);
1300
1301        // Old codec bucket should not contain cid-1
1302        assert!(idx.find_by_codec("raw").is_empty());
1303        // Old tag should not contain cid-1
1304        assert!(idx.find_by_tag("old").is_empty());
1305        // New codec/tag should contain it
1306        assert_eq!(idx.find_by_codec("dag-cbor").len(), 1);
1307        assert_eq!(idx.find_by_tag("new").len(), 1);
1308    }
1309
1310    // ── 26. multiple entries same codec ───────────────────────────────────────
1311
1312    #[test]
1313    fn test_secondary_multiple_same_codec() {
1314        let mut idx = SecondaryBlockIndex::new();
1315        idx.insert(entry("a", 10, "raw", 1, &[]));
1316        idx.insert(entry("b", 20, "raw", 2, &[]));
1317        idx.insert(entry("c", 30, "raw", 3, &[]));
1318        let results = idx.find_by_codec("raw");
1319        assert_eq!(results.len(), 3);
1320    }
1321
1322    // ── 27. empty index operations ───────────────────────────────────────────
1323
1324    #[test]
1325    fn test_secondary_empty_index_operations() {
1326        let idx = SecondaryBlockIndex::new();
1327        assert!(idx.find_by_codec("raw").is_empty());
1328        assert!(idx.find_by_tag("any").is_empty());
1329        assert!(idx.find_by_size_range(0, u64::MAX).is_empty());
1330        assert!(idx.find_by_created_range(0, u64::MAX).is_empty());
1331        assert!(idx.unique_codecs().is_empty());
1332        assert!(idx.unique_tags().is_empty());
1333        let s = idx.stats();
1334        assert_eq!(s.entry_count, 0);
1335        assert_eq!(s.total_bytes, 0);
1336        assert_eq!(s.unique_codecs, 0);
1337        assert_eq!(s.unique_tags, 0);
1338    }
1339
1340    // ── 28. unique_codecs excludes emptied buckets ───────────────────────────
1341
1342    #[test]
1343    fn test_secondary_unique_codecs_after_remove() {
1344        let mut idx = SecondaryBlockIndex::new();
1345        idx.insert(entry("a", 10, "raw", 1, &[]));
1346        assert_eq!(idx.unique_codecs().len(), 1);
1347        idx.remove("a");
1348        assert!(idx.unique_codecs().is_empty());
1349    }
1350
1351    // ── 29. unique_tags excludes emptied buckets ─────────────────────────────
1352
1353    #[test]
1354    fn test_secondary_unique_tags_after_remove() {
1355        let mut idx = SecondaryBlockIndex::new();
1356        idx.insert(entry("a", 10, "raw", 1, &["only"]));
1357        assert_eq!(idx.unique_tags().len(), 1);
1358        idx.remove("a");
1359        assert!(idx.unique_tags().is_empty());
1360    }
1361
1362    // ── 30. find results are sorted by cid ───────────────────────────────────
1363
1364    #[test]
1365    fn test_secondary_find_sorted_by_cid() {
1366        let mut idx = SecondaryBlockIndex::new();
1367        idx.insert(entry("z", 10, "raw", 1, &["t"]));
1368        idx.insert(entry("a", 20, "raw", 2, &["t"]));
1369        idx.insert(entry("m", 30, "raw", 3, &["t"]));
1370
1371        let by_codec = idx.find_by_codec("raw");
1372        let cids: Vec<&str> = by_codec.iter().map(|e| e.cid.as_str()).collect();
1373        assert_eq!(cids, vec!["a", "m", "z"]);
1374
1375        let by_tag = idx.find_by_tag("t");
1376        let cids: Vec<&str> = by_tag.iter().map(|e| e.cid.as_str()).collect();
1377        assert_eq!(cids, vec!["a", "m", "z"]);
1378    }
1379}