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