Skip to main content

weave_core/
merge.rs

1use std::collections::{HashMap, HashSet};
2use std::io::Write;
3use std::process::Command;
4use std::sync::{mpsc, LazyLock};
5use std::time::Duration;
6
7use serde::Serialize;
8use sem_core::model::change::ChangeType;
9use sem_core::model::entity::SemanticEntity;
10use sem_core::model::identity::match_entities;
11use sem_core::parser::plugins::create_default_registry;
12use sem_core::parser::registry::ParserRegistry;
13
14/// Static parser registry shared across all merge operations.
15/// Avoids recreating 11 tree-sitter language parsers per merge call.
16static PARSER_REGISTRY: LazyLock<ParserRegistry> = LazyLock::new(create_default_registry);
17
18use crate::conflict::{classify_conflict, ConflictComplexity, ConflictKind, EntityConflict, MarkerFormat, MergeStats};
19use crate::region::{extract_regions, EntityRegion, FileRegion};
20use crate::validate::SemanticWarning;
21use crate::reconstruct::reconstruct;
22
23/// How an individual entity was resolved during merge.
24#[derive(Debug, Clone, Serialize)]
25#[serde(rename_all = "snake_case")]
26pub enum ResolutionStrategy {
27    Unchanged,
28    OursOnly,
29    TheirsOnly,
30    ContentEqual,
31    DiffyMerged,
32    DecoratorMerged,
33    InnerMerged,
34    ConflictBothModified,
35    ConflictModifyDelete,
36    ConflictBothAdded,
37    ConflictRenameRename,
38    ConflictRenameModify,
39    AddedOurs,
40    AddedTheirs,
41    Deleted,
42    Renamed { from: String, to: String },
43    Fallback,
44}
45
46/// Audit record for a single entity's merge resolution.
47#[derive(Debug, Clone, Serialize)]
48pub struct EntityAudit {
49    pub name: String,
50    #[serde(rename = "type")]
51    pub entity_type: String,
52    pub resolution: ResolutionStrategy,
53}
54
55/// Result of a merge operation.
56#[derive(Debug)]
57pub struct MergeResult {
58    pub content: String,
59    pub conflicts: Vec<EntityConflict>,
60    pub warnings: Vec<SemanticWarning>,
61    pub stats: MergeStats,
62    pub audit: Vec<EntityAudit>,
63}
64
65impl MergeResult {
66    pub fn is_clean(&self) -> bool {
67        self.conflicts.is_empty()
68            && !self.content.lines().any(|l| l.starts_with("<<<<<<< ours"))
69    }
70}
71
72/// The resolved content for a single entity after merging.
73#[derive(Debug, Clone)]
74pub enum ResolvedEntity {
75    /// Clean resolution — use this content.
76    Clean(EntityRegion),
77    /// Conflict — render conflict markers.
78    Conflict(EntityConflict),
79    /// Inner merge with per-member scoped conflicts.
80    /// Content already contains per-member conflict markers; emit as-is.
81    ScopedConflict {
82        content: String,
83        conflict: EntityConflict,
84    },
85    /// Entity was deleted.
86    Deleted,
87}
88
89/// Perform entity-level 3-way merge.
90///
91/// Falls back to line-level merge (via diffy) when:
92/// - No parser matches the file type
93/// - Parser returns 0 entities for non-empty content
94/// - File exceeds 1MB
95pub fn entity_merge(
96    base: &str,
97    ours: &str,
98    theirs: &str,
99    file_path: &str,
100) -> MergeResult {
101    entity_merge_fmt(base, ours, theirs, file_path, &MarkerFormat::default())
102}
103
104/// Perform entity-level 3-way merge with configurable marker format.
105pub fn entity_merge_fmt(
106    base: &str,
107    ours: &str,
108    theirs: &str,
109    file_path: &str,
110    marker_format: &MarkerFormat,
111) -> MergeResult {
112    let timeout_secs = std::env::var("WEAVE_TIMEOUT")
113        .ok()
114        .and_then(|v| v.parse::<u64>().ok())
115        .unwrap_or(5);
116
117    // Timeout: if entity merge takes too long, diffy is likely hitting
118    // pathological input. Fall back to git merge-file which always terminates.
119    let base_owned = base.to_string();
120    let ours_owned = ours.to_string();
121    let theirs_owned = theirs.to_string();
122    let path_owned = file_path.to_string();
123    let fmt_owned = marker_format.clone();
124
125    let (tx, rx) = mpsc::channel();
126    std::thread::spawn(move || {
127        let result = entity_merge_with_registry(&base_owned, &ours_owned, &theirs_owned, &path_owned, &PARSER_REGISTRY, &fmt_owned);
128        let _ = tx.send(result);
129    });
130
131    match rx.recv_timeout(Duration::from_secs(timeout_secs)) {
132        Ok(result) => result,
133        Err(_) => {
134            eprintln!("weave: merge timed out after {}s for {}, falling back to git merge-file", timeout_secs, file_path);
135            let mut stats = MergeStats::default();
136            stats.used_fallback = true;
137            git_merge_file(base, ours, theirs, &mut stats)
138        }
139    }
140}
141
142pub fn entity_merge_with_registry(
143    base: &str,
144    ours: &str,
145    theirs: &str,
146    file_path: &str,
147    registry: &ParserRegistry,
148    marker_format: &MarkerFormat,
149) -> MergeResult {
150    // Guard: if any input already contains conflict markers (e.g. AU/AA conflicts
151    // where git bakes markers into stage blobs), report as conflict immediately.
152    // We can't do a meaningful 3-way merge on pre-conflicted content.
153    if has_conflict_markers(base) || has_conflict_markers(ours) || has_conflict_markers(theirs) {
154        let mut stats = MergeStats::default();
155        stats.entities_conflicted = 1;
156        stats.used_fallback = true;
157        // Use whichever input has markers as the merged content (preserves
158        // the conflict for the user to resolve manually).
159        let content = if has_conflict_markers(ours) {
160            ours
161        } else if has_conflict_markers(theirs) {
162            theirs
163        } else {
164            base
165        };
166        let complexity = classify_conflict(Some(base), Some(ours), Some(theirs));
167        return MergeResult {
168            content: content.to_string(),
169            conflicts: vec![EntityConflict {
170                entity_name: "(file)".to_string(),
171                entity_type: "file".to_string(),
172                kind: ConflictKind::BothModified,
173                complexity,
174                ours_content: Some(ours.to_string()),
175                theirs_content: Some(theirs.to_string()),
176                base_content: Some(base.to_string()),
177            }],
178            warnings: vec![],
179            stats,
180            audit: vec![],
181        };
182    }
183
184    // Fast path: if ours == theirs, no merge needed
185    if ours == theirs {
186        return MergeResult {
187            content: ours.to_string(),
188            conflicts: vec![],
189            warnings: vec![],
190            stats: MergeStats::default(),
191            audit: vec![],
192        };
193    }
194
195    // Fast path: if base == ours, take theirs entirely
196    if base == ours {
197        return MergeResult {
198            content: theirs.to_string(),
199            conflicts: vec![],
200            warnings: vec![],
201            stats: MergeStats {
202                entities_theirs_only: 1,
203                ..Default::default()
204            },
205            audit: vec![],
206        };
207    }
208
209    // Fast path: if base == theirs, take ours entirely
210    if base == theirs {
211        return MergeResult {
212            content: ours.to_string(),
213            conflicts: vec![],
214            warnings: vec![],
215            stats: MergeStats {
216                entities_ours_only: 1,
217                ..Default::default()
218            },
219            audit: vec![],
220        };
221    }
222
223    // Binary file detection: if any version has null bytes, use git merge-file directly
224    if is_binary(base) || is_binary(ours) || is_binary(theirs) {
225        let mut stats = MergeStats::default();
226        stats.used_fallback = true;
227        return git_merge_file(base, ours, theirs, &mut stats);
228    }
229
230    // Large file fallback
231    if base.len() > 1_000_000 || ours.len() > 1_000_000 || theirs.len() > 1_000_000 {
232        return line_level_fallback(base, ours, theirs, file_path);
233    }
234
235    // If the file type isn't natively supported, the registry returns the fallback
236    // plugin (20-line chunks). Entity merge on arbitrary chunks produces WORSE
237    // results than line-level merge (confirmed on GitButler's .svelte files where
238    // chunk boundaries don't align with structural boundaries). So we skip entity
239    // merge entirely for fallback-plugin files and go straight to line-level merge.
240    let plugin = match registry.get_plugin(file_path) {
241        Some(p) if p.id() != "fallback" => p,
242        _ => return line_level_fallback(base, ours, theirs, file_path),
243    };
244
245    // Extract entities from all three versions. Keep unfiltered lists for inner merge
246    // (child entities provide tree-sitter-based method decomposition for classes).
247    let base_all = plugin.extract_entities(base, file_path);
248    let ours_all = plugin.extract_entities(ours, file_path);
249    let theirs_all = plugin.extract_entities(theirs, file_path);
250
251    // Filter out nested entities for top-level matching and region extraction
252    let base_entities = filter_nested_entities(base_all.clone());
253    let ours_entities = filter_nested_entities(ours_all.clone());
254    let theirs_entities = filter_nested_entities(theirs_all.clone());
255
256    // Fallback if parser returns nothing for non-empty content
257    if base_entities.is_empty() && !base.trim().is_empty() {
258        return line_level_fallback(base, ours, theirs, file_path);
259    }
260    // When base is empty (file newly added in both branches), entity-level
261    // reconstruction can produce invalid output for structured formats like JSON
262    // (e.g. content appended after closing delimiter). Fall back to line-level
263    // merge which handles this case correctly.
264    if base.trim().is_empty() && !ours.trim().is_empty() && !theirs.trim().is_empty() {
265        return line_level_fallback(base, ours, theirs, file_path);
266    }
267    // Allow empty entities if content is actually empty
268    if ours_entities.is_empty() && !ours.trim().is_empty() && theirs_entities.is_empty() && !theirs.trim().is_empty() {
269        return line_level_fallback(base, ours, theirs, file_path);
270    }
271
272    // Fallback if too many duplicate entity names. Entity matching is O(n*m) on
273    // same-named entities which can hang on files with many `var app = ...` etc.
274    if has_excessive_duplicates(&base_entities) || has_excessive_duplicates(&ours_entities) || has_excessive_duplicates(&theirs_entities) {
275        return line_level_fallback(base, ours, theirs, file_path);
276    }
277
278    // Extract regions from all three
279    let base_regions = extract_regions(base, &base_entities);
280    let ours_regions = extract_regions(ours, &ours_entities);
281    let theirs_regions = extract_regions(theirs, &theirs_entities);
282
283    // Build region content maps (entity_id → content from file lines, preserving
284    // surrounding syntax like `export` that sem-core's entity.content may strip)
285    let base_region_content = build_region_content_map(&base_regions);
286    let ours_region_content = build_region_content_map(&ours_regions);
287    let theirs_region_content = build_region_content_map(&theirs_regions);
288
289    // Match entities: base↔ours and base↔theirs
290    let ours_changes = match_entities(&base_entities, &ours_entities, file_path, None, None, None);
291    let theirs_changes = match_entities(&base_entities, &theirs_entities, file_path, None, None, None);
292
293    // Build lookup maps
294    let base_entity_map: HashMap<&str, &SemanticEntity> =
295        base_entities.iter().map(|e| (e.id.as_str(), e)).collect();
296    let ours_entity_map: HashMap<&str, &SemanticEntity> =
297        ours_entities.iter().map(|e| (e.id.as_str(), e)).collect();
298    let theirs_entity_map: HashMap<&str, &SemanticEntity> =
299        theirs_entities.iter().map(|e| (e.id.as_str(), e)).collect();
300
301    // Classify what happened to each entity in each branch
302    let mut ours_change_map: HashMap<String, ChangeType> = HashMap::new();
303    for change in &ours_changes.changes {
304        ours_change_map.insert(change.entity_id.clone(), change.change_type);
305    }
306    let mut theirs_change_map: HashMap<String, ChangeType> = HashMap::new();
307    for change in &theirs_changes.changes {
308        theirs_change_map.insert(change.entity_id.clone(), change.change_type);
309    }
310
311    // Detect renames using structural_hash (RefFilter / IntelliMerge-inspired).
312    // When one branch renames an entity, connect the old and new IDs so the merge
313    // treats it as the same entity rather than a delete+add.
314    let ours_rename_to_base = build_rename_map(&base_entities, &ours_entities);
315    let theirs_rename_to_base = build_rename_map(&base_entities, &theirs_entities);
316    // Reverse maps: base_id → renamed_id in that branch
317    let base_to_ours_rename: HashMap<String, String> = ours_rename_to_base
318        .iter()
319        .map(|(new, old)| (old.clone(), new.clone()))
320        .collect();
321    let base_to_theirs_rename: HashMap<String, String> = theirs_rename_to_base
322        .iter()
323        .map(|(new, old)| (old.clone(), new.clone()))
324        .collect();
325
326    // Collect all entity IDs across all versions
327    let mut all_entity_ids: Vec<String> = Vec::new();
328    let mut seen: HashSet<String> = HashSet::new();
329    // Track renamed IDs so we don't process them twice
330    let mut skip_ids: HashSet<String> = HashSet::new();
331    // The "new" IDs from renames should be skipped — they'll be handled via the base ID
332    for new_id in ours_rename_to_base.keys() {
333        skip_ids.insert(new_id.clone());
334    }
335    for new_id in theirs_rename_to_base.keys() {
336        skip_ids.insert(new_id.clone());
337    }
338
339    // Start with ours ordering (skeleton)
340    for entity in &ours_entities {
341        if skip_ids.contains(&entity.id) {
342            continue;
343        }
344        if seen.insert(entity.id.clone()) {
345            all_entity_ids.push(entity.id.clone());
346        }
347    }
348    // Add theirs-only entities
349    for entity in &theirs_entities {
350        if skip_ids.contains(&entity.id) {
351            continue;
352        }
353        if seen.insert(entity.id.clone()) {
354            all_entity_ids.push(entity.id.clone());
355        }
356    }
357    // Add base-only entities (deleted in both → skip, deleted in one → handled below)
358    for entity in &base_entities {
359        if seen.insert(entity.id.clone()) {
360            all_entity_ids.push(entity.id.clone());
361        }
362    }
363
364    let mut stats = MergeStats::default();
365    let mut conflicts: Vec<EntityConflict> = Vec::new();
366    let mut audit: Vec<EntityAudit> = Vec::new();
367    let mut resolved_entities: HashMap<String, ResolvedEntity> = HashMap::new();
368
369    // Detect rename/rename conflicts: same base entity renamed differently in both branches.
370    // These must be flagged before the entity resolution loop, which would otherwise silently
371    // pick ours and also include theirs as an unmatched entity.
372    let mut rename_conflict_ids: HashSet<String> = HashSet::new();
373    for (base_id, ours_new_id) in &base_to_ours_rename {
374        if let Some(theirs_new_id) = base_to_theirs_rename.get(base_id) {
375            if ours_new_id != theirs_new_id {
376                rename_conflict_ids.insert(base_id.clone());
377            }
378        }
379    }
380
381    for entity_id in &all_entity_ids {
382        // Handle rename/rename conflicts: both branches renamed this base entity differently
383        if rename_conflict_ids.contains(entity_id) {
384            let ours_new_id = &base_to_ours_rename[entity_id];
385            let theirs_new_id = &base_to_theirs_rename[entity_id];
386            let base_entity = base_entity_map.get(entity_id.as_str());
387            let ours_entity = ours_entity_map.get(ours_new_id.as_str());
388            let theirs_entity = theirs_entity_map.get(theirs_new_id.as_str());
389            let base_name = base_entity.map(|e| e.name.as_str()).unwrap_or(entity_id);
390            let ours_name = ours_entity.map(|e| e.name.as_str()).unwrap_or(ours_new_id);
391            let theirs_name = theirs_entity.map(|e| e.name.as_str()).unwrap_or(theirs_new_id);
392
393            let base_rc = base_entity.map(|e| base_region_content.get(e.id.as_str()).map(|s| s.to_string()).unwrap_or_else(|| e.content.clone()));
394            let ours_rc = ours_entity.map(|e| ours_region_content.get(e.id.as_str()).map(|s| s.to_string()).unwrap_or_else(|| e.content.clone()));
395            let theirs_rc = theirs_entity.map(|e| theirs_region_content.get(e.id.as_str()).map(|s| s.to_string()).unwrap_or_else(|| e.content.clone()));
396
397            stats.entities_conflicted += 1;
398            let conflict = EntityConflict {
399                entity_name: base_name.to_string(),
400                entity_type: base_entity.map(|e| e.entity_type.clone()).unwrap_or_default(),
401                kind: ConflictKind::RenameRename {
402                    base_name: base_name.to_string(),
403                    ours_name: ours_name.to_string(),
404                    theirs_name: theirs_name.to_string(),
405                },
406                complexity: crate::conflict::ConflictComplexity::Syntax,
407                ours_content: ours_rc,
408                theirs_content: theirs_rc,
409                base_content: base_rc,
410            };
411            conflicts.push(conflict.clone());
412            audit.push(EntityAudit {
413                name: base_name.to_string(),
414                entity_type: base_entity.map(|e| e.entity_type.clone()).unwrap_or_default(),
415                resolution: ResolutionStrategy::ConflictRenameRename,
416            });
417            let resolution = ResolvedEntity::Conflict(conflict);
418            resolved_entities.insert(entity_id.clone(), resolution.clone());
419            resolved_entities.insert(ours_new_id.clone(), resolution);
420            // Mark theirs renamed ID as Deleted so reconstruct doesn't emit the conflict twice
421            // (once from ours skeleton, once from theirs-only insertion)
422            resolved_entities.insert(theirs_new_id.clone(), ResolvedEntity::Deleted);
423            continue;
424        }
425
426        let in_base = base_entity_map.get(entity_id.as_str());
427        // Follow rename chains: if base entity was renamed in ours/theirs, use renamed version
428        let ours_id = base_to_ours_rename.get(entity_id.as_str()).map(|s| s.as_str()).unwrap_or(entity_id.as_str());
429        let theirs_id = base_to_theirs_rename.get(entity_id.as_str()).map(|s| s.as_str()).unwrap_or(entity_id.as_str());
430        let in_ours = ours_entity_map.get(ours_id).or_else(|| ours_entity_map.get(entity_id.as_str()));
431        let in_theirs = theirs_entity_map.get(theirs_id).or_else(|| theirs_entity_map.get(entity_id.as_str()));
432
433        let ours_change = ours_change_map.get(entity_id);
434        let theirs_change = theirs_change_map.get(entity_id);
435
436        let (resolution, strategy) = resolve_entity(
437            entity_id,
438            in_base,
439            in_ours,
440            in_theirs,
441            ours_change,
442            theirs_change,
443            &base_region_content,
444            &ours_region_content,
445            &theirs_region_content,
446            &base_all,
447            &ours_all,
448            &theirs_all,
449            &mut stats,
450            marker_format,
451        );
452
453        // Build audit entry from entity info
454        let entity_name = in_ours.map(|e| e.name.as_str())
455            .or_else(|| in_theirs.map(|e| e.name.as_str()))
456            .or_else(|| in_base.map(|e| e.name.as_str()))
457            .unwrap_or(entity_id)
458            .to_string();
459        let entity_type = in_ours.map(|e| e.entity_type.as_str())
460            .or_else(|| in_theirs.map(|e| e.entity_type.as_str()))
461            .or_else(|| in_base.map(|e| e.entity_type.as_str()))
462            .unwrap_or("")
463            .to_string();
464        audit.push(EntityAudit {
465            name: entity_name,
466            entity_type,
467            resolution: strategy,
468        });
469
470        match &resolution {
471            ResolvedEntity::Conflict(ref c) => conflicts.push(c.clone()),
472            ResolvedEntity::ScopedConflict { conflict, .. } => conflicts.push(conflict.clone()),
473            _ => {}
474        }
475
476        resolved_entities.insert(entity_id.clone(), resolution.clone());
477        // Also store under renamed IDs so reconstruct can find them
478        if let Some(ours_renamed_id) = base_to_ours_rename.get(entity_id.as_str()) {
479            resolved_entities.insert(ours_renamed_id.clone(), resolution.clone());
480        }
481        if let Some(theirs_renamed_id) = base_to_theirs_rename.get(entity_id.as_str()) {
482            resolved_entities.insert(theirs_renamed_id.clone(), resolution);
483        }
484    }
485
486    // Merge interstitial regions
487    let (merged_interstitials, interstitial_conflicts) =
488        merge_interstitials(&base_regions, &ours_regions, &theirs_regions, marker_format);
489    stats.entities_conflicted += interstitial_conflicts.len();
490    conflicts.extend(interstitial_conflicts);
491
492    // Collect base IDs that were renamed in ours — these should not be treated
493    // as "theirs-only additions" during reconstruction.
494    let theirs_rename_base_ids: HashSet<String> = base_to_ours_rename.keys().cloned().collect();
495
496    // Reconstruct the file
497    let content = reconstruct(
498        &ours_regions,
499        &theirs_regions,
500        &theirs_entities,
501        &ours_entity_map,
502        &resolved_entities,
503        &merged_interstitials,
504        marker_format,
505        &theirs_rename_base_ids,
506    );
507
508    // Post-merge cleanup: remove duplicate lines and normalize blank lines
509    let content = post_merge_cleanup(&content);
510
511    // Post-merge validation: verify the merged result is structurally sound.
512    // Catches silent data loss from entity merge / reconstruction bugs.
513    let mut warnings = vec![];
514    if conflicts.is_empty() && stats.entities_both_changed_merged > 0 {
515        let merged_entities = plugin.extract_entities(&content, file_path);
516        if merged_entities.is_empty() && !content.trim().is_empty() {
517            warnings.push(crate::validate::SemanticWarning {
518                entity_name: "(file)".to_string(),
519                entity_type: "file".to_string(),
520                file_path: file_path.to_string(),
521                kind: crate::validate::WarningKind::ParseFailedAfterMerge,
522                related: vec![],
523            });
524        }
525
526        // Entity coverage check: every resolved-clean entity's content should
527        // appear in the merged output. If it doesn't, reconstruct dropped it.
528        if conflicts.is_empty() {
529            for (_, resolved) in &resolved_entities {
530                if let ResolvedEntity::Clean(region) = resolved {
531                    let trimmed = region.content.trim();
532                    if !trimmed.is_empty() && trimmed.len() > 20 && !content.contains(trimmed) {
533                        // Entity resolved cleanly but its content is missing from output.
534                        // Fall back to git merge-file to avoid silent data loss.
535                        return git_merge_file(base, ours, theirs, &mut stats);
536                    }
537                }
538            }
539        }
540
541        // Entity count check: re-parsed merged output should have at least as many
542        // entities as the minimum of ours/theirs (minus deletions). A significant
543        // drop means entities were silently lost.
544        if conflicts.is_empty() && !merged_entities.is_empty() {
545            let merged_top = filter_nested_entities(merged_entities);
546            let deleted_count = resolved_entities.values()
547                .filter(|r| matches!(r, ResolvedEntity::Deleted))
548                .count();
549            let expected_min = ours_entities.len().min(theirs_entities.len()).saturating_sub(deleted_count);
550            if expected_min > 3 && merged_top.len() < expected_min * 80 / 100 {
551                return git_merge_file(base, ours, theirs, &mut stats);
552            }
553        }
554
555    }
556
557    // Line-level drop detection: lines present in all 3 inputs (unchanged by
558    // either branch) must appear in the output. Dropping them is always wrong.
559    // This runs on ALL clean merges, not just those with both-changed entities.
560    if conflicts.is_empty() {
561        let base_lines: HashSet<&str> = base.lines()
562            .map(|l| l.trim())
563            .filter(|l| l.len() > 15 && !is_import_line_trimmed(l))
564            .collect();
565        let ours_lines: HashSet<&str> = ours.lines()
566            .map(|l| l.trim())
567            .filter(|l| l.len() > 15 && !is_import_line_trimmed(l))
568            .collect();
569        let theirs_lines: HashSet<&str> = theirs.lines()
570            .map(|l| l.trim())
571            .filter(|l| l.len() > 15 && !is_import_line_trimmed(l))
572            .collect();
573        let output_lines: HashSet<&str> = content.lines()
574            .map(|l| l.trim())
575            .collect();
576
577        // Unchanged lines (in all 3) must be in output
578        let missing_unchanged = base_lines.iter()
579            .filter(|l| ours_lines.contains(*l) && theirs_lines.contains(*l))
580            .filter(|l| !output_lines.contains(*l))
581            .count();
582
583        if missing_unchanged > 0 {
584            return git_merge_file(base, ours, theirs, &mut stats);
585        }
586
587        // Lines added by either branch (not in base) shouldn't be dropped either.
588        // Split into two tiers:
589        // - "Significant" lines (> 40 chars): even 1 missing is likely a bug, since
590        //   long lines are unique enough that reformatting won't change them completely
591        // - Shorter lines (16-40 chars): use threshold > 3, since these could be
592        //   structural lines reformatted by the import merger or brace placement
593        let mut missing_added_significant = 0;
594        let mut missing_added_short = 0;
595        for l in ours_lines.iter().chain(theirs_lines.iter()) {
596            if base_lines.contains(l) || output_lines.contains(l) {
597                continue;
598            }
599            // Also check if the core content appears as a substring in any output line
600            // (handles reformatting like `Foo {` becoming `Foo\n{`)
601            if l.len() > 25 && content.contains(*l) {
602                continue;
603            }
604            if l.len() > 40 {
605                missing_added_significant += 1;
606            } else {
607                missing_added_short += 1;
608            }
609        }
610
611        if missing_added_significant > 0 || missing_added_short > 3 {
612            return git_merge_file(base, ours, theirs, &mut stats);
613        }
614    }
615
616    let entity_result = MergeResult {
617        content,
618        conflicts,
619        warnings,
620        stats: stats.clone(),
621        audit,
622    };
623
624    // Floor: never produce more conflict markers than git merge-file.
625    // Entity merge can split one git conflict into multiple per-entity conflicts,
626    // or interstitial merges can produce conflicts not tracked in the conflicts vec.
627    let entity_markers = entity_result.content.lines().filter(|l| l.starts_with("<<<<<<<")).count();
628    if entity_markers > 0 {
629        let git_result = git_merge_file(base, ours, theirs, &mut stats);
630        let git_markers = git_result.content.lines().filter(|l| l.starts_with("<<<<<<<")).count();
631        if entity_markers > git_markers {
632            return git_result;
633        }
634    }
635
636    // Safety net: detect silent data loss from entity merge.
637    // If the merged result is significantly shorter than expected, fall back to git.
638    if entity_markers == 0 {
639        let merged_len = entity_result.content.len();
640        let max_input_len = ours.len().max(theirs.len());
641        let min_input_len = ours.len().min(theirs.len());
642        // Expected length: at least 90% of the shorter input (both branches
643        // contribute content, so the merge should be at least as long as the
644        // shorter one minus some deletions).
645        if min_input_len > 200 && merged_len < min_input_len * 90 / 100 {
646            return git_merge_file(base, ours, theirs, &mut stats);
647        }
648        // Also check: merged shouldn't be much shorter than max input unless
649        // there were intentional deletions from one branch
650        if max_input_len > 500 && merged_len < max_input_len * 70 / 100 {
651            // Check if the length reduction is explained by one branch deleting content
652            let base_len = base.len();
653            let ours_deleted = base_len > ours.len() && (base_len - ours.len()) > max_input_len * 20 / 100;
654            let theirs_deleted = base_len > theirs.len() && (base_len - theirs.len()) > max_input_len * 20 / 100;
655            if !ours_deleted && !theirs_deleted {
656                return git_merge_file(base, ours, theirs, &mut stats);
657            }
658        }
659    }
660
661    entity_result
662}
663
664fn resolve_entity(
665    _entity_id: &str,
666    in_base: Option<&&SemanticEntity>,
667    in_ours: Option<&&SemanticEntity>,
668    in_theirs: Option<&&SemanticEntity>,
669    _ours_change: Option<&ChangeType>,
670    _theirs_change: Option<&ChangeType>,
671    base_region_content: &HashMap<&str, &str>,
672    ours_region_content: &HashMap<&str, &str>,
673    theirs_region_content: &HashMap<&str, &str>,
674    base_all: &[SemanticEntity],
675    ours_all: &[SemanticEntity],
676    theirs_all: &[SemanticEntity],
677    stats: &mut MergeStats,
678    marker_format: &MarkerFormat,
679) -> (ResolvedEntity, ResolutionStrategy) {
680    // Helper: get region content (from file lines) for an entity, falling back to entity.content
681    let region_content = |entity: &SemanticEntity, map: &HashMap<&str, &str>| -> String {
682        map.get(entity.id.as_str()).map(|s| s.to_string()).unwrap_or_else(|| entity.content.clone())
683    };
684
685    match (in_base, in_ours, in_theirs) {
686        // Entity exists in all three versions
687        (Some(base), Some(ours), Some(theirs)) => {
688            // Check modification status via structural hash AND region content.
689            // Region content may differ even when structural hash is the same
690            // (e.g., doc comment added/changed but function body unchanged).
691            let base_rc_lazy = || region_content(base, base_region_content);
692            let ours_rc_lazy = || region_content(ours, ours_region_content);
693            let theirs_rc_lazy = || region_content(theirs, theirs_region_content);
694
695            let ours_modified = ours.content_hash != base.content_hash
696                || ours_rc_lazy() != base_rc_lazy();
697            let theirs_modified = theirs.content_hash != base.content_hash
698                || theirs_rc_lazy() != base_rc_lazy();
699
700            match (ours_modified, theirs_modified) {
701                (false, false) => {
702                    // Neither changed
703                    stats.entities_unchanged += 1;
704                    (ResolvedEntity::Clean(entity_to_region_with_content(ours, &region_content(ours, ours_region_content))), ResolutionStrategy::Unchanged)
705                }
706                (true, false) => {
707                    // Only ours changed
708                    stats.entities_ours_only += 1;
709                    (ResolvedEntity::Clean(entity_to_region_with_content(ours, &region_content(ours, ours_region_content))), ResolutionStrategy::OursOnly)
710                }
711                (false, true) => {
712                    // Only theirs changed
713                    stats.entities_theirs_only += 1;
714                    (ResolvedEntity::Clean(entity_to_region_with_content(theirs, &region_content(theirs, theirs_region_content))), ResolutionStrategy::TheirsOnly)
715                }
716                (true, true) => {
717                    // Both changed — try intra-entity merge
718                    if ours.content_hash == theirs.content_hash {
719                        // Same change in both — take ours
720                        stats.entities_both_changed_merged += 1;
721                        (ResolvedEntity::Clean(entity_to_region_with_content(ours, &region_content(ours, ours_region_content))), ResolutionStrategy::ContentEqual)
722                    } else {
723                        // Try diffy 3-way merge on region content (preserves full syntax)
724                        let base_rc = region_content(base, base_region_content);
725                        let ours_rc = region_content(ours, ours_region_content);
726                        let theirs_rc = region_content(theirs, theirs_region_content);
727
728                        // Rename-aware merge: when one side renamed an entity and the
729                        // other modified it, normalize names before 3-way merge so diffy
730                        // only sees the structural changes, not name conflicts.
731                        let ours_renamed = base.name != ours.name;
732                        let theirs_renamed = base.name != theirs.name;
733                        let (merge_base_rc, merge_ours_rc, merge_theirs_rc, _final_name) =
734                            if ours_renamed && !theirs_renamed {
735                                // Ours renamed: normalize base+theirs to use ours' name
736                                let nb = replace_at_word_boundaries(&base_rc, &base.name, &ours.name);
737                                let nt = replace_at_word_boundaries(&theirs_rc, &theirs.name, &ours.name);
738                                (nb, ours_rc.clone(), nt, Some(&ours.name))
739                            } else if theirs_renamed && !ours_renamed {
740                                // Theirs renamed: normalize base+ours to use theirs' name
741                                let nb = replace_at_word_boundaries(&base_rc, &base.name, &theirs.name);
742                                let no = replace_at_word_boundaries(&ours_rc, &ours.name, &theirs.name);
743                                (nb, no, theirs_rc.clone(), Some(&theirs.name))
744                            } else {
745                                (base_rc.clone(), ours_rc.clone(), theirs_rc.clone(), None)
746                            };
747
748                        // Rename + modify: if one side renamed and the other modified
749                        // the body, conflict. The modifying developer didn't know about
750                        // the rename, so the merge needs human/agent review.
751                        if (ours_renamed && !theirs_renamed) || (!ours_renamed && theirs_renamed) {
752                            let renamed_in_ours = ours_renamed;
753                            let (old_name, new_name) = if renamed_in_ours {
754                                (base.name.clone(), ours.name.clone())
755                            } else {
756                                (base.name.clone(), theirs.name.clone())
757                            };
758                            stats.entities_conflicted += 1;
759                            let conflict = EntityConflict {
760                                entity_name: base.name.clone(),
761                                entity_type: base.entity_type.clone(),
762                                kind: ConflictKind::RenameModify {
763                                    old_name,
764                                    new_name,
765                                    renamed_in_ours,
766                                },
767                                complexity: ConflictComplexity::SyntaxFunctional,
768                                ours_content: Some(ours_rc),
769                                theirs_content: Some(theirs_rc),
770                                base_content: Some(base_rc),
771                            };
772                            return (ResolvedEntity::Conflict(conflict), ResolutionStrategy::ConflictRenameModify);
773                        }
774
775                        // Whitespace-aware shortcut: if one side only changed
776                        // whitespace/formatting, take the other side's content changes.
777                        // This handles the common case where one agent reformats while
778                        // another makes semantic changes.
779                        if is_whitespace_only_diff(&base_rc, &ours_rc) {
780                            stats.entities_theirs_only += 1;
781                            return (ResolvedEntity::Clean(entity_to_region_with_content(theirs, &theirs_rc)), ResolutionStrategy::TheirsOnly);
782                        }
783                        if is_whitespace_only_diff(&base_rc, &theirs_rc) {
784                            stats.entities_ours_only += 1;
785                            return (ResolvedEntity::Clean(entity_to_region_with_content(ours, &ours_rc)), ResolutionStrategy::OursOnly);
786                        }
787
788                        // Pick the renamed entity for output (prefer the side that did the rename)
789                        let output_entity = if ours_renamed { ours } else if theirs_renamed { theirs } else { ours };
790
791                        match diffy_merge(&merge_base_rc, &merge_ours_rc, &merge_theirs_rc) {
792                            Some(merged) => {
793                                stats.entities_both_changed_merged += 1;
794                                stats.resolved_via_diffy += 1;
795                                (ResolvedEntity::Clean(EntityRegion {
796                                    entity_id: output_entity.id.clone(),
797                                    entity_name: output_entity.name.clone(),
798                                    entity_type: output_entity.entity_type.clone(),
799                                    content: merged,
800                                    start_line: output_entity.start_line,
801                                    end_line: output_entity.end_line,
802                                }), ResolutionStrategy::DiffyMerged)
803                            }
804                            None => {
805                                // Strategy 1: decorator/annotation-aware merge
806                                // Decorators are unordered annotations — merge them commutatively
807                                if let Some(merged) = try_decorator_aware_merge(&base_rc, &ours_rc, &theirs_rc) {
808                                    stats.entities_both_changed_merged += 1;
809                                    stats.resolved_via_diffy += 1;
810                                    return (ResolvedEntity::Clean(EntityRegion {
811                                        entity_id: ours.id.clone(),
812                                        entity_name: ours.name.clone(),
813                                        entity_type: ours.entity_type.clone(),
814                                        content: merged,
815                                        start_line: ours.start_line,
816                                        end_line: ours.end_line,
817                                    }), ResolutionStrategy::DecoratorMerged);
818                                }
819
820                                // Strategy 2: inner entity merge for container types
821                                // (LastMerge insight: class members are unordered children)
822                                if is_container_entity_type(&ours.entity_type) {
823                                    let base_children = in_base
824                                        .map(|b| get_child_entities(b, base_all))
825                                        .unwrap_or_default();
826                                    let ours_children = get_child_entities(ours, ours_all);
827                                    let theirs_children = in_theirs
828                                        .map(|t| get_child_entities(t, theirs_all))
829                                        .unwrap_or_default();
830                                    let base_start = in_base.map(|b| b.start_line).unwrap_or(1);
831                                    let ours_start = ours.start_line;
832                                    let theirs_start = in_theirs.map(|t| t.start_line).unwrap_or(1);
833                                    if let Some(inner) = try_inner_entity_merge(
834                                        &base_rc, &ours_rc, &theirs_rc,
835                                        &base_children, &ours_children, &theirs_children,
836                                        base_start, ours_start, theirs_start,
837                                        marker_format,
838                                    ) {
839                                        if inner.has_conflicts {
840                                            // Inner merge produced per-member conflicts:
841                                            // content has scoped markers for just the conflicted
842                                            // members; clean members are merged normally.
843                                            stats.entities_conflicted += 1;
844                                            stats.resolved_via_inner_merge += 1;
845                                            let complexity = classify_conflict(Some(&base_rc), Some(&ours_rc), Some(&theirs_rc));
846                                            return (ResolvedEntity::ScopedConflict {
847                                                content: inner.content,
848                                                conflict: EntityConflict {
849                                                    entity_name: ours.name.clone(),
850                                                    entity_type: ours.entity_type.clone(),
851                                                    kind: ConflictKind::BothModified,
852                                                    complexity,
853                                                    ours_content: Some(ours_rc),
854                                                    theirs_content: Some(theirs_rc),
855                                                    base_content: Some(base_rc),
856                                                },
857                                            }, ResolutionStrategy::InnerMerged);
858                                        } else {
859                                            stats.entities_both_changed_merged += 1;
860                                            stats.resolved_via_inner_merge += 1;
861                                            return (ResolvedEntity::Clean(EntityRegion {
862                                                entity_id: ours.id.clone(),
863                                                entity_name: ours.name.clone(),
864                                                entity_type: ours.entity_type.clone(),
865                                                content: inner.content,
866                                                start_line: ours.start_line,
867                                                end_line: ours.end_line,
868                                            }), ResolutionStrategy::InnerMerged);
869                                        }
870                                    }
871                                }
872                                stats.entities_conflicted += 1;
873                                let complexity = classify_conflict(Some(&base_rc), Some(&ours_rc), Some(&theirs_rc));
874                                (ResolvedEntity::Conflict(EntityConflict {
875                                    entity_name: ours.name.clone(),
876                                    entity_type: ours.entity_type.clone(),
877                                    kind: ConflictKind::BothModified,
878                                    complexity,
879                                    ours_content: Some(ours_rc),
880                                    theirs_content: Some(theirs_rc),
881                                    base_content: Some(base_rc),
882                                }), ResolutionStrategy::ConflictBothModified)
883                            }
884                        }
885                    }
886                }
887            }
888        }
889
890        // Entity in base and ours, but not theirs → theirs deleted it
891        (Some(_base), Some(ours), None) => {
892            let ours_modified = ours.content_hash != _base.content_hash;
893            if ours_modified {
894                // Modify/delete conflict
895                stats.entities_conflicted += 1;
896                let ours_rc = region_content(ours, ours_region_content);
897                let base_rc = region_content(_base, base_region_content);
898                let complexity = classify_conflict(Some(&base_rc), Some(&ours_rc), None);
899                (ResolvedEntity::Conflict(EntityConflict {
900                    entity_name: ours.name.clone(),
901                    entity_type: ours.entity_type.clone(),
902                    kind: ConflictKind::ModifyDelete {
903                        modified_in_ours: true,
904                    },
905                    complexity,
906                    ours_content: Some(ours_rc),
907                    theirs_content: None,
908                    base_content: Some(base_rc),
909                }), ResolutionStrategy::ConflictModifyDelete)
910            } else {
911                // Theirs deleted, ours unchanged → accept deletion
912                stats.entities_deleted += 1;
913                (ResolvedEntity::Deleted, ResolutionStrategy::Deleted)
914            }
915        }
916
917        // Entity in base and theirs, but not ours → ours deleted it
918        (Some(_base), None, Some(theirs)) => {
919            let theirs_modified = theirs.content_hash != _base.content_hash;
920            if theirs_modified {
921                // Modify/delete conflict
922                stats.entities_conflicted += 1;
923                let theirs_rc = region_content(theirs, theirs_region_content);
924                let base_rc = region_content(_base, base_region_content);
925                let complexity = classify_conflict(Some(&base_rc), None, Some(&theirs_rc));
926                (ResolvedEntity::Conflict(EntityConflict {
927                    entity_name: theirs.name.clone(),
928                    entity_type: theirs.entity_type.clone(),
929                    kind: ConflictKind::ModifyDelete {
930                        modified_in_ours: false,
931                    },
932                    complexity,
933                    ours_content: None,
934                    theirs_content: Some(theirs_rc),
935                    base_content: Some(base_rc),
936                }), ResolutionStrategy::ConflictModifyDelete)
937            } else {
938                // Ours deleted, theirs unchanged → accept deletion
939                stats.entities_deleted += 1;
940                (ResolvedEntity::Deleted, ResolutionStrategy::Deleted)
941            }
942        }
943
944        // Entity only in ours (added by ours)
945        (None, Some(ours), None) => {
946            stats.entities_added_ours += 1;
947            (ResolvedEntity::Clean(entity_to_region_with_content(ours, &region_content(ours, ours_region_content))), ResolutionStrategy::AddedOurs)
948        }
949
950        // Entity only in theirs (added by theirs)
951        (None, None, Some(theirs)) => {
952            stats.entities_added_theirs += 1;
953            (ResolvedEntity::Clean(entity_to_region_with_content(theirs, &region_content(theirs, theirs_region_content))), ResolutionStrategy::AddedTheirs)
954        }
955
956        // Entity in both ours and theirs but not base (both added)
957        (None, Some(ours), Some(theirs)) => {
958            if ours.content_hash == theirs.content_hash {
959                // Same content added by both → take ours
960                stats.entities_added_ours += 1;
961                (ResolvedEntity::Clean(entity_to_region_with_content(ours, &region_content(ours, ours_region_content))), ResolutionStrategy::ContentEqual)
962            } else {
963                // Different content → conflict
964                stats.entities_conflicted += 1;
965                let ours_rc = region_content(ours, ours_region_content);
966                let theirs_rc = region_content(theirs, theirs_region_content);
967                let complexity = classify_conflict(None, Some(&ours_rc), Some(&theirs_rc));
968                (ResolvedEntity::Conflict(EntityConflict {
969                    entity_name: ours.name.clone(),
970                    entity_type: ours.entity_type.clone(),
971                    kind: ConflictKind::BothAdded,
972                    complexity,
973                    ours_content: Some(ours_rc),
974                    theirs_content: Some(theirs_rc),
975                    base_content: None,
976                }), ResolutionStrategy::ConflictBothAdded)
977            }
978        }
979
980        // Entity only in base (deleted by both)
981        (Some(_), None, None) => {
982            stats.entities_deleted += 1;
983            (ResolvedEntity::Deleted, ResolutionStrategy::Deleted)
984        }
985
986        // Should not happen
987        (None, None, None) => (ResolvedEntity::Deleted, ResolutionStrategy::Deleted),
988    }
989}
990
991fn entity_to_region_with_content(entity: &SemanticEntity, content: &str) -> EntityRegion {
992    EntityRegion {
993        entity_id: entity.id.clone(),
994        entity_name: entity.name.clone(),
995        entity_type: entity.entity_type.clone(),
996        content: content.to_string(),
997        start_line: entity.start_line,
998        end_line: entity.end_line,
999    }
1000}
1001
1002/// Build a map from entity_id to region content (from file lines).
1003/// This preserves surrounding syntax (like `export`) that sem-core's entity.content may strip.
1004/// Returns borrowed references since regions live for the merge duration.
1005fn build_region_content_map(regions: &[FileRegion]) -> HashMap<&str, &str> {
1006    regions
1007        .iter()
1008        .filter_map(|r| match r {
1009            FileRegion::Entity(e) => Some((e.entity_id.as_str(), e.content.as_str())),
1010            _ => None,
1011        })
1012        .collect()
1013}
1014
1015/// Check if the only differences between two strings are whitespace changes.
1016/// This includes: indentation changes, trailing whitespace, blank line additions/removals.
1017fn is_whitespace_only_diff(a: &str, b: &str) -> bool {
1018    if a == b {
1019        return true; // identical, not really a "whitespace-only diff" but safe
1020    }
1021    let a_normalized: Vec<&str> = a.lines().map(|l| l.trim()).filter(|l| !l.is_empty()).collect();
1022    let b_normalized: Vec<&str> = b.lines().map(|l| l.trim()).filter(|l| !l.is_empty()).collect();
1023    a_normalized == b_normalized
1024}
1025
1026/// Check if a line is a decorator or annotation.
1027/// Covers Python (@decorator), Java/TS (@Annotation), and comment-style annotations.
1028fn is_decorator_line(line: &str) -> bool {
1029    let trimmed = line.trim();
1030    trimmed.starts_with('@')
1031        && !trimmed.starts_with("@param")
1032        && !trimmed.starts_with("@return")
1033        && !trimmed.starts_with("@type")
1034        && !trimmed.starts_with("@see")
1035}
1036
1037/// Split content into (decorators, body) where decorators are leading @-prefixed lines.
1038fn split_decorators(content: &str) -> (Vec<&str>, &str) {
1039    let mut decorator_end = 0;
1040    let mut byte_offset = 0;
1041    for line in content.lines() {
1042        if is_decorator_line(line) || line.trim().is_empty() {
1043            decorator_end += 1;
1044            byte_offset += line.len() + 1; // +1 for newline
1045        } else {
1046            break;
1047        }
1048    }
1049    // Trim trailing empty lines from decorator section
1050    let lines: Vec<&str> = content.lines().collect();
1051    while decorator_end > 0 && lines.get(decorator_end - 1).map_or(false, |l| l.trim().is_empty()) {
1052        byte_offset -= lines[decorator_end - 1].len() + 1;
1053        decorator_end -= 1;
1054    }
1055    let decorators: Vec<&str> = lines[..decorator_end]
1056        .iter()
1057        .filter(|l| is_decorator_line(l))
1058        .copied()
1059        .collect();
1060    let body = &content[byte_offset.min(content.len())..];
1061    (decorators, body)
1062}
1063
1064/// Try decorator-aware merge: when both sides add different decorators/annotations,
1065/// merge them commutatively (like imports). Also try merging the bodies separately.
1066///
1067/// This handles the common pattern where one agent adds @cache and another adds @deprecated
1068/// to the same function — they should both be preserved.
1069fn try_decorator_aware_merge(base: &str, ours: &str, theirs: &str) -> Option<String> {
1070    let (base_decorators, base_body) = split_decorators(base);
1071    let (ours_decorators, ours_body) = split_decorators(ours);
1072    let (theirs_decorators, theirs_body) = split_decorators(theirs);
1073
1074    // Only useful if at least one side has decorators
1075    if ours_decorators.is_empty() && theirs_decorators.is_empty() {
1076        return None;
1077    }
1078
1079    // Merge bodies using diffy (or take unchanged side)
1080    let merged_body = if base_body == ours_body && base_body == theirs_body {
1081        base_body.to_string()
1082    } else if base_body == ours_body {
1083        theirs_body.to_string()
1084    } else if base_body == theirs_body {
1085        ours_body.to_string()
1086    } else {
1087        // Both changed body — try diffy on just the body
1088        diffy_merge(base_body, ours_body, theirs_body)?
1089    };
1090
1091    // Merge decorators commutatively (set union)
1092    let base_set: HashSet<&str> = base_decorators.iter().copied().collect();
1093    let ours_set: HashSet<&str> = ours_decorators.iter().copied().collect();
1094    let theirs_set: HashSet<&str> = theirs_decorators.iter().copied().collect();
1095
1096    // Deletions
1097    let ours_deleted: HashSet<&str> = base_set.difference(&ours_set).copied().collect();
1098    let theirs_deleted: HashSet<&str> = base_set.difference(&theirs_set).copied().collect();
1099
1100    // Start with base decorators, remove deletions
1101    let mut merged_decorators: Vec<&str> = base_decorators
1102        .iter()
1103        .filter(|d| !ours_deleted.contains(**d) && !theirs_deleted.contains(**d))
1104        .copied()
1105        .collect();
1106
1107    // Add new decorators from ours (not in base)
1108    for d in &ours_decorators {
1109        if !base_set.contains(d) && !merged_decorators.contains(d) {
1110            merged_decorators.push(d);
1111        }
1112    }
1113    // Add new decorators from theirs (not in base, not already added)
1114    for d in &theirs_decorators {
1115        if !base_set.contains(d) && !merged_decorators.contains(d) {
1116            merged_decorators.push(d);
1117        }
1118    }
1119
1120    // Reconstruct
1121    let mut result = String::new();
1122    for d in &merged_decorators {
1123        result.push_str(d);
1124        result.push('\n');
1125    }
1126    result.push_str(&merged_body);
1127
1128    Some(result)
1129}
1130
1131/// Try 3-way merge on text using diffy. Returns None if there are conflicts.
1132fn diffy_merge(base: &str, ours: &str, theirs: &str) -> Option<String> {
1133    let result = diffy::merge(base, ours, theirs);
1134    match result {
1135        Ok(merged) => Some(merged),
1136        Err(_conflicted) => None,
1137    }
1138}
1139
1140/// Try 3-way merge using git merge-file. Returns None on conflict or error.
1141/// This uses a different diff algorithm than diffy and can sometimes merge
1142/// cases that diffy cannot (and vice versa).
1143fn git_merge_string(base: &str, ours: &str, theirs: &str) -> Option<String> {
1144    let dir = tempfile::tempdir().ok()?;
1145    let base_path = dir.path().join("base");
1146    let ours_path = dir.path().join("ours");
1147    let theirs_path = dir.path().join("theirs");
1148
1149    std::fs::write(&base_path, base).ok()?;
1150    std::fs::write(&ours_path, ours).ok()?;
1151    std::fs::write(&theirs_path, theirs).ok()?;
1152
1153    let output = Command::new("git")
1154        .arg("merge-file")
1155        .arg("-p")
1156        .arg(&ours_path)
1157        .arg(&base_path)
1158        .arg(&theirs_path)
1159        .output()
1160        .ok()?;
1161
1162    if output.status.success() {
1163        String::from_utf8(output.stdout).ok()
1164    } else {
1165        None
1166    }
1167}
1168
1169/// Merge interstitial regions from all three versions.
1170/// Uses commutative (set-based) merge for import blocks — inspired by
1171/// LastMerge/Mergiraf's "unordered children" concept.
1172/// Falls back to line-level 3-way merge for non-import content.
1173fn merge_interstitials(
1174    base_regions: &[FileRegion],
1175    ours_regions: &[FileRegion],
1176    theirs_regions: &[FileRegion],
1177    marker_format: &MarkerFormat,
1178) -> (HashMap<String, String>, Vec<EntityConflict>) {
1179    let base_map: HashMap<&str, &str> = base_regions
1180        .iter()
1181        .filter_map(|r| match r {
1182            FileRegion::Interstitial(i) => Some((i.position_key.as_str(), i.content.as_str())),
1183            _ => None,
1184        })
1185        .collect();
1186
1187    let ours_map: HashMap<&str, &str> = ours_regions
1188        .iter()
1189        .filter_map(|r| match r {
1190            FileRegion::Interstitial(i) => Some((i.position_key.as_str(), i.content.as_str())),
1191            _ => None,
1192        })
1193        .collect();
1194
1195    let theirs_map: HashMap<&str, &str> = theirs_regions
1196        .iter()
1197        .filter_map(|r| match r {
1198            FileRegion::Interstitial(i) => Some((i.position_key.as_str(), i.content.as_str())),
1199            _ => None,
1200        })
1201        .collect();
1202
1203    let mut all_keys: HashSet<&str> = HashSet::new();
1204    all_keys.extend(base_map.keys());
1205    all_keys.extend(ours_map.keys());
1206    all_keys.extend(theirs_map.keys());
1207
1208    let mut merged: HashMap<String, String> = HashMap::new();
1209    let mut interstitial_conflicts: Vec<EntityConflict> = Vec::new();
1210
1211    for key in all_keys {
1212        let base_content = base_map.get(key).copied().unwrap_or("");
1213        let ours_content = ours_map.get(key).copied().unwrap_or("");
1214        let theirs_content = theirs_map.get(key).copied().unwrap_or("");
1215
1216        // If all same, no merge needed
1217        if ours_content == theirs_content {
1218            merged.insert(key.to_string(), ours_content.to_string());
1219        } else if base_content == ours_content {
1220            merged.insert(key.to_string(), theirs_content.to_string());
1221        } else if base_content == theirs_content {
1222            merged.insert(key.to_string(), ours_content.to_string());
1223        } else {
1224            // Both changed — check whitespace-only first
1225            if is_whitespace_only_diff(base_content, ours_content)
1226                && is_whitespace_only_diff(base_content, theirs_content)
1227            {
1228                // Both sides only changed whitespace, take theirs (arbitrary)
1229                merged.insert(key.to_string(), theirs_content.to_string());
1230            } else if is_whitespace_only_diff(base_content, ours_content) {
1231                // Ours is whitespace-only, theirs has real changes
1232                merged.insert(key.to_string(), theirs_content.to_string());
1233            } else if is_whitespace_only_diff(base_content, theirs_content) {
1234                // Theirs is whitespace-only, ours has real changes
1235                merged.insert(key.to_string(), ours_content.to_string());
1236            } else if is_import_region(base_content)
1237                || is_import_region(ours_content)
1238                || is_import_region(theirs_content)
1239            {
1240                // Commutative merge: treat import lines as a set
1241                let result = merge_imports_commutatively(base_content, ours_content, theirs_content);
1242                merged.insert(key.to_string(), result);
1243            } else {
1244                // Regular line-level merge
1245                match diffy::merge(base_content, ours_content, theirs_content) {
1246                    Ok(m) => {
1247                        merged.insert(key.to_string(), m);
1248                    }
1249                    Err(_conflicted) => {
1250                        // Create a proper conflict instead of silently embedding
1251                        // raw conflict markers into the output.
1252                        let complexity = classify_conflict(
1253                            Some(base_content),
1254                            Some(ours_content),
1255                            Some(theirs_content),
1256                        );
1257                        let conflict = EntityConflict {
1258                            entity_name: key.to_string(),
1259                            entity_type: "interstitial".to_string(),
1260                            kind: ConflictKind::BothModified,
1261                            complexity,
1262                            ours_content: Some(ours_content.to_string()),
1263                            theirs_content: Some(theirs_content.to_string()),
1264                            base_content: Some(base_content.to_string()),
1265                        };
1266                        merged.insert(key.to_string(), conflict.to_conflict_markers(marker_format));
1267                        interstitial_conflicts.push(conflict);
1268                    }
1269                }
1270            }
1271        }
1272    }
1273
1274    (merged, interstitial_conflicts)
1275}
1276
1277/// Check if a region is predominantly import/use statements.
1278/// Handles both single-line imports and multi-line import blocks
1279/// (e.g. `import { type a, type b } from "..."` spread across lines).
1280fn is_import_region(content: &str) -> bool {
1281    let lines: Vec<&str> = content
1282        .lines()
1283        .filter(|l| !l.trim().is_empty())
1284        .collect();
1285    if lines.is_empty() {
1286        return false;
1287    }
1288    let mut import_count = 0;
1289    let mut in_multiline_import = false;
1290    for line in &lines {
1291        if in_multiline_import {
1292            import_count += 1;
1293            let trimmed = line.trim();
1294            if trimmed.starts_with('}') || trimmed.ends_with(')') {
1295                in_multiline_import = false;
1296            }
1297        } else if is_import_line(line) {
1298            import_count += 1;
1299            let trimmed = line.trim();
1300            // Detect start of multi-line import: `import {` or `import (` without closing on same line
1301            if (trimmed.contains('{') && !trimmed.contains('}'))
1302                || (trimmed.starts_with("import (") && !trimmed.contains(')'))
1303                || (trimmed.starts_with("from ") && trimmed.contains("import (") && !trimmed.contains(')'))
1304            {
1305                in_multiline_import = true;
1306            }
1307        }
1308    }
1309    // If >50% of non-empty lines are imports, treat as import region
1310    import_count * 2 > lines.len()
1311}
1312
1313/// Post-merge cleanup: remove consecutive duplicate lines and normalize blank lines.
1314///
1315/// Fixes two classes of merge artifacts:
1316/// 1. Duplicate lines/blocks that appear when both sides add the same content
1317///    (e.g. duplicate typedefs, forward declarations)
1318/// 2. Missing blank lines between entities or declarations, and excessive
1319///    blank lines (3+ consecutive) collapsed to 2
1320fn post_merge_cleanup(content: &str) -> String {
1321    let lines: Vec<&str> = content.lines().collect();
1322    let mut result: Vec<&str> = Vec::with_capacity(lines.len());
1323
1324    // Pass 1: Remove consecutive duplicate lines that look like declarations or imports.
1325    // Only dedup lines that are plausibly merge artifacts (imports, exports, forward decls).
1326    // Preserve intentional duplicates like repeated assertions, assignments, or data lines.
1327    for line in &lines {
1328        if line.trim().is_empty() {
1329            result.push(line);
1330            continue;
1331        }
1332        if let Some(prev) = result.last() {
1333            if !prev.trim().is_empty() && *prev == *line && looks_like_declaration(line) {
1334                continue; // skip consecutive exact duplicate of declaration-like line
1335            }
1336        }
1337        result.push(line);
1338    }
1339
1340    // Pass 2: Collapse 3+ consecutive blank lines to 2 (one separator blank line).
1341    let mut final_lines: Vec<&str> = Vec::with_capacity(result.len());
1342    let mut consecutive_blanks = 0;
1343    for line in &result {
1344        if line.trim().is_empty() {
1345            consecutive_blanks += 1;
1346            if consecutive_blanks <= 2 {
1347                final_lines.push(line);
1348            }
1349        } else {
1350            consecutive_blanks = 0;
1351            final_lines.push(line);
1352        }
1353    }
1354
1355    let mut out = final_lines.join("\n");
1356    if content.ends_with('\n') && !out.ends_with('\n') {
1357        out.push('\n');
1358    }
1359    out
1360}
1361
1362/// Check if a line looks like a declaration/import that merge might duplicate.
1363/// Returns false for lines that could be intentionally repeated (assertions,
1364/// assignments, data initializers, struct fields, etc.).
1365fn looks_like_declaration(line: &str) -> bool {
1366    let trimmed = line.trim();
1367    trimmed.starts_with("import ")
1368        || trimmed.starts_with("from ")
1369        || trimmed.starts_with("use ")
1370        || trimmed.starts_with("export ")
1371        || trimmed.starts_with("require(")
1372        || trimmed.starts_with("#include")
1373        || trimmed.starts_with("typedef ")
1374        || trimmed.starts_with("using ")
1375        || (trimmed.starts_with("pub ") && trimmed.contains("mod "))
1376}
1377
1378/// Check if a line is a top-level import/use/require statement.
1379///
1380/// Only matches unindented lines to avoid picking up conditional imports
1381/// inside `if TYPE_CHECKING:` blocks or similar constructs.
1382fn is_import_line(line: &str) -> bool {
1383    // Skip indented lines: these are inside conditional blocks (TYPE_CHECKING, etc.)
1384    if line.starts_with(' ') || line.starts_with('\t') {
1385        return false;
1386    }
1387    let trimmed = line.trim();
1388    trimmed.starts_with("import ")
1389        || trimmed.starts_with("from ")
1390        || trimmed.starts_with("use ")
1391        || trimmed.starts_with("require(")
1392        || trimmed.starts_with("const ") && trimmed.contains("require(")
1393        || trimmed.starts_with("package ")
1394        || trimmed.starts_with("#include ")
1395        || trimmed.starts_with("using ")
1396}
1397
1398/// Like is_import_line but operates on already-trimmed strings.
1399/// Used in the line-level safety net where we compare trimmed lines.
1400fn is_import_line_trimmed(trimmed: &str) -> bool {
1401    trimmed.starts_with("import ")
1402        || trimmed.starts_with("from ")
1403        || trimmed.starts_with("use ")
1404        || trimmed.starts_with("require(")
1405        || trimmed.starts_with("const ") && trimmed.contains("require(")
1406        || trimmed.starts_with("package ")
1407        || trimmed.starts_with("#include ")
1408        || trimmed.starts_with("using ")
1409}
1410
1411/// A complete import statement (possibly multi-line) as a single unit.
1412#[derive(Debug, Clone)]
1413struct ImportStatement {
1414    /// The full text of the import (may span multiple lines)
1415    lines: Vec<String>,
1416    /// The source module (e.g. "./foo", "react", "std::io")
1417    source: String,
1418    /// For multi-line imports: the individual specifiers (e.g. ["type a", "type b"])
1419    specifiers: Vec<String>,
1420    /// Whether this is a multi-line import block
1421    is_multiline: bool,
1422}
1423
1424/// Extract specifiers from a single-line import statement.
1425/// e.g. "from X import A, B, C" → ["A", "B", "C"]
1426///      "import { A, B } from 'X'" → ["A", "B"]
1427///      "import X from 'Y'" → [] (default import, no named specifiers)
1428fn parse_single_line_specifiers(trimmed: &str) -> Vec<String> {
1429    // Python: "from X import A, B, C"
1430    if let Some(import_pos) = trimmed.find(" import ") {
1431        let after_import = &trimmed[import_pos + 8..];
1432        // Skip if it's "import *" or "import (..." (multi-line handled elsewhere)
1433        if after_import.starts_with('*') || after_import.starts_with('(') {
1434            return Vec::new();
1435        }
1436        return after_import
1437            .split(',')
1438            .map(|s| s.trim().trim_end_matches(';').to_string())
1439            .filter(|s| !s.is_empty())
1440            .collect();
1441    }
1442    // JS/TS: "import { A, B } from 'X'"
1443    if trimmed.starts_with("import ") {
1444        if let Some(brace_start) = trimmed.find('{') {
1445            if let Some(brace_end) = trimmed.find('}') {
1446                let inner = &trimmed[brace_start + 1..brace_end];
1447                return inner
1448                    .split(',')
1449                    .map(|s| s.trim().to_string())
1450                    .filter(|s| !s.is_empty())
1451                    .collect();
1452            }
1453        }
1454    }
1455    Vec::new()
1456}
1457
1458/// Parse content into import statements, handling multi-line imports as single units.
1459fn parse_import_statements(content: &str) -> (Vec<ImportStatement>, Vec<String>) {
1460    let mut imports: Vec<ImportStatement> = Vec::new();
1461    let mut non_import_lines: Vec<String> = Vec::new();
1462    let lines: Vec<&str> = content.lines().collect();
1463    let mut i = 0;
1464
1465    while i < lines.len() {
1466        let line = lines[i];
1467
1468        if line.trim().is_empty() {
1469            non_import_lines.push(line.to_string());
1470            i += 1;
1471            continue;
1472        }
1473
1474        if is_import_line(line) {
1475            let trimmed = line.trim();
1476            // Check for multi-line import: `import {` without `}` on same line
1477            let starts_multiline = (trimmed.contains('{') && !trimmed.contains('}'))
1478                || (trimmed.starts_with("import (") && !trimmed.contains(')'))
1479                || (trimmed.starts_with("from ") && trimmed.contains("import (") && !trimmed.contains(')'));
1480
1481            if starts_multiline {
1482                let mut block_lines = vec![line.to_string()];
1483                let mut specifiers = Vec::new();
1484                let close_char = if trimmed.contains('{') { '}' } else { ')' };
1485                i += 1;
1486
1487                // Collect lines until closing brace/paren
1488                while i < lines.len() {
1489                    let inner = lines[i];
1490                    block_lines.push(inner.to_string());
1491                    let inner_trimmed = inner.trim();
1492
1493                    if inner_trimmed.starts_with(close_char) {
1494                        // This is the closing line (e.g. `} from "./foo"`)
1495                        break;
1496                    } else if !inner_trimmed.is_empty() {
1497                        // This is a specifier line — strip trailing comma
1498                        let spec = inner_trimmed.trim_end_matches(',').trim().to_string();
1499                        if !spec.is_empty() {
1500                            specifiers.push(spec);
1501                        }
1502                    }
1503                    i += 1;
1504                }
1505
1506                let full_text = block_lines.join("\n");
1507                let source = import_source_prefix(&full_text).to_string();
1508                imports.push(ImportStatement {
1509                    lines: block_lines,
1510                    source,
1511                    specifiers,
1512                    is_multiline: true,
1513                });
1514            } else {
1515                // Single-line import — also parse specifiers for set-based merging
1516                let source = import_source_prefix(line).to_string();
1517                let specifiers = parse_single_line_specifiers(trimmed);
1518                imports.push(ImportStatement {
1519                    lines: vec![line.to_string()],
1520                    source,
1521                    specifiers,
1522                    is_multiline: false,
1523                });
1524            }
1525        } else {
1526            non_import_lines.push(line.to_string());
1527        }
1528        i += 1;
1529    }
1530
1531    (imports, non_import_lines)
1532}
1533
1534/// Merge import blocks commutatively (as unordered sets), preserving grouping.
1535///
1536/// Handles both single-line imports and multi-line import blocks.
1537/// For multi-line imports from the same source, merges specifiers as a set.
1538/// Single-line imports are merged as before: set union with deletions.
1539fn merge_imports_commutatively(base: &str, ours: &str, theirs: &str) -> String {
1540    let (base_imports, _) = parse_import_statements(base);
1541    let (ours_imports, _) = parse_import_statements(ours);
1542    let (theirs_imports, _) = parse_import_statements(theirs);
1543
1544    let has_multiline = base_imports.iter().any(|i| i.is_multiline)
1545        || ours_imports.iter().any(|i| i.is_multiline)
1546        || theirs_imports.iter().any(|i| i.is_multiline);
1547
1548    if has_multiline {
1549        return merge_imports_with_multiline(base, ours, theirs,
1550            &base_imports, &ours_imports, &theirs_imports);
1551    }
1552
1553    // Original single-line-only logic
1554    let base_lines: HashSet<&str> = base.lines().filter(|l| is_import_line(l)).collect();
1555    let ours_lines: HashSet<&str> = ours.lines().filter(|l| is_import_line(l)).collect();
1556
1557    let theirs_deleted: HashSet<&str> = base_lines.difference(
1558        &theirs.lines().filter(|l| is_import_line(l)).collect::<HashSet<&str>>()
1559    ).copied().collect();
1560
1561    let theirs_added: Vec<&str> = theirs
1562        .lines()
1563        .filter(|l| is_import_line(l) && !base_lines.contains(l) && !ours_lines.contains(l))
1564        .collect();
1565
1566    // Build import groups from ours (import lines only)
1567    let mut groups: Vec<Vec<&str>> = Vec::new();
1568    let mut current_group: Vec<&str> = Vec::new();
1569
1570    for line in ours.lines() {
1571        if line.trim().is_empty() {
1572            if !current_group.is_empty() {
1573                groups.push(current_group);
1574                current_group = Vec::new();
1575            }
1576        } else if is_import_line(line) {
1577            if theirs_deleted.contains(line) {
1578                continue;
1579            }
1580            current_group.push(line);
1581        }
1582    }
1583    if !current_group.is_empty() {
1584        groups.push(current_group);
1585    }
1586
1587    for add in &theirs_added {
1588        let prefix = import_source_prefix(add);
1589        let mut best_group = if groups.is_empty() { 0 } else { groups.len() - 1 };
1590        for (i, group) in groups.iter().enumerate() {
1591            if group.iter().any(|l| {
1592                is_import_line(l) && import_source_prefix(l) == prefix
1593            }) {
1594                best_group = i;
1595                break;
1596            }
1597        }
1598        if best_group < groups.len() {
1599            groups[best_group].push(add);
1600        } else {
1601            groups.push(vec![add]);
1602        }
1603    }
1604
1605    // Sort import lines within each group alphabetically
1606    for group in &mut groups {
1607        group.sort_unstable();
1608    }
1609
1610    let mut import_lines: Vec<&str> = Vec::new();
1611    for (i, group) in groups.iter().enumerate() {
1612        if i > 0 {
1613            import_lines.push("");
1614        }
1615        import_lines.extend(group);
1616    }
1617
1618    let mut result = import_lines.join("\n");
1619
1620    // Non-import lines: use diffy 3-way merge so adds/deletes/edits on
1621    // either side are handled correctly (fixes #60).
1622    let extract_non_imports = |content: &str| -> String {
1623        content
1624            .lines()
1625            .filter(|l| !l.trim().is_empty() && !is_import_line(l))
1626            .collect::<Vec<_>>()
1627            .join("\n")
1628    };
1629    let base_ni = extract_non_imports(base);
1630    let ours_ni = extract_non_imports(ours);
1631    let theirs_ni = extract_non_imports(theirs);
1632
1633    if !base_ni.is_empty() || !ours_ni.is_empty() || !theirs_ni.is_empty() {
1634        let merged_ni = match diffy::merge(&base_ni, &ours_ni, &theirs_ni) {
1635            Ok(m) => m,
1636            Err(conflicted) => conflicted,
1637        };
1638        if !merged_ni.trim().is_empty() {
1639            result.push('\n');
1640            result.push('\n');
1641            result.push_str(&merged_ni);
1642        }
1643    }
1644    let ours_trailing = ours.len() - ours.trim_end_matches('\n').len();
1645    let result_trailing = result.len() - result.trim_end_matches('\n').len();
1646    for _ in result_trailing..ours_trailing {
1647        result.push('\n');
1648    }
1649    result
1650}
1651
1652/// Merge imports when multi-line import blocks are involved.
1653/// Matches imports by source module, merges specifiers as a set.
1654fn merge_imports_with_multiline(
1655    _base_raw: &str,
1656    ours_raw: &str,
1657    _theirs_raw: &str,
1658    base_imports: &[ImportStatement],
1659    ours_imports: &[ImportStatement],
1660    theirs_imports: &[ImportStatement],
1661) -> String {
1662    // Build source → specifier sets for base and theirs
1663    let base_specs: HashMap<&str, HashSet<&str>> = base_imports.iter().map(|imp| {
1664        let specs: HashSet<&str> = imp.specifiers.iter().map(|s| s.as_str()).collect();
1665        (imp.source.as_str(), specs)
1666    }).collect();
1667
1668    let theirs_specs: HashMap<&str, HashSet<&str>> = theirs_imports.iter().map(|imp| {
1669        let specs: HashSet<&str> = imp.specifiers.iter().map(|s| s.as_str()).collect();
1670        (imp.source.as_str(), specs)
1671    }).collect();
1672
1673    // Single-line import tracking: base lines and theirs-deleted
1674    let base_single: HashSet<String> = base_imports.iter()
1675        .filter(|i| !i.is_multiline)
1676        .map(|i| i.lines[0].clone())
1677        .collect();
1678    let theirs_single: HashSet<String> = theirs_imports.iter()
1679        .filter(|i| !i.is_multiline)
1680        .map(|i| i.lines[0].clone())
1681        .collect();
1682    let theirs_deleted_single: HashSet<&str> = base_single.iter()
1683        .filter(|l| !theirs_single.contains(l.as_str()))
1684        .map(|l| l.as_str())
1685        .collect();
1686
1687    // Process ours imports, merging in theirs specifiers
1688    let mut result_parts: Vec<String> = Vec::new();
1689    let mut handled_theirs_sources: HashSet<&str> = HashSet::new();
1690
1691    // Walk through ours_raw to preserve formatting (blank lines, comments)
1692    let lines: Vec<&str> = ours_raw.lines().collect();
1693    let mut i = 0;
1694    let mut ours_imp_idx = 0;
1695
1696    while i < lines.len() {
1697        let line = lines[i];
1698
1699        if line.trim().is_empty() {
1700            result_parts.push(line.to_string());
1701            i += 1;
1702            continue;
1703        }
1704
1705        if is_import_line(line) {
1706            let trimmed = line.trim();
1707            let starts_multiline = (trimmed.contains('{') && !trimmed.contains('}'))
1708                || (trimmed.starts_with("import (") && !trimmed.contains(')'))
1709                || (trimmed.starts_with("from ") && trimmed.contains("import (") && !trimmed.contains(')'));
1710
1711            if starts_multiline && ours_imp_idx < ours_imports.len() {
1712                let imp = &ours_imports[ours_imp_idx];
1713                // Find the matching import by source
1714                let source = imp.source.as_str();
1715                handled_theirs_sources.insert(source);
1716
1717                // Merge specifiers: ours + theirs additions - theirs deletions
1718                let base_spec_set = base_specs.get(source).cloned().unwrap_or_default();
1719                let theirs_spec_set = theirs_specs.get(source).cloned().unwrap_or_default();
1720                // Added by theirs: in theirs but not in base
1721                let theirs_added: HashSet<&str> = theirs_spec_set.difference(&base_spec_set).copied().collect();
1722                // Deleted by theirs: in base but not in theirs
1723                let theirs_removed: HashSet<&str> = base_spec_set.difference(&theirs_spec_set).copied().collect();
1724
1725                // Final set: ours (in original order) + theirs_added - theirs_removed
1726                let mut final_specs: Vec<&str> = imp.specifiers.iter()
1727                    .map(|s| s.as_str())
1728                    .filter(|s| !theirs_removed.contains(s))
1729                    .collect();
1730                for added in &theirs_added {
1731                    if !final_specs.contains(added) {
1732                        final_specs.push(added);
1733                    }
1734                }
1735
1736                // Detect indentation from the original block
1737                let indent = if imp.lines.len() > 1 {
1738                    let second = &imp.lines[1];
1739                    &second[..second.len() - second.trim_start().len()]
1740                } else {
1741                    "     "
1742                };
1743
1744                // Reconstruct multi-line import
1745                result_parts.push(imp.lines[0].clone()); // `import {`
1746                for spec in &final_specs {
1747                    result_parts.push(format!("{}{},", indent, spec));
1748                }
1749                // Closing line from ours
1750                if let Some(last) = imp.lines.last() {
1751                    result_parts.push(last.clone());
1752                }
1753
1754                // Skip past the original multi-line block in ours_raw
1755                let close_char = if trimmed.contains('{') { '}' } else { ')' };
1756                i += 1;
1757                while i < lines.len() {
1758                    if lines[i].trim().starts_with(close_char) {
1759                        i += 1;
1760                        break;
1761                    }
1762                    i += 1;
1763                }
1764                ours_imp_idx += 1;
1765                continue;
1766            } else {
1767                // Single-line import
1768                if ours_imp_idx < ours_imports.len() {
1769                    let imp = &ours_imports[ours_imp_idx];
1770                    let source = imp.source.as_str();
1771                    handled_theirs_sources.insert(source);
1772                    ours_imp_idx += 1;
1773
1774                    // Check if theirs has a multi-line version with more specifiers
1775                    if let Some(theirs_spec_set) = theirs_specs.get(source) {
1776                        let base_spec_set = base_specs.get(source).cloned().unwrap_or_default();
1777                        let theirs_added: HashSet<&str> = theirs_spec_set.difference(&base_spec_set).copied().collect();
1778
1779                        if !theirs_added.is_empty() {
1780                            // Parse ours single-line specifier from the line text
1781                            let mut ours_specifiers: Vec<&str> = Vec::new();
1782                            let trimmed_line = line.trim();
1783                            // Python: "from X import Y, Z" → extract after "import "
1784                            if let Some(import_pos) = trimmed_line.find(" import ") {
1785                                let after_import = &trimmed_line[import_pos + 8..];
1786                                for spec in after_import.split(',') {
1787                                    let s = spec.trim().trim_end_matches(';');
1788                                    if !s.is_empty() {
1789                                        ours_specifiers.push(s);
1790                                    }
1791                                }
1792                            }
1793                            // JS/TS: "import X from 'Y'" → X is a default import, not a specifier
1794                            // Only merge if we found named specifiers
1795                            if !ours_specifiers.is_empty() {
1796                                let mut final_specs: Vec<&str> = ours_specifiers.clone();
1797                                for added in &theirs_added {
1798                                    if !final_specs.contains(added) {
1799                                        final_specs.push(added);
1800                                    }
1801                                }
1802                                // Find theirs import to get formatting
1803                                if let Some(theirs_imp) = theirs_imports.iter().find(|ti| ti.source == source) {
1804                                    if theirs_imp.is_multiline {
1805                                        // Use theirs's multi-line formatting
1806                                        let indent = if theirs_imp.lines.len() > 1 {
1807                                            let second = &theirs_imp.lines[1];
1808                                            &second[..second.len() - second.trim_start().len()]
1809                                        } else {
1810                                            "    "
1811                                        };
1812                                        // Reconstruct as multi-line using theirs's opening/closing style
1813                                        result_parts.push(theirs_imp.lines[0].clone());
1814                                        for spec in &final_specs {
1815                                            result_parts.push(format!("{}{},", indent, spec));
1816                                        }
1817                                        if let Some(last) = theirs_imp.lines.last() {
1818                                            result_parts.push(last.clone());
1819                                        }
1820                                        i += 1;
1821                                        continue;
1822                                    }
1823                                }
1824                                // Theirs not multi-line but has more specifiers: reconstruct single-line
1825                                // e.g. "from X import A, B, C"
1826                                let prefix = &trimmed_line[..trimmed_line.find(" import ").unwrap() + 8];
1827                                result_parts.push(format!("{}{}", prefix, final_specs.join(", ")));
1828                                i += 1;
1829                                continue;
1830                            }
1831                        }
1832                    }
1833                } else {
1834                    // No more ours imports to match
1835                }
1836                // Check if theirs deleted this single-line import
1837                if !theirs_deleted_single.contains(line) {
1838                    result_parts.push(line.to_string());
1839                }
1840            }
1841        } else {
1842            result_parts.push(line.to_string());
1843        }
1844        i += 1;
1845    }
1846
1847    // Add any new imports from theirs that have new sources
1848    for imp in theirs_imports {
1849        if handled_theirs_sources.contains(imp.source.as_str()) {
1850            continue;
1851        }
1852        // Truly new import from theirs (source wasn't handled in the main loop)
1853        for line in &imp.lines {
1854            result_parts.push(line.clone());
1855        }
1856    }
1857
1858    let mut result = result_parts.join("\n");
1859
1860    // Non-import lines: use diffy 3-way merge so adds/deletes/edits on
1861    // either side are handled correctly (fixes #60).
1862    let extract_non_imports = |content: &str| -> String {
1863        content
1864            .lines()
1865            .filter(|l| !l.trim().is_empty() && !is_import_line(l))
1866            .filter(|l| {
1867                let t = l.trim();
1868                // Exclude multi-line import continuation lines (specifiers, closing parens/braces)
1869                !(t.ends_with(',') && !t.contains('='))
1870                    && t != ")"
1871                    && t != "}"
1872            })
1873            .collect::<Vec<_>>()
1874            .join("\n")
1875    };
1876    let base_ni = extract_non_imports(_base_raw);
1877    let ours_ni = extract_non_imports(ours_raw);
1878    let theirs_ni = extract_non_imports(_theirs_raw);
1879
1880    if !base_ni.is_empty() || !ours_ni.is_empty() || !theirs_ni.is_empty() {
1881        let merged_ni = match diffy::merge(&base_ni, &ours_ni, &theirs_ni) {
1882            Ok(m) => m,
1883            Err(conflicted) => conflicted,
1884        };
1885        if !merged_ni.trim().is_empty() {
1886            result.push('\n');
1887            result.push('\n');
1888            result.push_str(&merged_ni);
1889        }
1890    }
1891
1892    let ours_trailing = ours_raw.len() - ours_raw.trim_end_matches('\n').len();
1893    let result_trailing = result.len() - result.trim_end_matches('\n').len();
1894    for _ in result_trailing..ours_trailing {
1895        result.push('\n');
1896    }
1897    result
1898}
1899
1900/// Extract the source/module prefix from an import line for group matching.
1901/// e.g. "from collections import OrderedDict" -> "collections"
1902///      "import React from 'react'" -> "react"
1903///      "use std::collections::HashMap;" -> "std::collections"
1904fn import_source_prefix(line: &str) -> &str {
1905    // For multi-line imports, search all lines for the source module
1906    // (e.g. `} from "./foo"` on the closing line)
1907    for l in line.lines() {
1908        let trimmed = l.trim();
1909        // Python: "from X import Y" -> X
1910        if let Some(rest) = trimmed.strip_prefix("from ") {
1911            return rest.split_whitespace().next().unwrap_or("");
1912        }
1913        // JS/TS closing line: `} from 'Y'` or `} from "Y"`
1914        if trimmed.starts_with('}') && trimmed.contains("from ") {
1915            if let Some(quote_start) = trimmed.find(|c: char| c == '\'' || c == '"') {
1916                let after = &trimmed[quote_start + 1..];
1917                if let Some(quote_end) = after.find(|c: char| c == '\'' || c == '"') {
1918                    return &after[..quote_end];
1919                }
1920            }
1921        }
1922        // JS/TS: "import X from 'Y'" -> Y (between quotes)
1923        if trimmed.starts_with("import ") {
1924            if let Some(quote_start) = trimmed.find(|c: char| c == '\'' || c == '"') {
1925                let after = &trimmed[quote_start + 1..];
1926                if let Some(quote_end) = after.find(|c: char| c == '\'' || c == '"') {
1927                    return &after[..quote_end];
1928                }
1929            }
1930        }
1931        // Rust: "use X::Y;" -> X
1932        if let Some(rest) = trimmed.strip_prefix("use ") {
1933            return rest.split("::").next().unwrap_or("").trim_end_matches(';');
1934        }
1935    }
1936    line.trim()
1937}
1938
1939/// Fallback to line-level 3-way merge when entity extraction isn't possible.
1940///
1941/// Uses Sesame-inspired separator preprocessing (arXiv:2407.18888) to get
1942/// finer-grained alignment before line-level merge. Inserts newlines around
1943/// syntactic separators ({, }, ;) so that changes in different code blocks
1944/// align independently, reducing spurious conflicts.
1945///
1946/// Sesame expansion is skipped for data formats (JSON, YAML, TOML, lock files)
1947/// where `{`, `}`, `;` are structural content rather than code separators.
1948/// Expanding them destroys alignment and produces far more conflicts (confirmed
1949/// on GitButler: YAML went from 68 git markers to 192 weave markers with Sesame).
1950fn line_level_fallback(base: &str, ours: &str, theirs: &str, file_path: &str) -> MergeResult {
1951    let mut stats = MergeStats::default();
1952    stats.used_fallback = true;
1953
1954    // Skip Sesame preprocessing for data formats where {/}/; are content, not separators
1955    let skip = skip_sesame(file_path);
1956
1957    if skip {
1958        // Use git merge-file for data formats so we match git's output exactly.
1959        // diffy::merge uses a different diff algorithm that can produce more
1960        // conflict markers on structured data like lock files.
1961        return git_merge_file(base, ours, theirs, &mut stats);
1962    }
1963
1964    // Try Sesame expansion + diffy first, then compare against git merge-file.
1965    // Use whichever produces fewer conflict markers so we're never worse than git.
1966    let base_expanded = expand_separators(base);
1967    let ours_expanded = expand_separators(ours);
1968    let theirs_expanded = expand_separators(theirs);
1969
1970    let sesame_result = match diffy::merge(&base_expanded, &ours_expanded, &theirs_expanded) {
1971        Ok(merged) => {
1972            let content = collapse_separators(&merged, base);
1973            Some(MergeResult {
1974                content: post_merge_cleanup(&content),
1975                conflicts: vec![],
1976                warnings: vec![],
1977                stats: stats.clone(),
1978                audit: vec![],
1979            })
1980        }
1981        Err(_) => {
1982            // Sesame expansion conflicted, try plain diffy
1983            match diffy::merge(base, ours, theirs) {
1984                Ok(merged) => Some(MergeResult {
1985                    content: merged,
1986                    conflicts: vec![],
1987                    warnings: vec![],
1988                    stats: stats.clone(),
1989                    audit: vec![],
1990                }),
1991                Err(conflicted) => {
1992                    let _markers = conflicted.lines().filter(|l| l.starts_with("<<<<<<<")).count();
1993                    let mut s = stats.clone();
1994                    s.entities_conflicted = 1;
1995                    Some(MergeResult {
1996                        content: conflicted,
1997                        conflicts: vec![EntityConflict {
1998                            entity_name: "(file)".to_string(),
1999                            entity_type: "file".to_string(),
2000                            kind: ConflictKind::BothModified,
2001                            complexity: classify_conflict(Some(base), Some(ours), Some(theirs)),
2002                            ours_content: Some(ours.to_string()),
2003                            theirs_content: Some(theirs.to_string()),
2004                            base_content: Some(base.to_string()),
2005                        }],
2006                        warnings: vec![],
2007                        stats: s,
2008                        audit: vec![],
2009                    })
2010                }
2011            }
2012        }
2013    };
2014
2015    // Get git merge-file result as our floor
2016    let git_result = git_merge_file(base, ours, theirs, &mut stats);
2017
2018    // Compare: use sesame result only if it has fewer or equal markers
2019    match sesame_result {
2020        Some(sesame) if sesame.conflicts.is_empty() && !git_result.conflicts.is_empty() => {
2021            // Sesame resolved cleanly, git didn't: use sesame
2022            sesame
2023        }
2024        Some(sesame) if !sesame.conflicts.is_empty() && !git_result.conflicts.is_empty() => {
2025            // Both conflicted: use whichever has fewer markers
2026            let sesame_markers = sesame.content.lines().filter(|l| l.starts_with("<<<<<<<")).count();
2027            let git_markers = git_result.content.lines().filter(|l| l.starts_with("<<<<<<<")).count();
2028            if sesame_markers <= git_markers { sesame } else { git_result }
2029        }
2030        _ => git_result,
2031    }
2032}
2033
2034/// Shell out to `git merge-file` for an exact match with git's line-level merge.
2035///
2036/// We use this instead of `diffy::merge` for data formats (lock files, JSON, YAML, TOML)
2037/// where weave can't improve on git. `diffy` uses a different diff algorithm that can
2038/// produce more conflict markers on structured data (e.g. 22 markers vs git's 19 on uv.lock).
2039fn git_merge_file(base: &str, ours: &str, theirs: &str, stats: &mut MergeStats) -> MergeResult {
2040    let dir = match tempfile::tempdir() {
2041        Ok(d) => d,
2042        Err(_) => return diffy_fallback(base, ours, theirs, stats),
2043    };
2044
2045    let base_path = dir.path().join("base");
2046    let ours_path = dir.path().join("ours");
2047    let theirs_path = dir.path().join("theirs");
2048
2049    let write_ok = (|| -> std::io::Result<()> {
2050        std::fs::File::create(&base_path)?.write_all(base.as_bytes())?;
2051        std::fs::File::create(&ours_path)?.write_all(ours.as_bytes())?;
2052        std::fs::File::create(&theirs_path)?.write_all(theirs.as_bytes())?;
2053        Ok(())
2054    })();
2055
2056    if write_ok.is_err() {
2057        return diffy_fallback(base, ours, theirs, stats);
2058    }
2059
2060    // git merge-file writes result to the first file (ours) in place
2061    let output = Command::new("git")
2062        .arg("merge-file")
2063        .arg("-p") // print to stdout instead of modifying ours in place
2064        .arg("--diff3") // include ||||||| base section for jj compatibility
2065        .arg("-L").arg("ours")
2066        .arg("-L").arg("base")
2067        .arg("-L").arg("theirs")
2068        .arg(&ours_path)
2069        .arg(&base_path)
2070        .arg(&theirs_path)
2071        .output();
2072
2073    match output {
2074        Ok(result) => {
2075            let content = String::from_utf8_lossy(&result.stdout).into_owned();
2076            if result.status.success() {
2077                // Exit 0 = clean merge
2078                MergeResult {
2079                    content: post_merge_cleanup(&content),
2080                    conflicts: vec![],
2081                    warnings: vec![],
2082                    stats: stats.clone(),
2083                    audit: vec![],
2084                }
2085            } else {
2086                // Exit >0 = conflicts (exit code = number of conflicts)
2087                stats.entities_conflicted = 1;
2088                MergeResult {
2089                    content,
2090                    conflicts: vec![EntityConflict {
2091                        entity_name: "(file)".to_string(),
2092                        entity_type: "file".to_string(),
2093                        kind: ConflictKind::BothModified,
2094                        complexity: classify_conflict(Some(base), Some(ours), Some(theirs)),
2095                        ours_content: Some(ours.to_string()),
2096                        theirs_content: Some(theirs.to_string()),
2097                        base_content: Some(base.to_string()),
2098                    }],
2099                    warnings: vec![],
2100                    stats: stats.clone(),
2101                    audit: vec![],
2102                }
2103            }
2104        }
2105        // git not available, fall back to diffy
2106        Err(_) => diffy_fallback(base, ours, theirs, stats),
2107    }
2108}
2109
2110/// Fallback to diffy::merge when git merge-file is unavailable.
2111fn diffy_fallback(base: &str, ours: &str, theirs: &str, stats: &mut MergeStats) -> MergeResult {
2112    match diffy::merge(base, ours, theirs) {
2113        Ok(merged) => {
2114            let content = post_merge_cleanup(&merged);
2115            MergeResult {
2116                content,
2117                conflicts: vec![],
2118                warnings: vec![],
2119                stats: stats.clone(),
2120                audit: vec![],
2121            }
2122        }
2123        Err(conflicted) => {
2124            stats.entities_conflicted = 1;
2125            MergeResult {
2126                content: conflicted,
2127                conflicts: vec![EntityConflict {
2128                    entity_name: "(file)".to_string(),
2129                    entity_type: "file".to_string(),
2130                    kind: ConflictKind::BothModified,
2131                    complexity: classify_conflict(Some(base), Some(ours), Some(theirs)),
2132                    ours_content: Some(ours.to_string()),
2133                    theirs_content: Some(theirs.to_string()),
2134                    base_content: Some(base.to_string()),
2135                }],
2136                warnings: vec![],
2137                stats: stats.clone(),
2138                audit: vec![],
2139            }
2140        }
2141    }
2142}
2143
2144/// Filter out entities that are nested inside other entities.
2145///
2146/// When a class contains methods which contain local variables, sem-core may extract
2147/// all of them as entities. But for merge purposes, nested entities are part of their
2148/// parent — we handle them via inner entity merge. Keeping them causes false conflicts
2149/// (e.g. two methods both declaring `const user` would appear as BothAdded).
2150/// Check if entity list has too many duplicate names, which causes matching to hang.
2151fn has_excessive_duplicates(entities: &[SemanticEntity]) -> bool {
2152    let threshold = std::env::var("WEAVE_MAX_DUPLICATES")
2153        .ok()
2154        .and_then(|v| v.parse::<usize>().ok())
2155        .unwrap_or(10);
2156    let mut counts: HashMap<&str, usize> = HashMap::new();
2157    for e in entities {
2158        *counts.entry(&e.name).or_default() += 1;
2159    }
2160    counts.values().any(|&c| c >= threshold)
2161}
2162
2163/// Filter out entities that are nested inside other entities.
2164/// O(n log n) via sort + stack, replacing the previous O(n^2) approach.
2165fn filter_nested_entities(mut entities: Vec<SemanticEntity>) -> Vec<SemanticEntity> {
2166    if entities.len() <= 1 {
2167        return entities;
2168    }
2169
2170    // Sort by start_line ASC, then by end_line DESC (widest span first).
2171    // A parent entity always appears before its children in this order.
2172    entities.sort_by(|a, b| {
2173        a.start_line.cmp(&b.start_line).then(b.end_line.cmp(&a.end_line))
2174    });
2175
2176    // Stack-based filter: track the end_line of the current outermost entity.
2177    let mut result: Vec<SemanticEntity> = Vec::with_capacity(entities.len());
2178    let mut max_end: usize = 0;
2179
2180    for entity in entities {
2181        if entity.start_line > max_end || max_end == 0 {
2182            // Not nested: new top-level entity
2183            max_end = entity.end_line;
2184            result.push(entity);
2185        } else if entity.start_line == result.last().map_or(0, |e| e.start_line)
2186            && entity.end_line == result.last().map_or(0, |e| e.end_line)
2187        {
2188            // Exact same span (e.g. decorated_definition wrapping function_definition)
2189            result.push(entity);
2190        }
2191        // else: strictly nested, skip
2192    }
2193
2194    result
2195}
2196
2197/// Get child entities of a parent, sorted by start line.
2198fn get_child_entities<'a>(
2199    parent: &SemanticEntity,
2200    all_entities: &'a [SemanticEntity],
2201) -> Vec<&'a SemanticEntity> {
2202    let mut children: Vec<&SemanticEntity> = all_entities
2203        .iter()
2204        .filter(|e| e.parent_id.as_deref() == Some(&parent.id))
2205        .collect();
2206    children.sort_by_key(|e| e.start_line);
2207    children
2208}
2209
2210/// Compute a body hash for rename detection: the entity content with the entity
2211/// name replaced at word boundaries by a placeholder, so entities with identical
2212/// bodies but different names produce the same hash.
2213///
2214/// Compute Jaccard similarity on whitespace-split tokens of two strings.
2215/// Returns a value in [0.0, 1.0] where 1.0 means identical token sets.
2216fn token_jaccard(a: &str, b: &str) -> f64 {
2217    let tokens_a: HashSet<&str> = a.split_whitespace().collect();
2218    let tokens_b: HashSet<&str> = b.split_whitespace().collect();
2219    if tokens_a.is_empty() && tokens_b.is_empty() {
2220        return 1.0;
2221    }
2222    let intersection = tokens_a.intersection(&tokens_b).count();
2223    let union = tokens_a.union(&tokens_b).count();
2224    if union == 0 { 1.0 } else { intersection as f64 / union as f64 }
2225}
2226
2227/// Uses word-boundary matching to avoid partial replacements (e.g. replacing
2228/// "get" inside "getAll"). Works across all languages since it operates on
2229/// the content string, not language-specific AST features.
2230fn body_hash(entity: &SemanticEntity) -> u64 {
2231    use std::collections::hash_map::DefaultHasher;
2232    use std::hash::{Hash, Hasher};
2233    let normalized = replace_at_word_boundaries(&entity.content, &entity.name, "__ENTITY__");
2234    let mut hasher = DefaultHasher::new();
2235    normalized.hash(&mut hasher);
2236    hasher.finish()
2237}
2238
2239/// Replace `needle` with `replacement` only at word boundaries.
2240/// A word boundary means the character before/after the match is not
2241/// alphanumeric or underscore (i.e. not an identifier character).
2242fn replace_at_word_boundaries(content: &str, needle: &str, replacement: &str) -> String {
2243    if needle.is_empty() {
2244        return content.to_string();
2245    }
2246    let bytes = content.as_bytes();
2247    let mut result = String::with_capacity(content.len());
2248    let mut i = 0;
2249    while i < content.len() {
2250        if content.is_char_boundary(i) && content[i..].starts_with(needle) {
2251            let before_ok = i == 0 || {
2252                let prev_idx = content[..i]
2253                    .char_indices()
2254                    .next_back()
2255                    .map(|(idx, _)| idx)
2256                    .unwrap_or(0);
2257                !is_ident_char(bytes[prev_idx])
2258            };
2259            let after_idx = i + needle.len();
2260            let after_ok = after_idx >= content.len()
2261                || (content.is_char_boundary(after_idx)
2262                    && !is_ident_char(bytes[after_idx]));
2263            if before_ok && after_ok {
2264                result.push_str(replacement);
2265                i += needle.len();
2266                continue;
2267            }
2268        }
2269        if content.is_char_boundary(i) {
2270            let ch = content[i..].chars().next().unwrap();
2271            result.push(ch);
2272            i += ch.len_utf8();
2273        } else {
2274            i += 1;
2275        }
2276    }
2277    result
2278}
2279
2280fn is_ident_char(b: u8) -> bool {
2281    b.is_ascii_alphanumeric() || b == b'_'
2282}
2283
2284/// Build a rename map from new_id → base_id using confidence-scored matching.
2285///
2286/// Detects when an entity in the branch has the same body as an entity
2287/// in base but a different name/ID, indicating it was renamed.
2288/// Uses body_hash (name-stripped content hash) and structural_hash with
2289/// confidence scoring to resolve ambiguous matches correctly.
2290fn build_rename_map(
2291    base_entities: &[SemanticEntity],
2292    branch_entities: &[SemanticEntity],
2293) -> HashMap<String, String> {
2294    let mut rename_map: HashMap<String, String> = HashMap::new();
2295
2296    let base_ids: HashSet<&str> = base_entities.iter().map(|e| e.id.as_str()).collect();
2297
2298    // Build body_hash → base entities (multiple can have same hash)
2299    let mut base_by_body: HashMap<u64, Vec<&SemanticEntity>> = HashMap::new();
2300    for entity in base_entities {
2301        base_by_body.entry(body_hash(entity)).or_default().push(entity);
2302    }
2303
2304    // Also keep structural_hash index as fallback
2305    let mut base_by_structural: HashMap<&str, Vec<&SemanticEntity>> = HashMap::new();
2306    for entity in base_entities {
2307        if let Some(ref sh) = entity.structural_hash {
2308            base_by_structural.entry(sh.as_str()).or_default().push(entity);
2309        }
2310    }
2311
2312    // Collect all candidate (branch_entity, base_entity, confidence) triples
2313    struct RenameCandidate<'a> {
2314        branch: &'a SemanticEntity,
2315        base: &'a SemanticEntity,
2316        confidence: f64,
2317    }
2318    let mut candidates: Vec<RenameCandidate> = Vec::new();
2319
2320    for branch_entity in branch_entities {
2321        if base_ids.contains(branch_entity.id.as_str()) {
2322            continue;
2323        }
2324
2325        let bh = body_hash(branch_entity);
2326
2327        // Body hash matches
2328        if let Some(base_entities_for_hash) = base_by_body.get(&bh) {
2329            for &base_entity in base_entities_for_hash {
2330                let same_type = base_entity.entity_type == branch_entity.entity_type;
2331                let same_parent = base_entity.parent_id == branch_entity.parent_id;
2332                let confidence = match (same_type, same_parent) {
2333                    (true, true) => 0.95,
2334                    (true, false) => 0.8,
2335                    (false, _) => 0.6,
2336                };
2337                candidates.push(RenameCandidate { branch: branch_entity, base: base_entity, confidence });
2338            }
2339        }
2340
2341        // Structural hash fallback (lower confidence)
2342        if let Some(ref sh) = branch_entity.structural_hash {
2343            if let Some(base_entities_for_sh) = base_by_structural.get(sh.as_str()) {
2344                for &base_entity in base_entities_for_sh {
2345                    // Skip if already covered by body hash match
2346                    if candidates.iter().any(|c| c.branch.id == branch_entity.id && c.base.id == base_entity.id) {
2347                        continue;
2348                    }
2349                    candidates.push(RenameCandidate { branch: branch_entity, base: base_entity, confidence: 0.6 });
2350                }
2351            }
2352        }
2353
2354        // Token similarity fallback: catches renames where internal references were also updated
2355        // (e.g. IDE "rename symbol" changes the name everywhere in the body).
2356        // Only check same-file, same-type entities not already matched above.
2357        let has_candidate = candidates.iter().any(|c| c.branch.id == branch_entity.id);
2358        if !has_candidate {
2359            for base_entity in base_entities {
2360                if base_entity.entity_type != branch_entity.entity_type {
2361                    continue;
2362                }
2363                if base_entity.file_path != branch_entity.file_path {
2364                    continue;
2365                }
2366                // Skip if base entity still exists in branch (not actually deleted/renamed)
2367                if branch_entities.iter().any(|e| e.id == base_entity.id) {
2368                    continue;
2369                }
2370                let similarity = token_jaccard(&base_entity.content, &branch_entity.content);
2371                if similarity >= 0.7 {
2372                    let same_parent = base_entity.parent_id == branch_entity.parent_id;
2373                    let confidence = if same_parent { 0.75 } else { 0.65 };
2374                    candidates.push(RenameCandidate { branch: branch_entity, base: base_entity, confidence });
2375                }
2376            }
2377        }
2378    }
2379
2380    // Sort by confidence descending, assign greedily
2381    candidates.sort_by(|a, b| b.confidence.partial_cmp(&a.confidence).unwrap_or(std::cmp::Ordering::Equal));
2382
2383    let mut used_base_ids: HashSet<String> = HashSet::new();
2384    let mut used_branch_ids: HashSet<String> = HashSet::new();
2385
2386    for candidate in &candidates {
2387        if candidate.confidence < 0.6 {
2388            break;
2389        }
2390        if used_base_ids.contains(&candidate.base.id) || used_branch_ids.contains(&candidate.branch.id) {
2391            continue;
2392        }
2393        // Don't rename if the base entity's ID still exists in branch (it wasn't actually renamed)
2394        let base_id_in_branch = branch_entities.iter().any(|e| e.id == candidate.base.id);
2395        if base_id_in_branch {
2396            continue;
2397        }
2398        rename_map.insert(candidate.branch.id.clone(), candidate.base.id.clone());
2399        used_base_ids.insert(candidate.base.id.clone());
2400        used_branch_ids.insert(candidate.branch.id.clone());
2401    }
2402
2403    rename_map
2404}
2405
2406/// Check if an entity type is a container that may benefit from inner entity merge.
2407fn is_container_entity_type(entity_type: &str) -> bool {
2408    matches!(
2409        entity_type,
2410        "class" | "interface" | "enum" | "impl" | "trait" | "module" | "impl_item" | "trait_item"
2411            | "struct" | "union" | "namespace" | "struct_item" | "struct_specifier"
2412            | "variable" | "export"
2413    )
2414}
2415
2416/// A named member chunk extracted from a class/container body.
2417#[derive(Debug, Clone)]
2418struct MemberChunk {
2419    /// The member name (method name, field name, etc.)
2420    name: String,
2421    /// Full content of the member including its body
2422    content: String,
2423}
2424
2425/// Result of an inner entity merge attempt.
2426struct InnerMergeResult {
2427    /// Merged content (may contain per-member conflict markers)
2428    content: String,
2429    /// Whether any members had conflicts
2430    has_conflicts: bool,
2431}
2432
2433/// Convert sem-core child entities to MemberChunks for inner merge.
2434///
2435/// Uses child entity line positions to extract content from the container text,
2436/// including any leading decorators/annotations that tree-sitter attaches as
2437/// sibling nodes rather than part of the method node.
2438fn children_to_chunks(
2439    children: &[&SemanticEntity],
2440    container_content: &str,
2441    container_start_line: usize,
2442) -> Vec<MemberChunk> {
2443    if children.is_empty() {
2444        return Vec::new();
2445    }
2446
2447    let lines: Vec<&str> = container_content.lines().collect();
2448    let mut chunks = Vec::new();
2449
2450    for (i, child) in children.iter().enumerate() {
2451        let child_start_idx = child.start_line.saturating_sub(container_start_line);
2452        // +1 because end_line is inclusive but we need an exclusive upper bound for slicing
2453        let child_end_idx = child.end_line.saturating_sub(container_start_line) + 1;
2454
2455        if child_end_idx > lines.len() + 1 || child_start_idx >= lines.len() {
2456            // Position out of range, fall back to entity content
2457            chunks.push(MemberChunk {
2458                name: child.name.clone(),
2459                content: child.content.clone(),
2460            });
2461            continue;
2462        }
2463        let child_end_idx = child_end_idx.min(lines.len());
2464
2465        // Determine the earliest line we can claim (after previous child's end, or body start)
2466        let floor = if i > 0 {
2467            children[i - 1].end_line.saturating_sub(container_start_line) + 1
2468        } else {
2469            // First child: start after the container header line (the `{` or `:` line)
2470            // For Python-style containers, find the declaration line ending with `:`
2471            // For brace-delimited, find the opening `{` on a declaration line
2472            let header_end = if is_python_style_container(&lines) {
2473                lines
2474                    .iter()
2475                    .position(|l| {
2476                        let t = l.trim();
2477                        (t.starts_with("class ") || t.starts_with("def ") || t.starts_with("async def "))
2478                            && t.ends_with(':')
2479                    })
2480                    .map(|p| p + 1)
2481                    .unwrap_or(0)
2482            } else {
2483                lines
2484                    .iter()
2485                    .position(|l| l.contains('{'))
2486                    .map(|p| p + 1)
2487                    .unwrap_or(0)
2488            };
2489            header_end
2490        };
2491
2492        // Scan backwards from child_start_idx to include decorators/annotations/comments
2493        let mut content_start = child_start_idx;
2494        while content_start > floor {
2495            let prev = content_start - 1;
2496            let trimmed = lines[prev].trim();
2497            if trimmed.starts_with('@')
2498                || trimmed.starts_with("#[")
2499                || trimmed.starts_with("//")
2500                || trimmed.starts_with("///")
2501                || trimmed.starts_with("/**")
2502                || trimmed.starts_with("* ")
2503                || trimmed == "*/"
2504            {
2505                content_start = prev;
2506            } else if trimmed.is_empty() && content_start > floor + 1 {
2507                // Allow one blank line between decorator and method
2508                content_start = prev;
2509            } else {
2510                break;
2511            }
2512        }
2513
2514        // Skip leading blank lines
2515        while content_start < child_start_idx && lines[content_start].trim().is_empty() {
2516            content_start += 1;
2517        }
2518
2519        let chunk_content: String = lines[content_start..child_end_idx].join("\n");
2520        chunks.push(MemberChunk {
2521            name: child.name.clone(),
2522            content: chunk_content,
2523        });
2524    }
2525
2526    chunks
2527}
2528
2529/// Generate a scoped conflict marker for a single member within a container merge.
2530fn scoped_conflict_marker(
2531    name: &str,
2532    base: Option<&str>,
2533    ours: Option<&str>,
2534    theirs: Option<&str>,
2535    ours_deleted: bool,
2536    theirs_deleted: bool,
2537    fmt: &MarkerFormat,
2538) -> String {
2539    let open = "<".repeat(fmt.marker_length);
2540    let sep = "=".repeat(fmt.marker_length);
2541    let close = ">".repeat(fmt.marker_length);
2542
2543    let o = ours.unwrap_or("");
2544    let t = theirs.unwrap_or("");
2545
2546    // Narrow conflict markers to just the differing lines
2547    let ours_lines: Vec<&str> = o.lines().collect();
2548    let theirs_lines: Vec<&str> = t.lines().collect();
2549    let (prefix_len, suffix_len) = if ours.is_some() && theirs.is_some() {
2550        crate::conflict::narrow_conflict_lines(&ours_lines, &theirs_lines)
2551    } else {
2552        (0, 0)
2553    };
2554    let has_narrowing = prefix_len > 0 || suffix_len > 0;
2555    let ours_mid = &ours_lines[prefix_len..ours_lines.len() - suffix_len];
2556    let theirs_mid = &theirs_lines[prefix_len..theirs_lines.len() - suffix_len];
2557
2558    let mut out = String::new();
2559
2560    // Emit common prefix as clean text
2561    if has_narrowing {
2562        for line in &ours_lines[..prefix_len] {
2563            out.push_str(line);
2564            out.push('\n');
2565        }
2566    }
2567
2568    // Opening marker
2569    if fmt.enhanced {
2570        if ours_deleted {
2571            out.push_str(&format!("{} ours ({} deleted)\n", open, name));
2572        } else {
2573            out.push_str(&format!("{} ours ({})\n", open, name));
2574        }
2575    } else {
2576        out.push_str(&format!("{} ours\n", open));
2577    }
2578
2579    // Ours content (narrowed or full)
2580    if ours.is_some() {
2581        if has_narrowing {
2582            for line in ours_mid {
2583                out.push_str(line);
2584                out.push('\n');
2585            }
2586        } else {
2587            out.push_str(o);
2588            if !o.ends_with('\n') {
2589                out.push('\n');
2590            }
2591        }
2592    }
2593
2594    // Base section for diff3 format (standard mode only)
2595    if !fmt.enhanced {
2596        let base_marker = "|".repeat(fmt.marker_length);
2597        out.push_str(&format!("{} base\n", base_marker));
2598        let b = base.unwrap_or("");
2599        if has_narrowing {
2600            let base_lines: Vec<&str> = b.lines().collect();
2601            let base_prefix = prefix_len.min(base_lines.len());
2602            let base_suffix = suffix_len.min(base_lines.len().saturating_sub(base_prefix));
2603            for line in &base_lines[base_prefix..base_lines.len() - base_suffix] {
2604                out.push_str(line);
2605                out.push('\n');
2606            }
2607        } else {
2608            out.push_str(b);
2609            if !b.is_empty() && !b.ends_with('\n') {
2610                out.push('\n');
2611            }
2612        }
2613    }
2614
2615    // Separator
2616    out.push_str(&format!("{}\n", sep));
2617
2618    // Theirs content (narrowed or full)
2619    if theirs.is_some() {
2620        if has_narrowing {
2621            for line in theirs_mid {
2622                out.push_str(line);
2623                out.push('\n');
2624            }
2625        } else {
2626            out.push_str(t);
2627            if !t.ends_with('\n') {
2628                out.push('\n');
2629            }
2630        }
2631    }
2632
2633    // Closing marker
2634    if fmt.enhanced {
2635        if theirs_deleted {
2636            out.push_str(&format!("{} theirs ({} deleted)\n", close, name));
2637        } else {
2638            out.push_str(&format!("{} theirs ({})\n", close, name));
2639        }
2640    } else {
2641        out.push_str(&format!("{} theirs\n", close));
2642    }
2643
2644    // Emit common suffix as clean text
2645    if has_narrowing {
2646        for line in &ours_lines[ours_lines.len() - suffix_len..] {
2647            out.push_str(line);
2648            out.push('\n');
2649        }
2650    }
2651
2652    out
2653}
2654
2655/// Try recursive inner entity merge for container types (classes, impls, etc.).
2656///
2657/// Inspired by LastMerge (arXiv:2507.19687): class members are "unordered children" —
2658/// reordering them is not a conflict. We chunk the class body into members, match by
2659/// name, and merge each member independently.
2660///
2661/// Returns Some(result) if chunking succeeded, None if we can't parse the container.
2662/// The result may contain per-member conflict markers (scoped conflicts).
2663fn try_inner_entity_merge(
2664    base: &str,
2665    ours: &str,
2666    theirs: &str,
2667    base_children: &[&SemanticEntity],
2668    ours_children: &[&SemanticEntity],
2669    theirs_children: &[&SemanticEntity],
2670    base_start_line: usize,
2671    ours_start_line: usize,
2672    theirs_start_line: usize,
2673    marker_format: &MarkerFormat,
2674) -> Option<InnerMergeResult> {
2675    // Try sem-core child entities first (tree-sitter-accurate boundaries),
2676    // fall back to indentation heuristic if children aren't available.
2677    // When children_to_chunks produces chunks, try indentation as a fallback
2678    // if the tree-sitter chunks lead to conflicts (the indentation heuristic
2679    // can include trailing context that helps diffy merge adjacent changes).
2680    let use_children = !ours_children.is_empty() || !theirs_children.is_empty();
2681    let (base_chunks, ours_chunks, theirs_chunks) = if use_children {
2682        (
2683            children_to_chunks(base_children, base, base_start_line),
2684            children_to_chunks(ours_children, ours, ours_start_line),
2685            children_to_chunks(theirs_children, theirs, theirs_start_line),
2686        )
2687    } else {
2688        (
2689            extract_member_chunks(base)?,
2690            extract_member_chunks(ours)?,
2691            extract_member_chunks(theirs)?,
2692        )
2693    };
2694
2695    // Need at least 1 member to attempt inner merge
2696    // (Even single-member containers benefit from decorator-aware merge)
2697    if base_chunks.is_empty() && ours_chunks.is_empty() && theirs_chunks.is_empty() {
2698        return None;
2699    }
2700
2701    // Build name → content maps
2702    let base_map: HashMap<&str, &str> = base_chunks
2703        .iter()
2704        .map(|c| (c.name.as_str(), c.content.as_str()))
2705        .collect();
2706    let ours_map: HashMap<&str, &str> = ours_chunks
2707        .iter()
2708        .map(|c| (c.name.as_str(), c.content.as_str()))
2709        .collect();
2710    let theirs_map: HashMap<&str, &str> = theirs_chunks
2711        .iter()
2712        .map(|c| (c.name.as_str(), c.content.as_str()))
2713        .collect();
2714
2715    // Collect all member names
2716    let mut all_names: Vec<String> = Vec::new();
2717    let mut seen: HashSet<String> = HashSet::new();
2718    // Use ours ordering as skeleton
2719    for chunk in &ours_chunks {
2720        if seen.insert(chunk.name.clone()) {
2721            all_names.push(chunk.name.clone());
2722        }
2723    }
2724    // Add theirs-only members
2725    for chunk in &theirs_chunks {
2726        if seen.insert(chunk.name.clone()) {
2727            all_names.push(chunk.name.clone());
2728        }
2729    }
2730
2731    // Extract header/footer (class declaration line and closing brace)
2732    let (ours_header, ours_footer) = extract_container_wrapper(ours)?;
2733
2734    let mut merged_members: Vec<String> = Vec::new();
2735    let mut has_conflict = false;
2736
2737    for name in &all_names {
2738        let in_base = base_map.get(name.as_str());
2739        let in_ours = ours_map.get(name.as_str());
2740        let in_theirs = theirs_map.get(name.as_str());
2741
2742        match (in_base, in_ours, in_theirs) {
2743            // In all three
2744            (Some(b), Some(o), Some(t)) => {
2745                if o == t {
2746                    merged_members.push(o.to_string());
2747                } else if b == o {
2748                    merged_members.push(t.to_string());
2749                } else if b == t {
2750                    merged_members.push(o.to_string());
2751                } else {
2752                    // Both changed differently: try diffy, then git merge-file, then decorator merge
2753                    if let Some(merged) = diffy_merge(b, o, t) {
2754                        merged_members.push(merged);
2755                    } else if let Some(merged) = git_merge_string(b, o, t) {
2756                        merged_members.push(merged);
2757                    } else if let Some(merged) = try_decorator_aware_merge(b, o, t) {
2758                        merged_members.push(merged);
2759                    } else {
2760                        // Emit per-member conflict markers
2761                        has_conflict = true;
2762                        merged_members.push(scoped_conflict_marker(name, Some(b), Some(o), Some(t), false, false, marker_format));
2763                    }
2764                }
2765            }
2766            // Deleted by theirs, ours unchanged or not in base
2767            (Some(b), Some(o), None) => {
2768                if *b == *o {
2769                    // Ours unchanged, theirs deleted → accept deletion
2770                } else {
2771                    // Ours modified, theirs deleted → per-member conflict
2772                    has_conflict = true;
2773                    merged_members.push(scoped_conflict_marker(name, Some(b), Some(o), None, false, true, marker_format));
2774                }
2775            }
2776            // Deleted by ours, theirs unchanged or not in base
2777            (Some(b), None, Some(t)) => {
2778                if *b == *t {
2779                    // Theirs unchanged, ours deleted → accept deletion
2780                } else {
2781                    // Theirs modified, ours deleted → per-member conflict
2782                    has_conflict = true;
2783                    merged_members.push(scoped_conflict_marker(name, Some(b), None, Some(t), true, false, marker_format));
2784                }
2785            }
2786            // Added by ours only
2787            (None, Some(o), None) => {
2788                merged_members.push(o.to_string());
2789            }
2790            // Added by theirs only
2791            (None, None, Some(t)) => {
2792                merged_members.push(t.to_string());
2793            }
2794            // Added by both with different content
2795            (None, Some(o), Some(t)) => {
2796                if o == t {
2797                    merged_members.push(o.to_string());
2798                } else {
2799                    has_conflict = true;
2800                    merged_members.push(scoped_conflict_marker(name, None, Some(o), Some(t), false, false, marker_format));
2801                }
2802            }
2803            // Deleted by both
2804            (Some(_), None, None) => {}
2805            (None, None, None) => {}
2806        }
2807    }
2808
2809    // Reconstruct: header + merged members + footer
2810    let mut result = String::new();
2811    result.push_str(ours_header);
2812    if !ours_header.ends_with('\n') {
2813        result.push('\n');
2814    }
2815
2816    // Detect if members are single-line (fields, variants) vs multi-line (methods)
2817    let has_multiline_members = merged_members.iter().any(|m| m.contains('\n'));
2818    // Check if the original content had blank lines between members
2819    let original_has_blank_separators = {
2820        let body = ours_header.len()..ours.rfind(ours_footer).unwrap_or(ours.len());
2821        let body_content = &ours[body];
2822        body_content.contains("\n\n")
2823    };
2824
2825    for (i, member) in merged_members.iter().enumerate() {
2826        result.push_str(member);
2827        if !member.ends_with('\n') {
2828            result.push('\n');
2829        }
2830        // Add blank line between multi-line members only if the original had them
2831        if i < merged_members.len() - 1 && has_multiline_members && original_has_blank_separators && !member.ends_with("\n\n") {
2832            result.push('\n');
2833        }
2834    }
2835
2836    result.push_str(ours_footer);
2837    if !ours_footer.ends_with('\n') && ours.ends_with('\n') {
2838        result.push('\n');
2839    }
2840
2841    // If children_to_chunks led to conflicts, retry with indentation heuristic.
2842    // The indentation approach includes trailing blank lines in chunks, giving
2843    // diffy more context to merge adjacent changes from different branches.
2844    if has_conflict && use_children {
2845        if let (Some(bc), Some(oc), Some(tc)) = (
2846            extract_member_chunks(base),
2847            extract_member_chunks(ours),
2848            extract_member_chunks(theirs),
2849        ) {
2850            if !bc.is_empty() || !oc.is_empty() || !tc.is_empty() {
2851                let fallback = try_inner_merge_with_chunks(
2852                    &bc, &oc, &tc, ours, ours_header, ours_footer,
2853                    has_multiline_members, marker_format,
2854                );
2855                if let Some(fb) = fallback {
2856                    if !fb.has_conflicts {
2857                        return Some(fb);
2858                    }
2859                }
2860            }
2861        }
2862    }
2863
2864    Some(InnerMergeResult {
2865        content: result,
2866        has_conflicts: has_conflict,
2867    })
2868}
2869
2870/// Inner merge helper using pre-extracted chunks. Used for indentation-heuristic fallback.
2871fn try_inner_merge_with_chunks(
2872    base_chunks: &[MemberChunk],
2873    ours_chunks: &[MemberChunk],
2874    theirs_chunks: &[MemberChunk],
2875    ours: &str,
2876    ours_header: &str,
2877    ours_footer: &str,
2878    has_multiline_hint: bool,
2879    marker_format: &MarkerFormat,
2880) -> Option<InnerMergeResult> {
2881    let base_map: HashMap<&str, &str> = base_chunks.iter().map(|c| (c.name.as_str(), c.content.as_str())).collect();
2882    let ours_map: HashMap<&str, &str> = ours_chunks.iter().map(|c| (c.name.as_str(), c.content.as_str())).collect();
2883    let theirs_map: HashMap<&str, &str> = theirs_chunks.iter().map(|c| (c.name.as_str(), c.content.as_str())).collect();
2884
2885    let mut all_names: Vec<String> = Vec::new();
2886    let mut seen: HashSet<String> = HashSet::new();
2887    for chunk in ours_chunks {
2888        if seen.insert(chunk.name.clone()) {
2889            all_names.push(chunk.name.clone());
2890        }
2891    }
2892    for chunk in theirs_chunks {
2893        if seen.insert(chunk.name.clone()) {
2894            all_names.push(chunk.name.clone());
2895        }
2896    }
2897
2898    let mut merged_members: Vec<String> = Vec::new();
2899    let mut has_conflict = false;
2900
2901    for name in &all_names {
2902        let in_base = base_map.get(name.as_str());
2903        let in_ours = ours_map.get(name.as_str());
2904        let in_theirs = theirs_map.get(name.as_str());
2905
2906        match (in_base, in_ours, in_theirs) {
2907            (Some(b), Some(o), Some(t)) => {
2908                if o == t {
2909                    merged_members.push(o.to_string());
2910                } else if b == o {
2911                    merged_members.push(t.to_string());
2912                } else if b == t {
2913                    merged_members.push(o.to_string());
2914                } else if let Some(merged) = diffy_merge(b, o, t) {
2915                    merged_members.push(merged);
2916                } else if let Some(merged) = git_merge_string(b, o, t) {
2917                    merged_members.push(merged);
2918                } else {
2919                    has_conflict = true;
2920                    merged_members.push(scoped_conflict_marker(name, Some(b), Some(o), Some(t), false, false, marker_format));
2921                }
2922            }
2923            (Some(b), Some(o), None) => {
2924                if *b != *o { merged_members.push(o.to_string()); }
2925            }
2926            (Some(b), None, Some(t)) => {
2927                if *b != *t { merged_members.push(t.to_string()); }
2928            }
2929            (None, Some(o), None) => merged_members.push(o.to_string()),
2930            (None, None, Some(t)) => merged_members.push(t.to_string()),
2931            (None, Some(o), Some(t)) => {
2932                if o == t {
2933                    merged_members.push(o.to_string());
2934                } else {
2935                    has_conflict = true;
2936                    merged_members.push(scoped_conflict_marker(name, None, Some(o), Some(t), false, false, marker_format));
2937                }
2938            }
2939            (Some(_), None, None) | (None, None, None) => {}
2940        }
2941    }
2942
2943    let has_multiline_members = has_multiline_hint || merged_members.iter().any(|m| m.contains('\n'));
2944    let mut result = String::new();
2945    result.push_str(ours_header);
2946    if !ours_header.ends_with('\n') { result.push('\n'); }
2947    for (i, member) in merged_members.iter().enumerate() {
2948        result.push_str(member);
2949        if !member.ends_with('\n') { result.push('\n'); }
2950        if i < merged_members.len() - 1 && has_multiline_members && !member.ends_with("\n\n") {
2951            result.push('\n');
2952        }
2953    }
2954    result.push_str(ours_footer);
2955    if !ours_footer.ends_with('\n') && ours.ends_with('\n') { result.push('\n'); }
2956
2957    Some(InnerMergeResult {
2958        content: result,
2959        has_conflicts: has_conflict,
2960    })
2961}
2962
2963/// Extract the header (class declaration) and footer (closing brace) from a container.
2964/// Supports both brace-delimited (JS/TS/Java/Rust/C) and indentation-based (Python) containers.
2965/// Detect whether a container entity uses Python-style indentation (`:` terminated
2966/// declaration) or brace-delimited style (`{`). Only inspects the declaration
2967/// line(s), not the body, so dict literals / set comprehensions inside methods
2968/// don't cause a false negative.
2969fn is_python_style_container(lines: &[&str]) -> bool {
2970    // Find the first line that looks like a class/def/async def declaration
2971    let has_colon_decl = lines.iter().any(|l| {
2972        let trimmed = l.trim();
2973        (trimmed.starts_with("class ")
2974            || trimmed.starts_with("def ")
2975            || trimmed.starts_with("async def "))
2976            && trimmed.ends_with(':')
2977    });
2978    if !has_colon_decl {
2979        return false;
2980    }
2981    // Verify the container itself opens with `:`, not `{`.
2982    // Look at the declaration line (first line ending with `:` that is class/def).
2983    // If that line also contains `{` it's not Python style (edge case: shouldn't happen).
2984    if let Some(decl) = lines.iter().find(|l| {
2985        let t = l.trim();
2986        (t.starts_with("class ") || t.starts_with("def ") || t.starts_with("async def "))
2987            && t.ends_with(':')
2988    }) {
2989        !decl.contains('{')
2990    } else {
2991        false
2992    }
2993}
2994
2995fn extract_container_wrapper(content: &str) -> Option<(&str, &str)> {
2996    let lines: Vec<&str> = content.lines().collect();
2997    if lines.len() < 2 {
2998        return None;
2999    }
3000
3001    // Check if this is a Python-style container (ends with `:` instead of `{`)
3002    let is_python_style = is_python_style_container(&lines);
3003
3004    if is_python_style {
3005        // Python: header is the `class Foo:` line, no footer
3006        let header_end = lines.iter().position(|l| l.trim().ends_with(':'))?;
3007        let header_byte_end: usize = lines[..=header_end]
3008            .iter()
3009            .map(|l| l.len() + 1)
3010            .sum();
3011        let header = &content[..header_byte_end.min(content.len())];
3012        // No closing brace in Python — footer is empty
3013        let footer = &content[content.len()..];
3014        Some((header, footer))
3015    } else {
3016        // Brace-delimited: header up to `{`, footer from last `}`
3017        let header_end = lines.iter().position(|l| l.contains('{'))?;
3018        let header_byte_end = lines[..=header_end]
3019            .iter()
3020            .map(|l| l.len() + 1)
3021            .sum::<usize>();
3022        let header = &content[..header_byte_end.min(content.len())];
3023
3024        let footer_start = lines.iter().rposition(|l| {
3025            let trimmed = l.trim();
3026            trimmed == "}" || trimmed == "};"
3027        })?;
3028
3029        let footer_byte_start: usize = lines[..footer_start]
3030            .iter()
3031            .map(|l| l.len() + 1)
3032            .sum();
3033        let footer = &content[footer_byte_start.min(content.len())..];
3034
3035        Some((header, footer))
3036    }
3037}
3038
3039/// Extract named member chunks from a container body.
3040///
3041/// Identifies member boundaries by indentation: members start at the first
3042/// indentation level inside the container. Each member extends until the next
3043/// member starts or the container closes.
3044fn extract_member_chunks(content: &str) -> Option<Vec<MemberChunk>> {
3045    let lines: Vec<&str> = content.lines().collect();
3046    if lines.len() < 2 {
3047        return None;
3048    }
3049
3050    // Check if Python-style (indentation-based)
3051    let is_python_style = is_python_style_container(&lines);
3052
3053    // Find the body range
3054    let body_start = if is_python_style {
3055        lines.iter().position(|l| l.trim().ends_with(':'))? + 1
3056    } else {
3057        lines.iter().position(|l| l.contains('{'))? + 1
3058    };
3059    let body_end = if is_python_style {
3060        // Python: body extends to end of content
3061        lines.len()
3062    } else {
3063        lines.iter().rposition(|l| {
3064            let trimmed = l.trim();
3065            trimmed == "}" || trimmed == "};"
3066        })?
3067    };
3068
3069    if body_start >= body_end {
3070        return None;
3071    }
3072
3073    // Determine member indentation level by looking at first non-empty body line
3074    let member_indent = lines[body_start..body_end]
3075        .iter()
3076        .find(|l| !l.trim().is_empty())
3077        .map(|l| l.len() - l.trim_start().len())?;
3078
3079    let mut chunks: Vec<MemberChunk> = Vec::new();
3080    let mut current_chunk_lines: Vec<&str> = Vec::new();
3081    let mut current_name: Option<String> = None;
3082
3083    for line in &lines[body_start..body_end] {
3084        let trimmed = line.trim();
3085        if trimmed.is_empty() {
3086            // Blank lines: if we have a current chunk, include them
3087            if current_name.is_some() {
3088                // Only include if not trailing blanks
3089                current_chunk_lines.push(line);
3090            }
3091            continue;
3092        }
3093
3094        let indent = line.len() - line.trim_start().len();
3095
3096        // Is this a new member declaration at the member indent level?
3097        // Exclude closing braces, comments, and decorators/annotations
3098        if indent == member_indent
3099            && !trimmed.starts_with("//")
3100            && !trimmed.starts_with("/*")
3101            && !trimmed.starts_with("*")
3102            && !trimmed.starts_with("#")
3103            && !trimmed.starts_with("@")
3104            && !trimmed.starts_with("}")
3105            && trimmed != ","
3106        {
3107            // Save previous chunk
3108            if let Some(name) = current_name.take() {
3109                // Trim trailing blank lines
3110                while current_chunk_lines.last().map_or(false, |l| l.trim().is_empty()) {
3111                    current_chunk_lines.pop();
3112                }
3113                if !current_chunk_lines.is_empty() {
3114                    chunks.push(MemberChunk {
3115                        name,
3116                        content: current_chunk_lines.join("\n"),
3117                    });
3118                }
3119                current_chunk_lines.clear();
3120            }
3121
3122            // Start new chunk — extract member name
3123            let name = extract_member_name(trimmed);
3124            current_name = Some(name);
3125            current_chunk_lines.push(line);
3126        } else if current_name.is_some() {
3127            // Continuation of current member (body lines, nested blocks)
3128            current_chunk_lines.push(line);
3129        } else {
3130            // Content before first member (decorators, comments for first member)
3131            // Attach to next member
3132            current_chunk_lines.push(line);
3133        }
3134    }
3135
3136    // Save last chunk
3137    if let Some(name) = current_name {
3138        while current_chunk_lines.last().map_or(false, |l| l.trim().is_empty()) {
3139            current_chunk_lines.pop();
3140        }
3141        if !current_chunk_lines.is_empty() {
3142            chunks.push(MemberChunk {
3143                name,
3144                content: current_chunk_lines.join("\n"),
3145            });
3146        }
3147    }
3148
3149    // Post-process: if any chunk has a brace-only name (anonymous struct literal
3150    // entries like Go's `{ Name: "x", ... }`), derive a name from the first
3151    // key-value field inside the chunk to avoid HashMap collisions.
3152    for chunk in &mut chunks {
3153        if chunk.name == "{" || chunk.name == "{}" {
3154            if let Some(better) = derive_name_from_struct_literal(&chunk.content) {
3155                chunk.name = better;
3156            }
3157        }
3158    }
3159
3160    if chunks.is_empty() {
3161        None
3162    } else {
3163        Some(chunks)
3164    }
3165}
3166
3167/// Extract a member name from a declaration line.
3168fn extract_member_name(line: &str) -> String {
3169    let trimmed = line.trim();
3170
3171    // Go method receiver: `func (c *Calculator) Add(` -> skip receiver, find name before second `(`
3172    if trimmed.starts_with("func ") && trimmed.get(5..6) == Some("(") {
3173        // Skip past the receiver: find closing `)`, then extract name before next `(`
3174        if let Some(recv_close) = trimmed.find(')') {
3175            let after_recv = &trimmed[recv_close + 1..];
3176            if let Some(paren_pos) = after_recv.find('(') {
3177                let before = after_recv[..paren_pos].trim();
3178                let name: String = before
3179                    .chars()
3180                    .rev()
3181                    .take_while(|c| c.is_alphanumeric() || *c == '_')
3182                    .collect::<Vec<_>>()
3183                    .into_iter()
3184                    .rev()
3185                    .collect();
3186                if !name.is_empty() {
3187                    return name;
3188                }
3189            }
3190        }
3191    }
3192
3193    // Strategy 1: For method/function declarations with parentheses,
3194    // the name is the identifier immediately before `(`.
3195    // This handles all languages: Java `public int add(`, Rust `pub fn add(`,
3196    // Python `def add(`, TS `async getUser(`, Go `func add(`, etc.
3197    if let Some(paren_pos) = trimmed.find('(') {
3198        let before = trimmed[..paren_pos].trim_end();
3199        let name: String = before
3200            .chars()
3201            .rev()
3202            .take_while(|c| c.is_alphanumeric() || *c == '_')
3203            .collect::<Vec<_>>()
3204            .into_iter()
3205            .rev()
3206            .collect();
3207        if !name.is_empty() {
3208            return name;
3209        }
3210    }
3211
3212    // Strategy 2: For fields/properties/variants without parens,
3213    // strip keywords and take the first identifier.
3214    let mut s = trimmed;
3215    for keyword in &[
3216        "export ", "public ", "private ", "protected ", "static ",
3217        "abstract ", "async ", "override ", "readonly ",
3218        "pub ", "pub(crate) ", "fn ", "def ", "get ", "set ",
3219    ] {
3220        if s.starts_with(keyword) {
3221            s = &s[keyword.len()..];
3222        }
3223    }
3224    if s.starts_with("fn ") {
3225        s = &s[3..];
3226    }
3227
3228    let name: String = s
3229        .chars()
3230        .take_while(|c| c.is_alphanumeric() || *c == '_')
3231        .collect();
3232
3233    if name.is_empty() {
3234        trimmed.chars().take(20).collect()
3235    } else {
3236        name
3237    }
3238}
3239
3240/// For anonymous struct literal entries (e.g., Go slice entries starting with `{`),
3241/// derive a name from the first key-value field inside the chunk.
3242/// E.g., `{ Name: "panelTitleSearch", ... }` → `panelTitleSearch`
3243fn derive_name_from_struct_literal(content: &str) -> Option<String> {
3244    for line in content.lines().skip(1) {
3245        let trimmed = line.trim().trim_end_matches(',');
3246        // Look for `Key: "value"` or `Key: value` pattern
3247        if let Some(colon_pos) = trimmed.find(':') {
3248            let value = trimmed[colon_pos + 1..].trim();
3249            // Strip quotes from string values
3250            let value = value.trim_matches('"').trim_matches('\'');
3251            if !value.is_empty() {
3252                return Some(value.to_string());
3253            }
3254        }
3255    }
3256    None
3257}
3258
3259/// Returns true for data/config file formats where Sesame separator expansion
3260/// (`{`, `}`, `;`) is counterproductive because those chars are structural
3261/// content rather than code block separators.
3262///
3263/// Note: template files like .svelte/.vue are NOT included here because their
3264/// embedded `<script>` sections contain real code where Sesame helps.
3265/// Check if content looks binary (contains null bytes in first 8KB).
3266fn is_binary(content: &str) -> bool {
3267    content.as_bytes().iter().take(8192).any(|&b| b == 0)
3268}
3269
3270/// Check if content already contains git conflict markers.
3271/// This happens with AU/AA conflicts where git stores markers in stage blobs.
3272fn has_conflict_markers(content: &str) -> bool {
3273    content.contains("<<<<<<<") && content.contains(">>>>>>>")
3274}
3275
3276fn skip_sesame(file_path: &str) -> bool {
3277    let path_lower = file_path.to_lowercase();
3278    let extensions = [
3279        // Data/config formats
3280        ".json", ".yaml", ".yml", ".toml", ".lock", ".xml", ".csv", ".tsv",
3281        ".ini", ".cfg", ".conf", ".properties", ".env",
3282        // Markup/document formats
3283        ".md", ".markdown", ".txt", ".rst", ".svg", ".html", ".htm",
3284    ];
3285    extensions.iter().any(|ext| path_lower.ends_with(ext))
3286}
3287
3288/// Expand syntactic separators into separate lines for finer merge alignment.
3289/// Inspired by Sesame (arXiv:2407.18888): isolating separators lets line-based
3290/// merge tools see block boundaries as independent change units.
3291/// Uses byte-level iteration since separators ({, }, ;) and string delimiters
3292/// (", ', `) are all ASCII.
3293fn expand_separators(content: &str) -> String {
3294    let bytes = content.as_bytes();
3295    let mut result = Vec::with_capacity(content.len() * 2);
3296    let mut in_string = false;
3297    let mut escape_next = false;
3298    let mut string_char = b'"';
3299
3300    for &b in bytes {
3301        if escape_next {
3302            result.push(b);
3303            escape_next = false;
3304            continue;
3305        }
3306        if b == b'\\' && in_string {
3307            result.push(b);
3308            escape_next = true;
3309            continue;
3310        }
3311        if !in_string && (b == b'"' || b == b'\'' || b == b'`') {
3312            in_string = true;
3313            string_char = b;
3314            result.push(b);
3315            continue;
3316        }
3317        if in_string && b == string_char {
3318            in_string = false;
3319            result.push(b);
3320            continue;
3321        }
3322
3323        if !in_string && (b == b'{' || b == b'}' || b == b';') {
3324            if result.last() != Some(&b'\n') && !result.is_empty() {
3325                result.push(b'\n');
3326            }
3327            result.push(b);
3328            result.push(b'\n');
3329        } else {
3330            result.push(b);
3331        }
3332    }
3333
3334    // Safe: we only inserted ASCII bytes into valid UTF-8 content
3335    unsafe { String::from_utf8_unchecked(result) }
3336}
3337
3338/// Collapse separator expansion back to original formatting.
3339/// Uses the base formatting as a guide where possible.
3340fn collapse_separators(merged: &str, _base: &str) -> String {
3341    // Simple approach: join lines that contain only a separator with adjacent lines
3342    let lines: Vec<&str> = merged.lines().collect();
3343    let mut result = String::new();
3344    let mut i = 0;
3345
3346    while i < lines.len() {
3347        let trimmed = lines[i].trim();
3348        if (trimmed == "{" || trimmed == "}" || trimmed == ";") && trimmed.len() == 1 {
3349            // This is a separator-only line we may have created
3350            // Try to join with previous line if it doesn't end with a separator
3351            if !result.is_empty() && !result.ends_with('\n') {
3352                // Peek: if it's an opening brace, join with previous
3353                if trimmed == "{" {
3354                    result.push(' ');
3355                    result.push_str(trimmed);
3356                    result.push('\n');
3357                } else if trimmed == "}" {
3358                    result.push('\n');
3359                    result.push_str(trimmed);
3360                    result.push('\n');
3361                } else {
3362                    result.push_str(trimmed);
3363                    result.push('\n');
3364                }
3365            } else {
3366                result.push_str(lines[i]);
3367                result.push('\n');
3368            }
3369        } else {
3370            result.push_str(lines[i]);
3371            result.push('\n');
3372        }
3373        i += 1;
3374    }
3375
3376    // Trim any trailing extra newlines to match original style
3377    while result.ends_with("\n\n") {
3378        result.pop();
3379    }
3380
3381    result
3382}
3383
3384#[cfg(test)]
3385mod tests {
3386    use super::*;
3387
3388    #[test]
3389    fn test_replace_at_word_boundaries() {
3390        // Should replace standalone occurrences
3391        assert_eq!(replace_at_word_boundaries("fn get() {}", "get", "__E__"), "fn __E__() {}");
3392        // Should NOT replace inside longer identifiers
3393        assert_eq!(replace_at_word_boundaries("fn getAll() {}", "get", "__E__"), "fn getAll() {}");
3394        assert_eq!(replace_at_word_boundaries("fn _get() {}", "get", "__E__"), "fn _get() {}");
3395        // Should replace multiple standalone occurrences
3396        assert_eq!(
3397            replace_at_word_boundaries("pub enum Source { Source }", "Source", "__E__"),
3398            "pub enum __E__ { __E__ }"
3399        );
3400        // Should not replace substring at start/end of identifiers
3401        assert_eq!(
3402            replace_at_word_boundaries("SourceManager isSource", "Source", "__E__"),
3403            "SourceManager isSource"
3404        );
3405        // Should handle multi-byte UTF-8 characters (emojis) without panicking
3406        assert_eq!(
3407            replace_at_word_boundaries("❌ get ✅", "get", "__E__"),
3408            "❌ __E__ ✅"
3409        );
3410        assert_eq!(
3411            replace_at_word_boundaries("fn 名前() { get }", "get", "__E__"),
3412            "fn 名前() { __E__ }"
3413        );
3414        // Emoji-only content with no needle match should pass through unchanged
3415        assert_eq!(
3416            replace_at_word_boundaries("🎉🚀✨", "get", "__E__"),
3417            "🎉🚀✨"
3418        );
3419    }
3420
3421    #[test]
3422    fn test_fast_path_identical() {
3423        let content = "hello world";
3424        let result = entity_merge(content, content, content, "test.ts");
3425        assert!(result.is_clean());
3426        assert_eq!(result.content, content);
3427    }
3428
3429    #[test]
3430    fn test_fast_path_only_ours_changed() {
3431        let base = "hello";
3432        let ours = "hello world";
3433        let result = entity_merge(base, ours, base, "test.ts");
3434        assert!(result.is_clean());
3435        assert_eq!(result.content, ours);
3436    }
3437
3438    #[test]
3439    fn test_fast_path_only_theirs_changed() {
3440        let base = "hello";
3441        let theirs = "hello world";
3442        let result = entity_merge(base, base, theirs, "test.ts");
3443        assert!(result.is_clean());
3444        assert_eq!(result.content, theirs);
3445    }
3446
3447    #[test]
3448    fn test_different_functions_no_conflict() {
3449        // Core value prop: two agents add different functions to the same file
3450        let base = r#"export function existing() {
3451    return 1;
3452}
3453"#;
3454        let ours = r#"export function existing() {
3455    return 1;
3456}
3457
3458export function agentA() {
3459    return "added by agent A";
3460}
3461"#;
3462        let theirs = r#"export function existing() {
3463    return 1;
3464}
3465
3466export function agentB() {
3467    return "added by agent B";
3468}
3469"#;
3470        let result = entity_merge(base, ours, theirs, "test.ts");
3471        assert!(
3472            result.is_clean(),
3473            "Should auto-resolve: different functions added. Conflicts: {:?}",
3474            result.conflicts
3475        );
3476        assert!(
3477            result.content.contains("agentA"),
3478            "Should contain agentA function"
3479        );
3480        assert!(
3481            result.content.contains("agentB"),
3482            "Should contain agentB function"
3483        );
3484    }
3485
3486    #[test]
3487    fn test_same_function_modified_by_both_conflict() {
3488        let base = r#"export function shared() {
3489    return "original";
3490}
3491"#;
3492        let ours = r#"export function shared() {
3493    return "modified by ours";
3494}
3495"#;
3496        let theirs = r#"export function shared() {
3497    return "modified by theirs";
3498}
3499"#;
3500        let result = entity_merge(base, ours, theirs, "test.ts");
3501        // This should be a conflict since both modified the same function incompatibly
3502        assert!(
3503            !result.is_clean(),
3504            "Should conflict when both modify same function differently"
3505        );
3506        assert_eq!(result.conflicts.len(), 1);
3507        assert_eq!(result.conflicts[0].entity_name, "shared");
3508    }
3509
3510    #[test]
3511    fn test_fallback_for_unknown_filetype() {
3512        // Non-adjacent changes should merge cleanly with line-level merge
3513        let base = "line 1\nline 2\nline 3\nline 4\nline 5\n";
3514        let ours = "line 1 modified\nline 2\nline 3\nline 4\nline 5\n";
3515        let theirs = "line 1\nline 2\nline 3\nline 4\nline 5 modified\n";
3516        let result = entity_merge(base, ours, theirs, "test.xyz");
3517        assert!(
3518            result.is_clean(),
3519            "Non-adjacent changes should merge cleanly. Conflicts: {:?}",
3520            result.conflicts,
3521        );
3522    }
3523
3524    #[test]
3525    fn test_line_level_fallback() {
3526        // Non-adjacent changes merge cleanly in 3-way merge
3527        let base = "a\nb\nc\nd\ne\n";
3528        let ours = "A\nb\nc\nd\ne\n";
3529        let theirs = "a\nb\nc\nd\nE\n";
3530        let result = line_level_fallback(base, ours, theirs, "test.rs");
3531        assert!(result.is_clean());
3532        assert!(result.stats.used_fallback);
3533        assert_eq!(result.content, "A\nb\nc\nd\nE\n");
3534    }
3535
3536    #[test]
3537    fn test_line_level_fallback_conflict() {
3538        // Same line changed differently → conflict
3539        let base = "a\nb\nc\n";
3540        let ours = "X\nb\nc\n";
3541        let theirs = "Y\nb\nc\n";
3542        let result = line_level_fallback(base, ours, theirs, "test.rs");
3543        assert!(!result.is_clean());
3544        assert!(result.stats.used_fallback);
3545    }
3546
3547    #[test]
3548    fn test_expand_separators() {
3549        let code = "function foo() { return 1; }";
3550        let expanded = expand_separators(code);
3551        // Separators should be on their own lines
3552        assert!(expanded.contains("{\n"), "Opening brace should have newline after");
3553        assert!(expanded.contains(";\n"), "Semicolons should have newline after");
3554        assert!(expanded.contains("\n}"), "Closing brace should have newline before");
3555    }
3556
3557    #[test]
3558    fn test_expand_separators_preserves_strings() {
3559        let code = r#"let x = "hello { world };";"#;
3560        let expanded = expand_separators(code);
3561        // Separators inside strings should NOT be expanded
3562        assert!(
3563            expanded.contains("\"hello { world };\""),
3564            "Separators in strings should be preserved: {}",
3565            expanded
3566        );
3567    }
3568
3569    #[test]
3570    fn test_is_import_region() {
3571        assert!(is_import_region("import foo from 'foo';\nimport bar from 'bar';\n"));
3572        assert!(is_import_region("use std::io;\nuse std::fs;\n"));
3573        assert!(!is_import_region("let x = 1;\nlet y = 2;\n"));
3574        // Mixed: 1 import + 2 non-imports → not import region
3575        assert!(!is_import_region("import foo from 'foo';\nlet x = 1;\nlet y = 2;\n"));
3576        // Empty → not import region
3577        assert!(!is_import_region(""));
3578    }
3579
3580    #[test]
3581    fn test_is_import_line() {
3582        // JS/TS
3583        assert!(is_import_line("import foo from 'foo';"));
3584        assert!(is_import_line("import { bar } from 'bar';"));
3585        assert!(is_import_line("from typing import List"));
3586        // Rust
3587        assert!(is_import_line("use std::io::Read;"));
3588        // C/C++
3589        assert!(is_import_line("#include <stdio.h>"));
3590        // Node require
3591        assert!(is_import_line("const fs = require('fs');"));
3592        // Not imports
3593        assert!(!is_import_line("let x = 1;"));
3594        assert!(!is_import_line("function foo() {}"));
3595    }
3596
3597    #[test]
3598    fn test_commutative_import_merge_both_add_different() {
3599        // The key scenario: both branches add different imports
3600        let base = "import a from 'a';\nimport b from 'b';\n";
3601        let ours = "import a from 'a';\nimport b from 'b';\nimport c from 'c';\n";
3602        let theirs = "import a from 'a';\nimport b from 'b';\nimport d from 'd';\n";
3603        let result = merge_imports_commutatively(base, ours, theirs);
3604        assert!(result.contains("import a from 'a';"));
3605        assert!(result.contains("import b from 'b';"));
3606        assert!(result.contains("import c from 'c';"));
3607        assert!(result.contains("import d from 'd';"));
3608    }
3609
3610    #[test]
3611    fn test_commutative_import_merge_one_removes() {
3612        // Ours removes an import, theirs keeps it → removed
3613        let base = "import a from 'a';\nimport b from 'b';\nimport c from 'c';\n";
3614        let ours = "import a from 'a';\nimport c from 'c';\n";
3615        let theirs = "import a from 'a';\nimport b from 'b';\nimport c from 'c';\n";
3616        let result = merge_imports_commutatively(base, ours, theirs);
3617        assert!(result.contains("import a from 'a';"));
3618        assert!(!result.contains("import b from 'b';"), "Removed import should stay removed");
3619        assert!(result.contains("import c from 'c';"));
3620    }
3621
3622    #[test]
3623    fn test_commutative_import_merge_both_add_same() {
3624        // Both add the same import → should appear only once
3625        let base = "import a from 'a';\n";
3626        let ours = "import a from 'a';\nimport b from 'b';\n";
3627        let theirs = "import a from 'a';\nimport b from 'b';\n";
3628        let result = merge_imports_commutatively(base, ours, theirs);
3629        let count = result.matches("import b from 'b';").count();
3630        assert_eq!(count, 1, "Duplicate import should be deduplicated");
3631    }
3632
3633    #[test]
3634    fn test_inner_entity_merge_different_methods() {
3635        // Two agents modify different methods in the same class
3636        // This would normally conflict with diffy because the changes are adjacent
3637        let base = r#"export class Calculator {
3638    add(a: number, b: number): number {
3639        return a + b;
3640    }
3641
3642    subtract(a: number, b: number): number {
3643        return a - b;
3644    }
3645}
3646"#;
3647        let ours = r#"export class Calculator {
3648    add(a: number, b: number): number {
3649        // Added logging
3650        console.log("adding", a, b);
3651        return a + b;
3652    }
3653
3654    subtract(a: number, b: number): number {
3655        return a - b;
3656    }
3657}
3658"#;
3659        let theirs = r#"export class Calculator {
3660    add(a: number, b: number): number {
3661        return a + b;
3662    }
3663
3664    subtract(a: number, b: number): number {
3665        // Added validation
3666        if (b > a) throw new Error("negative");
3667        return a - b;
3668    }
3669}
3670"#;
3671        let result = entity_merge(base, ours, theirs, "test.ts");
3672        assert!(
3673            result.is_clean(),
3674            "Different methods modified should auto-merge via inner entity merge. Conflicts: {:?}",
3675            result.conflicts,
3676        );
3677        assert!(result.content.contains("console.log"), "Should contain ours changes");
3678        assert!(result.content.contains("negative"), "Should contain theirs changes");
3679    }
3680
3681    #[test]
3682    fn test_inner_entity_merge_both_add_different_methods() {
3683        // Both branches add different methods to the same class
3684        let base = r#"export class Calculator {
3685    add(a: number, b: number): number {
3686        return a + b;
3687    }
3688}
3689"#;
3690        let ours = r#"export class Calculator {
3691    add(a: number, b: number): number {
3692        return a + b;
3693    }
3694
3695    multiply(a: number, b: number): number {
3696        return a * b;
3697    }
3698}
3699"#;
3700        let theirs = r#"export class Calculator {
3701    add(a: number, b: number): number {
3702        return a + b;
3703    }
3704
3705    divide(a: number, b: number): number {
3706        return a / b;
3707    }
3708}
3709"#;
3710        let result = entity_merge(base, ours, theirs, "test.ts");
3711        assert!(
3712            result.is_clean(),
3713            "Both adding different methods should auto-merge. Conflicts: {:?}",
3714            result.conflicts,
3715        );
3716        assert!(result.content.contains("multiply"), "Should contain ours's new method");
3717        assert!(result.content.contains("divide"), "Should contain theirs's new method");
3718    }
3719
3720    #[test]
3721    fn test_inner_entity_merge_same_method_modified_still_conflicts() {
3722        // Both modify the same method differently → should still conflict
3723        let base = r#"export class Calculator {
3724    add(a: number, b: number): number {
3725        return a + b;
3726    }
3727
3728    subtract(a: number, b: number): number {
3729        return a - b;
3730    }
3731}
3732"#;
3733        let ours = r#"export class Calculator {
3734    add(a: number, b: number): number {
3735        return a + b + 1;
3736    }
3737
3738    subtract(a: number, b: number): number {
3739        return a - b;
3740    }
3741}
3742"#;
3743        let theirs = r#"export class Calculator {
3744    add(a: number, b: number): number {
3745        return a + b + 2;
3746    }
3747
3748    subtract(a: number, b: number): number {
3749        return a - b;
3750    }
3751}
3752"#;
3753        let result = entity_merge(base, ours, theirs, "test.ts");
3754        assert!(
3755            !result.is_clean(),
3756            "Both modifying same method differently should still conflict"
3757        );
3758    }
3759
3760    #[test]
3761    fn test_extract_member_chunks() {
3762        let class_body = r#"export class Foo {
3763    bar() {
3764        return 1;
3765    }
3766
3767    baz() {
3768        return 2;
3769    }
3770}
3771"#;
3772        let chunks = extract_member_chunks(class_body).unwrap();
3773        assert_eq!(chunks.len(), 2, "Should find 2 members, found {:?}", chunks.iter().map(|c| &c.name).collect::<Vec<_>>());
3774        assert_eq!(chunks[0].name, "bar");
3775        assert_eq!(chunks[1].name, "baz");
3776    }
3777
3778    #[test]
3779    fn test_extract_member_name() {
3780        assert_eq!(extract_member_name("add(a, b) {"), "add");
3781        assert_eq!(extract_member_name("fn add(&self, a: i32) -> i32 {"), "add");
3782        assert_eq!(extract_member_name("def add(self, a, b):"), "add");
3783        assert_eq!(extract_member_name("public static getValue(): number {"), "getValue");
3784        assert_eq!(extract_member_name("async fetchData() {"), "fetchData");
3785    }
3786
3787    #[test]
3788    fn test_commutative_import_merge_rust_use() {
3789        let base = "use std::io;\nuse std::fs;\n";
3790        let ours = "use std::io;\nuse std::fs;\nuse std::path::Path;\n";
3791        let theirs = "use std::io;\nuse std::fs;\nuse std::collections::HashMap;\n";
3792        let result = merge_imports_commutatively(base, ours, theirs);
3793        assert!(result.contains("use std::path::Path;"));
3794        assert!(result.contains("use std::collections::HashMap;"));
3795        assert!(result.contains("use std::io;"));
3796        assert!(result.contains("use std::fs;"));
3797    }
3798
3799    #[test]
3800    fn test_is_whitespace_only_diff_true() {
3801        // Same content, different indentation
3802        assert!(is_whitespace_only_diff(
3803            "    return 1;\n    return 2;\n",
3804            "      return 1;\n      return 2;\n"
3805        ));
3806        // Same content, extra blank lines
3807        assert!(is_whitespace_only_diff(
3808            "return 1;\nreturn 2;\n",
3809            "return 1;\n\nreturn 2;\n"
3810        ));
3811    }
3812
3813    #[test]
3814    fn test_is_whitespace_only_diff_false() {
3815        // Different content
3816        assert!(!is_whitespace_only_diff(
3817            "    return 1;\n",
3818            "    return 2;\n"
3819        ));
3820        // Added code
3821        assert!(!is_whitespace_only_diff(
3822            "return 1;\n",
3823            "return 1;\nconsole.log('x');\n"
3824        ));
3825    }
3826
3827    #[test]
3828    fn test_ts_interface_both_add_different_fields() {
3829        let base = "interface Config {\n    name: string;\n}\n";
3830        let ours = "interface Config {\n    name: string;\n    age: number;\n}\n";
3831        let theirs = "interface Config {\n    name: string;\n    email: string;\n}\n";
3832        let result = entity_merge(base, ours, theirs, "test.ts");
3833        eprintln!("TS interface: clean={}, conflicts={:?}", result.is_clean(), result.conflicts);
3834        eprintln!("Content: {:?}", result.content);
3835        assert!(
3836            result.is_clean(),
3837            "Both adding different fields to TS interface should merge. Conflicts: {:?}",
3838            result.conflicts,
3839        );
3840        assert!(result.content.contains("age"));
3841        assert!(result.content.contains("email"));
3842    }
3843
3844    #[test]
3845    fn test_rust_enum_both_add_different_variants() {
3846        let base = "enum Color {\n    Red,\n    Blue,\n}\n";
3847        let ours = "enum Color {\n    Red,\n    Blue,\n    Green,\n}\n";
3848        let theirs = "enum Color {\n    Red,\n    Blue,\n    Yellow,\n}\n";
3849        let result = entity_merge(base, ours, theirs, "test.rs");
3850        eprintln!("Rust enum: clean={}, conflicts={:?}", result.is_clean(), result.conflicts);
3851        eprintln!("Content: {:?}", result.content);
3852        assert!(
3853            result.is_clean(),
3854            "Both adding different enum variants should merge. Conflicts: {:?}",
3855            result.conflicts,
3856        );
3857        assert!(result.content.contains("Green"));
3858        assert!(result.content.contains("Yellow"));
3859    }
3860
3861    #[test]
3862    fn test_python_both_add_different_decorators() {
3863        // Both add different decorators to the same function
3864        let base = "def foo():\n    return 1\n\ndef bar():\n    return 2\n";
3865        let ours = "@cache\ndef foo():\n    return 1\n\ndef bar():\n    return 2\n";
3866        let theirs = "@deprecated\ndef foo():\n    return 1\n\ndef bar():\n    return 2\n";
3867        let result = entity_merge(base, ours, theirs, "test.py");
3868        assert!(
3869            result.is_clean(),
3870            "Both adding different decorators should merge. Conflicts: {:?}",
3871            result.conflicts,
3872        );
3873        assert!(result.content.contains("@cache"));
3874        assert!(result.content.contains("@deprecated"));
3875        assert!(result.content.contains("def foo()"));
3876    }
3877
3878    #[test]
3879    fn test_decorator_plus_body_change() {
3880        // One adds decorator, other modifies body — should merge both
3881        let base = "def foo():\n    return 1\n";
3882        let ours = "@cache\ndef foo():\n    return 1\n";
3883        let theirs = "def foo():\n    return 42\n";
3884        let result = entity_merge(base, ours, theirs, "test.py");
3885        assert!(
3886            result.is_clean(),
3887            "Decorator + body change should merge. Conflicts: {:?}",
3888            result.conflicts,
3889        );
3890        assert!(result.content.contains("@cache"));
3891        assert!(result.content.contains("return 42"));
3892    }
3893
3894    #[test]
3895    fn test_ts_class_decorator_merge() {
3896        // TypeScript decorators on class methods — both add different decorators
3897        let base = "class Foo {\n    bar() {\n        return 1;\n    }\n}\n";
3898        let ours = "class Foo {\n    @Injectable()\n    bar() {\n        return 1;\n    }\n}\n";
3899        let theirs = "class Foo {\n    @Deprecated()\n    bar() {\n        return 1;\n    }\n}\n";
3900        let result = entity_merge(base, ours, theirs, "test.ts");
3901        assert!(
3902            result.is_clean(),
3903            "Both adding different decorators to same method should merge. Conflicts: {:?}",
3904            result.conflicts,
3905        );
3906        assert!(result.content.contains("@Injectable()"));
3907        assert!(result.content.contains("@Deprecated()"));
3908        assert!(result.content.contains("bar()"));
3909    }
3910
3911    #[test]
3912    fn test_non_adjacent_intra_function_changes() {
3913        let base = r#"export function process(data: any) {
3914    const validated = validate(data);
3915    const transformed = transform(validated);
3916    const saved = save(transformed);
3917    return saved;
3918}
3919"#;
3920        let ours = r#"export function process(data: any) {
3921    const validated = validate(data);
3922    const transformed = transform(validated);
3923    const saved = save(transformed);
3924    console.log("saved", saved);
3925    return saved;
3926}
3927"#;
3928        let theirs = r#"export function process(data: any) {
3929    console.log("input", data);
3930    const validated = validate(data);
3931    const transformed = transform(validated);
3932    const saved = save(transformed);
3933    return saved;
3934}
3935"#;
3936        let result = entity_merge(base, ours, theirs, "test.ts");
3937        assert!(
3938            result.is_clean(),
3939            "Non-adjacent changes within same function should merge via diffy. Conflicts: {:?}",
3940            result.conflicts,
3941        );
3942        assert!(result.content.contains("console.log(\"saved\""));
3943        assert!(result.content.contains("console.log(\"input\""));
3944    }
3945
3946    #[test]
3947    fn test_method_reordering_with_modification() {
3948        // Agent A reorders methods in class, Agent B modifies one method
3949        // Inner entity merge matches by name, so reordering should be transparent
3950        let base = r#"class Service {
3951    getUser(id: string) {
3952        return db.find(id);
3953    }
3954
3955    createUser(data: any) {
3956        return db.create(data);
3957    }
3958
3959    deleteUser(id: string) {
3960        return db.delete(id);
3961    }
3962}
3963"#;
3964        // Ours: reorder methods (move deleteUser before createUser)
3965        let ours = r#"class Service {
3966    getUser(id: string) {
3967        return db.find(id);
3968    }
3969
3970    deleteUser(id: string) {
3971        return db.delete(id);
3972    }
3973
3974    createUser(data: any) {
3975        return db.create(data);
3976    }
3977}
3978"#;
3979        // Theirs: modify getUser
3980        let theirs = r#"class Service {
3981    getUser(id: string) {
3982        console.log("fetching", id);
3983        return db.find(id);
3984    }
3985
3986    createUser(data: any) {
3987        return db.create(data);
3988    }
3989
3990    deleteUser(id: string) {
3991        return db.delete(id);
3992    }
3993}
3994"#;
3995        let result = entity_merge(base, ours, theirs, "test.ts");
3996        eprintln!("Method reorder: clean={}, conflicts={:?}", result.is_clean(), result.conflicts);
3997        eprintln!("Content:\n{}", result.content);
3998        assert!(
3999            result.is_clean(),
4000            "Method reordering + modification should merge. Conflicts: {:?}",
4001            result.conflicts,
4002        );
4003        assert!(result.content.contains("console.log(\"fetching\""), "Should contain theirs modification");
4004        assert!(result.content.contains("deleteUser"), "Should have deleteUser");
4005        assert!(result.content.contains("createUser"), "Should have createUser");
4006    }
4007
4008    #[test]
4009    fn test_doc_comment_plus_body_change() {
4010        // One side adds JSDoc comment, other modifies function body
4011        // Doc comments are part of the entity region — they should merge with body changes
4012        let base = r#"export function calculate(a: number, b: number): number {
4013    return a + b;
4014}
4015"#;
4016        let ours = r#"/**
4017 * Calculate the sum of two numbers.
4018 * @param a - First number
4019 * @param b - Second number
4020 */
4021export function calculate(a: number, b: number): number {
4022    return a + b;
4023}
4024"#;
4025        let theirs = r#"export function calculate(a: number, b: number): number {
4026    const result = a + b;
4027    console.log("result:", result);
4028    return result;
4029}
4030"#;
4031        let result = entity_merge(base, ours, theirs, "test.ts");
4032        eprintln!("Doc comment + body: clean={}, conflicts={:?}", result.is_clean(), result.conflicts);
4033        eprintln!("Content:\n{}", result.content);
4034        // This tests whether weave can merge doc comment additions with body changes
4035    }
4036
4037    #[test]
4038    fn test_both_add_different_guard_clauses() {
4039        // Both add different guard clauses at the start of a function
4040        let base = r#"export function processOrder(order: Order): Result {
4041    const total = calculateTotal(order);
4042    return { success: true, total };
4043}
4044"#;
4045        let ours = r#"export function processOrder(order: Order): Result {
4046    if (!order) throw new Error("Order required");
4047    const total = calculateTotal(order);
4048    return { success: true, total };
4049}
4050"#;
4051        let theirs = r#"export function processOrder(order: Order): Result {
4052    if (order.items.length === 0) throw new Error("Empty order");
4053    const total = calculateTotal(order);
4054    return { success: true, total };
4055}
4056"#;
4057        let result = entity_merge(base, ours, theirs, "test.ts");
4058        eprintln!("Guard clauses: clean={}, conflicts={:?}", result.is_clean(), result.conflicts);
4059        eprintln!("Content:\n{}", result.content);
4060        // Both add at same position — diffy may struggle since they're at the same insertion point
4061    }
4062
4063    #[test]
4064    fn test_both_modify_different_enum_variants() {
4065        // One modifies a variant's value, other adds new variants
4066        let base = r#"enum Status {
4067    Active = "active",
4068    Inactive = "inactive",
4069    Pending = "pending",
4070}
4071"#;
4072        let ours = r#"enum Status {
4073    Active = "active",
4074    Inactive = "disabled",
4075    Pending = "pending",
4076}
4077"#;
4078        let theirs = r#"enum Status {
4079    Active = "active",
4080    Inactive = "inactive",
4081    Pending = "pending",
4082    Deleted = "deleted",
4083}
4084"#;
4085        let result = entity_merge(base, ours, theirs, "test.ts");
4086        eprintln!("Enum modify+add: clean={}, conflicts={:?}", result.is_clean(), result.conflicts);
4087        eprintln!("Content:\n{}", result.content);
4088        assert!(
4089            result.is_clean(),
4090            "Modify variant + add new variant should merge. Conflicts: {:?}",
4091            result.conflicts,
4092        );
4093        assert!(result.content.contains("\"disabled\""), "Should have modified Inactive");
4094        assert!(result.content.contains("Deleted"), "Should have new Deleted variant");
4095    }
4096
4097    #[test]
4098    fn test_config_object_field_additions() {
4099        // Both add different fields to a config object (exported const)
4100        let base = r#"export const config = {
4101    timeout: 5000,
4102    retries: 3,
4103};
4104"#;
4105        let ours = r#"export const config = {
4106    timeout: 5000,
4107    retries: 3,
4108    maxConnections: 10,
4109};
4110"#;
4111        let theirs = r#"export const config = {
4112    timeout: 5000,
4113    retries: 3,
4114    logLevel: "info",
4115};
4116"#;
4117        let result = entity_merge(base, ours, theirs, "test.ts");
4118        eprintln!("Config fields: clean={}, conflicts={:?}", result.is_clean(), result.conflicts);
4119        eprintln!("Content:\n{}", result.content);
4120        // This tests whether inner entity merge handles object literals
4121        // (it probably won't since object fields aren't extracted as members the same way)
4122    }
4123
4124    #[test]
4125    fn test_rust_impl_block_both_add_methods() {
4126        // Both add different methods to a Rust impl block
4127        let base = r#"impl Calculator {
4128    fn add(&self, a: i32, b: i32) -> i32 {
4129        a + b
4130    }
4131}
4132"#;
4133        let ours = r#"impl Calculator {
4134    fn add(&self, a: i32, b: i32) -> i32 {
4135        a + b
4136    }
4137
4138    fn multiply(&self, a: i32, b: i32) -> i32 {
4139        a * b
4140    }
4141}
4142"#;
4143        let theirs = r#"impl Calculator {
4144    fn add(&self, a: i32, b: i32) -> i32 {
4145        a + b
4146    }
4147
4148    fn divide(&self, a: i32, b: i32) -> i32 {
4149        a / b
4150    }
4151}
4152"#;
4153        let result = entity_merge(base, ours, theirs, "test.rs");
4154        eprintln!("Rust impl: clean={}, conflicts={:?}", result.is_clean(), result.conflicts);
4155        eprintln!("Content:\n{}", result.content);
4156        assert!(
4157            result.is_clean(),
4158            "Both adding methods to Rust impl should merge. Conflicts: {:?}",
4159            result.conflicts,
4160        );
4161        assert!(result.content.contains("multiply"), "Should have multiply");
4162        assert!(result.content.contains("divide"), "Should have divide");
4163    }
4164
4165    #[test]
4166    fn test_rust_impl_same_trait_different_types() {
4167        // Two impl blocks for the same trait but different types.
4168        // Each branch modifies a different impl. Both should be preserved.
4169        // Regression: sem-core <0.3.10 named both "Stream", causing collision.
4170        let base = r#"struct Foo;
4171struct Bar;
4172
4173impl Stream for Foo {
4174    type Item = i32;
4175    fn poll_next(&self) -> Option<i32> {
4176        Some(1)
4177    }
4178}
4179
4180impl Stream for Bar {
4181    type Item = String;
4182    fn poll_next(&self) -> Option<String> {
4183        Some("hello".into())
4184    }
4185}
4186
4187fn other() {}
4188"#;
4189        let ours = r#"struct Foo;
4190struct Bar;
4191
4192impl Stream for Foo {
4193    type Item = i32;
4194    fn poll_next(&self) -> Option<i32> {
4195        let x = compute();
4196        Some(x + 1)
4197    }
4198}
4199
4200impl Stream for Bar {
4201    type Item = String;
4202    fn poll_next(&self) -> Option<String> {
4203        Some("hello".into())
4204    }
4205}
4206
4207fn other() {}
4208"#;
4209        let theirs = r#"struct Foo;
4210struct Bar;
4211
4212impl Stream for Foo {
4213    type Item = i32;
4214    fn poll_next(&self) -> Option<i32> {
4215        Some(1)
4216    }
4217}
4218
4219impl Stream for Bar {
4220    type Item = String;
4221    fn poll_next(&self) -> Option<String> {
4222        let s = format!("hello {}", name);
4223        Some(s)
4224    }
4225}
4226
4227fn other() {}
4228"#;
4229        let result = entity_merge(base, ours, theirs, "test.rs");
4230        assert!(
4231            result.is_clean(),
4232            "Same trait, different types should not conflict. Conflicts: {:?}",
4233            result.conflicts,
4234        );
4235        assert!(result.content.contains("impl Stream for Foo"), "Should have Foo impl");
4236        assert!(result.content.contains("impl Stream for Bar"), "Should have Bar impl");
4237        assert!(result.content.contains("compute()"), "Should have ours' Foo change");
4238        assert!(result.content.contains("format!"), "Should have theirs' Bar change");
4239    }
4240
4241    #[test]
4242    fn test_rust_doc_comment_plus_body_change() {
4243        // One side adds Rust doc comment, other modifies body
4244        // Comment bundling ensures the doc comment is part of the entity
4245        let base = r#"fn add(a: i32, b: i32) -> i32 {
4246    a + b
4247}
4248
4249fn subtract(a: i32, b: i32) -> i32 {
4250    a - b
4251}
4252"#;
4253        let ours = r#"/// Adds two numbers together.
4254fn add(a: i32, b: i32) -> i32 {
4255    a + b
4256}
4257
4258fn subtract(a: i32, b: i32) -> i32 {
4259    a - b
4260}
4261"#;
4262        let theirs = r#"fn add(a: i32, b: i32) -> i32 {
4263    a + b
4264}
4265
4266fn subtract(a: i32, b: i32) -> i32 {
4267    a - b - 1
4268}
4269"#;
4270        let result = entity_merge(base, ours, theirs, "test.rs");
4271        assert!(
4272            result.is_clean(),
4273            "Rust doc comment + body change should merge. Conflicts: {:?}",
4274            result.conflicts,
4275        );
4276        assert!(result.content.contains("/// Adds two numbers"), "Should have ours doc comment");
4277        assert!(result.content.contains("a - b - 1"), "Should have theirs body change");
4278    }
4279
4280    #[test]
4281    fn test_both_add_different_doc_comments() {
4282        // Both add doc comments to different functions — should merge cleanly
4283        let base = r#"fn add(a: i32, b: i32) -> i32 {
4284    a + b
4285}
4286
4287fn subtract(a: i32, b: i32) -> i32 {
4288    a - b
4289}
4290"#;
4291        let ours = r#"/// Adds two numbers.
4292fn add(a: i32, b: i32) -> i32 {
4293    a + b
4294}
4295
4296fn subtract(a: i32, b: i32) -> i32 {
4297    a - b
4298}
4299"#;
4300        let theirs = r#"fn add(a: i32, b: i32) -> i32 {
4301    a + b
4302}
4303
4304/// Subtracts b from a.
4305fn subtract(a: i32, b: i32) -> i32 {
4306    a - b
4307}
4308"#;
4309        let result = entity_merge(base, ours, theirs, "test.rs");
4310        assert!(
4311            result.is_clean(),
4312            "Both adding doc comments to different functions should merge. Conflicts: {:?}",
4313            result.conflicts,
4314        );
4315        assert!(result.content.contains("/// Adds two numbers"), "Should have add's doc comment");
4316        assert!(result.content.contains("/// Subtracts b from a"), "Should have subtract's doc comment");
4317    }
4318
4319    #[test]
4320    fn test_go_import_block_both_add_different() {
4321        // Go uses import (...) blocks — both add different imports
4322        let base = "package main\n\nimport (\n\t\"fmt\"\n\t\"os\"\n)\n\nfunc main() {\n\tfmt.Println(\"hello\")\n}\n";
4323        let ours = "package main\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"strings\"\n)\n\nfunc main() {\n\tfmt.Println(\"hello\")\n}\n";
4324        let theirs = "package main\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"io\"\n)\n\nfunc main() {\n\tfmt.Println(\"hello\")\n}\n";
4325        let result = entity_merge(base, ours, theirs, "main.go");
4326        eprintln!("Go import block: clean={}, conflicts={:?}", result.is_clean(), result.conflicts);
4327        eprintln!("Content:\n{}", result.content);
4328        // This tests whether Go import blocks (a single entity) get inner-merged
4329    }
4330
4331    #[test]
4332    fn test_python_class_both_add_methods() {
4333        // Python class — both add different methods
4334        let base = "class Calculator:\n    def add(self, a, b):\n        return a + b\n";
4335        let ours = "class Calculator:\n    def add(self, a, b):\n        return a + b\n\n    def multiply(self, a, b):\n        return a * b\n";
4336        let theirs = "class Calculator:\n    def add(self, a, b):\n        return a + b\n\n    def divide(self, a, b):\n        return a / b\n";
4337        let result = entity_merge(base, ours, theirs, "test.py");
4338        eprintln!("Python class: clean={}, conflicts={:?}", result.is_clean(), result.conflicts);
4339        eprintln!("Content:\n{}", result.content);
4340        assert!(
4341            result.is_clean(),
4342            "Both adding methods to Python class should merge. Conflicts: {:?}",
4343            result.conflicts,
4344        );
4345        assert!(result.content.contains("multiply"), "Should have multiply");
4346        assert!(result.content.contains("divide"), "Should have divide");
4347    }
4348
4349    #[test]
4350    fn test_interstitial_conflict_not_silently_embedded() {
4351        // Regression test: when interstitial content between entities has a
4352        // both-modified conflict, merge_interstitials must report it as a real
4353        // conflict instead of silently embedding raw diffy markers and claiming
4354        // is_clean=true.
4355        //
4356        // Scenario: a barrel export file (index.ts) with comments between
4357        // export statements. Both sides modify the SAME interstitial comment
4358        // block differently. The exports are the entities; the comment between
4359        // them is interstitial content that goes through merge_interstitials
4360        // → diffy, which cannot auto-merge conflicting edits.
4361        let base = r#"export { alpha } from "./alpha";
4362
4363// Section: data utilities
4364// TODO: add more exports here
4365
4366export { beta } from "./beta";
4367"#;
4368        let ours = r#"export { alpha } from "./alpha";
4369
4370// Section: data utilities (sorting)
4371// Sorting helpers for list views
4372
4373export { beta } from "./beta";
4374"#;
4375        let theirs = r#"export { alpha } from "./alpha";
4376
4377// Section: data utilities (filtering)
4378// Filtering helpers for search views
4379
4380export { beta } from "./beta";
4381"#;
4382        let result = entity_merge(base, ours, theirs, "index.ts");
4383
4384        // The key assertions:
4385        // 1. If the content has conflict markers, is_clean() MUST be false
4386        let has_markers = result.content.contains("<<<<<<<") || result.content.contains(">>>>>>>");
4387        if has_markers {
4388            assert!(
4389                !result.is_clean(),
4390                "BUG: is_clean()=true but merged content has conflict markers!\n\
4391                 stats: {}\nconflicts: {:?}\ncontent:\n{}",
4392                result.stats, result.conflicts, result.content
4393            );
4394            assert!(
4395                result.stats.entities_conflicted > 0,
4396                "entities_conflicted should be > 0 when markers are present"
4397            );
4398        }
4399
4400        // 2. If it was resolved cleanly, no markers should exist
4401        if result.is_clean() {
4402            assert!(
4403                !has_markers,
4404                "Clean merge should not contain conflict markers!\ncontent:\n{}",
4405                result.content
4406            );
4407        }
4408    }
4409
4410    #[test]
4411    fn test_pre_conflicted_input_not_treated_as_clean() {
4412        // Regression test for AU/AA conflicts: git can store conflict markers
4413        // directly into stage blobs. Weave must not return is_clean=true.
4414        let base = "";
4415        let theirs = "";
4416        let ours = r#"/**
4417 * MIT License
4418 */
4419
4420<<<<<<<< HEAD:src/lib/exports/index.ts
4421export { renderDocToBuffer } from "./doc-exporter";
4422export type { ExportOptions, ExportMetadata, RenderContext } from "./types";
4423========
4424export * from "./editor";
4425export * from "./types";
4426>>>>>>>> feature:packages/core/src/editor/index.ts
4427"#;
4428        let result = entity_merge(base, ours, theirs, "index.ts");
4429
4430        assert!(
4431            !result.is_clean(),
4432            "Pre-conflicted input must not be reported as clean!\n\
4433             stats: {}\nconflicts: {:?}",
4434            result.stats, result.conflicts,
4435        );
4436        assert!(result.stats.entities_conflicted > 0);
4437        assert!(!result.conflicts.is_empty());
4438    }
4439
4440    #[test]
4441    fn test_multi_line_signature_classified_as_syntax() {
4442        // Multi-line parameter list: changing a param should be Syntax, not Functional
4443        let base = "function process(\n    a: number,\n    b: string\n) {\n    return a;\n}\n";
4444        let ours = "function process(\n    a: number,\n    b: string,\n    c: boolean\n) {\n    return a;\n}\n";
4445        let theirs = "function process(\n    a: number,\n    b: number\n) {\n    return a;\n}\n";
4446        let complexity = crate::conflict::classify_conflict(Some(base), Some(ours), Some(theirs));
4447        assert_eq!(
4448            complexity,
4449            crate::conflict::ConflictComplexity::Syntax,
4450            "Multi-line signature change should be classified as Syntax, got {:?}",
4451            complexity
4452        );
4453    }
4454
4455    #[test]
4456    fn test_grouped_import_merge_preserves_groups() {
4457        let base = "import os\nimport sys\n\nfrom collections import OrderedDict\nfrom typing import List\n";
4458        let ours = "import os\nimport sys\nimport json\n\nfrom collections import OrderedDict\nfrom typing import List\n";
4459        let theirs = "import os\nimport sys\n\nfrom collections import OrderedDict\nfrom collections import defaultdict\nfrom typing import List\n";
4460        let result = merge_imports_commutatively(base, ours, theirs);
4461        // json should be in the first group (stdlib), defaultdict in the second (collections)
4462        let lines: Vec<&str> = result.lines().collect();
4463        let json_idx = lines.iter().position(|l| l.contains("json"));
4464        let blank_idx = lines.iter().position(|l| l.trim().is_empty());
4465        let defaultdict_idx = lines.iter().position(|l| l.contains("defaultdict"));
4466        assert!(json_idx.is_some(), "json import should be present");
4467        assert!(blank_idx.is_some(), "blank line separator should be present");
4468        assert!(defaultdict_idx.is_some(), "defaultdict import should be present");
4469        // json should come before the blank line, defaultdict after
4470        assert!(json_idx.unwrap() < blank_idx.unwrap(), "json should be in first group");
4471        assert!(defaultdict_idx.unwrap() > blank_idx.unwrap(), "defaultdict should be in second group");
4472    }
4473
4474    #[test]
4475    fn test_configurable_duplicate_threshold() {
4476        // Create entities with 15 same-name entities
4477        let entities: Vec<SemanticEntity> = (0..15).map(|i| SemanticEntity {
4478            id: format!("test::function::test_{}", i),
4479            file_path: "test.ts".to_string(),
4480            entity_type: "function".to_string(),
4481            name: "test".to_string(),
4482            parent_id: None,
4483            content: format!("function test() {{ return {}; }}", i),
4484            content_hash: format!("hash_{}", i),
4485            structural_hash: None,
4486            start_line: i * 3 + 1,
4487            end_line: i * 3 + 3,
4488            metadata: None,
4489        }).collect();
4490        // Default threshold (10): should trigger
4491        assert!(has_excessive_duplicates(&entities));
4492        // Set threshold to 20: should not trigger
4493        std::env::set_var("WEAVE_MAX_DUPLICATES", "20");
4494        assert!(!has_excessive_duplicates(&entities));
4495        std::env::remove_var("WEAVE_MAX_DUPLICATES");
4496    }
4497
4498    #[test]
4499    fn test_ts_multiline_import_consolidation() {
4500        // Issue #24: when incoming consolidates two imports into one multi-line import,
4501        // the `import {` opening line can get dropped.
4502        let base = "\
4503import type { Foo } from \"./foo\"
4504import {
4505     type a,
4506     type b,
4507     type c,
4508} from \"./foo\"
4509
4510export function bar() {
4511    return 1;
4512}
4513";
4514        let ours = base;
4515        let theirs = "\
4516import {
4517     type Foo,
4518     type a,
4519     type b,
4520     type c,
4521} from \"./foo\"
4522
4523export function bar() {
4524    return 1;
4525}
4526";
4527        let result = entity_merge(base, ours, theirs, "test.ts");
4528        eprintln!("TS import consolidation: clean={}, conflicts={:?}", result.is_clean(), result.conflicts);
4529        eprintln!("Content:\n{}", result.content);
4530        // Theirs is the only change, result should match theirs exactly
4531        assert!(result.content.contains("import {"), "import {{ must not be dropped");
4532        assert!(result.content.contains("type Foo,"), "type Foo must be present");
4533        assert!(result.content.contains("} from \"./foo\""), "closing must be present");
4534        assert!(!result.content.contains("import type { Foo }"), "old separate import should be removed");
4535    }
4536
4537    #[test]
4538    fn test_ts_multiline_import_both_modify() {
4539        // Issue #24 variant: both sides modify the import block
4540        let base = "\
4541import type { Foo } from \"./foo\"
4542import {
4543     type a,
4544     type b,
4545     type c,
4546} from \"./foo\"
4547
4548export function bar() {
4549    return 1;
4550}
4551";
4552        // Ours: consolidates imports + adds type d
4553        let ours = "\
4554import {
4555     type Foo,
4556     type a,
4557     type b,
4558     type c,
4559     type d,
4560} from \"./foo\"
4561
4562export function bar() {
4563    return 1;
4564}
4565";
4566        // Theirs: consolidates imports + adds type e
4567        let theirs = "\
4568import {
4569     type Foo,
4570     type a,
4571     type b,
4572     type c,
4573     type e,
4574} from \"./foo\"
4575
4576export function bar() {
4577    return 1;
4578}
4579";
4580        let result = entity_merge(base, ours, theirs, "test.ts");
4581        eprintln!("TS import both modify: clean={}, conflicts={:?}", result.is_clean(), result.conflicts);
4582        eprintln!("Content:\n{}", result.content);
4583        assert!(result.content.contains("import {"), "import {{ must not be dropped");
4584        assert!(result.content.contains("type Foo,"), "type Foo must be present");
4585        assert!(result.content.contains("type d,"), "ours addition must be present");
4586        assert!(result.content.contains("type e,"), "theirs addition must be present");
4587        assert!(result.content.contains("} from \"./foo\""), "closing must be present");
4588    }
4589
4590    #[test]
4591    fn test_ts_multiline_import_no_entities() {
4592        // Issue #24: file with only imports, no other entities
4593        let base = "\
4594import type { Foo } from \"./foo\"
4595import {
4596     type a,
4597     type b,
4598     type c,
4599} from \"./foo\"
4600";
4601        let ours = base;
4602        let theirs = "\
4603import {
4604     type Foo,
4605     type a,
4606     type b,
4607     type c,
4608} from \"./foo\"
4609";
4610        let result = entity_merge(base, ours, theirs, "test.ts");
4611        eprintln!("TS import no entities: clean={}, conflicts={:?}", result.is_clean(), result.conflicts);
4612        eprintln!("Content:\n{}", result.content);
4613        assert!(result.content.contains("import {"), "import {{ must not be dropped");
4614        assert!(result.content.contains("type Foo,"), "type Foo must be present");
4615    }
4616
4617    #[test]
4618    fn test_ts_multiline_import_export_variable() {
4619        // Issue #24: import block near an export variable entity
4620        let base = "\
4621import type { Foo } from \"./foo\"
4622import {
4623     type a,
4624     type b,
4625     type c,
4626} from \"./foo\"
4627
4628export const X = 1;
4629
4630export function bar() {
4631    return 1;
4632}
4633";
4634        let ours = "\
4635import type { Foo } from \"./foo\"
4636import {
4637     type a,
4638     type b,
4639     type c,
4640     type d,
4641} from \"./foo\"
4642
4643export const X = 1;
4644
4645export function bar() {
4646    return 1;
4647}
4648";
4649        let theirs = "\
4650import {
4651     type Foo,
4652     type a,
4653     type b,
4654     type c,
4655} from \"./foo\"
4656
4657export const X = 2;
4658
4659export function bar() {
4660    return 1;
4661}
4662";
4663        let result = entity_merge(base, ours, theirs, "test.ts");
4664        eprintln!("TS import + export var: clean={}, conflicts={:?}", result.is_clean(), result.conflicts);
4665        eprintln!("Content:\n{}", result.content);
4666        assert!(result.content.contains("import {"), "import {{ must not be dropped");
4667    }
4668
4669    #[test]
4670    fn test_ts_multiline_import_adjacent_to_entity() {
4671        // Issue #24: import block directly adjacent to entity (no blank line)
4672        let base = "\
4673import type { Foo } from \"./foo\"
4674import {
4675     type a,
4676     type b,
4677     type c,
4678} from \"./foo\"
4679export function bar() {
4680    return 1;
4681}
4682";
4683        let ours = base;
4684        let theirs = "\
4685import {
4686     type Foo,
4687     type a,
4688     type b,
4689     type c,
4690} from \"./foo\"
4691export function bar() {
4692    return 1;
4693}
4694";
4695        let result = entity_merge(base, ours, theirs, "test.ts");
4696        eprintln!("TS import adjacent: clean={}, conflicts={:?}", result.is_clean(), result.conflicts);
4697        eprintln!("Content:\n{}", result.content);
4698        assert!(result.content.contains("import {"), "import {{ must not be dropped");
4699        assert!(result.content.contains("type Foo,"), "type Foo must be present");
4700    }
4701
4702    #[test]
4703    fn test_ts_multiline_import_both_consolidate_differently() {
4704        // Issue #24: both sides consolidate imports but add different specifiers
4705        let base = "\
4706import type { Foo } from \"./foo\"
4707import {
4708     type a,
4709     type b,
4710} from \"./foo\"
4711
4712export function bar() {
4713    return 1;
4714}
4715";
4716        let ours = "\
4717import {
4718     type Foo,
4719     type a,
4720     type b,
4721     type c,
4722} from \"./foo\"
4723
4724export function bar() {
4725    return 1;
4726}
4727";
4728        let theirs = "\
4729import {
4730     type Foo,
4731     type a,
4732     type b,
4733     type d,
4734} from \"./foo\"
4735
4736export function bar() {
4737    return 1;
4738}
4739";
4740        let result = entity_merge(base, ours, theirs, "test.ts");
4741        eprintln!("TS both consolidate: clean={}, conflicts={:?}", result.is_clean(), result.conflicts);
4742        eprintln!("Content:\n{}", result.content);
4743        assert!(result.content.contains("import {"), "import {{ must not be dropped");
4744        assert!(result.content.contains("type Foo,"), "type Foo must be present");
4745        assert!(result.content.contains("} from \"./foo\""), "closing must be present");
4746    }
4747
4748    #[test]
4749    fn test_ts_multiline_import_ours_adds_theirs_consolidates() {
4750        // Issue #24 variant: ours adds new import, theirs consolidates
4751        let base = "\
4752import type { Foo } from \"./foo\"
4753import {
4754     type a,
4755     type b,
4756     type c,
4757} from \"./foo\"
4758
4759export function bar() {
4760    return 1;
4761}
4762";
4763        // Ours: adds a new specifier to the multiline import
4764        let ours = "\
4765import type { Foo } from \"./foo\"
4766import {
4767     type a,
4768     type b,
4769     type c,
4770     type d,
4771} from \"./foo\"
4772
4773export function bar() {
4774    return 1;
4775}
4776";
4777        // Theirs: consolidates into one import
4778        let theirs = "\
4779import {
4780     type Foo,
4781     type a,
4782     type b,
4783     type c,
4784} from \"./foo\"
4785
4786export function bar() {
4787    return 1;
4788}
4789";
4790        let result = entity_merge(base, ours, theirs, "test.ts");
4791        eprintln!("TS import ours-adds theirs-consolidates: clean={}, conflicts={:?}", result.is_clean(), result.conflicts);
4792        eprintln!("Content:\n{}", result.content);
4793        assert!(result.content.contains("import {"), "import {{ must not be dropped");
4794        assert!(result.content.contains("type d,"), "ours addition must be present");
4795        assert!(result.content.contains("} from \"./foo\""), "closing must be present");
4796    }
4797
4798    #[test]
4799    fn test_rename_plus_modify_auto_resolves() {
4800        // Issue #53: ours renames a variable (IDE rename symbol), theirs modifies it.
4801        // Should detect the rename via token similarity and merge cleanly.
4802        let base = r#"export const cubeQueryExecutorTool = tool({
4803    name: "cubeQueryExecutorTool",
4804    description: "Execute a cube query",
4805    schema: z.object({ query: z.string() }),
4806    execute: async (input) => {
4807        return await runQuery(input.query);
4808    },
4809});
4810"#;
4811        // Ours: renamed cubeQueryExecutorTool → cubeQueryTool (all refs updated)
4812        let ours = r#"export const cubeQueryTool = tool({
4813    name: "cubeQueryTool",
4814    description: "Execute a cube query",
4815    schema: z.object({ query: z.string() }),
4816    execute: async (input) => {
4817        return await runQuery(input.query);
4818    },
4819});
4820"#;
4821        // Theirs: modified the body (added unit inference logic)
4822        let theirs = r#"export const cubeQueryExecutorTool = tool({
4823    name: "cubeQueryExecutorTool",
4824    description: "Execute a cube query with unit inference",
4825    schema: z.object({ query: z.string(), unit: z.string().optional() }),
4826    execute: async (input) => {
4827        const unit = input.unit || inferUnit(input.query);
4828        return await runQuery(input.query, unit);
4829    },
4830});
4831"#;
4832        let result = entity_merge(base, ours, theirs, "cubeQueryTool.ts");
4833        // Rename + modify should conflict (developer who modified didn't know about rename)
4834        assert_eq!(result.conflicts.len(), 1, "Should have exactly one conflict");
4835        assert!(
4836            matches!(result.conflicts[0].kind, ConflictKind::RenameModify { renamed_in_ours: true, .. }),
4837            "Should be a RenameModify conflict, got: {:?}",
4838            result.conflicts[0].kind
4839        );
4840        // Both versions should be present in the conflict
4841        assert!(result.content.contains("cubeQueryTool"), "Ours (renamed) should be in conflict markers");
4842        assert!(result.content.contains("unit inference"), "Theirs (modified) should be in conflict markers");
4843    }
4844}