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
14static 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#[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#[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#[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#[derive(Debug, Clone)]
74pub enum ResolvedEntity {
75 Clean(EntityRegion),
77 Conflict(EntityConflict),
79 ScopedConflict {
82 content: String,
83 conflict: EntityConflict,
84 },
85 Deleted,
87}
88
89pub 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
104pub 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 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 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 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 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 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 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 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 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 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 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 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 if base_entities.is_empty() && !base.trim().is_empty() {
258 return line_level_fallback(base, ours, theirs, file_path);
259 }
260 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 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 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 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 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 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 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 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 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 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 let mut all_entity_ids: Vec<String> = Vec::new();
328 let mut seen: HashSet<String> = HashSet::new();
329 let mut skip_ids: HashSet<String> = HashSet::new();
331 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 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 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 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 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 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 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 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 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 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 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 let theirs_rename_base_ids: HashSet<String> = base_to_ours_rename.keys().cloned().collect();
495
496 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 let content = post_merge_cleanup(&content);
510
511 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 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 return git_merge_file(base, ours, theirs, &mut stats);
536 }
537 }
538 }
539 }
540
541 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 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 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 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 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 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 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 if min_input_len > 200 && merged_len < min_input_len * 90 / 100 {
646 return git_merge_file(base, ours, theirs, &mut stats);
647 }
648 if max_input_len > 500 && merged_len < max_input_len * 70 / 100 {
651 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 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 (Some(base), Some(ours), Some(theirs)) => {
688 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 stats.entities_unchanged += 1;
704 (ResolvedEntity::Clean(entity_to_region_with_content(ours, ®ion_content(ours, ours_region_content))), ResolutionStrategy::Unchanged)
705 }
706 (true, false) => {
707 stats.entities_ours_only += 1;
709 (ResolvedEntity::Clean(entity_to_region_with_content(ours, ®ion_content(ours, ours_region_content))), ResolutionStrategy::OursOnly)
710 }
711 (false, true) => {
712 stats.entities_theirs_only += 1;
714 (ResolvedEntity::Clean(entity_to_region_with_content(theirs, ®ion_content(theirs, theirs_region_content))), ResolutionStrategy::TheirsOnly)
715 }
716 (true, true) => {
717 if ours.content_hash == theirs.content_hash {
719 stats.entities_both_changed_merged += 1;
721 (ResolvedEntity::Clean(entity_to_region_with_content(ours, ®ion_content(ours, ours_region_content))), ResolutionStrategy::ContentEqual)
722 } else {
723 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 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 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 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 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 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 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 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 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 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 (Some(_base), Some(ours), None) => {
892 let ours_modified = ours.content_hash != _base.content_hash;
893 if ours_modified {
894 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 stats.entities_deleted += 1;
913 (ResolvedEntity::Deleted, ResolutionStrategy::Deleted)
914 }
915 }
916
917 (Some(_base), None, Some(theirs)) => {
919 let theirs_modified = theirs.content_hash != _base.content_hash;
920 if theirs_modified {
921 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 stats.entities_deleted += 1;
940 (ResolvedEntity::Deleted, ResolutionStrategy::Deleted)
941 }
942 }
943
944 (None, Some(ours), None) => {
946 stats.entities_added_ours += 1;
947 (ResolvedEntity::Clean(entity_to_region_with_content(ours, ®ion_content(ours, ours_region_content))), ResolutionStrategy::AddedOurs)
948 }
949
950 (None, None, Some(theirs)) => {
952 stats.entities_added_theirs += 1;
953 (ResolvedEntity::Clean(entity_to_region_with_content(theirs, ®ion_content(theirs, theirs_region_content))), ResolutionStrategy::AddedTheirs)
954 }
955
956 (None, Some(ours), Some(theirs)) => {
958 if ours.content_hash == theirs.content_hash {
959 stats.entities_added_ours += 1;
961 (ResolvedEntity::Clean(entity_to_region_with_content(ours, ®ion_content(ours, ours_region_content))), ResolutionStrategy::ContentEqual)
962 } else {
963 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 (Some(_), None, None) => {
982 stats.entities_deleted += 1;
983 (ResolvedEntity::Deleted, ResolutionStrategy::Deleted)
984 }
985
986 (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
1002fn 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
1015fn is_whitespace_only_diff(a: &str, b: &str) -> bool {
1018 if a == b {
1019 return true; }
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
1026fn 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
1037fn 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; } else {
1046 break;
1047 }
1048 }
1049 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
1064fn 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 if ours_decorators.is_empty() && theirs_decorators.is_empty() {
1076 return None;
1077 }
1078
1079 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 diffy_merge(base_body, ours_body, theirs_body)?
1089 };
1090
1091 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 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 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 for d in &ours_decorators {
1109 if !base_set.contains(d) && !merged_decorators.contains(d) {
1110 merged_decorators.push(d);
1111 }
1112 }
1113 for d in &theirs_decorators {
1115 if !base_set.contains(d) && !merged_decorators.contains(d) {
1116 merged_decorators.push(d);
1117 }
1118 }
1119
1120 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
1131fn 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
1140fn 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
1169fn 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 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 if is_whitespace_only_diff(base_content, ours_content)
1226 && is_whitespace_only_diff(base_content, theirs_content)
1227 {
1228 merged.insert(key.to_string(), theirs_content.to_string());
1230 } else if is_whitespace_only_diff(base_content, ours_content) {
1231 merged.insert(key.to_string(), theirs_content.to_string());
1233 } else if is_whitespace_only_diff(base_content, theirs_content) {
1234 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 let result = merge_imports_commutatively(base_content, ours_content, theirs_content);
1242 merged.insert(key.to_string(), result);
1243 } else {
1244 match diffy::merge(base_content, ours_content, theirs_content) {
1246 Ok(m) => {
1247 merged.insert(key.to_string(), m);
1248 }
1249 Err(_conflicted) => {
1250 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
1277fn 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 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 import_count * 2 > lines.len()
1311}
1312
1313fn 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 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; }
1336 }
1337 result.push(line);
1338 }
1339
1340 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
1362fn 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
1378fn is_import_line(line: &str) -> bool {
1383 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
1398fn 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#[derive(Debug, Clone)]
1413struct ImportStatement {
1414 lines: Vec<String>,
1416 source: String,
1418 specifiers: Vec<String>,
1420 is_multiline: bool,
1422}
1423
1424fn parse_single_line_specifiers(trimmed: &str) -> Vec<String> {
1429 if let Some(import_pos) = trimmed.find(" import ") {
1431 let after_import = &trimmed[import_pos + 8..];
1432 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 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
1458fn 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 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 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 break;
1496 } else if !inner_trimmed.is_empty() {
1497 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 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
1534fn 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 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 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 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 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
1652fn 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 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 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 let mut result_parts: Vec<String> = Vec::new();
1689 let mut handled_theirs_sources: HashSet<&str> = HashSet::new();
1690
1691 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 let source = imp.source.as_str();
1715 handled_theirs_sources.insert(source);
1716
1717 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 let theirs_added: HashSet<&str> = theirs_spec_set.difference(&base_spec_set).copied().collect();
1722 let theirs_removed: HashSet<&str> = base_spec_set.difference(&theirs_spec_set).copied().collect();
1724
1725 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 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 result_parts.push(imp.lines[0].clone()); for spec in &final_specs {
1747 result_parts.push(format!("{}{},", indent, spec));
1748 }
1749 if let Some(last) = imp.lines.last() {
1751 result_parts.push(last.clone());
1752 }
1753
1754 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 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 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 let mut ours_specifiers: Vec<&str> = Vec::new();
1782 let trimmed_line = line.trim();
1783 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 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 if let Some(theirs_imp) = theirs_imports.iter().find(|ti| ti.source == source) {
1804 if theirs_imp.is_multiline {
1805 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 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 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 }
1836 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 for imp in theirs_imports {
1849 if handled_theirs_sources.contains(imp.source.as_str()) {
1850 continue;
1851 }
1852 for line in &imp.lines {
1854 result_parts.push(line.clone());
1855 }
1856 }
1857
1858 let mut result = result_parts.join("\n");
1859
1860 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 !(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
1900fn import_source_prefix(line: &str) -> &str {
1905 for l in line.lines() {
1908 let trimmed = l.trim();
1909 if let Some(rest) = trimmed.strip_prefix("from ") {
1911 return rest.split_whitespace().next().unwrap_or("");
1912 }
1913 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 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 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
1939fn 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 let skip = skip_sesame(file_path);
1956
1957 if skip {
1958 return git_merge_file(base, ours, theirs, &mut stats);
1962 }
1963
1964 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 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 let git_result = git_merge_file(base, ours, theirs, &mut stats);
2017
2018 match sesame_result {
2020 Some(sesame) if sesame.conflicts.is_empty() && !git_result.conflicts.is_empty() => {
2021 sesame
2023 }
2024 Some(sesame) if !sesame.conflicts.is_empty() && !git_result.conflicts.is_empty() => {
2025 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
2034fn 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 let output = Command::new("git")
2062 .arg("merge-file")
2063 .arg("-p") .arg("--diff3") .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 MergeResult {
2079 content: post_merge_cleanup(&content),
2080 conflicts: vec![],
2081 warnings: vec![],
2082 stats: stats.clone(),
2083 audit: vec![],
2084 }
2085 } else {
2086 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 Err(_) => diffy_fallback(base, ours, theirs, stats),
2107 }
2108}
2109
2110fn 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
2144fn 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
2163fn filter_nested_entities(mut entities: Vec<SemanticEntity>) -> Vec<SemanticEntity> {
2166 if entities.len() <= 1 {
2167 return entities;
2168 }
2169
2170 entities.sort_by(|a, b| {
2173 a.start_line.cmp(&b.start_line).then(b.end_line.cmp(&a.end_line))
2174 });
2175
2176 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 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 result.push(entity);
2190 }
2191 }
2193
2194 result
2195}
2196
2197fn 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
2210fn 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
2227fn 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
2239fn 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
2284fn 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 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 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 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 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 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 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 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 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 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 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
2406fn 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#[derive(Debug, Clone)]
2418struct MemberChunk {
2419 name: String,
2421 content: String,
2423}
2424
2425struct InnerMergeResult {
2427 content: String,
2429 has_conflicts: bool,
2431}
2432
2433fn 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 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 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 let floor = if i > 0 {
2467 children[i - 1].end_line.saturating_sub(container_start_line) + 1
2468 } else {
2469 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 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 content_start = prev;
2509 } else {
2510 break;
2511 }
2512 }
2513
2514 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
2529fn 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 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 if has_narrowing {
2562 for line in &ours_lines[..prefix_len] {
2563 out.push_str(line);
2564 out.push('\n');
2565 }
2566 }
2567
2568 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 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 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 out.push_str(&format!("{}\n", sep));
2617
2618 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 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 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
2655fn 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 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 if base_chunks.is_empty() && ours_chunks.is_empty() && theirs_chunks.is_empty() {
2698 return None;
2699 }
2700
2701 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 let mut all_names: Vec<String> = Vec::new();
2717 let mut seen: HashSet<String> = HashSet::new();
2718 for chunk in &ours_chunks {
2720 if seen.insert(chunk.name.clone()) {
2721 all_names.push(chunk.name.clone());
2722 }
2723 }
2724 for chunk in &theirs_chunks {
2726 if seen.insert(chunk.name.clone()) {
2727 all_names.push(chunk.name.clone());
2728 }
2729 }
2730
2731 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 (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 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 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 (Some(b), Some(o), None) => {
2768 if *b == *o {
2769 } else {
2771 has_conflict = true;
2773 merged_members.push(scoped_conflict_marker(name, Some(b), Some(o), None, false, true, marker_format));
2774 }
2775 }
2776 (Some(b), None, Some(t)) => {
2778 if *b == *t {
2779 } else {
2781 has_conflict = true;
2783 merged_members.push(scoped_conflict_marker(name, Some(b), None, Some(t), true, false, marker_format));
2784 }
2785 }
2786 (None, Some(o), None) => {
2788 merged_members.push(o.to_string());
2789 }
2790 (None, None, Some(t)) => {
2792 merged_members.push(t.to_string());
2793 }
2794 (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 (Some(_), None, None) => {}
2805 (None, None, None) => {}
2806 }
2807 }
2808
2809 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 let has_multiline_members = merged_members.iter().any(|m| m.contains('\n'));
2818 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 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 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
2870fn 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
2963fn is_python_style_container(lines: &[&str]) -> bool {
2970 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 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 let is_python_style = is_python_style_container(&lines);
3003
3004 if is_python_style {
3005 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 let footer = &content[content.len()..];
3014 Some((header, footer))
3015 } else {
3016 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
3039fn 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 let is_python_style = is_python_style_container(&lines);
3052
3053 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 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 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 if current_name.is_some() {
3088 current_chunk_lines.push(line);
3090 }
3091 continue;
3092 }
3093
3094 let indent = line.len() - line.trim_start().len();
3095
3096 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 if let Some(name) = current_name.take() {
3109 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 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 current_chunk_lines.push(line);
3129 } else {
3130 current_chunk_lines.push(line);
3133 }
3134 }
3135
3136 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 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
3167fn extract_member_name(line: &str) -> String {
3169 let trimmed = line.trim();
3170
3171 if trimmed.starts_with("func ") && trimmed.get(5..6) == Some("(") {
3173 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 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 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
3240fn 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 if let Some(colon_pos) = trimmed.find(':') {
3248 let value = trimmed[colon_pos + 1..].trim();
3249 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
3259fn is_binary(content: &str) -> bool {
3267 content.as_bytes().iter().take(8192).any(|&b| b == 0)
3268}
3269
3270fn 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 ".json", ".yaml", ".yml", ".toml", ".lock", ".xml", ".csv", ".tsv",
3281 ".ini", ".cfg", ".conf", ".properties", ".env",
3282 ".md", ".markdown", ".txt", ".rst", ".svg", ".html", ".htm",
3284 ];
3285 extensions.iter().any(|ext| path_lower.ends_with(ext))
3286}
3287
3288fn 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 unsafe { String::from_utf8_unchecked(result) }
3336}
3337
3338fn collapse_separators(merged: &str, _base: &str) -> String {
3341 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 if !result.is_empty() && !result.ends_with('\n') {
3352 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 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 assert_eq!(replace_at_word_boundaries("fn get() {}", "get", "__E__"), "fn __E__() {}");
3392 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 assert_eq!(
3397 replace_at_word_boundaries("pub enum Source { Source }", "Source", "__E__"),
3398 "pub enum __E__ { __E__ }"
3399 );
3400 assert_eq!(
3402 replace_at_word_boundaries("SourceManager isSource", "Source", "__E__"),
3403 "SourceManager isSource"
3404 );
3405 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 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 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 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 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 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 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 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 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 assert!(!is_import_region("import foo from 'foo';\nlet x = 1;\nlet y = 2;\n"));
3576 assert!(!is_import_region(""));
3578 }
3579
3580 #[test]
3581 fn test_is_import_line() {
3582 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 assert!(is_import_line("use std::io::Read;"));
3588 assert!(is_import_line("#include <stdio.h>"));
3590 assert!(is_import_line("const fs = require('fs');"));
3592 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 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 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 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 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 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 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 assert!(is_whitespace_only_diff(
3803 " return 1;\n return 2;\n",
3804 " return 1;\n return 2;\n"
3805 ));
3806 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 assert!(!is_whitespace_only_diff(
3817 " return 1;\n",
3818 " return 2;\n"
3819 ));
3820 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 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 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 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 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 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 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 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 }
4036
4037 #[test]
4038 fn test_both_add_different_guard_clauses() {
4039 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 }
4062
4063 #[test]
4064 fn test_both_modify_different_enum_variants() {
4065 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 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 }
4123
4124 #[test]
4125 fn test_rust_impl_block_both_add_methods() {
4126 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 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 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 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 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 }
4330
4331 #[test]
4332 fn test_python_class_both_add_methods() {
4333 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 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 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 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 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 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 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 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 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 assert!(has_excessive_duplicates(&entities));
4492 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}