Skip to main content

geographdb_core/storage/
symbol_metadata.rs

1//! Symbol semantic metadata storage
2//!
3//! Provides native storage for symbol semantics:
4//! - name, FQN, file_path
5//! - kind, language
6//! - byte spans and line/column positions
7//!
8//! Uses a string table approach for efficient storage of variable-length strings.
9
10use anyhow::Result;
11use std::collections::HashMap;
12
13/// Fixed-size symbol metadata record (80 bytes)
14/// Matches NodeRec.id for direct correlation
15#[repr(C)]
16#[derive(Clone, Copy, Debug, PartialEq)]
17pub struct SymbolMetadataRec {
18    /// Symbol ID (matches NodeRec.id)
19    pub symbol_id: u64,
20
21    /// Offset into string table for name
22    pub name_offset: u32,
23    /// Offset into string table for FQN
24    pub fqn_offset: u32,
25    /// Offset into string table for file path
26    pub file_path_offset: u32,
27
28    /// Symbol kind discriminant
29    pub kind: u8,
30    /// Language discriminant
31    pub language: u8,
32
33    /// Padding to 8-byte alignment (explicit)
34    pub _padding1: u16,
35    pub _padding2: u32,
36
37    /// Byte positions in source file
38    pub byte_start: u64,
39    pub byte_end: u64,
40
41    /// Line and column positions
42    pub start_line: u64,
43    pub start_col: u64,
44    pub end_line: u64,
45    pub end_col: u64,
46}
47
48impl SymbolMetadataRec {
49    pub const SIZE: usize = 80; // 8 + 4*3 + 1*2 + 2 + 4 + 8*6 = 80
50}
51
52// Manual Pod/Zeroable implementation since we have explicit padding
53unsafe impl bytemuck::Pod for SymbolMetadataRec {}
54unsafe impl bytemuck::Zeroable for SymbolMetadataRec {}
55
56/// In-memory symbol metadata with resolved strings
57#[derive(Debug, Clone, PartialEq)]
58pub struct SymbolMetadata {
59    pub symbol_id: u64,
60    pub name: String,
61    pub fqn: String,
62    pub file_path: String,
63    pub kind: u8,
64    pub language: u8,
65    pub byte_start: u64,
66    pub byte_end: u64,
67    pub start_line: u64,
68    pub start_col: u64,
69    pub end_line: u64,
70    pub end_col: u64,
71}
72
73/// String table for deduplicated string storage
74#[derive(Debug, Clone, Default)]
75pub struct StringTable {
76    /// Concatenated null-terminated strings
77    data: Vec<u8>,
78    /// Map from string to offset (for deduplication)
79    offset_map: HashMap<String, u32>,
80}
81
82impl StringTable {
83    pub fn new() -> Self {
84        Self {
85            data: Vec::new(),
86            offset_map: HashMap::new(),
87        }
88    }
89
90    /// Add a string to the table, return its offset
91    /// If string already exists, returns existing offset
92    pub fn add(&mut self, s: &str) -> u32 {
93        if let Some(&offset) = self.offset_map.get(s) {
94            return offset;
95        }
96
97        let offset = self.data.len() as u32;
98        self.data.extend_from_slice(s.as_bytes());
99        self.data.push(0); // Null terminator
100
101        self.offset_map.insert(s.to_string(), offset);
102        offset
103    }
104
105    /// Get string at given offset
106    pub fn get(&self, offset: u32) -> Option<String> {
107        if offset as usize >= self.data.len() {
108            return None;
109        }
110
111        let start = offset as usize;
112        let end = self.data[start..].iter().position(|&b| b == 0)?;
113
114        String::from_utf8(self.data[start..start + end].to_vec()).ok()
115    }
116
117    /// Serialize to bytes
118    pub fn to_bytes(&self) -> Vec<u8> {
119        // Format: [count: u64][data...]
120        let mut bytes = Vec::with_capacity(8 + self.data.len());
121        bytes.extend_from_slice(&(self.data.len() as u64).to_le_bytes());
122        bytes.extend_from_slice(&self.data);
123        bytes
124    }
125
126    /// Deserialize from bytes
127    pub fn from_bytes(bytes: &[u8]) -> Result<Self> {
128        if bytes.len() < 8 {
129            anyhow::bail!("String table too short");
130        }
131
132        let data_len = u64::from_le_bytes(bytes[0..8].try_into()?) as usize;
133        if bytes.len() < 8 + data_len {
134            anyhow::bail!("String table data truncated");
135        }
136
137        let data = bytes[8..8 + data_len].to_vec();
138
139        // Rebuild offset map by scanning
140        let mut offset_map = HashMap::new();
141        let mut offset = 0;
142        while offset < data.len() {
143            let end = data[offset..]
144                .iter()
145                .position(|&b| b == 0)
146                .map(|p| offset + p)
147                .unwrap_or(data.len());
148
149            if let Ok(s) = String::from_utf8(data[offset..end].to_vec()) {
150                offset_map.insert(s, offset as u32);
151            }
152
153            offset = end + 1; // Skip null terminator
154        }
155
156        Ok(Self { data, offset_map })
157    }
158
159    pub fn is_empty(&self) -> bool {
160        self.data.is_empty()
161    }
162
163    pub fn len(&self) -> usize {
164        self.offset_map.len()
165    }
166}
167
168/// File info with hash
169#[derive(Debug, Clone, Default)]
170pub struct FileInfo {
171    pub path: String,
172    pub hash: Option<String>, // SHA-256 hash hex encoded
173    pub last_indexed_at: i64, // Unix timestamp
174}
175
176/// File table for tracking unique source files
177#[derive(Debug, Clone, Default)]
178pub struct FileTable {
179    /// Map from file path to file ID
180    path_to_id: HashMap<String, u32>,
181    /// Map from file ID to file info
182    id_to_info: HashMap<u32, FileInfo>,
183    /// Next file ID to assign
184    next_id: u32,
185}
186
187impl FileTable {
188    pub fn new() -> Self {
189        Self {
190            path_to_id: HashMap::new(),
191            id_to_info: HashMap::new(),
192            next_id: 1, // Start at 1, 0 can mean "no file"
193        }
194    }
195
196    /// Get or assign file ID for a path
197    pub fn get_or_assign_id(&mut self, path: &str) -> u32 {
198        if let Some(&id) = self.path_to_id.get(path) {
199            return id;
200        }
201
202        let id = self.next_id;
203        self.next_id += 1;
204
205        self.path_to_id.insert(path.to_string(), id);
206        self.id_to_info.insert(
207            id,
208            FileInfo {
209                path: path.to_string(),
210                hash: None,
211                last_indexed_at: 0,
212            },
213        );
214
215        id
216    }
217
218    /// Get file path by ID
219    pub fn get_path(&self, id: u32) -> Option<&str> {
220        self.id_to_info.get(&id).map(|info| info.path.as_str())
221    }
222
223    /// Get file info by ID
224    pub fn get_info(&self, id: u32) -> Option<&FileInfo> {
225        self.id_to_info.get(&id)
226    }
227
228    /// Get file info by path
229    pub fn get_info_by_path(&self, path: &str) -> Option<&FileInfo> {
230        self.path_to_id
231            .get(path)
232            .and_then(|&id| self.id_to_info.get(&id))
233    }
234
235    /// Set file hash
236    pub fn set_file_hash(&mut self, path: &str, hash: &str) {
237        let id = self.get_or_assign_id(path);
238        if let Some(info) = self.id_to_info.get_mut(&id) {
239            info.hash = Some(hash.to_string());
240            info.last_indexed_at = std::time::SystemTime::now()
241                .duration_since(std::time::UNIX_EPOCH)
242                .unwrap_or_default()
243                .as_secs() as i64;
244        }
245    }
246
247    /// Get file hash
248    pub fn get_file_hash(&self, path: &str) -> Option<&str> {
249        self.get_info_by_path(path)
250            .and_then(|info| info.hash.as_deref())
251    }
252
253    /// Get file ID by path
254    pub fn get_id(&self, path: &str) -> Option<u32> {
255        self.path_to_id.get(path).copied()
256    }
257
258    /// Get total number of unique files
259    pub fn file_count(&self) -> usize {
260        self.path_to_id.len()
261    }
262
263    /// Get all file paths
264    pub fn all_paths(&self) -> Vec<&str> {
265        self.id_to_info
266            .values()
267            .map(|info| info.path.as_str())
268            .collect()
269    }
270
271    /// Get all file info
272    pub fn all_files(&self) -> Vec<&FileInfo> {
273        self.id_to_info.values().collect()
274    }
275
276    /// Serialize to bytes
277    pub fn to_bytes(&self) -> Vec<u8> {
278        // Format: [count: u64][entries...]
279        // Each entry: [id: u32][path_len: u32][hash_len: u32][last_indexed: i64][path bytes...][hash bytes...]
280        let mut bytes = Vec::new();
281        bytes.extend_from_slice(&(self.path_to_id.len() as u64).to_le_bytes());
282
283        for (id, info) in &self.id_to_info {
284            bytes.extend_from_slice(&id.to_le_bytes());
285            bytes.extend_from_slice(&(info.path.len() as u32).to_le_bytes());
286            let hash_len = info.hash.as_ref().map(|h| h.len()).unwrap_or(0) as u32;
287            bytes.extend_from_slice(&hash_len.to_le_bytes());
288            bytes.extend_from_slice(&info.last_indexed_at.to_le_bytes());
289            bytes.extend_from_slice(info.path.as_bytes());
290            if let Some(hash) = &info.hash {
291                bytes.extend_from_slice(hash.as_bytes());
292            }
293        }
294
295        bytes
296    }
297
298    /// Deserialize from bytes
299    pub fn from_bytes(bytes: &[u8]) -> Result<Self> {
300        if bytes.len() < 8 {
301            anyhow::bail!("File table too short");
302        }
303
304        let count = u64::from_le_bytes(bytes[0..8].try_into()?) as usize;
305        let mut offset = 8;
306
307        let mut path_to_id = HashMap::new();
308        let mut id_to_info = HashMap::new();
309        let mut max_id = 0;
310
311        for _ in 0..count {
312            if offset + 20 > bytes.len() {
313                anyhow::bail!("File table entry truncated");
314            }
315
316            let id = u32::from_le_bytes(bytes[offset..offset + 4].try_into()?);
317            let path_len = u32::from_le_bytes(bytes[offset + 4..offset + 8].try_into()?) as usize;
318            let hash_len = u32::from_le_bytes(bytes[offset + 8..offset + 12].try_into()?) as usize;
319            let last_indexed_at = i64::from_le_bytes(bytes[offset + 12..offset + 20].try_into()?);
320            offset += 20;
321
322            if offset + path_len + hash_len > bytes.len() {
323                anyhow::bail!("File data truncated");
324            }
325
326            let path = String::from_utf8(bytes[offset..offset + path_len].to_vec())?;
327            offset += path_len;
328
329            let hash = if hash_len > 0 {
330                Some(String::from_utf8(
331                    bytes[offset..offset + hash_len].to_vec(),
332                )?)
333            } else {
334                None
335            };
336            offset += hash_len;
337
338            path_to_id.insert(path.clone(), id);
339            id_to_info.insert(
340                id,
341                FileInfo {
342                    path,
343                    hash,
344                    last_indexed_at,
345                },
346            );
347            max_id = max_id.max(id);
348        }
349
350        Ok(Self {
351            path_to_id,
352            id_to_info,
353            next_id: max_id + 1,
354        })
355    }
356}
357
358/// Complete symbol metadata storage
359#[derive(Debug, Clone, Default)]
360pub struct SymbolMetadataStore {
361    /// Symbol metadata records indexed by symbol_id
362    pub metadata: HashMap<u64, SymbolMetadataRec>,
363    /// String table for names/FQNs/paths
364    pub strings: StringTable,
365    /// File tracking
366    pub files: FileTable,
367}
368
369impl SymbolMetadataStore {
370    pub fn new() -> Self {
371        Self {
372            metadata: HashMap::new(),
373            strings: StringTable::new(),
374            files: FileTable::new(),
375        }
376    }
377
378    /// Add symbol metadata
379    pub fn add(&mut self, meta: SymbolMetadata) {
380        let name_offset = self.strings.add(&meta.name);
381        let fqn_offset = self.strings.add(&meta.fqn);
382        let file_path_offset = self.strings.add(&meta.file_path);
383
384        // Track the file
385        self.files.get_or_assign_id(&meta.file_path);
386
387        let rec = SymbolMetadataRec {
388            symbol_id: meta.symbol_id,
389            name_offset,
390            fqn_offset,
391            file_path_offset,
392            kind: meta.kind,
393            language: meta.language,
394            _padding1: 0,
395            _padding2: 0,
396            byte_start: meta.byte_start,
397            byte_end: meta.byte_end,
398            start_line: meta.start_line,
399            start_col: meta.start_col,
400            end_line: meta.end_line,
401            end_col: meta.end_col,
402        };
403
404        self.metadata.insert(meta.symbol_id, rec);
405    }
406
407    /// Get symbol metadata by ID
408    pub fn get(&self, symbol_id: u64) -> Option<SymbolMetadata> {
409        let rec = self.metadata.get(&symbol_id)?;
410
411        Some(SymbolMetadata {
412            symbol_id: rec.symbol_id,
413            name: self.strings.get(rec.name_offset)?,
414            fqn: self.strings.get(rec.fqn_offset)?,
415            file_path: self.strings.get(rec.file_path_offset)?,
416            kind: rec.kind,
417            language: rec.language,
418            byte_start: rec.byte_start,
419            byte_end: rec.byte_end,
420            start_line: rec.start_line,
421            start_col: rec.start_col,
422            end_line: rec.end_line,
423            end_col: rec.end_col,
424        })
425    }
426
427    /// Find symbol by FQN
428    pub fn find_by_fqn(&self, fqn: &str) -> Option<u64> {
429        let target_offset = self.strings.offset_map.get(fqn)?;
430
431        self.metadata
432            .values()
433            .find(|rec| rec.fqn_offset == *target_offset)
434            .map(|rec| rec.symbol_id)
435    }
436
437    /// Find symbols by name (may return multiple)
438    pub fn find_by_name(&self, name: &str) -> Vec<u64> {
439        let Some(&target_offset) = self.strings.offset_map.get(name) else {
440            return Vec::new();
441        };
442
443        self.metadata
444            .values()
445            .filter(|rec| rec.name_offset == target_offset)
446            .map(|rec| rec.symbol_id)
447            .collect()
448    }
449
450    /// Get all symbols in a file
451    pub fn symbols_in_file(&self, file_path: &str) -> Vec<u64> {
452        let Some(&target_offset) = self.strings.offset_map.get(file_path) else {
453            return Vec::new();
454        };
455
456        self.metadata
457            .values()
458            .filter(|rec| rec.file_path_offset == target_offset)
459            .map(|rec| rec.symbol_id)
460            .collect()
461    }
462
463    /// Get total symbol count
464    pub fn symbol_count(&self) -> usize {
465        self.metadata.len()
466    }
467
468    /// Get file count
469    pub fn file_count(&self) -> usize {
470        self.files.file_count()
471    }
472
473    /// Get all file paths
474    pub fn all_file_paths(&self) -> Vec<String> {
475        self.files
476            .all_paths()
477            .into_iter()
478            .map(|s| s.to_string())
479            .collect()
480    }
481
482    /// Get all symbol IDs
483    pub fn all_symbol_ids(&self) -> Vec<u64> {
484        self.metadata.keys().copied().collect()
485    }
486
487    /// Serialize to bytes
488    pub fn to_bytes(&self) -> Vec<u8> {
489        // Format:
490        // [metadata_count: u64]
491        // [metadata records...]
492        // [string_table_bytes...]
493        // [file_table_bytes...]
494
495        let mut bytes = Vec::new();
496
497        // Metadata count
498        bytes.extend_from_slice(&(self.metadata.len() as u64).to_le_bytes());
499
500        // Metadata records
501        for rec in self.metadata.values() {
502            bytes.extend_from_slice(bytemuck::bytes_of(rec));
503        }
504
505        // String table
506        let string_bytes = self.strings.to_bytes();
507        bytes.extend_from_slice(&(string_bytes.len() as u64).to_le_bytes());
508        bytes.extend_from_slice(&string_bytes);
509
510        // File table
511        let file_bytes = self.files.to_bytes();
512        bytes.extend_from_slice(&(file_bytes.len() as u64).to_le_bytes());
513        bytes.extend_from_slice(&file_bytes);
514
515        bytes
516    }
517
518    /// Deserialize from bytes
519    pub fn from_bytes(bytes: &[u8]) -> Result<Self> {
520        let mut offset = 0;
521
522        // Metadata count
523        if bytes.len() < 8 {
524            anyhow::bail!("Symbol metadata too short for count");
525        }
526        let metadata_count = u64::from_le_bytes(bytes[offset..offset + 8].try_into()?) as usize;
527        offset += 8;
528
529        // Metadata records
530        let mut metadata = HashMap::with_capacity(metadata_count);
531        let rec_size = std::mem::size_of::<SymbolMetadataRec>();
532
533        for _ in 0..metadata_count {
534            if offset + rec_size > bytes.len() {
535                anyhow::bail!("Metadata record truncated");
536            }
537            let rec_bytes = &bytes[offset..offset + rec_size];
538            let rec: SymbolMetadataRec = match bytemuck::try_from_bytes(rec_bytes) {
539                Ok(r) => *r,
540                Err(e) => anyhow::bail!("Failed to parse metadata record: {:?}", e),
541            };
542            offset += rec_size;
543            metadata.insert(rec.symbol_id, rec);
544        }
545
546        // String table
547        if offset + 8 > bytes.len() {
548            anyhow::bail!("Missing string table length");
549        }
550        let string_table_len = u64::from_le_bytes(bytes[offset..offset + 8].try_into()?) as usize;
551        offset += 8;
552
553        if offset + string_table_len > bytes.len() {
554            anyhow::bail!("String table truncated");
555        }
556        let strings = StringTable::from_bytes(&bytes[offset..offset + string_table_len])?;
557        offset += string_table_len;
558
559        // File table
560        if offset + 8 > bytes.len() {
561            anyhow::bail!("Missing file table length");
562        }
563        let file_table_len = u64::from_le_bytes(bytes[offset..offset + 8].try_into()?) as usize;
564        offset += 8;
565
566        if offset + file_table_len > bytes.len() {
567            anyhow::bail!("File table truncated");
568        }
569        let files = FileTable::from_bytes(&bytes[offset..offset + file_table_len])?;
570
571        Ok(Self {
572            metadata,
573            strings,
574            files,
575        })
576    }
577
578    /// Set file hash for a file path
579    pub fn set_file_hash(&mut self, path: &str, hash: &str) {
580        self.files.set_file_hash(path, hash);
581    }
582
583    /// Get file hash for a file path
584    pub fn get_file_hash(&self, path: &str) -> Option<&str> {
585        self.files.get_file_hash(path)
586    }
587
588    /// Get all files with their info
589    pub fn all_files(&self) -> Vec<&FileInfo> {
590        self.files.all_files()
591    }
592
593    /// Get file info by path
594    pub fn get_file_info(&self, path: &str) -> Option<&FileInfo> {
595        self.files.get_info_by_path(path)
596    }
597}
598
599#[cfg(test)]
600mod tests {
601    use super::*;
602
603    #[test]
604    fn test_string_table_basic() {
605        let mut table = StringTable::new();
606
607        let offset1 = table.add("hello");
608        let offset2 = table.add("world");
609        let offset3 = table.add("hello"); // Duplicate
610
611        assert_eq!(offset1, offset3); // Deduplication
612        assert_ne!(offset1, offset2);
613
614        assert_eq!(table.get(offset1), Some("hello".to_string()));
615        assert_eq!(table.get(offset2), Some("world".to_string()));
616    }
617
618    #[test]
619    fn test_string_table_serialization() {
620        let mut table = StringTable::new();
621        table.add("foo");
622        table.add("bar");
623
624        let bytes = table.to_bytes();
625        let restored = StringTable::from_bytes(&bytes).unwrap();
626
627        assert_eq!(restored.len(), 2);
628        assert!(restored.get(0).is_some());
629    }
630
631    #[test]
632    fn test_file_table_basic() {
633        let mut table = FileTable::new();
634
635        let id1 = table.get_or_assign_id("/src/main.rs");
636        let id2 = table.get_or_assign_id("/src/lib.rs");
637        let id3 = table.get_or_assign_id("/src/main.rs"); // Duplicate
638
639        assert_eq!(id1, id3);
640        assert_ne!(id1, id2);
641
642        assert_eq!(table.file_count(), 2);
643        assert_eq!(table.get_path(id1), Some("/src/main.rs"));
644        assert_eq!(table.get_id("/src/lib.rs"), Some(id2));
645    }
646
647    #[test]
648    fn test_file_table_serialization() {
649        let mut table = FileTable::new();
650        table.get_or_assign_id("/a.rs");
651        table.get_or_assign_id("/b.rs");
652
653        let bytes = table.to_bytes();
654        let restored = FileTable::from_bytes(&bytes).unwrap();
655
656        assert_eq!(restored.file_count(), 2);
657        assert!(restored.get_id("/a.rs").is_some());
658        assert!(restored.get_id("/b.rs").is_some());
659    }
660
661    #[test]
662    fn test_symbol_metadata_store_basic() {
663        let mut store = SymbolMetadataStore::new();
664
665        let meta = SymbolMetadata {
666            symbol_id: 1,
667            name: "my_func".to_string(),
668            fqn: "crate::my_func".to_string(),
669            file_path: "/src/lib.rs".to_string(),
670            kind: 1,
671            language: 1,
672            byte_start: 100,
673            byte_end: 200,
674            start_line: 10,
675            start_col: 0,
676            end_line: 20,
677            end_col: 1,
678        };
679
680        store.add(meta.clone());
681
682        assert_eq!(store.symbol_count(), 1);
683        assert_eq!(store.file_count(), 1);
684
685        let retrieved = store.get(1).unwrap();
686        assert_eq!(retrieved.name, "my_func");
687        assert_eq!(retrieved.fqn, "crate::my_func");
688        assert_eq!(retrieved.file_path, "/src/lib.rs");
689    }
690
691    #[test]
692    fn test_symbol_metadata_find_by_fqn() {
693        let mut store = SymbolMetadataStore::new();
694
695        store.add(SymbolMetadata {
696            symbol_id: 1,
697            name: "func1".to_string(),
698            fqn: "crate::module::func1".to_string(),
699            file_path: "/src/lib.rs".to_string(),
700            kind: 1,
701            language: 1,
702            byte_start: 0,
703            byte_end: 10,
704            start_line: 0,
705            start_col: 0,
706            end_line: 0,
707            end_col: 0,
708        });
709
710        store.add(SymbolMetadata {
711            symbol_id: 2,
712            name: "func2".to_string(),
713            fqn: "crate::module::func2".to_string(),
714            file_path: "/src/lib.rs".to_string(),
715            kind: 1,
716            language: 1,
717            byte_start: 20,
718            byte_end: 30,
719            start_line: 0,
720            start_col: 0,
721            end_line: 0,
722            end_col: 0,
723        });
724
725        assert_eq!(store.find_by_fqn("crate::module::func1"), Some(1));
726        assert_eq!(store.find_by_fqn("crate::module::func2"), Some(2));
727        assert_eq!(store.find_by_fqn("nonexistent"), None);
728    }
729
730    #[test]
731    fn test_symbol_metadata_find_by_name() {
732        let mut store = SymbolMetadataStore::new();
733
734        store.add(SymbolMetadata {
735            symbol_id: 1,
736            name: "foo".to_string(),
737            fqn: "crate::A::foo".to_string(),
738            file_path: "/src/a.rs".to_string(),
739            kind: 1,
740            language: 1,
741            byte_start: 0,
742            byte_end: 10,
743            start_line: 0,
744            start_col: 0,
745            end_line: 0,
746            end_col: 0,
747        });
748
749        store.add(SymbolMetadata {
750            symbol_id: 2,
751            name: "foo".to_string(), // Same name, different FQN
752            fqn: "crate::B::foo".to_string(),
753            file_path: "/src/b.rs".to_string(),
754            kind: 1,
755            language: 1,
756            byte_start: 0,
757            byte_end: 10,
758            start_line: 0,
759            start_col: 0,
760            end_line: 0,
761            end_col: 0,
762        });
763
764        let results = store.find_by_name("foo");
765        assert_eq!(results.len(), 2);
766        assert!(results.contains(&1));
767        assert!(results.contains(&2));
768    }
769
770    #[test]
771    fn test_symbol_metadata_symbols_in_file() {
772        let mut store = SymbolMetadataStore::new();
773
774        store.add(SymbolMetadata {
775            symbol_id: 1,
776            name: "func1".to_string(),
777            fqn: "crate::func1".to_string(),
778            file_path: "/src/main.rs".to_string(),
779            kind: 1,
780            language: 1,
781            byte_start: 0,
782            byte_end: 10,
783            start_line: 0,
784            start_col: 0,
785            end_line: 0,
786            end_col: 0,
787        });
788
789        store.add(SymbolMetadata {
790            symbol_id: 2,
791            name: "func2".to_string(),
792            fqn: "crate::func2".to_string(),
793            file_path: "/src/lib.rs".to_string(),
794            kind: 1,
795            language: 1,
796            byte_start: 0,
797            byte_end: 10,
798            start_line: 0,
799            start_col: 0,
800            end_line: 0,
801            end_col: 0,
802        });
803
804        let main_symbols = store.symbols_in_file("/src/main.rs");
805        assert_eq!(main_symbols.len(), 1);
806        assert_eq!(main_symbols[0], 1);
807
808        assert_eq!(store.file_count(), 2);
809    }
810
811    #[test]
812    fn test_symbol_metadata_store_serialization() {
813        let mut store = SymbolMetadataStore::new();
814
815        store.add(SymbolMetadata {
816            symbol_id: 42,
817            name: "test_function".to_string(),
818            fqn: "my_crate::test_function".to_string(),
819            file_path: "/home/user/project/src/lib.rs".to_string(),
820            kind: 2,
821            language: 1,
822            byte_start: 150,
823            byte_end: 300,
824            start_line: 15,
825            start_col: 4,
826            end_line: 25,
827            end_col: 5,
828        });
829
830        let bytes = store.to_bytes();
831        let restored = SymbolMetadataStore::from_bytes(&bytes).unwrap();
832
833        assert_eq!(restored.symbol_count(), 1);
834        assert_eq!(restored.file_count(), 1);
835
836        let meta = restored.get(42).unwrap();
837        assert_eq!(meta.name, "test_function");
838        assert_eq!(meta.fqn, "my_crate::test_function");
839        assert_eq!(meta.file_path, "/home/user/project/src/lib.rs");
840        assert_eq!(meta.byte_start, 150);
841        assert_eq!(meta.byte_end, 300);
842        assert_eq!(meta.start_line, 15);
843        assert_eq!(meta.start_col, 4);
844        assert_eq!(meta.end_line, 25);
845        assert_eq!(meta.end_col, 5);
846    }
847
848    #[test]
849    fn test_symbol_metadata_store_reopen_preserves_all() {
850        let mut store = SymbolMetadataStore::new();
851
852        // Add multiple symbols
853        for i in 0..10 {
854            store.add(SymbolMetadata {
855                symbol_id: i as u64,
856                name: format!("func{}", i),
857                fqn: format!("crate::module::func{}", i),
858                file_path: format!("/src/file{}.rs", i % 3), // 3 different files
859                kind: (i % 5) as u8,
860                language: 1,
861                byte_start: i as u64 * 100,
862                byte_end: i as u64 * 100 + 50,
863                start_line: i as u64,
864                start_col: 0,
865                end_line: i as u64 + 5,
866                end_col: 1,
867            });
868        }
869
870        // Serialize and deserialize
871        let bytes = store.to_bytes();
872        let restored = SymbolMetadataStore::from_bytes(&bytes).unwrap();
873
874        // Verify counts
875        assert_eq!(restored.symbol_count(), 10);
876        assert_eq!(restored.file_count(), 3);
877
878        // Verify all symbols retrievable
879        for i in 0..10 {
880            let meta = restored.get(i as u64).unwrap();
881            assert_eq!(meta.name, format!("func{}", i));
882            assert_eq!(meta.fqn, format!("crate::module::func{}", i));
883
884            // Verify lookups work
885            assert_eq!(restored.find_by_fqn(&meta.fqn), Some(i as u64));
886        }
887
888        // Verify file-scoped queries
889        let file0_symbols = restored.symbols_in_file("/src/file0.rs");
890        assert_eq!(file0_symbols.len(), 4); // IDs 0, 3, 6, 9
891    }
892}