Skip to main content

dk_engine/workspace/
session_graph.rs

1//! SessionGraph — delta-based semantic graph layered on a shared base.
2//!
3//! The shared base symbol table is stored in an `ArcSwap` so it can be
4//! atomically replaced when the repository is re-indexed. Each session
5//! maintains its own deltas (added, modified, removed symbols and edges)
6//! in lock-free `DashMap`/`DashSet` collections.
7
8use std::collections::HashMap;
9use std::sync::Arc;
10
11use arc_swap::ArcSwap;
12use dashmap::{DashMap, DashSet};
13use dk_core::{CallEdge, Symbol, SymbolId};
14use serde::{Deserialize, Serialize};
15use uuid::Uuid;
16
17// ── SessionGraph ─────────────────────────────────────────────────────
18
19/// A delta-based semantic graph for a single session workspace.
20///
21/// Lookups resolve in order: removed -> modified -> added -> base.
22/// This gives each session a consistent, isolated view of the symbol
23/// graph without copying the entire base.
24pub struct SessionGraph {
25    /// Shared, read-only base symbol table (repo-wide).
26    base_symbols: Option<Arc<ArcSwap<HashMap<SymbolId, Symbol>>>>,
27
28    /// Symbols that existed in the base and were modified in this session.
29    pub(crate) modified_symbols: DashMap<SymbolId, Symbol>,
30
31    /// Symbols that are newly created in this session.
32    added_symbols: DashMap<SymbolId, Symbol>,
33
34    /// Symbols that existed in the base and were removed in this session.
35    pub(crate) removed_symbols: DashSet<SymbolId>,
36
37    /// Cached names of removed symbols (populated during serialization or
38    /// deserialization so `changed_symbol_names()` works without the base).
39    removed_symbol_names: DashMap<SymbolId, String>,
40
41    /// Call edges added in this session.
42    pub(crate) added_edges: DashMap<Uuid, CallEdge>,
43
44    /// Call edge IDs removed from the base in this session.
45    pub(crate) removed_edges: DashSet<Uuid>,
46}
47
48// ── Snapshot (serde bridge) ───────────────────────────────────────────
49
50/// Serializable snapshot of the session delta.
51///
52/// `DashMap`/`DashSet` are not directly serializable, so we flatten
53/// them into `Vec`s. The shared `base_symbols` is intentionally excluded
54/// — it is repo-wide state that is not owned by the session.
55#[derive(Serialize, Deserialize)]
56struct SessionGraphSnapshot {
57    modified_symbols: Vec<(SymbolId, Symbol)>,
58    added_symbols: Vec<(SymbolId, Symbol)>,
59    removed_symbols: Vec<SymbolId>,
60    /// Names of removed symbols, so `changed_symbol_names()` works on
61    /// deserialized graphs without requiring the base symbol table.
62    #[serde(default)]
63    removed_symbol_names: Vec<(SymbolId, String)>,
64    added_edges: Vec<(Uuid, CallEdge)>,
65    removed_edges: Vec<Uuid>,
66}
67
68impl SessionGraph {
69    /// Fork from a shared base symbol table.
70    pub fn fork_from(base: Arc<ArcSwap<HashMap<SymbolId, Symbol>>>) -> Self {
71        Self {
72            base_symbols: Some(base),
73            modified_symbols: DashMap::new(),
74            added_symbols: DashMap::new(),
75            removed_symbols: DashSet::new(),
76            removed_symbol_names: DashMap::new(),
77            added_edges: DashMap::new(),
78            removed_edges: DashSet::new(),
79        }
80    }
81
82    /// Create an empty session graph (no shared base).
83    pub fn empty() -> Self {
84        Self {
85            base_symbols: None,
86            modified_symbols: DashMap::new(),
87            added_symbols: DashMap::new(),
88            removed_symbols: DashSet::new(),
89            removed_symbol_names: DashMap::new(),
90            added_edges: DashMap::new(),
91            removed_edges: DashSet::new(),
92        }
93    }
94
95    /// Look up a symbol by ID, respecting the session delta.
96    ///
97    /// Resolution order:
98    /// 1. If removed in this session, return `None`.
99    /// 2. If modified in this session, return the modified version.
100    /// 3. If added in this session, return it.
101    /// 4. Fall through to the shared base.
102    pub fn get_symbol(&self, id: SymbolId) -> Option<Symbol> {
103        // Removed in this session?
104        if self.removed_symbols.contains(&id) {
105            return None;
106        }
107
108        // Modified in this session?
109        if let Some(sym) = self.modified_symbols.get(&id) {
110            return Some(sym.value().clone());
111        }
112
113        // Added in this session?
114        if let Some(sym) = self.added_symbols.get(&id) {
115            return Some(sym.value().clone());
116        }
117
118        // Base lookup
119        if let Some(base) = &self.base_symbols {
120            let snapshot = base.load();
121            return snapshot.get(&id).cloned();
122        }
123
124        None
125    }
126
127    /// Add a new symbol to this session.
128    pub fn add_symbol(&self, symbol: Symbol) {
129        self.added_symbols.insert(symbol.id, symbol);
130    }
131
132    /// Modify an existing symbol (base or previously added).
133    pub fn modify_symbol(&self, symbol: Symbol) {
134        let id = symbol.id;
135
136        // If it was added in this session, update the added entry.
137        if self.added_symbols.contains_key(&id) {
138            self.added_symbols.insert(id, symbol);
139        } else {
140            self.modified_symbols.insert(id, symbol);
141        }
142    }
143
144    /// Remove a symbol from the session view.
145    pub fn remove_symbol(&self, id: SymbolId) {
146        // If it was added in this session, just drop it.
147        if self.added_symbols.remove(&id).is_some() {
148            return;
149        }
150
151        // If it was modified, capture the name before dropping.
152        if let Some((_, sym)) = self.modified_symbols.remove(&id) {
153            self.removed_symbol_names
154                .insert(id, sym.qualified_name.clone());
155        } else if let Some(base) = &self.base_symbols {
156            // Look up name from base for the cache.
157            let snapshot = base.load();
158            if let Some(sym) = snapshot.get(&id) {
159                self.removed_symbol_names
160                    .insert(id, sym.qualified_name.clone());
161            }
162        }
163
164        // Mark as removed from base.
165        self.removed_symbols.insert(id);
166    }
167
168    /// Add a call edge.
169    pub fn add_edge(&self, edge: CallEdge) {
170        self.added_edges.insert(edge.id, edge);
171    }
172
173    /// Remove a call edge.
174    pub fn remove_edge(&self, edge_id: Uuid) {
175        // If it was added in this session, just drop it.
176        if self.added_edges.remove(&edge_id).is_some() {
177            return;
178        }
179        self.removed_edges.insert(edge_id);
180    }
181
182    /// Look up an added edge by ID.
183    ///
184    /// Returns `None` if the edge was not added in this session or has been
185    /// removed.
186    pub fn get_edge(&self, edge_id: Uuid) -> Option<CallEdge> {
187        if self.removed_edges.contains(&edge_id) {
188            return None;
189        }
190        self.added_edges.get(&edge_id).map(|e| e.value().clone())
191    }
192
193    /// Returns `true` if the given edge ID is marked as removed in this
194    /// session.
195    pub fn is_edge_removed(&self, edge_id: Uuid) -> bool {
196        self.removed_edges.contains(&edge_id)
197    }
198
199    /// Return the names of all symbols changed in this session
200    /// (added, modified, or removed).
201    ///
202    /// Used by the conflict detector to find overlapping changes.
203    pub fn changed_symbol_names(&self) -> Vec<String> {
204        let mut names = Vec::new();
205
206        for entry in self.added_symbols.iter() {
207            names.push(entry.value().qualified_name.clone());
208        }
209
210        for entry in self.modified_symbols.iter() {
211            names.push(entry.value().qualified_name.clone());
212        }
213
214        // For removed symbols, try the base first, then the cached names
215        // (which are populated during remove_symbol and deserialization).
216        for id in self.removed_symbols.iter() {
217            let found = self
218                .base_symbols
219                .as_ref()
220                .and_then(|base| base.load().get(id.key()).map(|s| s.qualified_name.clone()));
221            if let Some(name) = found {
222                names.push(name);
223            } else if let Some(name) = self.removed_symbol_names.get(id.key()) {
224                names.push(name.value().clone());
225            }
226        }
227
228        names
229    }
230
231    /// Remove all session-owned (added or modified) symbols that belong to
232    /// `file_path`. Used by `reindex_from_overlay` when an overlay entry is
233    /// a deletion — the file no longer exists, so any symbols the session
234    /// added or modified for it should be dropped from the delta.
235    ///
236    /// Symbols from the base that happen to live in this file are NOT marked
237    /// as removed here; that would require knowledge of the base table which
238    /// is not always present. After a resume-from-overlay the base is empty
239    /// so this covers the common case correctly.
240    pub fn remove_session_symbols_for_file(&self, file_path: &str) {
241        let target = std::path::Path::new(file_path);
242
243        let added_ids: Vec<SymbolId> = self
244            .added_symbols
245            .iter()
246            .filter(|e| e.value().file_path == target)
247            .map(|e| *e.key())
248            .collect();
249
250        let modified_ids: Vec<SymbolId> = self
251            .modified_symbols
252            .iter()
253            .filter(|e| e.value().file_path == target)
254            .map(|e| *e.key())
255            .collect();
256
257        for id in added_ids {
258            self.added_symbols.remove(&id);
259        }
260        for id in modified_ids {
261            // Capture name before removing from modified (for removed_symbol_names cache).
262            self.remove_symbol(id);
263        }
264    }
265
266    /// Update the session graph from a parse result for a single file.
267    ///
268    /// Compares the new symbols against the base symbols for that file,
269    /// and classifies each as added, modified, or removed within the
270    /// session delta.
271    ///
272    /// `base_symbols_for_file` should contain all symbols from the base
273    /// that belong to the given file path.
274    pub fn update_from_parse(
275        &self,
276        _file_path: &str,
277        new_symbols: Vec<Symbol>,
278        base_symbols_for_file: &[Symbol],
279    ) {
280        // Build a lookup of base symbols by qualified name for this file.
281        let base_by_name: HashMap<&str, &Symbol> = base_symbols_for_file
282            .iter()
283            .map(|s| (s.qualified_name.as_str(), s))
284            .collect();
285
286        let new_by_name: HashMap<&str, &Symbol> = new_symbols
287            .iter()
288            .map(|s| (s.qualified_name.as_str(), s))
289            .collect();
290
291        // Symbols in new but not in base -> added
292        // Symbols in both but changed -> modified
293        for sym in &new_symbols {
294            if let Some(base_sym) = base_by_name.get(sym.qualified_name.as_str()) {
295                // Compare span, signature, etc. to detect modification.
296                if sym.span != base_sym.span
297                    || sym.signature != base_sym.signature
298                    || sym.kind != base_sym.kind
299                    || sym.visibility != base_sym.visibility
300                {
301                    self.modify_symbol(sym.clone());
302                }
303            } else {
304                self.add_symbol(sym.clone());
305            }
306        }
307
308        // Symbols in base but not in new -> removed
309        for base_sym in base_symbols_for_file {
310            if !new_by_name.contains_key(base_sym.qualified_name.as_str()) {
311                self.remove_symbol(base_sym.id);
312            }
313        }
314    }
315
316    /// Return the names of symbols changed in this session that belong
317    /// to the given file path. Useful for cross-session file awareness.
318    pub fn changed_symbols_for_file(&self, file_path: &str) -> Vec<String> {
319        let target = std::path::Path::new(file_path);
320        let mut names = Vec::new();
321
322        for entry in self.added_symbols.iter() {
323            if entry.value().file_path == target {
324                names.push(entry.value().name.clone());
325            }
326        }
327
328        for entry in self.modified_symbols.iter() {
329            if entry.value().file_path == target {
330                names.push(entry.value().name.clone());
331            }
332        }
333
334        names
335    }
336
337    /// Number of symbols changed (added + modified + removed).
338    pub fn change_count(&self) -> usize {
339        self.added_symbols.len() + self.modified_symbols.len() + self.removed_symbols.len()
340    }
341
342    // ── Serialization ─────────────────────────────────────────────────
343
344    /// Serialize the session delta (modified/added/removed symbols and edges)
345    /// to MessagePack bytes.
346    ///
347    /// The shared `base_symbols` table is NOT included — it is repo-wide
348    /// state managed independently of individual sessions.
349    pub fn to_msgpack(&self) -> anyhow::Result<Vec<u8>> {
350        let snapshot = SessionGraphSnapshot {
351            modified_symbols: self
352                .modified_symbols
353                .iter()
354                .map(|e| (*e.key(), e.value().clone()))
355                .collect(),
356            added_symbols: self
357                .added_symbols
358                .iter()
359                .map(|e| (*e.key(), e.value().clone()))
360                .collect(),
361            removed_symbols: self.removed_symbols.iter().map(|r| *r).collect(),
362            removed_symbol_names: self
363                .removed_symbol_names
364                .iter()
365                .map(|e| (*e.key(), e.value().clone()))
366                .collect(),
367            added_edges: self
368                .added_edges
369                .iter()
370                .map(|e| (*e.key(), e.value().clone()))
371                .collect(),
372            removed_edges: self.removed_edges.iter().map(|r| *r).collect(),
373        };
374
375        Ok(rmp_serde::to_vec_named(&snapshot)?)
376    }
377
378    /// Deserialize a session delta from MessagePack bytes produced by
379    /// [`Self::to_msgpack`].
380    ///
381    /// The returned graph has no shared base (`base_symbols` is `None`).
382    /// Callers that need base-symbol lookups must call
383    /// [`Self::fork_from`] and replay the delta on top.
384    pub fn from_msgpack(bytes: &[u8]) -> anyhow::Result<Self> {
385        let snapshot: SessionGraphSnapshot = rmp_serde::from_slice(bytes)?;
386
387        let modified_symbols = DashMap::new();
388        for (id, sym) in snapshot.modified_symbols {
389            modified_symbols.insert(id, sym);
390        }
391
392        let added_symbols = DashMap::new();
393        for (id, sym) in snapshot.added_symbols {
394            added_symbols.insert(id, sym);
395        }
396
397        let removed_symbols = DashSet::new();
398        for id in snapshot.removed_symbols {
399            removed_symbols.insert(id);
400        }
401
402        let removed_symbol_names = DashMap::new();
403        for (id, name) in snapshot.removed_symbol_names {
404            removed_symbol_names.insert(id, name);
405        }
406
407        let added_edges = DashMap::new();
408        for (id, edge) in snapshot.added_edges {
409            added_edges.insert(id, edge);
410        }
411
412        let removed_edges = DashSet::new();
413        for id in snapshot.removed_edges {
414            removed_edges.insert(id);
415        }
416
417        Ok(Self {
418            base_symbols: None,
419            modified_symbols,
420            added_symbols,
421            removed_symbols,
422            removed_symbol_names,
423            added_edges,
424            removed_edges,
425        })
426    }
427}
428
429#[cfg(test)]
430mod tests {
431    use super::*;
432    use dk_core::{Span, SymbolKind, Visibility};
433    use std::path::PathBuf;
434
435    fn make_symbol(name: &str) -> Symbol {
436        Symbol {
437            id: Uuid::new_v4(),
438            name: name.to_string(),
439            qualified_name: name.to_string(),
440            kind: SymbolKind::Function,
441            visibility: Visibility::Public,
442            file_path: PathBuf::from("test.rs"),
443            span: Span {
444                start_byte: 0,
445                end_byte: 10,
446            },
447            signature: None,
448            doc_comment: None,
449            parent: None,
450            last_modified_by: None,
451            last_modified_intent: None,
452        }
453    }
454
455    #[test]
456    fn empty_graph_returns_none() {
457        let g = SessionGraph::empty();
458        assert!(g.get_symbol(Uuid::new_v4()).is_none());
459    }
460
461    #[test]
462    fn add_and_get_symbol() {
463        let g = SessionGraph::empty();
464        let sym = make_symbol("foo");
465        let id = sym.id;
466        g.add_symbol(sym);
467        assert!(g.get_symbol(id).is_some());
468        assert_eq!(g.get_symbol(id).unwrap().name, "foo");
469    }
470
471    #[test]
472    fn remove_added_symbol() {
473        let g = SessionGraph::empty();
474        let sym = make_symbol("bar");
475        let id = sym.id;
476        g.add_symbol(sym);
477        g.remove_symbol(id);
478        assert!(g.get_symbol(id).is_none());
479    }
480
481    #[test]
482    fn modify_added_symbol_updates_in_place() {
483        let g = SessionGraph::empty();
484        let mut sym = make_symbol("baz");
485        let id = sym.id;
486        g.add_symbol(sym.clone());
487
488        sym.name = "baz_v2".to_string();
489        g.modify_symbol(sym);
490
491        let got = g.get_symbol(id).unwrap();
492        assert_eq!(got.name, "baz_v2");
493    }
494
495    #[test]
496    fn fork_from_base_lookup() {
497        let mut base = HashMap::new();
498        let sym = make_symbol("base_fn");
499        let id = sym.id;
500        base.insert(id, sym);
501
502        let shared = Arc::new(ArcSwap::from_pointee(base));
503        let g = SessionGraph::fork_from(shared);
504
505        assert!(g.get_symbol(id).is_some());
506        assert_eq!(g.get_symbol(id).unwrap().name, "base_fn");
507    }
508
509    #[test]
510    fn remove_base_symbol_hides_it() {
511        let mut base = HashMap::new();
512        let sym = make_symbol("base_fn");
513        let id = sym.id;
514        base.insert(id, sym);
515
516        let shared = Arc::new(ArcSwap::from_pointee(base));
517        let g = SessionGraph::fork_from(shared);
518
519        g.remove_symbol(id);
520        assert!(g.get_symbol(id).is_none());
521    }
522
523    #[test]
524    fn changed_symbol_names_collects_all() {
525        let mut base = HashMap::new();
526        let sym = make_symbol("removed_fn");
527        let removed_id = sym.id;
528        base.insert(removed_id, sym);
529
530        let shared = Arc::new(ArcSwap::from_pointee(base));
531        let g = SessionGraph::fork_from(shared);
532
533        let added = make_symbol("added_fn");
534        g.add_symbol(added);
535
536        let mut modified = make_symbol("modified_fn");
537        modified.id = Uuid::new_v4();
538        let mid = modified.id;
539        // Pretend it's in base by inserting to modified_symbols directly
540        g.modified_symbols.insert(mid, modified);
541
542        g.remove_symbol(removed_id);
543
544        let names = g.changed_symbol_names();
545        assert!(names.contains(&"added_fn".to_string()));
546        assert!(names.contains(&"modified_fn".to_string()));
547        assert!(names.contains(&"removed_fn".to_string()));
548    }
549
550    #[test]
551    fn change_count() {
552        let g = SessionGraph::empty();
553        assert_eq!(g.change_count(), 0);
554
555        g.add_symbol(make_symbol("a"));
556        assert_eq!(g.change_count(), 1);
557    }
558
559    #[test]
560    fn changed_symbols_for_file_filters_by_path() {
561        let g = SessionGraph::empty();
562
563        let mut sym1 = make_symbol("create_task");
564        sym1.file_path = PathBuf::from("src/tasks.rs");
565        g.add_symbol(sym1);
566
567        let mut sym2 = make_symbol("delete_task");
568        sym2.file_path = PathBuf::from("src/tasks.rs");
569        g.add_symbol(sym2);
570
571        let mut sym3 = make_symbol("run_server");
572        sym3.file_path = PathBuf::from("src/main.rs");
573        g.add_symbol(sym3);
574
575        let task_syms = g.changed_symbols_for_file("src/tasks.rs");
576        assert_eq!(task_syms.len(), 2);
577        assert!(task_syms.contains(&"create_task".to_string()));
578        assert!(task_syms.contains(&"delete_task".to_string()));
579
580        let main_syms = g.changed_symbols_for_file("src/main.rs");
581        assert_eq!(main_syms.len(), 1);
582        assert!(main_syms.contains(&"run_server".to_string()));
583
584        let empty = g.changed_symbols_for_file("src/nonexistent.rs");
585        assert!(empty.is_empty());
586    }
587}