Skip to main content

sem_core/model/
identity.rs

1use std::collections::{HashMap, HashSet};
2
3use super::change::{ChangeType, SemanticChange};
4use super::entity::SemanticEntity;
5
6/// Extracts the leaf name from a parent_id string (last "::" segment).
7fn parent_name(entity: &SemanticEntity) -> Option<String> {
8    let pid = entity.parent_id.as_ref()?;
9    pid.rsplit("::").next().map(String::from)
10}
11
12pub struct MatchResult {
13    pub changes: Vec<SemanticChange>,
14}
15
16fn classify_match(before: &SemanticEntity, after: &SemanticEntity) -> ChangeType {
17    if before.file_path != after.file_path {
18        ChangeType::Moved
19    } else if before.parent_id != after.parent_id {
20        ChangeType::Moved // intra-file scope move (e.g. method moved between classes)
21    } else {
22        ChangeType::Renamed
23    }
24}
25
26fn make_change(
27    after_entity: &SemanticEntity,
28    change_type: ChangeType,
29    before_entity: Option<&SemanticEntity>,
30    commit_sha: Option<&str>,
31    author: Option<&str>,
32) -> SemanticChange {
33    let prefix = match change_type {
34        ChangeType::Added => "added::",
35        ChangeType::Deleted => "deleted::",
36        ChangeType::Reordered => "reordered::",
37        _ => "",
38    };
39    // For deleted entities, use the before entity as the primary source
40    let primary = if change_type == ChangeType::Deleted {
41        before_entity.unwrap_or(after_entity)
42    } else {
43        after_entity
44    };
45    SemanticChange {
46        id: format!("change::{prefix}{}", primary.id),
47        entity_id: primary.id.clone(),
48        change_type,
49        entity_type: primary.entity_type.clone(),
50        entity_name: primary.name.clone(),
51        entity_line: primary.start_line,
52        parent_name: parent_name(primary),
53        file_path: primary.file_path.clone(),
54        old_entity_name: before_entity.and_then(|b| {
55            (b.name != after_entity.name).then(|| b.name.clone())
56        }),
57        old_file_path: before_entity.and_then(|b| {
58            (b.file_path != after_entity.file_path).then(|| b.file_path.clone())
59        }),
60        old_parent_id: before_entity.and_then(|b| {
61            (b.parent_id != after_entity.parent_id).then(|| b.parent_id.clone()).flatten()
62        }),
63        before_content: before_entity.map(|b| b.content.clone()),
64        after_content: if change_type == ChangeType::Deleted || change_type == ChangeType::Reordered {
65            None
66        } else {
67            Some(after_entity.content.clone())
68        },
69        commit_sha: commit_sha.map(String::from),
70        author: author.map(String::from),
71        timestamp: None,
72        structural_change: None,
73    }
74}
75
76/// 3-phase entity matching algorithm:
77/// 1. Exact ID match — same entity ID in before/after → modified or unchanged
78/// 2. Content hash match — same hash, different ID → renamed or moved
79/// 3. Fuzzy similarity — >80% content similarity → probable rename
80pub fn match_entities(
81    before: &[SemanticEntity],
82    after: &[SemanticEntity],
83    _file_path: &str,
84    _similarity_fn: Option<&dyn Fn(&SemanticEntity, &SemanticEntity) -> f64>,
85    commit_sha: Option<&str>,
86    author: Option<&str>,
87) -> MatchResult {
88    let mut changes: Vec<SemanticChange> = Vec::new();
89    let mut matched_before: HashSet<&str> = HashSet::new();
90    let mut matched_after: HashSet<&str> = HashSet::new();
91
92    let before_by_id: HashMap<&str, &SemanticEntity> =
93        before.iter().map(|e| (e.id.as_str(), e)).collect();
94    let after_by_id: HashMap<&str, &SemanticEntity> =
95        after.iter().map(|e| (e.id.as_str(), e)).collect();
96
97    // Phase 1: Exact ID match
98    for (&id, after_entity) in &after_by_id {
99        if let Some(before_entity) = before_by_id.get(id) {
100            matched_before.insert(id);
101            matched_after.insert(id);
102
103            if before_entity.content_hash != after_entity.content_hash {
104                let mut change = make_change(after_entity, ChangeType::Modified, Some(before_entity), commit_sha, author);
105                change.structural_change = match (&before_entity.structural_hash, &after_entity.structural_hash) {
106                    (Some(before_sh), Some(after_sh)) => Some(before_sh != after_sh),
107                    _ => None,
108                };
109                changes.push(change);
110            }
111        }
112    }
113
114    // Collect unmatched
115    let unmatched_before: Vec<&SemanticEntity> = before
116        .iter()
117        .filter(|e| !matched_before.contains(e.id.as_str()))
118        .collect();
119    let unmatched_after: Vec<&SemanticEntity> = after
120        .iter()
121        .filter(|e| !matched_after.contains(e.id.as_str()))
122        .collect();
123
124    // Phase 2: Content hash match (rename/move detection)
125    let mut before_by_hash: HashMap<&str, Vec<&SemanticEntity>> = HashMap::new();
126    let mut before_by_structural: HashMap<&str, Vec<&SemanticEntity>> = HashMap::new();
127    for entity in &unmatched_before {
128        before_by_hash
129            .entry(entity.content_hash.as_str())
130            .or_default()
131            .push(entity);
132        if let Some(ref sh) = entity.structural_hash {
133            before_by_structural
134                .entry(sh.as_str())
135                .or_default()
136                .push(entity);
137        }
138    }
139
140    for after_entity in &unmatched_after {
141        if matched_after.contains(after_entity.id.as_str()) {
142            continue;
143        }
144        // Try exact content_hash first
145        let found = before_by_hash
146            .get_mut(after_entity.content_hash.as_str())
147            .and_then(|c| c.pop());
148        // Fall back to structural_hash (formatting/comment changes don't matter)
149        let found = found.or_else(|| {
150            after_entity.structural_hash.as_ref().and_then(|sh| {
151                before_by_structural.get_mut(sh.as_str()).and_then(|c| {
152                    c.iter()
153                        .position(|e| !matched_before.contains(e.id.as_str()))
154                        .map(|i| c.remove(i))
155                })
156            })
157        });
158
159        if let Some(before_entity) = found {
160            matched_before.insert(&before_entity.id);
161            matched_after.insert(&after_entity.id);
162
163            // If name, file, and parent are the same, only the parent qualifier in the ID changed
164            // (e.g. parent class was renamed). Skip — the entity itself is unchanged.
165            // But if parent_id differs, this is an intra-file move (e.g. method moved between classes).
166            if before_entity.name == after_entity.name
167                && before_entity.file_path == after_entity.file_path
168                && before_entity.content_hash == after_entity.content_hash
169                && before_entity.parent_id == after_entity.parent_id
170            {
171                continue;
172            }
173
174            changes.push(make_change(after_entity, classify_match(before_entity, after_entity), Some(before_entity), commit_sha, author));
175        }
176    }
177
178    // Phase 3: Fuzzy similarity (>80% threshold)
179    // Optimized: pre-compute token sets once per entity, group by type
180    let still_unmatched_before: Vec<&SemanticEntity> = unmatched_before
181        .iter()
182        .filter(|e| !matched_before.contains(e.id.as_str()))
183        .copied()
184        .collect();
185    let still_unmatched_after: Vec<&SemanticEntity> = unmatched_after
186        .iter()
187        .filter(|e| !matched_after.contains(e.id.as_str()))
188        .copied()
189        .collect();
190
191    if !still_unmatched_before.is_empty() && !still_unmatched_after.is_empty() {
192        const THRESHOLD: f64 = 0.8;
193        const SIZE_RATIO_CUTOFF: f64 = 0.5;
194
195        // Pre-compute token sets once per entity (N+M instead of N×M allocations)
196        let before_sets: Vec<HashSet<&str>> = still_unmatched_before
197            .iter()
198            .map(|e| e.content.split_whitespace().collect())
199            .collect();
200        let after_sets: Vec<HashSet<&str>> = still_unmatched_after
201            .iter()
202            .map(|e| e.content.split_whitespace().collect())
203            .collect();
204
205        // Group before entities by type: O(sum(n_t × m_t)) instead of O(N×M)
206        let mut before_by_type: HashMap<&str, Vec<usize>> = HashMap::new();
207        for (i, e) in still_unmatched_before.iter().enumerate() {
208            before_by_type
209                .entry(e.entity_type.as_str())
210                .or_default()
211                .push(i);
212        }
213
214        for (ai, after_entity) in still_unmatched_after.iter().enumerate() {
215            let candidates = match before_by_type.get(after_entity.entity_type.as_str()) {
216                Some(indices) => indices,
217                None => continue,
218            };
219
220            let a_set = &after_sets[ai];
221            let a_len = a_set.len();
222            let mut best_idx: Option<usize> = None;
223            let mut best_score: f64 = 0.0;
224
225            for &bi in candidates {
226                if matched_before.contains(still_unmatched_before[bi].id.as_str()) {
227                    continue;
228                }
229
230                let b_set = &before_sets[bi];
231                let b_len = b_set.len();
232
233                // Size ratio filter using pre-computed set lengths
234                let (min_l, max_l) = if a_len < b_len {
235                    (a_len, b_len)
236                } else {
237                    (b_len, a_len)
238                };
239                if max_l > 0 && (min_l as f64 / max_l as f64) < SIZE_RATIO_CUTOFF {
240                    continue;
241                }
242
243                // Inline Jaccard on pre-computed sets
244                let intersection = a_set.intersection(b_set).count();
245                let union = a_len + b_len - intersection;
246                let score = if union == 0 {
247                    0.0
248                } else {
249                    intersection as f64 / union as f64
250                };
251
252                if score >= THRESHOLD && score > best_score {
253                    best_score = score;
254                    best_idx = Some(bi);
255                }
256            }
257
258            if let Some(bi) = best_idx {
259                let matched = still_unmatched_before[bi];
260                matched_before.insert(&matched.id);
261                matched_after.insert(&after_entity.id);
262
263                // If name, file, and parent are the same, only the parent qualifier changed.
264                if matched.name == after_entity.name
265                    && matched.file_path == after_entity.file_path
266                    && matched.content_hash == after_entity.content_hash
267                    && matched.parent_id == after_entity.parent_id
268                {
269                    continue;
270                }
271
272                changes.push(make_change(after_entity, classify_match(matched, after_entity), Some(matched), commit_sha, author));
273            }
274        }
275    }
276
277    // Phase 4: Intra-file reorder detection
278    // For entities that matched by exact ID with identical content (unchanged),
279    // check if their relative ordering changed within the file.
280    detect_reorders(before, after, &matched_before, &matched_after, &mut changes, commit_sha, author);
281
282    // Remaining unmatched before = deleted
283    for entity in before.iter().filter(|e| !matched_before.contains(e.id.as_str())) {
284        changes.push(make_change(entity, ChangeType::Deleted, Some(entity), commit_sha, author));
285    }
286
287    // Remaining unmatched after = added
288    for entity in after.iter().filter(|e| !matched_after.contains(e.id.as_str())) {
289        changes.push(make_change(entity, ChangeType::Added, None, commit_sha, author));
290    }
291
292    MatchResult { changes }
293}
294
295/// Default content similarity using Jaccard index on whitespace-split tokens
296pub fn default_similarity(a: &SemanticEntity, b: &SemanticEntity) -> f64 {
297    let tokens_a: Vec<&str> = a.content.split_whitespace().collect();
298    let tokens_b: Vec<&str> = b.content.split_whitespace().collect();
299
300    // Early rejection: if token counts differ too much, Jaccard can't reach 0.8
301    let (min_c, max_c) = if tokens_a.len() < tokens_b.len() {
302        (tokens_a.len(), tokens_b.len())
303    } else {
304        (tokens_b.len(), tokens_a.len())
305    };
306    if max_c > 0 && (min_c as f64 / max_c as f64) < 0.6 {
307        return 0.0;
308    }
309
310    let set_a: HashSet<&str> = tokens_a.into_iter().collect();
311    let set_b: HashSet<&str> = tokens_b.into_iter().collect();
312
313    let intersection_size = set_a.intersection(&set_b).count();
314    let union_size = set_a.union(&set_b).count();
315
316    if union_size == 0 {
317        return 0.0;
318    }
319
320    intersection_size as f64 / union_size as f64
321}
322
323/// Detect intra-file reordering of unchanged entities.
324///
325/// Takes entities that matched by exact ID with identical content and checks
326/// if their relative ordering changed. Uses longest increasing subsequence
327/// (LIS) on the "after" positions to find the minimum set of moved entities.
328fn detect_reorders(
329    before: &[SemanticEntity],
330    after: &[SemanticEntity],
331    matched_before: &HashSet<&str>,
332    matched_after: &HashSet<&str>,
333    changes: &mut Vec<SemanticChange>,
334    commit_sha: Option<&str>,
335    author: Option<&str>,
336) {
337    // Collect unchanged entities: matched by ID with same content_hash
338    let before_by_id: HashMap<&str, &SemanticEntity> =
339        before.iter().map(|e| (e.id.as_str(), e)).collect();
340
341    // Group by file. For each file, collect unchanged entities in their
342    // before-order, then look up their after-positions.
343    let mut by_file: HashMap<&str, Vec<(&SemanticEntity, &SemanticEntity)>> = HashMap::new();
344    for after_entity in after {
345        if !matched_after.contains(after_entity.id.as_str()) {
346            continue;
347        }
348        if let Some(before_entity) = before_by_id.get(after_entity.id.as_str()) {
349            if !matched_before.contains(before_entity.id.as_str()) {
350                continue;
351            }
352            // Only consider truly unchanged entities (same content)
353            if before_entity.content_hash != after_entity.content_hash {
354                continue;
355            }
356            // Only intra-file
357            if before_entity.file_path != after_entity.file_path {
358                continue;
359            }
360            by_file
361                .entry(after_entity.file_path.as_str())
362                .or_default()
363                .push((before_entity, after_entity));
364        }
365    }
366
367    for (_file, pairs) in &mut by_file {
368        if pairs.len() < 2 {
369            continue;
370        }
371
372        // Sort by before start_line to get the "before" ordering
373        pairs.sort_by_key(|(b, _)| b.start_line);
374
375        // Map to after start_lines in before-order
376        let after_lines: Vec<usize> = pairs.iter().map(|(_, a)| a.start_line).collect();
377
378        // Find LIS indices (entities that stayed in relative order)
379        let lis_set = longest_increasing_subsequence_indices(&after_lines);
380
381        // Entities NOT in LIS were reordered
382        for (i, (_before_entity, after_entity)) in pairs.iter().enumerate() {
383            if lis_set.contains(&i) {
384                continue;
385            }
386            changes.push(make_change(after_entity, ChangeType::Reordered, None, commit_sha, author));
387        }
388    }
389}
390
391/// Find indices that form the longest increasing subsequence.
392/// Returns a HashSet of indices in the original array that are part of the LIS.
393fn longest_increasing_subsequence_indices(seq: &[usize]) -> HashSet<usize> {
394    let n = seq.len();
395    if n == 0 {
396        return HashSet::new();
397    }
398
399    // tails[i] = index in seq of the smallest tail element for IS of length i+1
400    let mut tails: Vec<usize> = Vec::new();
401    // parent[i] = index of previous element in the LIS ending at seq[i]
402    let mut parent: Vec<Option<usize>> = vec![None; n];
403    // tail_idx[i] = index in seq that tails[i] points to
404    let mut tail_idx: Vec<usize> = Vec::new();
405
406    for i in 0..n {
407        let pos = tails.partition_point(|&t| t < seq[i]);
408        if pos == tails.len() {
409            tails.push(seq[i]);
410            tail_idx.push(i);
411        } else {
412            tails[pos] = seq[i];
413            tail_idx[pos] = i;
414        }
415        parent[i] = if pos > 0 { Some(tail_idx[pos - 1]) } else { None };
416    }
417
418    // Trace back to find actual LIS indices
419    let mut result = HashSet::new();
420    let mut idx = *tail_idx.last().unwrap();
421    result.insert(idx);
422    while let Some(p) = parent[idx] {
423        result.insert(p);
424        idx = p;
425    }
426    result
427}
428
429#[cfg(test)]
430mod tests {
431    use super::*;
432    use crate::utils::hash::content_hash;
433
434    fn make_entity(id: &str, name: &str, content: &str, file_path: &str) -> SemanticEntity {
435        SemanticEntity {
436            id: id.to_string(),
437            file_path: file_path.to_string(),
438            entity_type: "function".to_string(),
439            name: name.to_string(),
440            parent_id: None,
441            content: content.to_string(),
442            content_hash: content_hash(content),
443            structural_hash: None,
444            start_line: 1,
445            end_line: 1,
446            metadata: None,
447        }
448    }
449
450    #[test]
451    fn test_exact_match_modified() {
452        let before = vec![make_entity("a::f::foo", "foo", "old content", "a.ts")];
453        let after = vec![make_entity("a::f::foo", "foo", "new content", "a.ts")];
454        let result = match_entities(&before, &after, "a.ts", None, None, None);
455        assert_eq!(result.changes.len(), 1);
456        assert_eq!(result.changes[0].change_type, ChangeType::Modified);
457    }
458
459    #[test]
460    fn test_exact_match_unchanged() {
461        let before = vec![make_entity("a::f::foo", "foo", "same", "a.ts")];
462        let after = vec![make_entity("a::f::foo", "foo", "same", "a.ts")];
463        let result = match_entities(&before, &after, "a.ts", None, None, None);
464        assert_eq!(result.changes.len(), 0);
465    }
466
467    #[test]
468    fn test_added_deleted() {
469        let before = vec![make_entity("a::f::old", "old", "content", "a.ts")];
470        let after = vec![make_entity("a::f::new", "new", "different", "a.ts")];
471        let result = match_entities(&before, &after, "a.ts", None, None, None);
472        assert_eq!(result.changes.len(), 2);
473        let types: Vec<ChangeType> = result.changes.iter().map(|c| c.change_type).collect();
474        assert!(types.contains(&ChangeType::Deleted));
475        assert!(types.contains(&ChangeType::Added));
476    }
477
478    #[test]
479    fn test_content_hash_rename() {
480        let before = vec![make_entity("a::f::old", "old", "same content", "a.ts")];
481        let after = vec![make_entity("a::f::new", "new", "same content", "a.ts")];
482        let result = match_entities(&before, &after, "a.ts", None, None, None);
483        assert_eq!(result.changes.len(), 1);
484        assert_eq!(result.changes[0].change_type, ChangeType::Renamed);
485    }
486
487    #[test]
488    fn test_parent_child_dedup_class_method() {
489        // Class entity contains the method body in its content.
490        // parent_id stores the full entity ID of the parent.
491        let class_before = SemanticEntity {
492            id: "a.ts::class::DataStack".to_string(),
493            file_path: "a.ts".to_string(),
494            entity_type: "class".to_string(),
495            name: "DataStack".to_string(),
496            parent_id: None,
497            content: "class DataStack { constructor() {} genPg() { old } }".to_string(),
498            content_hash: content_hash("class DataStack { constructor() {} genPg() { old } }"),
499            structural_hash: None,
500            start_line: 1,
501            end_line: 10,
502            metadata: None,
503        };
504        let method_before = SemanticEntity {
505            id: "a.ts::a.ts::class::DataStack::genPg".to_string(),
506            file_path: "a.ts".to_string(),
507            entity_type: "method".to_string(),
508            name: "genPg".to_string(),
509            parent_id: Some("a.ts::class::DataStack".to_string()),
510            content: "genPg() { old }".to_string(),
511            content_hash: content_hash("genPg() { old }"),
512            structural_hash: None,
513            start_line: 5,
514            end_line: 8,
515            metadata: None,
516        };
517
518        let class_after = SemanticEntity {
519            id: "a.ts::class::DataStack".to_string(),
520            file_path: "a.ts".to_string(),
521            entity_type: "class".to_string(),
522            name: "DataStack".to_string(),
523            parent_id: None,
524            content: "class DataStack { constructor() {} genPg() { new } }".to_string(),
525            content_hash: content_hash("class DataStack { constructor() {} genPg() { new } }"),
526            structural_hash: None,
527            start_line: 1,
528            end_line: 10,
529            metadata: None,
530        };
531        let method_after = SemanticEntity {
532            id: "a.ts::a.ts::class::DataStack::genPg".to_string(),
533            file_path: "a.ts".to_string(),
534            entity_type: "method".to_string(),
535            name: "genPg".to_string(),
536            parent_id: Some("a.ts::class::DataStack".to_string()),
537            content: "genPg() { new }".to_string(),
538            content_hash: content_hash("genPg() { new }"),
539            structural_hash: None,
540            start_line: 5,
541            end_line: 8,
542            metadata: None,
543        };
544
545        let before = vec![class_before, method_before];
546        let after = vec![class_after, method_after];
547        let result = match_entities(&before, &after, "a.ts", None, None, None);
548
549        // match_entities no longer deduplicates — suppression happens in differ.rs.
550        // Both the class and the method are Modified here.
551        assert_eq!(result.changes.len(), 2);
552        let types: Vec<ChangeType> = result.changes.iter().map(|c| c.change_type).collect();
553        assert!(types.iter().all(|t| *t == ChangeType::Modified));
554    }
555
556    #[test]
557    fn test_parent_not_deduped_when_no_child_changes() {
558        // Only the class-level content changes (e.g. a field added), no method changes
559        let class_before = SemanticEntity {
560            id: "a.ts::class::Foo".to_string(),
561            file_path: "a.ts".to_string(),
562            entity_type: "class".to_string(),
563            name: "Foo".to_string(),
564            parent_id: None,
565            content: "class Foo { bar() {} }".to_string(),
566            content_hash: content_hash("class Foo { bar() {} }"),
567            structural_hash: None,
568            start_line: 1,
569            end_line: 5,
570            metadata: None,
571        };
572        let method_before = SemanticEntity {
573            id: "a.ts::a.ts::class::Foo::bar".to_string(),
574            file_path: "a.ts".to_string(),
575            entity_type: "method".to_string(),
576            name: "bar".to_string(),
577            parent_id: Some("a.ts::class::Foo".to_string()),
578            content: "bar() {}".to_string(),
579            content_hash: content_hash("bar() {}"),
580            structural_hash: None,
581            start_line: 2,
582            end_line: 4,
583            metadata: None,
584        };
585
586        let class_after = SemanticEntity {
587            id: "a.ts::class::Foo".to_string(),
588            file_path: "a.ts".to_string(),
589            entity_type: "class".to_string(),
590            name: "Foo".to_string(),
591            parent_id: None,
592            content: "class Foo { x = 1; bar() {} }".to_string(),
593            content_hash: content_hash("class Foo { x = 1; bar() {} }"),
594            structural_hash: None,
595            start_line: 1,
596            end_line: 6,
597            metadata: None,
598        };
599        let method_after = SemanticEntity {
600            id: "a.ts::a.ts::class::Foo::bar".to_string(),
601            file_path: "a.ts".to_string(),
602            entity_type: "method".to_string(),
603            name: "bar".to_string(),
604            parent_id: Some("a.ts::class::Foo".to_string()),
605            content: "bar() {}".to_string(),
606            content_hash: content_hash("bar() {}"),
607            structural_hash: None,
608            start_line: 3,
609            end_line: 5,
610            metadata: None,
611        };
612
613        let before = vec![class_before, method_before];
614        let after = vec![class_after, method_after];
615        let result = match_entities(&before, &after, "a.ts", None, None, None);
616
617        // Class changed but method didn't, so class should still appear
618        assert_eq!(result.changes.len(), 1);
619        assert_eq!(result.changes[0].entity_name, "Foo");
620        assert_eq!(result.changes[0].change_type, ChangeType::Modified);
621    }
622
623    fn make_entity_with_parent(id: &str, name: &str, content: &str, file_path: &str, parent_id: Option<&str>) -> SemanticEntity {
624        SemanticEntity {
625            id: id.to_string(),
626            file_path: file_path.to_string(),
627            entity_type: "method".to_string(),
628            name: name.to_string(),
629            parent_id: parent_id.map(String::from),
630            content: content.to_string(),
631            content_hash: content_hash(content),
632            structural_hash: None,
633            start_line: 1,
634            end_line: 1,
635            metadata: None,
636        }
637    }
638
639    #[test]
640    fn test_intra_file_move_between_classes() {
641        // Method moves from ClassA to ClassB in the same file
642        let before = vec![make_entity_with_parent(
643            "a.rs::class::ClassA::foo", "foo", "fn foo() { do_thing() }",
644            "a.rs", Some("a.rs::class::ClassA"),
645        )];
646        let after = vec![make_entity_with_parent(
647            "a.rs::class::ClassB::foo", "foo", "fn foo() { do_thing() }",
648            "a.rs", Some("a.rs::class::ClassB"),
649        )];
650        let result = match_entities(&before, &after, "a.rs", None, None, None);
651        assert_eq!(result.changes.len(), 1);
652        assert_eq!(result.changes[0].change_type, ChangeType::Moved);
653        assert_eq!(result.changes[0].old_parent_id, Some("a.rs::class::ClassA".to_string()));
654    }
655
656    #[test]
657    fn test_same_parent_is_rename_not_move() {
658        // Same parent, different name = rename (not move)
659        // Content must be identical (same hash) so Phase 2 catches it
660        let body = "fn method(&self) { let x = self.compute(); self.validate(x); self.store(x) }";
661        let before = vec![make_entity_with_parent(
662            "a.rs::class::Foo::old_method", "old_method", body,
663            "a.rs", Some("a.rs::class::Foo"),
664        )];
665        let after = vec![make_entity_with_parent(
666            "a.rs::class::Foo::new_method", "new_method", body,
667            "a.rs", Some("a.rs::class::Foo"),
668        )];
669        let result = match_entities(&before, &after, "a.rs", None, None, None);
670        assert_eq!(result.changes.len(), 1);
671        assert_eq!(result.changes[0].change_type, ChangeType::Renamed);
672        assert!(result.changes[0].old_parent_id.is_none());
673    }
674
675    fn make_entity_at(id: &str, name: &str, content: &str, file_path: &str, line: usize) -> SemanticEntity {
676        SemanticEntity {
677            id: id.to_string(),
678            file_path: file_path.to_string(),
679            entity_type: "function".to_string(),
680            name: name.to_string(),
681            parent_id: None,
682            content: content.to_string(),
683            content_hash: content_hash(content),
684            structural_hash: None,
685            start_line: line,
686            end_line: line + 2,
687            metadata: None,
688        }
689    }
690
691    #[test]
692    fn test_reorder_detection() {
693        let before = vec![
694            make_entity_at("a::f::alpha", "alpha", "fn alpha() {}", "a.rs", 1),
695            make_entity_at("a::f::beta", "beta", "fn beta() {}", "a.rs", 5),
696            make_entity_at("a::f::gamma", "gamma", "fn gamma() {}", "a.rs", 9),
697        ];
698        let after = vec![
699            make_entity_at("a::f::alpha", "alpha", "fn alpha() {}", "a.rs", 1),
700            make_entity_at("a::f::gamma", "gamma", "fn gamma() {}", "a.rs", 5),
701            make_entity_at("a::f::beta", "beta", "fn beta() {}", "a.rs", 9),
702        ];
703        let result = match_entities(&before, &after, "a.rs", None, None, None);
704        assert_eq!(result.changes.len(), 1);
705        assert_eq!(result.changes[0].change_type, ChangeType::Reordered);
706        // Either beta or gamma is marked, LIS picks the minimum
707        assert!(result.changes[0].entity_name == "beta" || result.changes[0].entity_name == "gamma");
708    }
709
710    #[test]
711    fn test_no_reorder_when_order_preserved() {
712        let before = vec![
713            make_entity_at("a::f::alpha", "alpha", "fn alpha() {}", "a.rs", 1),
714            make_entity_at("a::f::beta", "beta", "fn beta() {}", "a.rs", 5),
715        ];
716        let after = vec![
717            make_entity_at("a::f::alpha", "alpha", "fn alpha() {}", "a.rs", 1),
718            make_entity_at("a::f::beta", "beta", "fn beta() {}", "a.rs", 10),
719        ];
720        let result = match_entities(&before, &after, "a.rs", None, None, None);
721        // Lines shifted but relative order is same, no reorder
722        assert_eq!(result.changes.len(), 0);
723    }
724
725    #[test]
726    fn test_default_similarity() {
727        let a = make_entity("a", "a", "the quick brown fox", "a.ts");
728        let b = make_entity("b", "b", "the quick brown dog", "a.ts");
729        let score = default_similarity(&a, &b);
730        assert!(score > 0.5);
731        assert!(score < 1.0);
732    }
733}