Skip to main content

sqry_core/cache/
summary.rs

1//! Cacheable graph node summary for AST cache.
2//!
3//! This module defines a lightweight graph-native representation optimized for
4//! caching. Unlike full graph storage, the cached summary contains only fields
5//! needed to rebuild query-facing metadata without Node types.
6//!
7//! # Size Budget
8//!
9//! The cached summary targets ≤256 bytes per node (postcard serialization).
10//! This keeps memory footprint reasonable for the 50 MB default cache.
11//!
12//! # Excluded Fields
13//!
14//! The following fields from `NodeEntry` are excluded from the cache payload:
15//! - **Docstrings**: Can be large (100-1000 bytes), rarely needed for search
16//! - **Body hashes**: Recomputed during graph build when needed
17//! - **Graph IDs**: Node IDs are graph-specific and not stable across runs
18//!
19//! # Example
20//!
21//! ```rust,ignore
22//! use sqry_core::cache::GraphNodeSummary;
23//! use sqry_core::graph::unified::CodeGraph;
24//!
25//! let graph = CodeGraph::new();
26//! let entry = graph.nodes().get(/* node id */).unwrap();
27//! let summary = GraphNodeSummary::from_entry(entry, &graph).unwrap();
28//!
29//! // Serialize for cache storage
30//! let bytes = postcard::to_allocvec(&summary)?;
31//! assert!(bytes.len() <= 256);
32//! ```
33
34use crate::graph::unified::concurrent::CodeGraph;
35use crate::graph::unified::node::NodeKind;
36use crate::graph::unified::storage::arena::NodeEntry;
37use serde::{Deserialize, Serialize};
38use std::path::Path;
39use std::sync::Arc;
40
41/// Lightweight node summary for cache storage.
42///
43/// Contains only the essential fields needed for semantic code search:
44/// - Node identification (name, kind)
45/// - Location information (file path, line/column ranges)
46/// - Optional metadata (qualified name, visibility, signature)
47///
48/// # Serialization
49///
50/// Uses postcard for compact binary serialization. Target size: ≤256 bytes.
51///
52/// # Memory Optimization
53///
54/// Uses `Arc<str>` for string fields and `Arc<Path>` for paths to enable
55/// efficient sharing when multiple cache entries reference the same data.
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57pub struct GraphNodeSummary {
58    /// Node name (shared via Arc)
59    #[serde(
60        serialize_with = "serialize_arc_str",
61        deserialize_with = "deserialize_arc_str"
62    )]
63    pub name: Arc<str>,
64
65    /// Node kind (function, class, etc.)
66    pub node_kind: NodeKind,
67
68    /// File path where node is defined (shared via Arc)
69    #[serde(
70        serialize_with = "serialize_arc_path",
71        deserialize_with = "deserialize_arc_path"
72    )]
73    pub file_path: Arc<Path>,
74
75    /// Line number where node starts (1-based)
76    pub start_line: u32,
77
78    /// Column where node starts (0-based)
79    pub start_column: u32,
80
81    /// Line number where node ends (1-based)
82    pub end_line: u32,
83
84    /// Column where node ends (0-based)
85    pub end_column: u32,
86
87    /// Fully qualified name (e.g., "`Module::Class::method`")
88    #[serde(
89        serialize_with = "serialize_option_arc_str",
90        deserialize_with = "deserialize_option_arc_str"
91    )]
92    pub qualified_name: Option<Arc<str>>,
93
94    /// Visibility modifier (public, private, protected, etc.)
95    #[serde(
96        serialize_with = "serialize_option_arc_str",
97        deserialize_with = "deserialize_option_arc_str"
98    )]
99    pub visibility: Option<Arc<str>>,
100
101    /// Optional signature/type information
102    #[serde(
103        serialize_with = "serialize_option_arc_str",
104        deserialize_with = "deserialize_option_arc_str"
105    )]
106    pub signature: Option<Arc<str>>,
107
108    /// Whether this is an async function/method.
109    pub is_async: bool,
110
111    /// Whether this is a static member.
112    pub is_static: bool,
113}
114
115// Custom serialization for Arc<str>
116fn serialize_arc_str<S>(arc: &Arc<str>, serializer: S) -> Result<S::Ok, S::Error>
117where
118    S: serde::Serializer,
119{
120    serializer.serialize_str(arc)
121}
122
123fn deserialize_arc_str<'de, D>(deserializer: D) -> Result<Arc<str>, D::Error>
124where
125    D: serde::Deserializer<'de>,
126{
127    let s = String::deserialize(deserializer)?;
128    Ok(Arc::from(s.as_str()))
129}
130
131// Custom serialization for Option<Arc<str>>
132#[allow(clippy::ref_option)] // serde serialize_with requires &Option<T>
133fn serialize_option_arc_str<S>(opt: &Option<Arc<str>>, serializer: S) -> Result<S::Ok, S::Error>
134where
135    S: serde::Serializer,
136{
137    match opt {
138        Some(arc) => serializer.serialize_some(arc.as_ref()),
139        None => serializer.serialize_none(),
140    }
141}
142
143fn deserialize_option_arc_str<'de, D>(deserializer: D) -> Result<Option<Arc<str>>, D::Error>
144where
145    D: serde::Deserializer<'de>,
146{
147    let opt: Option<String> = Option::deserialize(deserializer)?;
148    Ok(opt.map(|s| Arc::from(s.as_str())))
149}
150
151// Custom serialization for Arc<Path>
152fn serialize_arc_path<S>(arc: &Arc<Path>, serializer: S) -> Result<S::Ok, S::Error>
153where
154    S: serde::Serializer,
155{
156    serializer.serialize_str(arc.to_str().unwrap_or(""))
157}
158
159fn deserialize_arc_path<'de, D>(deserializer: D) -> Result<Arc<Path>, D::Error>
160where
161    D: serde::Deserializer<'de>,
162{
163    let s = String::deserialize(deserializer)?;
164    Ok(Arc::from(Path::new(&s)))
165}
166
167impl GraphNodeSummary {
168    /// Build a summary from a graph node entry.
169    ///
170    /// Returns `None` if required string or file resolution fails.
171    #[must_use]
172    pub fn from_entry(entry: &NodeEntry, graph: &CodeGraph) -> Option<Self> {
173        let strings = graph.strings();
174        let files = graph.files();
175
176        let name = strings.resolve(entry.name)?;
177        let file_path = files.resolve(entry.file)?;
178        let qualified_name = entry
179            .qualified_name
180            .and_then(|id| strings.resolve(id))
181            .map(|value| Arc::from(value.as_ref()));
182        let visibility = entry
183            .visibility
184            .and_then(|id| strings.resolve(id))
185            .map(|value| Arc::from(value.as_ref()));
186        let signature = entry
187            .signature
188            .and_then(|id| strings.resolve(id))
189            .map(|value| Arc::from(value.as_ref()));
190
191        Some(Self {
192            name: Arc::from(name.as_ref()),
193            node_kind: entry.kind,
194            file_path: Arc::from(file_path.as_ref()),
195            start_line: entry.start_line,
196            start_column: entry.start_column,
197            end_line: entry.end_line,
198            end_column: entry.end_column,
199            qualified_name,
200            visibility,
201            signature,
202            is_async: entry.is_async,
203            is_static: entry.is_static,
204        })
205    }
206
207    /// Create a summary from explicit fields (useful for tests).
208    #[must_use]
209    pub fn new(
210        name: impl Into<Arc<str>>,
211        node_kind: NodeKind,
212        file_path: impl Into<Arc<Path>>,
213        start_line: u32,
214        start_column: u32,
215        end_line: u32,
216        end_column: u32,
217    ) -> Self {
218        Self {
219            name: name.into(),
220            node_kind,
221            file_path: file_path.into(),
222            start_line,
223            start_column,
224            end_line,
225            end_column,
226            qualified_name: None,
227            visibility: None,
228            signature: None,
229            is_async: false,
230            is_static: false,
231        }
232    }
233
234    /// Estimate the serialized size in bytes (postcard format).
235    ///
236    /// Returns the actual postcard-serialized size of this summary.
237    /// Used for size budget validation and cache eviction calculations.
238    ///
239    /// Falls back to the documented 256-byte budget estimate if serialization
240    /// fails (which should be extremely rare in practice).
241    #[must_use]
242    pub fn serialized_size(&self) -> usize {
243        const BUDGET_FALLBACK: usize = 256;
244
245        match postcard::to_allocvec(self) {
246            Ok(bytes) => bytes.len(),
247            Err(e) => {
248                log::error!(
249                    "Failed to serialize GraphNodeSummary for size calculation: {e}. Falling back to {BUDGET_FALLBACK} byte budget estimate."
250                );
251                BUDGET_FALLBACK
252            }
253        }
254    }
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260    use std::path::PathBuf;
261
262    #[test]
263    fn test_serialized_size_budget() {
264        let summary = GraphNodeSummary::new(
265            Arc::from("test_function"),
266            NodeKind::Function,
267            Arc::from(Path::new("src/lib.rs")),
268            10,
269            0,
270            12,
271            0,
272        );
273        assert!(summary.serialized_size() <= 256);
274    }
275
276    #[test]
277    fn test_roundtrip_serialization() {
278        let summary = GraphNodeSummary::new(
279            Arc::from("test_fn"),
280            NodeKind::Function,
281            Arc::from(Path::new("src/lib.rs")),
282            1,
283            0,
284            2,
285            10,
286        );
287
288        let bytes = postcard::to_allocvec(&summary).unwrap();
289        let restored: GraphNodeSummary = postcard::from_bytes(&bytes).unwrap();
290        assert_eq!(summary, restored);
291    }
292
293    #[test]
294    fn test_new_fields_defaults() {
295        let summary = GraphNodeSummary::new(
296            "test",
297            NodeKind::Function,
298            Arc::from(PathBuf::from("src/lib.rs").as_path()),
299            3,
300            1,
301            4,
302            2,
303        );
304
305        assert!(summary.qualified_name.is_none());
306        assert!(summary.visibility.is_none());
307        assert!(summary.signature.is_none());
308        assert!(!summary.is_async);
309        assert!(!summary.is_static);
310    }
311}