Skip to main content

git_tailor/
fragmap.rs

1// Copyright 2026 Thomas Johannesson
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15// Fragmap: chunk clustering for visualizing commit relationships
16//
17// Uses span propagation to map all hunk positions to a common reference
18// frame (the final file version at HEAD), matching the algorithm from the
19// original fragmap tool. Without propagation, line numbers from different
20// commits refer to different file versions and cannot be compared directly.
21
22use std::collections::HashMap;
23
24use crate::CommitDiff;
25
26mod spg;
27pub use spg::dump_per_file_spg_stats;
28use spg::{build_file_clusters, build_file_clusters_and_assign_hunks, deduplicate_clusters};
29
30/// Build a map from every known file path to the canonical (earliest) name for
31/// that file, following rename chains across commits.
32///
33/// The commit diffs must be in chronological order (oldest first).  When a
34/// `FileDiff` has `old_path ≠ new_path` the old name's canonical entry is
35/// propagated to the new name.
36pub(crate) fn build_rename_map(commit_diffs: &[CommitDiff]) -> HashMap<String, String> {
37    let mut canonical: HashMap<String, String> = HashMap::new();
38    for diff in commit_diffs {
39        for file in &diff.files {
40            if let (Some(old), Some(new)) = (&file.old_path, &file.new_path)
41                && old != new
42            {
43                let root = canonical.get(old).cloned().unwrap_or_else(|| old.clone());
44                canonical.insert(new.clone(), root);
45            }
46        }
47    }
48    canonical
49}
50
51/// Resolve a file path to its canonical (earliest) name using the rename map.
52fn canonical_path<'a>(path: &'a str, rename_map: &'a HashMap<String, String>) -> &'a str {
53    rename_map.get(path).map(|s| s.as_str()).unwrap_or(path)
54}
55
56/// Collect per-file hunk lists grouped by canonical path.
57///
58/// This is the shared grouping logic used by [`build_fragmap`],
59/// [`assign_hunk_groups`], [`extract_spans_propagated`], and
60/// [`dump_per_file_spg_stats`].
61pub(crate) fn collect_file_commits(
62    commit_diffs: &[CommitDiff],
63    rename_map: &HashMap<String, String>,
64) -> HashMap<String, Vec<(usize, Vec<HunkInfo>)>> {
65    let mut file_commits: HashMap<String, Vec<(usize, Vec<HunkInfo>)>> = HashMap::new();
66    for (commit_idx, diff) in commit_diffs.iter().enumerate() {
67        for file in &diff.files {
68            let path = match &file.new_path {
69                Some(p) => p.clone(),
70                None => continue,
71            };
72            let key = canonical_path(&path, rename_map).to_owned();
73            let hunks: Vec<HunkInfo> = file
74                .hunks
75                .iter()
76                .map(|h| HunkInfo {
77                    old_start: h.old_start,
78                    old_lines: h.old_lines,
79                    new_start: h.new_start,
80                    new_lines: h.new_lines,
81                })
82                .collect();
83            if !hunks.is_empty() {
84                let entry = file_commits.entry(key).or_default();
85                if let Some(last) = entry.last_mut()
86                    && last.0 == commit_idx
87                {
88                    last.1.extend(hunks);
89                    continue;
90                }
91                entry.push((commit_idx, hunks));
92            }
93        }
94    }
95    file_commits
96}
97
98/// A span of line numbers within a specific file.
99///
100/// Represents a contiguous range of lines that were touched by a commit,
101/// propagated forward to the final file version so all spans share the
102/// same reference frame. This allows overlap-based clustering to correctly
103/// detect which commits touch related code regions.
104#[derive(Debug, Clone, PartialEq, Eq)]
105pub struct FileSpan {
106    /// The file path (from the new version of the file).
107    pub path: String,
108    /// First line number (1-indexed) in the range.
109    pub start_line: u32,
110    /// Last line number (1-indexed) in the range, inclusive.
111    pub end_line: u32,
112}
113
114/// Extract FileSpans from all commit diffs with span propagation.
115///
116/// Each hunk produces a span using its full `[new_start, new_start + new_lines)`
117/// range (the region of the file occupied after the commit). That span is then
118/// propagated forward through every subsequent commit that modifies the same
119/// file, adjusting line numbers to account for insertions and deletions.
120/// The result: every span is expressed in the FINAL file version's coordinates,
121/// making overlap-based clustering correct across commits.
122pub fn extract_spans_propagated(commit_diffs: &[CommitDiff]) -> Vec<(String, Vec<FileSpan>)> {
123    let rename_map = build_rename_map(commit_diffs);
124    let file_commits = collect_file_commits(commit_diffs, &rename_map);
125
126    // For each file, propagate every commit's spans forward to the final version.
127    let mut all_spans: Vec<(usize, FileSpan)> = Vec::new();
128
129    for (path, commits) in &file_commits {
130        for (ci, (commit_idx, hunks)) in commits.iter().enumerate() {
131            for hunk in hunks {
132                if hunk.new_lines == 0 {
133                    continue;
134                }
135
136                // Start with the hunk's new-side range [start, end) exclusive
137                let mut spans = vec![(hunk.new_start, hunk.new_start + hunk.new_lines)];
138
139                // Propagate through all subsequent commits that touch this file,
140                // splitting around each commit's hunks to avoid mapping positions
141                // that fall inside a hunk's replaced region.
142                for (_, later_hunks) in &commits[ci + 1..] {
143                    spans = spans
144                        .into_iter()
145                        .flat_map(|(s, e)| split_and_propagate(s, e, later_hunks))
146                        .collect();
147                }
148
149                // Convert exclusive end to inclusive and add to results
150                for (start, end) in spans {
151                    if end > start {
152                        all_spans.push((
153                            *commit_idx,
154                            FileSpan {
155                                path: path.clone(),
156                                start_line: start,
157                                end_line: end - 1,
158                            },
159                        ));
160                    }
161                }
162            }
163        }
164    }
165
166    // Group spans by commit OID to match the expected format
167    let mut result: Vec<(String, Vec<FileSpan>)> = commit_diffs
168        .iter()
169        .map(|d| (d.commit.oid.clone(), Vec::new()))
170        .collect();
171
172    for (commit_idx, span) in all_spans {
173        result[commit_idx].1.push(span);
174    }
175
176    result
177}
178
179/// Lightweight copy of the hunk header fields needed for propagation.
180#[derive(Debug, Clone)]
181pub(crate) struct HunkInfo {
182    old_start: u32,
183    old_lines: u32,
184    new_start: u32,
185    new_lines: u32,
186}
187
188/// Map a single line number forward through a commit's hunks.
189///
190/// Given a line number in the file version *before* the commit and the
191/// commit's hunks (sorted by `old_start`), returns the corresponding
192/// line number in the file version *after* the commit.
193///
194/// IMPORTANT: only call this for positions that are OUTSIDE any hunk's
195/// `[old_start, old_start + old_lines)` range. Use `split_and_propagate`
196/// for arbitrary spans that might overlap with hunks.
197fn map_line_forward(line: u32, hunks: &[HunkInfo]) -> u32 {
198    let mut cumulative_delta: i64 = 0;
199
200    for hunk in hunks {
201        // After split_and_propagate, positions are guaranteed outside any
202        // hunk's [old_start, old_start + old_lines). A position equal to
203        // old_start is the exclusive end of a fragment before the hunk.
204        if line <= hunk.old_start {
205            return (line as i64 + cumulative_delta) as u32;
206        }
207
208        cumulative_delta += hunk.new_lines as i64 - hunk.old_lines as i64;
209    }
210
211    (line as i64 + cumulative_delta) as u32
212}
213
214/// Propagate a span `[start, end)` (exclusive end) through a commit's hunks.
215///
216/// Follows the original fragmap's "overhang" algorithm: the span is first
217/// split around each hunk's old range so that only the non-overlapping
218/// parts survive, then those parts are mapped to the new file version
219/// using the cumulative line offsets.
220///
221/// Returns zero or more spans in the post-commit file version.
222fn split_and_propagate(start: u32, end: u32, hunks: &[HunkInfo]) -> Vec<(u32, u32)> {
223    // 1. Split the span around each hunk's [old_start, old_end)
224    let mut remaining = vec![(start, end)];
225
226    for hunk in hunks {
227        let old_start = hunk.old_start;
228        let old_end = hunk.old_start + hunk.old_lines;
229
230        let mut next = Vec::new();
231        for (s, e) in remaining {
232            if e <= old_start || s >= old_end {
233                next.push((s, e));
234            } else {
235                if s < old_start {
236                    next.push((s, old_start));
237                }
238                if e > old_end {
239                    next.push((old_end, e));
240                }
241            }
242        }
243        remaining = next;
244    }
245
246    // 2. Map the remaining (non-overlapping) parts through the line offsets
247    remaining
248        .into_iter()
249        .filter(|(s, e)| e > s)
250        .map(|(s, e)| (map_line_forward(s, hunks), map_line_forward(e, hunks)))
251        .filter(|(s, e)| e > s)
252        .collect()
253}
254
255/// (legacy) Extract FileSpans from a single commit diff without propagation.
256/// Kept for tests that operate on individual commits.
257pub fn extract_spans(commit_diff: &CommitDiff) -> Vec<FileSpan> {
258    let mut spans = Vec::new();
259
260    for file in &commit_diff.files {
261        let path = match &file.new_path {
262            Some(p) => p.clone(),
263            None => continue,
264        };
265
266        for hunk in &file.hunks {
267            if hunk.new_lines == 0 {
268                continue;
269            }
270
271            spans.push(FileSpan {
272                path: path.to_string(),
273                start_line: hunk.new_start,
274                end_line: hunk.new_start + hunk.new_lines - 1,
275            });
276        }
277    }
278
279    spans
280}
281
282/// The kind of change a commit makes to a code region.
283///
284/// Used in the fragmap matrix to show how each commit touches each cluster.
285#[derive(Debug, Clone, Copy, PartialEq, Eq)]
286pub enum TouchKind {
287    /// The commit added new lines in this region (file was added or lines inserted).
288    Added,
289    /// The commit modified existing lines in this region.
290    Modified,
291    /// The commit deleted lines in this region.
292    Deleted,
293    /// The commit did not touch this region.
294    None,
295}
296
297/// A cluster of overlapping or adjacent FileSpans across multiple commits.
298///
299/// Represents a code region that multiple commits touch. Spans are merged
300/// when they overlap or are adjacent (within same file).
301#[derive(Debug, Clone, PartialEq, Eq)]
302pub struct SpanCluster {
303    /// The merged spans in this cluster (typically one span per file touched).
304    pub spans: Vec<FileSpan>,
305    /// OIDs of commits that touch this cluster.
306    pub commit_oids: Vec<String>,
307}
308
309/// The complete fragmap: commits, span clusters, and the matrix showing
310/// which commits touch which clusters.
311///
312/// The matrix is commits × clusters, where matrix[commit_idx][cluster_idx]
313/// indicates how that commit touches that cluster.
314#[derive(Debug, Clone)]
315pub struct FragMap {
316    /// The commits in order (oldest to newest).
317    pub commits: Vec<String>,
318    /// The span clusters (code regions touched by commits).
319    pub clusters: Vec<SpanCluster>,
320    /// Matrix[commit_idx][cluster_idx] = TouchKind
321    pub matrix: Vec<Vec<TouchKind>>,
322}
323
324/// Build a fragmap from a collection of commits and their diffs.
325///
326/// Implements the Span Propagation Graph (SPG) algorithm from the original
327/// fragmap tool. For each file, a DAG is built where active nodes (hunks)
328/// and inactive nodes (propagated surviving spans) are connected by overlap
329/// edges. Columns correspond to unique paths through the DAG, with each
330/// path's active nodes determining which commits have CHANGE in that column.
331/// Build a fragmap from a list of commit diffs.
332///
333/// When `deduplicate` is `true` (the normal view), columns whose set of touching
334/// commits is identical are merged into one, producing a compact matrix. Pass
335/// `false` to keep every raw hunk cluster as its own column, which is useful
336/// for debugging the cluster layout.
337pub fn build_fragmap(commit_diffs: &[CommitDiff], deduplicate: bool) -> FragMap {
338    let rename_map = build_rename_map(commit_diffs);
339    let file_commits = collect_file_commits(commit_diffs, &rename_map);
340
341    let mut clusters: Vec<SpanCluster> = Vec::new();
342
343    let mut sorted_paths: Vec<&String> = file_commits.keys().collect();
344    sorted_paths.sort();
345
346    for path in sorted_paths {
347        let commits_for_file = &file_commits[path];
348        clusters.extend(build_file_clusters(path, commits_for_file, commit_diffs));
349    }
350
351    if deduplicate {
352        deduplicate_clusters(&mut clusters);
353    }
354
355    let commits: Vec<String> = commit_diffs.iter().map(|d| d.commit.oid.clone()).collect();
356    let matrix = build_matrix(&commits, &clusters, commit_diffs, &rename_map);
357
358    FragMap {
359        commits,
360        clusters,
361        matrix,
362    }
363}
364
365/// Compute the fragmap-based hunk group assignment for commit `commit_oid`.
366///
367/// Uses the same SPG clustering and deduplication as [`build_fragmap`] with
368/// `deduplicate = true`. Each hunk of `commit_oid` in `commit_diffs` is
369/// assigned to the deduplicated fragmap cluster (column) it belongs to.
370///
371/// Returns `Some((group_count, assignment))` where:
372/// - `group_count` = number of distinct groups after deduplication
373/// - `assignment[path][h]` = 0-based group index for hunk `h` of file `path`
374///   in the commit being split (indexed the same way as the 0-context full
375///   diff produced by [`GitRepo::commit_diff_for_fragmap`])
376///
377/// Returns `None` if `commit_oid` is not found in `commit_diffs`.
378pub fn assign_hunk_groups(
379    commit_diffs: &[CommitDiff],
380    commit_oid: &str,
381) -> Option<(usize, HashMap<String, Vec<usize>>)> {
382    let k_idx = commit_diffs
383        .iter()
384        .position(|d| d.commit.oid == commit_oid)?;
385
386    let rename_map = build_rename_map(commit_diffs);
387    let file_commits = collect_file_commits(commit_diffs, &rename_map);
388
389    let mut sorted_paths: Vec<&String> = file_commits.keys().collect();
390    sorted_paths.sort();
391
392    // Phase 1: accumulate pre-dedup clusters (in the same order as build_fragmap)
393    // and record, for each of K's hunks, ALL pre-dedup clusters it appears in.
394    //
395    // k_hunk_prededup[path][h] = list of global pre-dedup cluster indices.
396    // A hunk can appear on multiple SPG paths with different commit_oids,
397    // so it may belong to multiple clusters (and after dedup, multiple groups).
398    let mut pre_dedup_clusters: Vec<SpanCluster> = Vec::new();
399    let mut k_hunk_prededup: HashMap<String, Vec<Vec<usize>>> = HashMap::new();
400
401    for path in &sorted_paths {
402        let commits_for_file = &file_commits[*path];
403        let file_cluster_offset = pre_dedup_clusters.len();
404
405        let has_k = commits_for_file.iter().any(|(idx, _)| *idx == k_idx);
406        let (file_clusters, hunk_to_local) = if has_k {
407            build_file_clusters_and_assign_hunks(path, commits_for_file, commit_diffs, k_idx)
408        } else {
409            (
410                build_file_clusters(path, commits_for_file, commit_diffs),
411                vec![],
412            )
413        };
414
415        if has_k && !hunk_to_local.is_empty() {
416            let global_indices: Vec<Vec<usize>> = hunk_to_local
417                .iter()
418                .map(|locals| {
419                    locals
420                        .iter()
421                        .map(|&local| file_cluster_offset + local)
422                        .collect()
423                })
424                .collect();
425            k_hunk_prededup.insert((*path).clone(), global_indices);
426        }
427
428        pre_dedup_clusters.extend(file_clusters);
429    }
430
431    // Phase 2: compute the dedup mapping, replicating the logic of
432    // deduplicate_clusters but also building pre_idx → group_idx.
433    for cluster in &mut pre_dedup_clusters {
434        cluster.commit_oids.sort();
435    }
436    let mut seen_patterns: Vec<Vec<String>> = Vec::new();
437    let mut group_idx_for_prededup: Vec<usize> = Vec::with_capacity(pre_dedup_clusters.len());
438    for cluster in &pre_dedup_clusters {
439        if let Some(pos) = seen_patterns.iter().position(|p| p == &cluster.commit_oids) {
440            group_idx_for_prededup.push(pos);
441        } else {
442            let new_idx = seen_patterns.len();
443            seen_patterns.push(cluster.commit_oids.clone());
444            group_idx_for_prededup.push(new_idx);
445        }
446    }
447    let group_count = seen_patterns.len();
448
449    // Phase 3: Assign each of K's hunks to exactly one dedup group.
450    //
451    // A hunk can belong to multiple pre-dedup clusters (via different SPG
452    // paths) which may map to different dedup groups.  We must ensure every
453    // group that K contributes to gets at least one hunk assigned, so the
454    // split produces the same number of commits as fragmap columns K touches.
455    //
456    // Strategy:
457    //  1. Compute each hunk's set of possible groups.
458    //  2. Hunks with only one possible group are assigned immediately.
459    //  3. For uncovered groups, pick the unassigned hunk with the fewest
460    //     alternatives and assign it there.
461    //  4. Remaining unassigned hunks go to their lowest possible group.
462
463    // Flatten (path, hunk_idx, possible_groups) for every hunk of K.
464    let mut candidates: Vec<(String, usize, Vec<usize>)> = Vec::new();
465    for (path, prededup_multi) in &k_hunk_prededup {
466        for (h, prededup_indices) in prededup_multi.iter().enumerate() {
467            let mut groups: Vec<usize> = prededup_indices
468                .iter()
469                .map(|&pre_idx| group_idx_for_prededup[pre_idx])
470                .collect();
471            groups.sort();
472            groups.dedup();
473            if groups.is_empty() {
474                groups.push(0);
475            }
476            candidates.push((path.clone(), h, groups));
477        }
478    }
479
480    // All groups K can touch.
481    let all_touched: std::collections::BTreeSet<usize> = candidates
482        .iter()
483        .flat_map(|(_, _, gs)| gs.iter().copied())
484        .collect();
485
486    let mut assigned: Vec<Option<usize>> = vec![None; candidates.len()];
487
488    // Pass 1: hunks with only one possible group.
489    for (i, (_, _, groups)) in candidates.iter().enumerate() {
490        if groups.len() == 1 {
491            assigned[i] = Some(groups[0]);
492        }
493    }
494
495    // Pass 2: for each uncovered group, assign the best remaining hunk.
496    let mut covered: std::collections::HashSet<usize> =
497        assigned.iter().filter_map(|a| *a).collect();
498    for &g in &all_touched {
499        if covered.contains(&g) {
500            continue;
501        }
502        let best = candidates
503            .iter()
504            .enumerate()
505            .filter(|(i, (_, _, groups))| assigned[*i].is_none() && groups.contains(&g))
506            .min_by_key(|(_, (_, _, groups))| groups.len())
507            .map(|(i, _)| i);
508        if let Some(i) = best {
509            assigned[i] = Some(g);
510            covered.insert(g);
511        }
512    }
513
514    // Pass 3: remaining hunks go to their lowest possible group.
515    for (i, (_, _, groups)) in candidates.iter().enumerate() {
516        if assigned[i].is_none() {
517            assigned[i] = Some(groups[0]);
518        }
519    }
520
521    // Build the assignment map: path -> Vec<usize> (one group per hunk).
522    let mut assignment: HashMap<String, Vec<usize>> = HashMap::new();
523    for (i, (path, h, _)) in candidates.iter().enumerate() {
524        let entry = assignment
525            .entry(path.clone())
526            .or_insert_with(|| vec![0; k_hunk_prededup[path].len()]);
527        entry[*h] = assigned[i].unwrap_or(0);
528    }
529
530    Some((group_count, assignment))
531}
532
533impl FragMap {
534    /// Find the single commit this commit can be squashed into, if any.
535    ///
536    /// Returns `Some(target_idx)` when every cluster the commit touches is
537    /// squashable (no conflicting commits in between) and all clusters
538    /// point to the same single earlier commit. Returns `None` otherwise.
539    pub fn squash_target(&self, commit_idx: usize) -> Option<usize> {
540        let mut target: Option<usize> = None;
541
542        for cluster_idx in 0..self.clusters.len() {
543            if self.matrix[commit_idx][cluster_idx] == TouchKind::None {
544                continue;
545            }
546
547            let earlier = (0..commit_idx).find(|&i| self.matrix[i][cluster_idx] != TouchKind::None);
548
549            let earlier_idx = earlier?;
550
551            match self.cluster_relation(earlier_idx, commit_idx, cluster_idx) {
552                SquashRelation::Squashable => match target {
553                    None => target = Some(earlier_idx),
554                    Some(t) if t == earlier_idx => {}
555                    Some(_) => return None,
556                },
557                _ => return None,
558            }
559        }
560
561        target
562    }
563
564    /// Check if a commit is fully squashable into a single other commit.
565    pub fn is_fully_squashable(&self, commit_idx: usize) -> bool {
566        self.squash_target(commit_idx).is_some()
567    }
568
569    /// Check whether two commits both touch at least one common cluster.
570    pub fn shares_cluster_with(&self, a: usize, b: usize) -> bool {
571        if a == b {
572            return false;
573        }
574        (0..self.clusters.len())
575            .any(|c| self.matrix[a][c] != TouchKind::None && self.matrix[b][c] != TouchKind::None)
576    }
577
578    /// Determine the overall squash relationship between two commits.
579    ///
580    /// Examines every cluster both commits touch:
581    /// - `NoRelation` if they share no clusters at all.
582    /// - `Squashable` if every shared cluster is squashable (no interfering
583    ///   commits in between).
584    /// - `Conflicting` if any shared cluster has interfering commits.
585    pub fn pairwise_squash_relation(&self, a: usize, b: usize) -> SquashRelation {
586        if a == b || a >= self.commits.len() || b >= self.commits.len() {
587            return SquashRelation::NoRelation;
588        }
589        let (earlier, later) = if a < b { (a, b) } else { (b, a) };
590
591        let mut has_shared = false;
592        for c in 0..self.clusters.len() {
593            if self.matrix[earlier][c] == TouchKind::None
594                || self.matrix[later][c] == TouchKind::None
595            {
596                continue;
597            }
598            has_shared = true;
599            if self.cluster_relation(earlier, later, c) == SquashRelation::Conflicting {
600                return SquashRelation::Conflicting;
601            }
602        }
603
604        if has_shared {
605            SquashRelation::Squashable
606        } else {
607            SquashRelation::NoRelation
608        }
609    }
610
611    /// Determine the relationship between two commits for a specific cluster.
612    ///
613    /// Returns `NoRelation` if one or both commits don't touch the cluster,
614    /// `Squashable` if both touch it with no collisions in between, or
615    /// `Conflicting` if both touch it with other commits in between.
616    pub fn cluster_relation(
617        &self,
618        earlier_commit_idx: usize,
619        later_commit_idx: usize,
620        cluster_idx: usize,
621    ) -> SquashRelation {
622        if earlier_commit_idx >= self.commits.len()
623            || later_commit_idx >= self.commits.len()
624            || cluster_idx >= self.clusters.len()
625        {
626            return SquashRelation::NoRelation;
627        }
628
629        if earlier_commit_idx >= later_commit_idx {
630            return SquashRelation::NoRelation;
631        }
632
633        let earlier_touches = self.matrix[earlier_commit_idx][cluster_idx] != TouchKind::None;
634        let later_touches = self.matrix[later_commit_idx][cluster_idx] != TouchKind::None;
635
636        if !earlier_touches || !later_touches {
637            return SquashRelation::NoRelation;
638        }
639
640        for commit_idx in (earlier_commit_idx + 1)..later_commit_idx {
641            if self.matrix[commit_idx][cluster_idx] != TouchKind::None {
642                return SquashRelation::Conflicting;
643            }
644        }
645
646        SquashRelation::Squashable
647    }
648
649    /// Determine whether a connector between `below_idx` and its earliest
650    /// earlier neighbour in `cluster_idx` should be rendered as squashable.
651    ///
652    /// Returns `None` if there is no connector (no touch above or no `below`).
653    /// Returns `Some(true)` for squashable, `Some(false)` for conflicting.
654    ///
655    /// `scope` controls the rule used:
656    /// - `Cluster`: per-cluster pair only (fine-grained).
657    /// - `Commit`: the entire lower commit must be fully squashable into the
658    ///   same single upper commit (strict, matching the original fragmap tool).
659    pub fn connector_squashable(
660        &self,
661        commit_idx: usize,
662        cluster_idx: usize,
663        scope: SquashableScope,
664    ) -> Option<bool> {
665        let earlier = (0..commit_idx).find(|&i| self.matrix[i][cluster_idx] != TouchKind::None)?;
666
667        match scope {
668            SquashableScope::Group => Some(
669                self.cluster_relation(earlier, commit_idx, cluster_idx)
670                    == SquashRelation::Squashable,
671            ),
672            SquashableScope::Commit => {
673                Some(self.squash_target(commit_idx).is_some_and(|t| t == earlier))
674            }
675        }
676    }
677}
678
679/// Build the commits × clusters matrix with TouchKind values.
680///
681/// For each (commit, cluster), determine if the commit touches the cluster
682/// and if so, classify the touch as Added/Modified/Deleted.
683fn build_matrix(
684    commits: &[String],
685    clusters: &[SpanCluster],
686    commit_diffs: &[CommitDiff],
687    rename_map: &HashMap<String, String>,
688) -> Vec<Vec<TouchKind>> {
689    let mut matrix = vec![vec![TouchKind::None; clusters.len()]; commits.len()];
690
691    for (commit_idx, commit_oid) in commits.iter().enumerate() {
692        let commit_diff = &commit_diffs[commit_idx];
693
694        for (cluster_idx, cluster) in clusters.iter().enumerate() {
695            if cluster.commit_oids.contains(commit_oid) {
696                matrix[commit_idx][cluster_idx] =
697                    determine_touch_kind(commit_diff, cluster, rename_map);
698            }
699        }
700    }
701
702    matrix
703}
704
705/// Determine how a commit touches a cluster (Added/Modified/Deleted).
706///
707/// Looks at the files in the commit that overlap with the cluster's spans
708/// to classify the type of change. Uses the rename map to match file paths
709/// across renames.
710fn determine_touch_kind(
711    commit_diff: &CommitDiff,
712    cluster: &SpanCluster,
713    rename_map: &HashMap<String, String>,
714) -> TouchKind {
715    for cluster_span in &cluster.spans {
716        let cluster_canonical = canonical_path(&cluster_span.path, rename_map);
717        for file in &commit_diff.files {
718            let file_path = file.new_path.as_ref().or(file.old_path.as_ref());
719            let matches = file_path
720                .map(|p| canonical_path(p, rename_map) == cluster_canonical)
721                .unwrap_or(false);
722            if matches {
723                if file.old_path.is_none() && file.new_path.is_some() {
724                    return TouchKind::Added;
725                } else if file.old_path.is_some() && file.new_path.is_none() {
726                    return TouchKind::Deleted;
727                } else {
728                    return TouchKind::Modified;
729                }
730            }
731        }
732    }
733
734    TouchKind::None
735}
736
737/// Controls how the squashable connector indicator is computed.
738///
739/// Determines what the yellow-connector symbol means in the fragmap matrix.
740#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, clap::ValueEnum)]
741pub enum SquashableScope {
742    /// A connector is squashable when *that specific hunk group pair alone* has
743    /// no intervening touches. This is the granular per-group rule, and is
744    /// the default for the interactive TUI.
745    #[default]
746    Group,
747    /// A connector is squashable only when the *entire later commit* is fully
748    /// squashable into the same single earlier commit. This is a stricter
749    /// whole-commit rule, matching the original fragmap tool, and is
750    /// the default for `--static` output.
751    Commit,
752}
753
754/// The relationship between two commits within a specific cluster.
755///
756/// Used to determine if commits that touch the same cluster can be
757/// safely squashed together, following the original fragmap logic.
758#[derive(Debug, Clone, Copy, PartialEq, Eq)]
759pub enum SquashRelation {
760    /// Neither commit (or only one) touches this cluster.
761    NoRelation,
762    /// Both commits touch the cluster with no collisions in between.
763    /// These commits can potentially be squashed (yellow in UI).
764    Squashable,
765    /// Both commits touch the cluster with collisions (commits in between
766    /// also touch it). Squashing would conflict (red in UI).
767    Conflicting,
768}
769
770#[cfg(test)]
771mod tests {
772    use super::*;
773    use crate::{CommitDiff, CommitInfo, FileDiff, Hunk};
774
775    fn make_commit_info() -> CommitInfo {
776        CommitInfo {
777            oid: "abc123".to_string(),
778            summary: "Test commit".to_string(),
779            author: Some("Test Author".to_string()),
780            date: Some("123456789".to_string()),
781            parent_oids: vec![],
782            message: "Test commit".to_string(),
783            author_email: Some("test@example.com".to_string()),
784            author_date: Some(time::OffsetDateTime::from_unix_timestamp(123456789).unwrap()),
785            committer: Some("Test Committer".to_string()),
786            committer_email: Some("committer@example.com".to_string()),
787            commit_date: Some(time::OffsetDateTime::from_unix_timestamp(123456789).unwrap()),
788        }
789    }
790
791    #[test]
792    fn test_extract_spans_single_hunk() {
793        let commit_diff = CommitDiff {
794            commit: make_commit_info(),
795            files: vec![FileDiff {
796                old_path: Some("file.txt".to_string()),
797                new_path: Some("file.txt".to_string()),
798                status: crate::DeltaStatus::Modified,
799                hunks: vec![Hunk {
800                    old_start: 10,
801                    old_lines: 3,
802                    new_start: 10,
803                    new_lines: 5,
804                    lines: vec![],
805                }],
806            }],
807        };
808
809        let spans = extract_spans(&commit_diff);
810
811        assert_eq!(spans.len(), 1);
812        assert_eq!(spans[0].path, "file.txt");
813        assert_eq!(spans[0].start_line, 10);
814        assert_eq!(spans[0].end_line, 14); // 10 + 5 - 1
815    }
816
817    #[test]
818    fn test_extract_spans_multiple_hunks() {
819        let commit_diff = CommitDiff {
820            commit: make_commit_info(),
821            files: vec![FileDiff {
822                old_path: Some("file.txt".to_string()),
823                new_path: Some("file.txt".to_string()),
824                status: crate::DeltaStatus::Modified,
825                hunks: vec![
826                    Hunk {
827                        old_start: 5,
828                        old_lines: 2,
829                        new_start: 5,
830                        new_lines: 3,
831                        lines: vec![],
832                    },
833                    Hunk {
834                        old_start: 20,
835                        old_lines: 1,
836                        new_start: 21,
837                        new_lines: 2,
838                        lines: vec![],
839                    },
840                ],
841            }],
842        };
843
844        let spans = extract_spans(&commit_diff);
845
846        assert_eq!(spans.len(), 2);
847        assert_eq!(spans[0].path, "file.txt");
848        assert_eq!(spans[0].start_line, 5);
849        assert_eq!(spans[0].end_line, 7); // 5 + 3 - 1
850
851        assert_eq!(spans[1].path, "file.txt");
852        assert_eq!(spans[1].start_line, 21);
853        assert_eq!(spans[1].end_line, 22); // 21 + 2 - 1
854    }
855
856    #[test]
857    fn test_extract_spans_multiple_files() {
858        let commit_diff = CommitDiff {
859            commit: make_commit_info(),
860            files: vec![
861                FileDiff {
862                    old_path: Some("a.txt".to_string()),
863                    new_path: Some("a.txt".to_string()),
864                    status: crate::DeltaStatus::Modified,
865                    hunks: vec![Hunk {
866                        old_start: 1,
867                        old_lines: 1,
868                        new_start: 1,
869                        new_lines: 2,
870                        lines: vec![],
871                    }],
872                },
873                FileDiff {
874                    old_path: Some("b.txt".to_string()),
875                    new_path: Some("b.txt".to_string()),
876                    status: crate::DeltaStatus::Modified,
877                    hunks: vec![Hunk {
878                        old_start: 10,
879                        old_lines: 3,
880                        new_start: 10,
881                        new_lines: 4,
882                        lines: vec![],
883                    }],
884                },
885            ],
886        };
887
888        let spans = extract_spans(&commit_diff);
889
890        assert_eq!(spans.len(), 2);
891        assert_eq!(spans[0].path, "a.txt");
892        assert_eq!(spans[0].start_line, 1);
893        assert_eq!(spans[0].end_line, 2);
894
895        assert_eq!(spans[1].path, "b.txt");
896        assert_eq!(spans[1].start_line, 10);
897        assert_eq!(spans[1].end_line, 13);
898    }
899
900    #[test]
901    fn test_extract_spans_skips_deleted_files() {
902        let commit_diff = CommitDiff {
903            commit: make_commit_info(),
904            files: vec![
905                FileDiff {
906                    old_path: Some("file.txt".to_string()),
907                    new_path: Some("file.txt".to_string()),
908                    status: crate::DeltaStatus::Modified,
909                    hunks: vec![Hunk {
910                        old_start: 1,
911                        old_lines: 1,
912                        new_start: 1,
913                        new_lines: 2,
914                        lines: vec![],
915                    }],
916                },
917                FileDiff {
918                    old_path: Some("deleted.txt".to_string()),
919                    new_path: None, // File was deleted
920                    status: crate::DeltaStatus::Deleted,
921                    hunks: vec![Hunk {
922                        old_start: 1,
923                        old_lines: 5,
924                        new_start: 0,
925                        new_lines: 0,
926                        lines: vec![],
927                    }],
928                },
929            ],
930        };
931
932        let spans = extract_spans(&commit_diff);
933
934        // Should only have span from file.txt, not from deleted.txt
935        assert_eq!(spans.len(), 1);
936        assert_eq!(spans[0].path, "file.txt");
937    }
938
939    #[test]
940    fn test_extract_spans_skips_empty_hunks() {
941        let commit_diff = CommitDiff {
942            commit: make_commit_info(),
943            files: vec![FileDiff {
944                old_path: Some("file.txt".to_string()),
945                new_path: Some("file.txt".to_string()),
946                status: crate::DeltaStatus::Modified,
947                hunks: vec![
948                    Hunk {
949                        old_start: 5,
950                        old_lines: 2,
951                        new_start: 5,
952                        new_lines: 3,
953                        lines: vec![],
954                    },
955                    Hunk {
956                        old_start: 10,
957                        old_lines: 1,
958                        new_start: 8,
959                        new_lines: 0, // Empty hunk (pure deletion in context)
960                        lines: vec![],
961                    },
962                ],
963            }],
964        };
965
966        let spans = extract_spans(&commit_diff);
967
968        // Should only have span from first hunk, not the empty one
969        assert_eq!(spans.len(), 1);
970        assert_eq!(spans[0].start_line, 5);
971        assert_eq!(spans[0].end_line, 7);
972    }
973
974    #[test]
975    fn test_extract_spans_added_file() {
976        let commit_diff = CommitDiff {
977            commit: make_commit_info(),
978            files: vec![FileDiff {
979                old_path: None, // File was added
980                new_path: Some("new_file.txt".to_string()),
981                status: crate::DeltaStatus::Added,
982                hunks: vec![Hunk {
983                    old_start: 0,
984                    old_lines: 0,
985                    new_start: 1,
986                    new_lines: 10,
987                    lines: vec![],
988                }],
989            }],
990        };
991
992        let spans = extract_spans(&commit_diff);
993
994        assert_eq!(spans.len(), 1);
995        assert_eq!(spans[0].path, "new_file.txt");
996        assert_eq!(spans[0].start_line, 1);
997        assert_eq!(spans[0].end_line, 10);
998    }
999
1000    #[test]
1001    fn test_extract_spans_single_line_change() {
1002        let commit_diff = CommitDiff {
1003            commit: make_commit_info(),
1004            files: vec![FileDiff {
1005                old_path: Some("file.txt".to_string()),
1006                new_path: Some("file.txt".to_string()),
1007                status: crate::DeltaStatus::Modified,
1008                hunks: vec![Hunk {
1009                    old_start: 42,
1010                    old_lines: 1,
1011                    new_start: 42,
1012                    new_lines: 1,
1013                    lines: vec![],
1014                }],
1015            }],
1016        };
1017
1018        let spans = extract_spans(&commit_diff);
1019
1020        assert_eq!(spans.len(), 1);
1021        assert_eq!(spans[0].start_line, 42);
1022        assert_eq!(spans[0].end_line, 42); // Single line: 42 + 1 - 1 = 42
1023    }
1024
1025    #[test]
1026    fn test_extract_spans_empty_commit() {
1027        let commit_diff = CommitDiff {
1028            commit: make_commit_info(),
1029            files: vec![],
1030        };
1031
1032        let spans = extract_spans(&commit_diff);
1033
1034        assert_eq!(spans.len(), 0);
1035    }
1036
1037    #[test]
1038    fn test_map_line_forward_before_hunk() {
1039        let hunks = vec![HunkInfo {
1040            old_start: 10,
1041            old_lines: 5,
1042            new_start: 10,
1043            new_lines: 8,
1044        }];
1045        assert_eq!(map_line_forward(5, &hunks), 5);
1046    }
1047
1048    #[test]
1049    fn test_map_line_forward_after_hunk() {
1050        let hunks = vec![HunkInfo {
1051            old_start: 10,
1052            old_lines: 5,
1053            new_start: 10,
1054            new_lines: 8,
1055        }];
1056        // delta = 8 - 5 = 3, so line 20 → 23
1057        assert_eq!(map_line_forward(20, &hunks), 23);
1058    }
1059
1060    #[test]
1061    fn test_split_and_propagate_overlap() {
1062        let hunks = vec![HunkInfo {
1063            old_start: 10,
1064            old_lines: 5,
1065            new_start: 10,
1066            new_lines: 8,
1067        }];
1068        // Span [8, 20) partially overlaps with hunk [10, 15).
1069        // After splitting: [8, 10) (before) and [15, 20) (after).
1070        // [8, 10) → map: 8 < 10, no delta → [8, 10)
1071        // [15, 20) → map: 15 >= 15, delta = 8-5 = 3 → [18, 23)
1072        let result = split_and_propagate(8, 20, &hunks);
1073        assert_eq!(result, vec![(8, 10), (18, 23)]);
1074    }
1075
1076    #[test]
1077    fn test_map_line_forward_two_hunks() {
1078        let hunks = vec![
1079            HunkInfo {
1080                old_start: 10,
1081                old_lines: 5,
1082                new_start: 10,
1083                new_lines: 8,
1084            },
1085            HunkInfo {
1086                old_start: 30,
1087                old_lines: 3,
1088                new_start: 33,
1089                new_lines: 5,
1090            },
1091        ];
1092        // Between hunks: line 25 → 25 + 3 = 28
1093        assert_eq!(map_line_forward(25, &hunks), 28);
1094        // After both: line 40 → 40 + 3 + 2 = 45
1095        assert_eq!(map_line_forward(40, &hunks), 45);
1096    }
1097
1098    #[test]
1099    fn test_propagation_sequential_commits_same_file() {
1100        // Two commits touching different, distant parts of the same file.
1101        // After propagation they should not share a cluster.
1102        let commits = vec![
1103            CommitDiff {
1104                commit: make_commit_info_with_oid("c1"),
1105                files: vec![FileDiff {
1106                    old_path: Some("f.rs".to_string()),
1107                    new_path: Some("f.rs".to_string()),
1108                    status: crate::DeltaStatus::Modified,
1109                    hunks: vec![Hunk {
1110                        old_start: 10,
1111                        old_lines: 5,
1112                        new_start: 10,
1113                        new_lines: 8,
1114                        lines: vec![],
1115                    }],
1116                }],
1117            },
1118            CommitDiff {
1119                commit: make_commit_info_with_oid("c2"),
1120                files: vec![FileDiff {
1121                    old_path: Some("f.rs".to_string()),
1122                    new_path: Some("f.rs".to_string()),
1123                    status: crate::DeltaStatus::Modified,
1124                    hunks: vec![Hunk {
1125                        old_start: 50,
1126                        old_lines: 5,
1127                        new_start: 50,
1128                        new_lines: 5,
1129                        lines: vec![],
1130                    }],
1131                }],
1132            },
1133        ];
1134
1135        let fm = build_fragmap(&commits, true);
1136        assert!(!fm.shares_cluster_with(0, 1));
1137    }
1138
1139    #[test]
1140    fn test_propagation_overlapping_hunks_are_related() {
1141        // Commit 1 inserts a large block. Commit 2 modifies within that block.
1142        // After propagation commit 1's span includes commit 2's region.
1143        let commits = vec![
1144            CommitDiff {
1145                commit: make_commit_info_with_oid("c1"),
1146                files: vec![FileDiff {
1147                    old_path: Some("f.rs".to_string()),
1148                    new_path: Some("f.rs".to_string()),
1149                    status: crate::DeltaStatus::Modified,
1150                    hunks: vec![Hunk {
1151                        old_start: 10,
1152                        old_lines: 5,
1153                        new_start: 10,
1154                        new_lines: 55,
1155                        lines: vec![],
1156                    }],
1157                }],
1158            },
1159            CommitDiff {
1160                commit: make_commit_info_with_oid("c2"),
1161                files: vec![FileDiff {
1162                    old_path: Some("f.rs".to_string()),
1163                    new_path: Some("f.rs".to_string()),
1164                    status: crate::DeltaStatus::Modified,
1165                    hunks: vec![Hunk {
1166                        old_start: 30,
1167                        old_lines: 10,
1168                        new_start: 30,
1169                        new_lines: 10,
1170                        lines: vec![],
1171                    }],
1172                }],
1173            },
1174        ];
1175
1176        let fm = build_fragmap(&commits, true);
1177        assert!(fm.shares_cluster_with(0, 1));
1178    }
1179
1180    #[test]
1181    fn test_propagation_distant_changes_not_related() {
1182        // Changes far apart in the same file should not cluster.
1183        let commits = vec![
1184            CommitDiff {
1185                commit: make_commit_info_with_oid("c1"),
1186                files: vec![FileDiff {
1187                    old_path: Some("f.rs".to_string()),
1188                    new_path: Some("f.rs".to_string()),
1189                    status: crate::DeltaStatus::Modified,
1190                    hunks: vec![Hunk {
1191                        old_start: 10,
1192                        old_lines: 3,
1193                        new_start: 10,
1194                        new_lines: 5,
1195                        lines: vec![],
1196                    }],
1197                }],
1198            },
1199            CommitDiff {
1200                commit: make_commit_info_with_oid("c2"),
1201                files: vec![FileDiff {
1202                    old_path: Some("f.rs".to_string()),
1203                    new_path: Some("f.rs".to_string()),
1204                    status: crate::DeltaStatus::Modified,
1205                    hunks: vec![Hunk {
1206                        old_start: 200,
1207                        old_lines: 5,
1208                        new_start: 202,
1209                        new_lines: 5,
1210                        lines: vec![],
1211                    }],
1212                }],
1213            },
1214        ];
1215
1216        let fm = build_fragmap(&commits, true);
1217        assert!(!fm.shares_cluster_with(0, 1));
1218    }
1219
1220    // Helper functions for matrix generation tests
1221
1222    fn make_commit_info_with_oid(oid: &str) -> CommitInfo {
1223        CommitInfo {
1224            oid: oid.to_string(),
1225            summary: format!("Commit {}", oid),
1226            author: Some("Test Author".to_string()),
1227            date: Some("123456789".to_string()),
1228            parent_oids: vec![],
1229            message: format!("Commit {}", oid),
1230            author_email: Some("test@example.com".to_string()),
1231            author_date: Some(time::OffsetDateTime::from_unix_timestamp(123456789).unwrap()),
1232            committer: Some("Test Committer".to_string()),
1233            committer_email: Some("committer@example.com".to_string()),
1234            commit_date: Some(time::OffsetDateTime::from_unix_timestamp(123456789).unwrap()),
1235        }
1236    }
1237
1238    fn make_file_diff(
1239        old_path: Option<&str>,
1240        new_path: Option<&str>,
1241        old_start: u32,
1242        old_lines: u32,
1243        new_start: u32,
1244        new_lines: u32,
1245    ) -> FileDiff {
1246        FileDiff {
1247            old_path: old_path.map(|s| s.to_string()),
1248            new_path: new_path.map(|s| s.to_string()),
1249            status: crate::DeltaStatus::Modified,
1250            hunks: vec![Hunk {
1251                old_start,
1252                old_lines,
1253                new_start,
1254                new_lines,
1255                lines: vec![],
1256            }],
1257        }
1258    }
1259
1260    fn make_commit_diff(oid: &str, files: Vec<FileDiff>) -> CommitDiff {
1261        CommitDiff {
1262            commit: make_commit_info_with_oid(oid),
1263            files,
1264        }
1265    }
1266
1267    // Matrix generation tests
1268
1269    #[test]
1270    fn test_build_fragmap_empty_commits() {
1271        let fragmap = build_fragmap(&[], true);
1272
1273        assert_eq!(fragmap.commits.len(), 0);
1274        assert_eq!(fragmap.clusters.len(), 0);
1275        assert_eq!(fragmap.matrix.len(), 0);
1276    }
1277
1278    #[test]
1279    fn test_build_fragmap_single_commit() {
1280        let commits = vec![make_commit_diff(
1281            "c1",
1282            vec![make_file_diff(
1283                None, // File was added
1284                Some("file.txt"),
1285                0,
1286                0,
1287                1,
1288                3,
1289            )],
1290        )];
1291
1292        let fragmap = build_fragmap(&commits, true);
1293
1294        assert_eq!(fragmap.commits.len(), 1);
1295        assert_eq!(fragmap.commits[0], "c1");
1296
1297        // Should have one cluster
1298        assert_eq!(fragmap.clusters.len(), 1);
1299        assert_eq!(fragmap.clusters[0].spans.len(), 1);
1300        assert_eq!(fragmap.clusters[0].spans[0].path, "file.txt");
1301        assert_eq!(fragmap.clusters[0].commit_oids, vec!["c1"]);
1302
1303        // Matrix should be 1x1 with Added
1304        assert_eq!(fragmap.matrix.len(), 1);
1305        assert_eq!(fragmap.matrix[0].len(), 1);
1306        assert_eq!(fragmap.matrix[0][0], TouchKind::Added);
1307    }
1308
1309    #[test]
1310    fn test_build_fragmap_overlapping_spans_merge() {
1311        // Two commits touching overlapping regions should be related
1312        let commits = vec![
1313            make_commit_diff(
1314                "c1",
1315                vec![make_file_diff(
1316                    Some("file.txt"),
1317                    Some("file.txt"),
1318                    1,
1319                    0,
1320                    1,
1321                    5, // lines 1-5
1322                )],
1323            ),
1324            make_commit_diff(
1325                "c2",
1326                vec![make_file_diff(
1327                    Some("file.txt"),
1328                    Some("file.txt"),
1329                    3,
1330                    3,
1331                    3,
1332                    4, // lines 3-6 (overlaps with c1)
1333                )],
1334            ),
1335        ];
1336
1337        let fragmap = build_fragmap(&commits, true);
1338
1339        assert_eq!(fragmap.commits.len(), 2);
1340
1341        // Both commits should share at least one cluster
1342        assert!(fragmap.shares_cluster_with(0, 1));
1343
1344        // There should be a cluster containing both commits
1345        let shared = fragmap.clusters.iter().any(|c| {
1346            c.commit_oids.contains(&"c1".to_string()) && c.commit_oids.contains(&"c2".to_string())
1347        });
1348        assert!(shared);
1349
1350        // Both commits should have non-None entries in the shared cluster
1351        let shared_idx = fragmap
1352            .clusters
1353            .iter()
1354            .position(|c| {
1355                c.commit_oids.contains(&"c1".to_string())
1356                    && c.commit_oids.contains(&"c2".to_string())
1357            })
1358            .unwrap();
1359        assert_ne!(fragmap.matrix[0][shared_idx], TouchKind::None);
1360        assert_ne!(fragmap.matrix[1][shared_idx], TouchKind::None);
1361    }
1362
1363    #[test]
1364    fn test_build_fragmap_non_overlapping_separate_clusters() {
1365        // Two commits touching different regions should create two clusters
1366        let commits = vec![
1367            make_commit_diff(
1368                "c1",
1369                vec![make_file_diff(
1370                    Some("file.txt"),
1371                    Some("file.txt"),
1372                    1,
1373                    0,
1374                    1,
1375                    5, // lines 1-5
1376                )],
1377            ),
1378            make_commit_diff(
1379                "c2",
1380                vec![make_file_diff(
1381                    Some("file.txt"),
1382                    Some("file.txt"),
1383                    10,
1384                    3,
1385                    10,
1386                    4, // lines 10-13 (no overlap)
1387                )],
1388            ),
1389        ];
1390
1391        let fragmap = build_fragmap(&commits, true);
1392
1393        assert_eq!(fragmap.commits.len(), 2);
1394
1395        // Should have two clusters (no overlap)
1396        assert_eq!(fragmap.clusters.len(), 2);
1397
1398        // Matrix should be 2x2
1399        assert_eq!(fragmap.matrix.len(), 2);
1400        assert_eq!(fragmap.matrix[0].len(), 2);
1401        assert_eq!(fragmap.matrix[1].len(), 2);
1402
1403        // c1 touches first cluster, not second
1404        assert_ne!(fragmap.matrix[0][0], TouchKind::None);
1405        assert_eq!(fragmap.matrix[0][1], TouchKind::None);
1406
1407        // c2 touches second cluster, not first
1408        assert_eq!(fragmap.matrix[1][0], TouchKind::None);
1409        assert_ne!(fragmap.matrix[1][1], TouchKind::None);
1410    }
1411
1412    #[test]
1413    fn test_build_fragmap_adjacent_spans_stay_separate() {
1414        // Adjacent spans (end_line + 1 == start_line) should NOT merge.
1415        // Only actual overlap causes clustering, matching the original fragmap.
1416        let commits = vec![
1417            make_commit_diff(
1418                "c1",
1419                vec![make_file_diff(
1420                    Some("file.txt"),
1421                    Some("file.txt"),
1422                    1,
1423                    0,
1424                    1,
1425                    5, // lines 1-5
1426                )],
1427            ),
1428            make_commit_diff(
1429                "c2",
1430                vec![make_file_diff(
1431                    Some("file.txt"),
1432                    Some("file.txt"),
1433                    6,
1434                    2,
1435                    6,
1436                    3, // lines 6-8 (adjacent to c1, NOT overlapping)
1437                )],
1438            ),
1439        ];
1440
1441        let fragmap = build_fragmap(&commits, true);
1442
1443        assert_eq!(fragmap.commits.len(), 2);
1444
1445        // Should have two clusters (adjacent but not overlapping)
1446        assert_eq!(fragmap.clusters.len(), 2);
1447    }
1448
1449    #[test]
1450    fn test_no_snowball_effect_on_cluster_ranges() {
1451        // Regression test: distant spans must not be absorbed into a nearby cluster.
1452        //
1453        // Commit 1: lines 1-5, Commit 2: lines 3-12 (overlaps c1),
1454        // Commit 3: lines 50-53 (should NOT be absorbed)
1455        let commits = vec![
1456            make_commit_diff(
1457                "c1",
1458                vec![make_file_diff(
1459                    Some("file.txt"),
1460                    Some("file.txt"),
1461                    1,
1462                    0,
1463                    1,
1464                    5, // lines 1-5
1465                )],
1466            ),
1467            make_commit_diff(
1468                "c2",
1469                vec![make_file_diff(
1470                    Some("file.txt"),
1471                    Some("file.txt"),
1472                    3,
1473                    5,
1474                    3,
1475                    10, // lines 3-12 (overlaps c1)
1476                )],
1477            ),
1478            make_commit_diff(
1479                "c3",
1480                vec![make_file_diff(
1481                    Some("file.txt"),
1482                    Some("file.txt"),
1483                    50,
1484                    3,
1485                    50,
1486                    4, // lines 50-53 (far away, separate)
1487                )],
1488            ),
1489        ];
1490
1491        let fragmap = build_fragmap(&commits, true);
1492
1493        // c1 and c2 share a cluster, c3 does not overlap with either
1494        assert!(fragmap.shares_cluster_with(0, 1));
1495        assert!(!fragmap.shares_cluster_with(0, 2));
1496        assert!(!fragmap.shares_cluster_with(1, 2));
1497    }
1498
1499    #[test]
1500    fn test_different_functions_same_file_separate_clusters() {
1501        // Real-world scenario: two commits touch different functions in the
1502        // same file. They should be in separate clusters (separate columns),
1503        // not squashable into each other.
1504        let commits = vec![
1505            make_commit_diff(
1506                "c1",
1507                vec![make_file_diff(
1508                    Some("lib.rs"),
1509                    Some("lib.rs"),
1510                    10,
1511                    3,
1512                    10,
1513                    5, // function foo() at lines 10-14
1514                )],
1515            ),
1516            make_commit_diff(
1517                "c2",
1518                vec![make_file_diff(
1519                    Some("lib.rs"),
1520                    Some("lib.rs"),
1521                    80,
1522                    2,
1523                    80,
1524                    4, // function bar() at lines 80-83
1525                )],
1526            ),
1527        ];
1528
1529        let fragmap = build_fragmap(&commits, true);
1530
1531        // Separate clusters — these are different code regions
1532        assert_eq!(fragmap.clusters.len(), 2);
1533
1534        // Neither commit is squashable into the other
1535        assert!(!fragmap.is_fully_squashable(0));
1536        assert!(!fragmap.is_fully_squashable(1));
1537
1538        // They don't share any cluster
1539        assert!(!fragmap.shares_cluster_with(0, 1));
1540    }
1541
1542    #[test]
1543    fn test_build_fragmap_touchkind_added() {
1544        // Adding a new file should produce TouchKind::Added
1545        let commits = vec![make_commit_diff(
1546            "c1",
1547            vec![make_file_diff(
1548                None, // old_path
1549                Some("new_file.txt"),
1550                0,
1551                0,
1552                1,
1553                10,
1554            )],
1555        )];
1556
1557        let fragmap = build_fragmap(&commits, true);
1558
1559        assert_eq!(fragmap.matrix[0][0], TouchKind::Added);
1560    }
1561
1562    #[test]
1563    fn test_build_fragmap_touchkind_modified() {
1564        // Modifying existing lines should produce TouchKind::Modified
1565        let commits = vec![make_commit_diff(
1566            "c1",
1567            vec![make_file_diff(
1568                Some("file.txt"),
1569                Some("file.txt"),
1570                10,
1571                5, // old_lines > 0
1572                10,
1573                6, // new_lines > 0
1574            )],
1575        )];
1576
1577        let fragmap = build_fragmap(&commits, true);
1578
1579        assert_eq!(fragmap.matrix[0][0], TouchKind::Modified);
1580    }
1581
1582    #[test]
1583    fn test_build_fragmap_touchkind_deleted() {
1584        // Deleting lines should produce TouchKind::Deleted
1585        // But deleted files are skipped, so we test a hunk with deletions
1586        // Actually, we need to look at the determine_touch_kind logic more carefully
1587        // For now, test that pure deletions (no new_lines) are skipped at span extraction level
1588        // This test verifies the matrix generation doesn't crash with complex diffs
1589        let commits = vec![make_commit_diff(
1590            "c1",
1591            vec![make_file_diff(
1592                Some("file.txt"),
1593                Some("file.txt"),
1594                10,
1595                5,
1596                10,
1597                2, // Shrinking the region (some deletions)
1598            )],
1599        )];
1600
1601        let fragmap = build_fragmap(&commits, true);
1602
1603        // Should still generate a valid fragmap
1604        assert_eq!(fragmap.commits.len(), 1);
1605        assert_eq!(fragmap.clusters.len(), 1);
1606    }
1607
1608    #[test]
1609    fn test_build_fragmap_multiple_files_separate_clusters() {
1610        // Different files should always create separate clusters
1611        let commits = vec![
1612            make_commit_diff(
1613                "c1",
1614                vec![make_file_diff(Some("a.txt"), Some("a.txt"), 1, 0, 1, 5)],
1615            ),
1616            make_commit_diff(
1617                "c2",
1618                vec![make_file_diff(
1619                    Some("b.txt"),
1620                    Some("b.txt"),
1621                    1,
1622                    0,
1623                    1,
1624                    5, // Same line range but different file
1625                )],
1626            ),
1627        ];
1628
1629        let fragmap = build_fragmap(&commits, true);
1630
1631        assert_eq!(fragmap.commits.len(), 2);
1632
1633        // Should have two clusters (different files)
1634        assert_eq!(fragmap.clusters.len(), 2);
1635
1636        // Each commit touches only its own cluster
1637        assert_ne!(fragmap.matrix[0][0], TouchKind::None);
1638        assert_eq!(fragmap.matrix[0][1], TouchKind::None);
1639
1640        assert_eq!(fragmap.matrix[1][0], TouchKind::None);
1641        assert_ne!(fragmap.matrix[1][1], TouchKind::None);
1642    }
1643
1644    #[test]
1645    fn test_build_fragmap_commit_touches_multiple_clusters() {
1646        // A single commit touching multiple non-adjacent regions of the same
1647        // file produces columns with identical activation patterns (only c1
1648        // is active). BriefFragmap-style dedup merges them into one column.
1649        let mut c1 = make_commit_diff(
1650            "c1",
1651            vec![make_file_diff(
1652                Some("file.txt"),
1653                Some("file.txt"),
1654                1,
1655                0,
1656                1,
1657                5, // lines 1-5
1658            )],
1659        );
1660
1661        c1.files.push(make_file_diff(
1662            Some("file.txt"),
1663            Some("file.txt"),
1664            20,
1665            0,
1666            20,
1667            3, // lines 20-22 (separate region)
1668        ));
1669
1670        let fragmap = build_fragmap(&[c1], true);
1671
1672        assert_eq!(fragmap.commits.len(), 1);
1673
1674        // After dedup, both regions have the same activation pattern {c1},
1675        // so they collapse into a single column.
1676        assert_eq!(fragmap.clusters.len(), 1);
1677        assert_ne!(fragmap.matrix[0][0], TouchKind::None);
1678    }
1679
1680    // Squashability analysis tests
1681
1682    #[test]
1683    fn test_cluster_relation_no_relation_neither_touches() {
1684        // Two commits that don't touch the same cluster
1685        let commits = vec![
1686            make_commit_diff(
1687                "c1",
1688                vec![make_file_diff(Some("a.txt"), Some("a.txt"), 1, 0, 1, 5)],
1689            ),
1690            make_commit_diff(
1691                "c2",
1692                vec![make_file_diff(Some("b.txt"), Some("b.txt"), 1, 0, 1, 5)],
1693            ),
1694        ];
1695
1696        let fragmap = build_fragmap(&commits, true);
1697
1698        // Two clusters, c1 only touches cluster 0
1699        assert_eq!(fragmap.clusters.len(), 2);
1700
1701        let relation = fragmap.cluster_relation(0, 1, 0);
1702        assert_eq!(relation, SquashRelation::NoRelation);
1703    }
1704
1705    #[test]
1706    fn test_cluster_relation_no_relation_only_one_touches() {
1707        // Only one commit touches the cluster
1708        let commits = vec![
1709            make_commit_diff(
1710                "c1",
1711                vec![make_file_diff(
1712                    Some("file.txt"),
1713                    Some("file.txt"),
1714                    1,
1715                    0,
1716                    1,
1717                    5,
1718                )],
1719            ),
1720            make_commit_diff(
1721                "c2",
1722                vec![make_file_diff(
1723                    Some("file.txt"),
1724                    Some("file.txt"),
1725                    100,
1726                    0,
1727                    100,
1728                    5, // Far away, different cluster
1729                )],
1730            ),
1731        ];
1732
1733        let fragmap = build_fragmap(&commits, true);
1734
1735        assert_eq!(fragmap.clusters.len(), 2);
1736
1737        // c1 touches cluster 0, c2 doesn't
1738        let relation = fragmap.cluster_relation(0, 1, 0);
1739        assert_eq!(relation, SquashRelation::NoRelation);
1740    }
1741
1742    #[test]
1743    fn test_cluster_relation_squashable_no_collisions() {
1744        // Two commits touch same cluster, no commits in between
1745        let commits = vec![
1746            make_commit_diff(
1747                "c1",
1748                vec![make_file_diff(
1749                    Some("file.txt"),
1750                    Some("file.txt"),
1751                    1,
1752                    0,
1753                    1,
1754                    5,
1755                )],
1756            ),
1757            make_commit_diff(
1758                "c2",
1759                vec![make_file_diff(
1760                    Some("file.txt"),
1761                    Some("file.txt"),
1762                    3,
1763                    3,
1764                    3,
1765                    4, // Overlaps with c1
1766                )],
1767            ),
1768        ];
1769
1770        let fragmap = build_fragmap(&commits, true);
1771
1772        // Find the shared cluster
1773        let shared_idx = fragmap
1774            .clusters
1775            .iter()
1776            .position(|c| {
1777                c.commit_oids.contains(&"c1".to_string())
1778                    && c.commit_oids.contains(&"c2".to_string())
1779            })
1780            .expect("should have a shared cluster");
1781
1782        let relation = fragmap.cluster_relation(0, 1, shared_idx);
1783        assert_eq!(relation, SquashRelation::Squashable);
1784    }
1785
1786    #[test]
1787    fn test_cluster_relation_conflicting_with_collision() {
1788        // Three commits touch same code region - middle one creates a collision
1789        let commits = vec![
1790            make_commit_diff(
1791                "c1",
1792                vec![make_file_diff(
1793                    Some("file.txt"),
1794                    Some("file.txt"),
1795                    1,
1796                    0,
1797                    1,
1798                    5,
1799                )],
1800            ),
1801            make_commit_diff(
1802                "c2",
1803                vec![make_file_diff(
1804                    Some("file.txt"),
1805                    Some("file.txt"),
1806                    3,
1807                    2,
1808                    3,
1809                    3, // Overlaps - collision
1810                )],
1811            ),
1812            make_commit_diff(
1813                "c3",
1814                vec![make_file_diff(
1815                    Some("file.txt"),
1816                    Some("file.txt"),
1817                    2,
1818                    3,
1819                    2,
1820                    4, // Also overlaps
1821                )],
1822            ),
1823        ];
1824
1825        let fragmap = build_fragmap(&commits, true);
1826
1827        // All three commits should share at least one cluster
1828        let all_three_idx = fragmap
1829            .clusters
1830            .iter()
1831            .position(|c| {
1832                c.commit_oids.contains(&"c1".to_string())
1833                    && c.commit_oids.contains(&"c2".to_string())
1834                    && c.commit_oids.contains(&"c3".to_string())
1835            })
1836            .expect("should have a cluster with all three commits");
1837
1838        // c1 and c3 have a collision (c2 in between)
1839        let relation = fragmap.cluster_relation(0, 2, all_three_idx);
1840        assert_eq!(relation, SquashRelation::Conflicting);
1841    }
1842
1843    #[test]
1844    fn test_cluster_relation_invalid_indices() {
1845        let commits = vec![
1846            make_commit_diff(
1847                "c1",
1848                vec![make_file_diff(Some("a.txt"), Some("a.txt"), 1, 0, 1, 5)],
1849            ),
1850            make_commit_diff(
1851                "c2",
1852                vec![make_file_diff(Some("a.txt"), Some("a.txt"), 3, 2, 3, 3)],
1853            ),
1854        ];
1855
1856        let fragmap = build_fragmap(&commits, true);
1857
1858        // Out of range commit index
1859        let relation = fragmap.cluster_relation(0, 10, 0);
1860        assert_eq!(relation, SquashRelation::NoRelation);
1861
1862        // Out of range cluster index
1863        let relation = fragmap.cluster_relation(0, 1, 10);
1864        assert_eq!(relation, SquashRelation::NoRelation);
1865    }
1866
1867    #[test]
1868    fn test_cluster_relation_earlier_not_less_than_later() {
1869        let commits = vec![
1870            make_commit_diff(
1871                "c1",
1872                vec![make_file_diff(Some("a.txt"), Some("a.txt"), 1, 0, 1, 5)],
1873            ),
1874            make_commit_diff(
1875                "c2",
1876                vec![make_file_diff(Some("a.txt"), Some("a.txt"), 3, 2, 3, 3)],
1877            ),
1878        ];
1879
1880        let fragmap = build_fragmap(&commits, true);
1881
1882        // Same index
1883        let relation = fragmap.cluster_relation(1, 1, 0);
1884        assert_eq!(relation, SquashRelation::NoRelation);
1885
1886        // Earlier > later
1887        let relation = fragmap.cluster_relation(1, 0, 0);
1888        assert_eq!(relation, SquashRelation::NoRelation);
1889    }
1890
1891    #[test]
1892    fn test_cluster_relation_multiple_clusters() {
1893        // Complex scenario with multiple clusters across files
1894        let commits = vec![
1895            make_commit_diff(
1896                "c1",
1897                vec![
1898                    make_file_diff(Some("a.txt"), Some("a.txt"), 1, 0, 1, 5),
1899                    make_file_diff(Some("b.txt"), Some("b.txt"), 1, 0, 1, 5),
1900                ],
1901            ),
1902            make_commit_diff(
1903                "c2",
1904                vec![make_file_diff(Some("a.txt"), Some("a.txt"), 3, 2, 3, 3)],
1905            ),
1906            make_commit_diff(
1907                "c3",
1908                vec![make_file_diff(Some("b.txt"), Some("b.txt"), 3, 2, 3, 3)],
1909            ),
1910        ];
1911
1912        let fragmap = build_fragmap(&commits, true);
1913
1914        // Find shared clusters by file
1915        let a_cluster_idx = fragmap
1916            .clusters
1917            .iter()
1918            .position(|c| {
1919                c.spans[0].path == "a.txt"
1920                    && c.commit_oids.contains(&"c1".to_string())
1921                    && c.commit_oids.contains(&"c2".to_string())
1922            })
1923            .expect("should have a shared a.txt cluster");
1924        let b_cluster_idx = fragmap
1925            .clusters
1926            .iter()
1927            .position(|c| {
1928                c.spans[0].path == "b.txt"
1929                    && c.commit_oids.contains(&"c1".to_string())
1930                    && c.commit_oids.contains(&"c3".to_string())
1931            })
1932            .expect("should have a shared b.txt cluster");
1933
1934        // c1 and c2 both touch a.txt cluster - squashable (no collision)
1935        let relation = fragmap.cluster_relation(0, 1, a_cluster_idx);
1936        assert_eq!(relation, SquashRelation::Squashable);
1937
1938        // c1 and c3 both touch b.txt cluster - squashable (no collision)
1939        let relation = fragmap.cluster_relation(0, 2, b_cluster_idx);
1940        assert_eq!(relation, SquashRelation::Squashable);
1941
1942        // c2 and c3 don't share any cluster
1943        assert!(!fragmap.shares_cluster_with(1, 2));
1944    }
1945
1946    #[test]
1947    fn test_cluster_relation_squashable_with_gap() {
1948        // Four commits: c1 and c4 touch overlapping regions, c2 and c3 don't
1949        let commits = vec![
1950            make_commit_diff(
1951                "c1",
1952                vec![make_file_diff(
1953                    Some("file.txt"),
1954                    Some("file.txt"),
1955                    1,
1956                    0,
1957                    1,
1958                    5,
1959                )],
1960            ),
1961            make_commit_diff(
1962                "c2",
1963                vec![make_file_diff(
1964                    Some("other.txt"),
1965                    Some("other.txt"),
1966                    1,
1967                    0,
1968                    1,
1969                    5,
1970                )],
1971            ),
1972            make_commit_diff(
1973                "c3",
1974                vec![make_file_diff(
1975                    Some("another.txt"),
1976                    Some("another.txt"),
1977                    1,
1978                    0,
1979                    1,
1980                    5,
1981                )],
1982            ),
1983            make_commit_diff(
1984                "c4",
1985                vec![make_file_diff(
1986                    Some("file.txt"),
1987                    Some("file.txt"),
1988                    3,
1989                    2,
1990                    3,
1991                    3,
1992                )],
1993            ),
1994        ];
1995
1996        let fragmap = build_fragmap(&commits, true);
1997
1998        // Find the shared file.txt cluster containing c1 and c4
1999        let file_cluster_idx = fragmap
2000            .clusters
2001            .iter()
2002            .position(|c| {
2003                c.spans[0].path == "file.txt"
2004                    && c.commit_oids.contains(&"c1".to_string())
2005                    && c.commit_oids.contains(&"c4".to_string())
2006            })
2007            .expect("should have a shared file.txt cluster");
2008
2009        // c1 and c4 touch file.txt, c2 and c3 don't - squashable
2010        let relation = fragmap.cluster_relation(0, 3, file_cluster_idx);
2011        assert_eq!(relation, SquashRelation::Squashable);
2012    }
2013
2014    /// Build a FragMap directly from a matrix (bypasses span extraction).
2015    fn make_fragmap(commit_ids: &[&str], n_clusters: usize, touches: &[(usize, usize)]) -> FragMap {
2016        let commits: Vec<String> = commit_ids.iter().map(|s| s.to_string()).collect();
2017        let clusters = (0..n_clusters)
2018            .map(|_| SpanCluster {
2019                spans: vec![FileSpan {
2020                    path: "f.txt".to_string(),
2021                    start_line: 1,
2022                    end_line: 1,
2023                }],
2024                commit_oids: vec![],
2025            })
2026            .collect();
2027        let mut matrix = vec![vec![TouchKind::None; n_clusters]; commit_ids.len()];
2028        for &(c, cl) in touches {
2029            matrix[c][cl] = TouchKind::Modified;
2030        }
2031        FragMap {
2032            commits,
2033            clusters,
2034            matrix,
2035        }
2036    }
2037
2038    // squash_target tests
2039
2040    #[test]
2041    fn squash_target_no_shared_clusters() {
2042        // c0 touches cluster 0, c1 touches cluster 1 — no earlier commit in c1's cluster
2043        let fm = make_fragmap(&["c0", "c1"], 2, &[(0, 0), (1, 1)]);
2044        assert_eq!(fm.squash_target(1), None);
2045    }
2046
2047    #[test]
2048    fn squash_target_adjacent() {
2049        // c0 and c1 both touch cluster 0 — c1's target is c0
2050        let fm = make_fragmap(&["c0", "c1"], 1, &[(0, 0), (1, 0)]);
2051        assert_eq!(fm.squash_target(1), Some(0));
2052    }
2053
2054    #[test]
2055    fn squash_target_with_gap() {
2056        // c0 and c2 touch cluster 0, c1 does not — c2's target is c0
2057        let fm = make_fragmap(&["c0", "c1", "c2"], 1, &[(0, 0), (2, 0)]);
2058        assert_eq!(fm.squash_target(2), Some(0));
2059    }
2060
2061    #[test]
2062    fn squash_target_conflicting_returns_none() {
2063        // c0, c1, c2 all touch cluster 0 — c2 blocked by c1
2064        let fm = make_fragmap(&["c0", "c1", "c2"], 1, &[(0, 0), (1, 0), (2, 0)]);
2065        assert_eq!(fm.squash_target(2), None);
2066    }
2067
2068    #[test]
2069    fn squash_target_multiple_clusters_same_target() {
2070        // c0 and c1 share clusters 0 and 1 — target is c0
2071        let fm = make_fragmap(&["c0", "c1"], 2, &[(0, 0), (0, 1), (1, 0), (1, 1)]);
2072        assert_eq!(fm.squash_target(1), Some(0));
2073    }
2074
2075    #[test]
2076    fn squash_target_multiple_clusters_different_targets() {
2077        // cluster 0: c0 and c2 → target c0
2078        // cluster 1: c1 and c2 → target c1
2079        // c2 has divergent targets → None
2080        let fm = make_fragmap(&["c0", "c1", "c2"], 2, &[(0, 0), (1, 1), (2, 0), (2, 1)]);
2081        assert_eq!(fm.squash_target(2), None);
2082    }
2083
2084    #[test]
2085    fn squash_target_earliest_commit_returns_none() {
2086        // c0 is the earliest — nothing earlier to squash into
2087        let fm = make_fragmap(&["c0", "c1"], 1, &[(0, 0), (1, 0)]);
2088        assert_eq!(fm.squash_target(0), None);
2089    }
2090
2091    #[test]
2092    fn squash_target_no_clusters_touched() {
2093        // c1 doesn't touch any cluster
2094        let fm = make_fragmap(&["c0", "c1"], 1, &[(0, 0)]);
2095        assert_eq!(fm.squash_target(1), None);
2096    }
2097
2098    // is_fully_squashable tests
2099
2100    #[test]
2101    fn is_fully_squashable_single_cluster_adjacent() {
2102        // c0 and c1 touch cluster 0, c1 is squashable into c0
2103        let fm = make_fragmap(&["c0", "c1"], 1, &[(0, 0), (1, 0)]);
2104        assert!(fm.is_fully_squashable(1));
2105    }
2106
2107    #[test]
2108    fn is_fully_squashable_first_commit_not_squashable() {
2109        // c0 is the earliest — nothing to squash into
2110        let fm = make_fragmap(&["c0", "c1"], 1, &[(0, 0), (1, 0)]);
2111        assert!(!fm.is_fully_squashable(0));
2112    }
2113
2114    #[test]
2115    fn is_fully_squashable_multiple_clusters_same_target() {
2116        // c0 and c1 both touch clusters 0 and 1 — all squashable into c0
2117        let fm = make_fragmap(&["c0", "c1"], 2, &[(0, 0), (0, 1), (1, 0), (1, 1)]);
2118        assert!(fm.is_fully_squashable(1));
2119    }
2120
2121    #[test]
2122    fn is_fully_squashable_multiple_clusters_different_targets() {
2123        // cluster 0: c0 and c2 — target c0
2124        // cluster 1: c1 and c2 — target c1
2125        // c2 has different targets, not fully squashable
2126        let fm = make_fragmap(&["c0", "c1", "c2"], 2, &[(0, 0), (1, 1), (2, 0), (2, 1)]);
2127        assert!(!fm.is_fully_squashable(2));
2128    }
2129
2130    #[test]
2131    fn is_fully_squashable_conflicting_cluster() {
2132        // c0, c1, c2 all touch cluster 0 — c2 has c1 in between
2133        let fm = make_fragmap(&["c0", "c1", "c2"], 1, &[(0, 0), (1, 0), (2, 0)]);
2134        assert!(!fm.is_fully_squashable(2));
2135    }
2136
2137    #[test]
2138    fn is_fully_squashable_no_clusters_touched() {
2139        // c1 doesn't touch any cluster
2140        let fm = make_fragmap(&["c0", "c1"], 1, &[(0, 0)]);
2141        assert!(!fm.is_fully_squashable(1));
2142    }
2143
2144    // shares_cluster_with tests
2145
2146    #[test]
2147    fn shares_cluster_with_no_shared_cluster() {
2148        let fm = make_fragmap(&["c0", "c1"], 2, &[(0, 0), (1, 1)]);
2149        assert!(!fm.shares_cluster_with(0, 1));
2150    }
2151
2152    #[test]
2153    fn shares_cluster_with_adjacent_pair() {
2154        let fm = make_fragmap(&["c0", "c1"], 1, &[(0, 0), (1, 0)]);
2155        assert!(fm.shares_cluster_with(0, 1));
2156    }
2157
2158    #[test]
2159    fn shares_cluster_with_blocked_by_middle_commit() {
2160        let fm = make_fragmap(&["c0", "c1", "c2"], 1, &[(0, 0), (1, 0), (2, 0)]);
2161        assert!(fm.shares_cluster_with(0, 2));
2162    }
2163
2164    #[test]
2165    fn shares_cluster_with_is_symmetric() {
2166        let fm = make_fragmap(&["c0", "c1"], 1, &[(0, 0), (1, 0)]);
2167        assert_eq!(fm.shares_cluster_with(0, 1), fm.shares_cluster_with(1, 0));
2168    }
2169
2170    #[test]
2171    fn shares_cluster_with_same_commit() {
2172        let fm = make_fragmap(&["c0"], 1, &[(0, 0)]);
2173        assert!(!fm.shares_cluster_with(0, 0));
2174    }
2175
2176    #[test]
2177    fn shares_cluster_with_one_shared_is_enough() {
2178        // cluster 0: only c0. cluster 1: c0 and c1
2179        let fm = make_fragmap(&["c0", "c1"], 2, &[(0, 0), (0, 1), (1, 1)]);
2180        assert!(fm.shares_cluster_with(0, 1));
2181    }
2182
2183    // =========================================================
2184    // build_fragmap SPG edge cases
2185    // =========================================================
2186
2187    #[test]
2188    fn build_fragmap_pure_insertion_clusters_with_later_modifier() {
2189        // c1 inserts 10 lines starting at position 5 (old_lines=0).
2190        // c2 then modifies 3 lines starting at old position 7 (within c1's block).
2191        // They overlap → must share a cluster.
2192        let commits = vec![
2193            make_commit_diff(
2194                "c1",
2195                vec![make_file_diff(Some("f.rs"), Some("f.rs"), 5, 0, 5, 10)],
2196            ),
2197            make_commit_diff(
2198                "c2",
2199                vec![make_file_diff(Some("f.rs"), Some("f.rs"), 7, 3, 7, 3)],
2200            ),
2201        ];
2202        let fm = build_fragmap(&commits, true);
2203        assert!(fm.shares_cluster_with(0, 1));
2204    }
2205
2206    #[test]
2207    fn build_fragmap_far_deletion_does_not_cluster_with_unrelated_modify() {
2208        // c1 modifies lines 1-5 of one region.
2209        // c2 only deletes lines 50-53 (far away, different region, new_lines=0).
2210        // c2's deletion is far from c1 → separate clusters.
2211        let commits = vec![
2212            make_commit_diff(
2213                "c1",
2214                vec![make_file_diff(Some("f.rs"), Some("f.rs"), 1, 3, 1, 5)],
2215            ),
2216            make_commit_diff(
2217                "c2",
2218                vec![FileDiff {
2219                    old_path: Some("f.rs".to_string()),
2220                    new_path: Some("f.rs".to_string()),
2221                    status: crate::DeltaStatus::Modified,
2222                    hunks: vec![Hunk {
2223                        old_start: 50,
2224                        old_lines: 3,
2225                        new_start: 50,
2226                        new_lines: 0,
2227                        lines: vec![],
2228                    }],
2229                }],
2230            ),
2231        ];
2232        let fm = build_fragmap(&commits, true);
2233        assert!(!fm.shares_cluster_with(0, 1));
2234    }
2235
2236    #[test]
2237    fn build_fragmap_file_rename_cluster_uses_canonical_path() {
2238        // A commit that renames foo.rs → bar.rs. The cluster should track
2239        // the canonical (earliest) path — foo.rs.
2240        let c1 = CommitDiff {
2241            commit: make_commit_info_with_oid("c1"),
2242            files: vec![FileDiff {
2243                old_path: Some("foo.rs".to_string()),
2244                new_path: Some("bar.rs".to_string()),
2245                status: crate::DeltaStatus::Modified,
2246                hunks: vec![Hunk {
2247                    old_start: 5,
2248                    old_lines: 3,
2249                    new_start: 5,
2250                    new_lines: 4,
2251                    lines: vec![],
2252                }],
2253            }],
2254        };
2255        let fm = build_fragmap(&[c1], true);
2256        assert_eq!(fm.clusters.len(), 1);
2257        assert_eq!(fm.clusters[0].spans[0].path, "foo.rs");
2258    }
2259
2260    #[test]
2261    fn build_fragmap_rename_groups_old_and_new_in_same_cluster() {
2262        // Commit 0 touches foo.rs lines 1-10.
2263        // Commit 1 renames foo.rs → bar.rs and modifies overlapping lines 5-12.
2264        // Both should land in the same cluster because the rename map links
2265        // bar.rs back to the canonical name foo.rs.
2266        let c0 = make_commit_diff(
2267            "c0",
2268            vec![make_file_diff(Some("foo.rs"), Some("foo.rs"), 1, 0, 1, 10)],
2269        );
2270        let c1 = CommitDiff {
2271            commit: make_commit_info_with_oid("c1"),
2272            files: vec![FileDiff {
2273                old_path: Some("foo.rs".to_string()),
2274                new_path: Some("bar.rs".to_string()),
2275                status: crate::DeltaStatus::Modified,
2276                hunks: vec![Hunk {
2277                    old_start: 5,
2278                    old_lines: 6,
2279                    new_start: 5,
2280                    new_lines: 8,
2281                    lines: vec![],
2282                }],
2283            }],
2284        };
2285        let fm = build_fragmap(&[c0, c1], true);
2286        // Both commits share a cluster — the overlapping region groups them.
2287        assert!(
2288            fm.clusters.iter().any(|cl| {
2289                cl.commit_oids.contains(&"c0".to_string())
2290                    && cl.commit_oids.contains(&"c1".to_string())
2291            }),
2292            "expected c0 and c1 to share a cluster via rename tracking"
2293        );
2294    }
2295
2296    #[test]
2297    fn build_fragmap_single_commit_two_regions_deduped_to_one_column() {
2298        // One commit touching two non-overlapping regions of the same file.
2299        // Both SPG paths have the same active-node set {c1} → deduplicated to 1 column.
2300        let mut c1 = make_commit_diff(
2301            "c1",
2302            vec![make_file_diff(Some("f.rs"), Some("f.rs"), 1, 0, 1, 5)],
2303        );
2304        c1.files
2305            .push(make_file_diff(Some("f.rs"), Some("f.rs"), 100, 0, 100, 5));
2306        let fm = build_fragmap(&[c1], true);
2307        assert_eq!(fm.clusters.len(), 1);
2308        assert_ne!(fm.matrix[0][0], TouchKind::None);
2309    }
2310
2311    #[test]
2312    fn build_fragmap_no_dedup_keeps_identical_activation_pattern_columns() {
2313        // Same scenario as the dedup test above, but with deduplicate=false.
2314        // Both clusters have the same activation pattern {c1}, but they must
2315        // NOT be merged — the full view should expose all raw columns.
2316        let mut c1 = make_commit_diff(
2317            "c1",
2318            vec![make_file_diff(Some("f.rs"), Some("f.rs"), 1, 0, 1, 5)],
2319        );
2320        c1.files
2321            .push(make_file_diff(Some("f.rs"), Some("f.rs"), 100, 0, 100, 5));
2322        let fm = build_fragmap(&[c1], false);
2323        assert_eq!(fm.clusters.len(), 2);
2324        assert_ne!(fm.matrix[0][0], TouchKind::None);
2325        assert_ne!(fm.matrix[0][1], TouchKind::None);
2326    }
2327
2328    #[test]
2329    fn build_fragmap_two_commits_separate_regions_not_deduped() {
2330        // c1 and c2 each touch a distinct region → different activation patterns → 2 columns.
2331        let commits = vec![
2332            make_commit_diff(
2333                "c1",
2334                vec![make_file_diff(Some("f.rs"), Some("f.rs"), 1, 0, 1, 5)],
2335            ),
2336            make_commit_diff(
2337                "c2",
2338                vec![make_file_diff(Some("f.rs"), Some("f.rs"), 100, 0, 100, 5)],
2339            ),
2340        ];
2341        let fm = build_fragmap(&commits, true);
2342        assert_eq!(fm.clusters.len(), 2);
2343    }
2344
2345    #[test]
2346    fn build_fragmap_three_commits_sequential_on_same_region() {
2347        // c1 introduces a block, c2 refines it, c3 refines it again.
2348        // All three share a cluster; c1 and c3 are also related.
2349        let commits = vec![
2350            make_commit_diff(
2351                "c1",
2352                vec![make_file_diff(Some("f.rs"), Some("f.rs"), 10, 5, 10, 10)],
2353            ),
2354            make_commit_diff(
2355                "c2",
2356                vec![make_file_diff(Some("f.rs"), Some("f.rs"), 12, 3, 12, 3)],
2357            ),
2358            make_commit_diff(
2359                "c3",
2360                vec![make_file_diff(Some("f.rs"), Some("f.rs"), 11, 2, 11, 2)],
2361            ),
2362        ];
2363        let fm = build_fragmap(&commits, true);
2364        assert!(fm.shares_cluster_with(0, 1));
2365        assert!(fm.shares_cluster_with(0, 2));
2366        assert!(fm.shares_cluster_with(1, 2));
2367    }
2368
2369    #[test]
2370    fn build_fragmap_empty_span_does_not_panic() {
2371        // A commit with a single-line addition (new_lines=1) followed by a
2372        // commit that touches an adjacent but non-overlapping line.
2373        // Regression guard: no panic or infinite loop in SPG construction.
2374        let commits = vec![
2375            make_commit_diff(
2376                "c1",
2377                vec![make_file_diff(Some("f.rs"), Some("f.rs"), 10, 1, 10, 1)],
2378            ),
2379            make_commit_diff(
2380                "c2",
2381                vec![make_file_diff(Some("f.rs"), Some("f.rs"), 20, 1, 20, 1)],
2382            ),
2383        ];
2384        let fm = build_fragmap(&commits, true);
2385        assert_eq!(fm.commits.len(), 2);
2386    }
2387}