@@ -8,7 +8,7 @@ use crate::graph::RPGraph;
88use anyhow:: { Context , Result } ;
99use semver:: Version ;
1010
11- const CURRENT_VERSION : & str = "2.1 .0" ;
11+ const CURRENT_VERSION : & str = "2.2 .0" ;
1212
1313/// Validate an RPGraph's schema version using semver compatibility.
1414///
@@ -40,14 +40,82 @@ pub fn migrate(graph: &mut RPGraph) -> Result<()> {
4040 let found = Version :: parse ( & graph. version ) ?;
4141
4242 if found < current {
43- // Future migrations go here, e.g.:
44- // if found < Version::new(2, 1, 0) { ... }
43+ // v2.2.0: normalize backslash entity IDs to forward slashes (Windows compat fix)
44+ if found < Version :: new ( 2 , 2 , 0 ) {
45+ migrate_normalize_entity_ids ( graph) ;
46+ }
4547 graph. version = CURRENT_VERSION . to_string ( ) ;
4648 }
4749
4850 Ok ( ( ) )
4951}
5052
53+ /// Migrate entity IDs from backslash paths to forward-slash paths.
54+ ///
55+ /// On Windows, older graphs stored entity IDs with backslashes
56+ /// (e.g. `src\auth\login.py:validate`). This normalizes them to
57+ /// forward slashes so lookups from MCP tools match consistently.
58+ fn migrate_normalize_entity_ids ( graph : & mut RPGraph ) {
59+ fn norm ( id : & str ) -> String {
60+ id. replace ( '\\' , "/" )
61+ }
62+
63+ fn fix_hierarchy (
64+ node : & mut crate :: graph:: HierarchyNode ,
65+ remap : & std:: collections:: HashMap < String , String > ,
66+ ) {
67+ for id in & mut node. entities {
68+ if let Some ( new_id) = remap. get ( id. as_str ( ) ) {
69+ * id = new_id. clone ( ) ;
70+ }
71+ }
72+ for child in node. children . values_mut ( ) {
73+ fix_hierarchy ( child, remap) ;
74+ }
75+ }
76+
77+ // 1. Rebuild entities map with normalized keys + id fields
78+ let old_entities = std:: mem:: take ( & mut graph. entities ) ;
79+ let mut id_remap: std:: collections:: HashMap < String , String > = std:: collections:: HashMap :: new ( ) ;
80+ for ( old_key, mut entity) in old_entities {
81+ let new_key = norm ( & old_key) ;
82+ entity. id = new_key. clone ( ) ;
83+ if old_key != new_key {
84+ id_remap. insert ( old_key, new_key. clone ( ) ) ;
85+ }
86+ graph. entities . insert ( new_key, entity) ;
87+ }
88+
89+ // Nothing was remapped — graph already uses forward slashes
90+ if id_remap. is_empty ( ) {
91+ return ;
92+ }
93+
94+ // 2. Update file_index value lists
95+ for ids in graph. file_index . values_mut ( ) {
96+ for id in ids. iter_mut ( ) {
97+ if let Some ( new_id) = id_remap. get ( id. as_str ( ) ) {
98+ * id = new_id. clone ( ) ;
99+ }
100+ }
101+ }
102+
103+ // 3. Update edge source/target
104+ for edge in & mut graph. edges {
105+ if let Some ( new_id) = id_remap. get ( & edge. source ) {
106+ edge. source = new_id. clone ( ) ;
107+ }
108+ if let Some ( new_id) = id_remap. get ( & edge. target ) {
109+ edge. target = new_id. clone ( ) ;
110+ }
111+ }
112+
113+ // 4. Update hierarchy entity lists (recursive)
114+ for node in graph. hierarchy . values_mut ( ) {
115+ fix_hierarchy ( node, & id_remap) ;
116+ }
117+ }
118+
51119/// Serialize an RPGraph to a pretty-printed JSON string.
52120///
53121/// Edges are sorted by (source, target, kind) for deterministic output,
@@ -139,4 +207,79 @@ mod tests {
139207 let graph = graph_with_version ( "not-a-version" ) ;
140208 assert ! ( validate_version( & graph) . is_err( ) ) ;
141209 }
210+
211+ #[ test]
212+ fn test_migrate_normalizes_backslash_ids ( ) {
213+ use crate :: graph:: {
214+ DependencyEdge , EdgeKind , Entity , EntityDeps , EntityKind , HierarchyNode ,
215+ } ;
216+ use std:: path:: PathBuf ;
217+
218+ let mut graph = graph_with_version ( "2.1.0" ) ;
219+
220+ // Insert entity with backslash ID (as Windows would produce)
221+ let old_id = r"src\auth\login.py:validate" . to_string ( ) ;
222+ graph. entities . insert (
223+ old_id. clone ( ) ,
224+ Entity {
225+ id : old_id. clone ( ) ,
226+ kind : EntityKind :: Function ,
227+ name : "validate" . to_string ( ) ,
228+ file : PathBuf :: from ( "src/auth/login.py" ) ,
229+ line_start : 1 ,
230+ line_end : 10 ,
231+ parent_class : None ,
232+ semantic_features : vec ! [ "validates user credentials" . to_string( ) ] ,
233+ feature_source : Some ( "llm" . to_string ( ) ) ,
234+ hierarchy_path : String :: new ( ) ,
235+ deps : EntityDeps :: default ( ) ,
236+ signature : None ,
237+ } ,
238+ ) ;
239+
240+ // file_index references the old ID
241+ graph
242+ . file_index
243+ . insert ( PathBuf :: from ( "src/auth/login.py" ) , vec ! [ old_id. clone( ) ] ) ;
244+
245+ // edge references the old ID
246+ graph. edges . push ( DependencyEdge {
247+ source : old_id. clone ( ) ,
248+ target : "other:target" . to_string ( ) ,
249+ kind : EdgeKind :: Invokes ,
250+ } ) ;
251+
252+ // hierarchy references the old ID
253+ let mut node = HierarchyNode :: new ( "Auth" ) ;
254+ node. entities . push ( old_id. clone ( ) ) ;
255+ graph. hierarchy . insert ( "h:Auth" . to_string ( ) , node) ;
256+
257+ // Run migration
258+ migrate ( & mut graph) . unwrap ( ) ;
259+
260+ let new_id = "src/auth/login.py:validate" ;
261+
262+ // Entity moved to new key
263+ assert ! ( graph. entities. contains_key( new_id) ) ;
264+ assert ! ( !graph. entities. contains_key( & old_id) ) ;
265+ assert_eq ! ( graph. entities[ new_id] . id, new_id) ;
266+ // Semantic features preserved
267+ assert_eq ! (
268+ graph. entities[ new_id] . semantic_features,
269+ vec![ "validates user credentials" ]
270+ ) ;
271+
272+ // file_index updated
273+ let file_ids = & graph. file_index [ & PathBuf :: from ( "src/auth/login.py" ) ] ;
274+ assert_eq ! ( file_ids, & [ new_id] ) ;
275+
276+ // edge updated
277+ assert_eq ! ( graph. edges[ 0 ] . source, new_id) ;
278+
279+ // hierarchy updated
280+ assert_eq ! ( graph. hierarchy[ "h:Auth" ] . entities, vec![ new_id] ) ;
281+
282+ // version bumped
283+ assert_eq ! ( graph. version, CURRENT_VERSION ) ;
284+ }
142285}
0 commit comments