Skip to main content

_diffctx/
render.rs

1use std::path::Path;
2use std::sync::Arc;
3
4use once_cell::sync::Lazy;
5use regex::Regex;
6use rustc_hash::{FxHashMap, FxHashSet};
7use serde::Serialize;
8
9use crate::config::render::RENDER;
10use crate::types::{Fragment, FragmentId, FragmentKind};
11
12/// Orientation header for the diff-context output: tells the reader *what*
13/// changed before they read the fragments. Empty for working-tree / no-commit
14/// diffs.
15#[derive(Default)]
16pub struct ChangeSummary {
17    pub commit_message: Option<String>,
18    pub changed_files: Vec<String>,
19    pub deleted_files: Vec<String>,
20    pub renamed_files: Vec<(String, String)>,
21    pub lockfile_changes: Vec<String>,
22    pub ignored_changes: Vec<String>,
23    pub policy_excluded_count: usize,
24}
25
26fn is_zero(n: &usize) -> bool {
27    *n == 0
28}
29
30pub fn is_zero_pub(n: &usize) -> bool {
31    *n == 0
32}
33
34fn serialize_renames<S>(renames: &[(String, String)], serializer: S) -> Result<S::Ok, S::Error>
35where
36    S: serde::Serializer,
37{
38    use serde::ser::SerializeSeq;
39    let mut seq = serializer.serialize_seq(Some(renames.len()))?;
40    for (from, to) in renames {
41        let mut m = std::collections::BTreeMap::new();
42        m.insert("from", from);
43        m.insert("to", to);
44        seq.serialize_element(&m)?;
45    }
46    seq.end()
47}
48
49#[derive(Serialize)]
50pub struct DiffContextOutput {
51    pub name: String,
52    #[serde(rename = "type")]
53    pub output_type: String,
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub commit_message: Option<String>,
56    #[serde(skip_serializing_if = "Vec::is_empty")]
57    pub changed_files: Vec<String>,
58    #[serde(skip_serializing_if = "Vec::is_empty")]
59    pub deleted_files: Vec<String>,
60    #[serde(
61        skip_serializing_if = "Vec::is_empty",
62        serialize_with = "serialize_renames"
63    )]
64    pub renamed_files: Vec<(String, String)>,
65    /// Lock files touched by the range. Paths only — the raw hunks are
66    /// thousands of tokens of checksum churn for one line of signal (#112).
67    #[serde(skip_serializing_if = "Vec::is_empty")]
68    pub lockfile_changes: Vec<String>,
69    /// Changed files withheld by ignore rules. Silent exclusion misreads as
70    /// "the diff did not touch this" (#188: a reviewer filed "no tests"
71    /// against a change whose tests the tool had filtered).
72    #[serde(skip_serializing_if = "Vec::is_empty")]
73    pub ignored_changes: Vec<String>,
74    /// Files excluded by `.diffctx/ignore` policy — count only, see
75    /// `ScoredState::policy_excluded_count`.
76    #[serde(skip_serializing_if = "is_zero")]
77    pub policy_excluded_count: usize,
78    pub fragment_count: usize,
79    pub fragments: Vec<FragmentEntry>,
80    #[serde(skip)]
81    pub latency: Option<LatencyBreakdown>,
82}
83
84pub struct LatencyBreakdown {
85    /// Pre-heavy-phase work: hunk parse, untracked scan, ignore resolution and
86    /// the `git diff` calls. Outside every timer until #183, which is why the
87    /// reported phases could not be reconciled with the wall clock.
88    pub pre_phase_ms: f64,
89    pub parse_changed_ms: f64,
90    pub universe_walk_ms: f64,
91    pub discovery_ms: f64,
92    pub parse_discovered_ms: f64,
93    pub tokenization_ms: f64,
94    /// Typed dependency graph construction: edge builders + dedup + hub
95    /// suppression + per-source cap. Carved out of `scoring_ms` (which
96    /// used to absorb it) so the cost distribution is truthful. Zero for
97    /// BM25 mode (no graph built).
98    pub graph_build_ms: f64,
99    /// Combined graph build + scoring + selection time. Kept for
100    /// backward compatibility with the existing checkpoint schema; the
101    /// split values below are the new diagnostic signal.
102    pub scoring_selection_ms: f64,
103    pub total_ms: f64,
104    /// Heavy-phase rank computation only (PPR/EGO/BM25 + relevance
105    /// filtering). Graph construction is reported in `graph_build_ms`;
106    /// the selection stage is excluded.
107    pub scoring_ms: f64,
108    /// Selection stage only (lazy greedy / Boltzmann + post-passes).
109    pub selection_ms: f64,
110    /// Size of the candidate fragment universe handed to the scoring
111    /// strategy (after fragment generation + signature variants but
112    /// before per-strategy filtering). Surfaces blowup on large repos —
113    /// pathological scoring time is correlated with this number, not
114    /// with `fragment_count` (which is the *output* size after
115    /// selection).
116    pub candidate_count: usize,
117    /// Edge count of the typed dependency graph used by PPR/EGO. Zero
118    /// for BM25 mode (no graph built).
119    pub edge_count: usize,
120    /// Number of greedy iterations actually executed (selected non-core
121    /// fragments). Bounded by `selected.len() - core.len()`. Pairs with
122    /// `selection_ms` to spot lazy-heap blowup vs. genuine large output.
123    pub greedy_iters: usize,
124    /// Edge count after merge + hub suppression, before per-source cap.
125    pub edges_before_cap: usize,
126    /// Edges discarded by the per-source top-K cap.
127    pub edges_dropped_by_cap: usize,
128    /// Source nodes whose outgoing edge list was truncated by the cap.
129    pub nodes_capped: usize,
130    /// The K value applied for the per-source cap.
131    pub max_out_edges_per_node: usize,
132    /// PPR push iteration was truncated by `max_pushes_cap` before
133    /// convergence. When true, `rel_scores` are biased toward seeds
134    /// and absolute file_recall on this instance should be flagged
135    /// in post-analysis. Always false for non-PPR scoring modes.
136    pub ppr_truncated: bool,
137    pub ppr_forward_pushes: usize,
138    pub ppr_backward_pushes: usize,
139    /// Additive stopping certificate: upper bound
140    /// (`tau * peak_density * remaining_budget`) on utility foregone by
141    /// adaptive stopping. 0 when the greedy loop ended for another
142    /// reason (budget exhausted, no candidates, singleton override).
143    pub stopping_certificate: f64,
144    /// Lifetime peak physical memory of the process, sampled in-process
145    /// at the end of the run. 0 when the platform query fails.
146    pub peak_rss_bytes: u64,
147    /// Per-category (raw, first-seen deduped) edge emission counts from
148    /// pass 1 of the two-pass edge build, sorted by category name. Names
149    /// the builder category behind near-dense emission blowups (#116).
150    /// Empty for BM25 mode (no graph built).
151    pub edge_emissions_by_category: Vec<(&'static str, u64, u64)>,
152}
153
154#[derive(Serialize, Clone)]
155pub struct FragmentEntry {
156    pub path: String,
157    pub lines: String,
158    /// `Some("changed")` for fragments overlapping the diff hunks; omitted
159    /// (treated as supporting context) otherwise. This is the single signal a
160    /// reader needs to tell the change apart from its context.
161    #[serde(skip_serializing_if = "Option::is_none")]
162    pub role: Option<String>,
163    pub kind: String,
164    #[serde(skip_serializing_if = "Option::is_none")]
165    pub symbol: Option<String>,
166    #[serde(skip_serializing_if = "Option::is_none")]
167    pub content: Option<Arc<str>>,
168}
169
170struct SymbolPatterns {
171    function: Vec<Regex>,
172    class: Vec<Regex>,
173    r#struct: Vec<Regex>,
174    interface: Vec<Regex>,
175    r#enum: Vec<Regex>,
176    r#impl: Vec<Regex>,
177    r#type: Vec<Regex>,
178    module: Vec<Regex>,
179    section: Vec<Regex>,
180}
181
182static SYMBOL_PATTERNS: Lazy<SymbolPatterns> = Lazy::new(|| {
183    SymbolPatterns {
184    function: vec![
185        Regex::new(r"(?m)^\s*(?:async\s+)?def\s+(\w+)\s*\(").unwrap(),
186        Regex::new(r"(?m)^\s*(?:export\s+)?(?:async\s+)?function\s+(\w+)\s*[\(<]").unwrap(),
187        Regex::new(r"(?m)^\s*(?:export\s+)?(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s+)?(?:\([^)]*\)|\w)\s*=>").unwrap(),
188        Regex::new(r"(?m)^func\s+(?:\([^)]+\)\s+)?(\w+)\s*[\(\[]").unwrap(),
189        Regex::new(r"(?m)^\s*(?:pub\s+)?(?:async\s+)?fn\s+(\w+)\s*[\(<]").unwrap(),
190        Regex::new(r"(?m)^\s*(?:(?:public|private|protected|static)\s+)*\w[\w<>\[\],]*\s+(\w+)\s*\(").unwrap(),
191    ],
192    class: vec![
193        Regex::new(r"(?m)^\s*class\s+(\w+)\s*[:\({\s]").unwrap(),
194        Regex::new(r"(?m)^\s*(?:export\s+)?(?:abstract\s+)?class\s+(\w+)").unwrap(),
195    ],
196    r#struct: vec![
197        Regex::new(r"(?m)^\s*(?:pub\s+)?struct\s+(\w+)").unwrap(),
198        Regex::new(r"(?m)^\s*type\s+(\w+)\s+struct\s*\{").unwrap(),
199    ],
200    interface: vec![
201        Regex::new(r"(?m)^\s*(?:export\s+)?interface\s+(\w+)").unwrap(),
202        Regex::new(r"(?m)^\s*type\s+(\w+)\s+interface\s*\{").unwrap(),
203        Regex::new(r"(?m)^\s*(?:pub\s+)?trait\s+(\w+)").unwrap(),
204    ],
205    r#enum: vec![
206        Regex::new(r"(?m)^\s*(?:pub\s+)?enum\s+(\w+)").unwrap(),
207        Regex::new(r"(?m)^\s*class\s+(\w+)\s*\(.*Enum\)").unwrap(),
208    ],
209    r#impl: vec![
210        Regex::new(r"(?m)^\s*impl(?:<[^>]+>)?\s+(\w+)").unwrap(),
211    ],
212    r#type: vec![
213        Regex::new(r"(?m)^\s*(?:export\s+)?type\s+(\w+)").unwrap(),
214        Regex::new(r"(?m)^\s*type\s+(\w+)\s").unwrap(),
215    ],
216    module: vec![
217        Regex::new(r"(?m)^\s*(?:pub\s+)?mod\s+(\w+)").unwrap(),
218        Regex::new(r"(?m)^\s*package\s+(\w+)").unwrap(),
219    ],
220    section: vec![
221        Regex::new(r"(?m)^#{1,6}\s+(\S[^\n]*)$").unwrap(),
222    ],
223}
224});
225
226fn extract_symbol(frag: &Fragment) -> Option<String> {
227    let patterns = match frag.kind {
228        FragmentKind::Function | FragmentKind::FunctionSignature => &SYMBOL_PATTERNS.function,
229        FragmentKind::Class | FragmentKind::ClassSignature => &SYMBOL_PATTERNS.class,
230        FragmentKind::Struct | FragmentKind::StructSignature => &SYMBOL_PATTERNS.r#struct,
231        FragmentKind::Interface | FragmentKind::InterfaceSignature => &SYMBOL_PATTERNS.interface,
232        FragmentKind::Enum | FragmentKind::EnumSignature => &SYMBOL_PATTERNS.r#enum,
233        FragmentKind::Impl => &SYMBOL_PATTERNS.r#impl,
234        FragmentKind::Type => &SYMBOL_PATTERNS.r#type,
235        FragmentKind::Module => &SYMBOL_PATTERNS.module,
236        FragmentKind::Section => &SYMBOL_PATTERNS.section,
237        _ => return None,
238    };
239
240    for pattern in patterns {
241        if let Some(caps) = pattern.captures(&frag.content) {
242            if let Some(m) = caps.get(1) {
243                let result = m.as_str().trim();
244                return Some(if frag.kind == FragmentKind::Section {
245                    result
246                        .chars()
247                        .take(RENDER.section_symbol_max_chars)
248                        .collect()
249                } else {
250                    result.to_string()
251                });
252            }
253        }
254    }
255    None
256}
257
258use crate::paths::to_posix_display as normalize_path_separators;
259
260pub(crate) fn get_relative_path(frag: &Fragment, repo_root: &Path) -> String {
261    let frag_path = Path::new(frag.path());
262    if !frag_path.is_absolute() {
263        return normalize_path_separators(frag_path.to_string_lossy());
264    }
265    normalize_path_separators(
266        frag_path
267            .strip_prefix(repo_root)
268            .unwrap_or(frag_path)
269            .to_string_lossy(),
270    )
271}
272
273fn create_fragment_entry(frag: &Fragment, path_str: &str) -> FragmentEntry {
274    let symbol = frag.symbol_name.clone().or_else(|| extract_symbol(frag));
275    let content = if frag.content.is_empty() {
276        None
277    } else {
278        Some(Arc::clone(&frag.content))
279    };
280
281    FragmentEntry {
282        path: path_str.to_string(),
283        lines: format!("{}-{}", frag.start_line(), frag.end_line()),
284        role: None,
285        kind: frag.kind.as_str().to_string(),
286        symbol,
287        content,
288    }
289}
290
291/// A fragment carries the `changed` role when it IS a core, or when it is the
292/// hunk-window excerpt that was substituted for one. The excerpt's id is not in
293/// `core_ids` — it is a synthetic span cut out of the core — so without this the
294/// downshift would silently strip the change marker from the output, which is
295/// worse than the over-dump it replaces. `locate.rs` already treats `Excerpt`
296/// this way; both surfaces must agree.
297fn carries_changed_role(frag: &Fragment, core_ids: &FxHashSet<FragmentId>) -> bool {
298    core_ids.contains(&frag.id) || frag.kind == FragmentKind::Excerpt
299}
300
301/// Collapse a file's fragments (sorted by start line, ties by descending end
302/// line) into the rendered entries. Two behaviors:
303/// - a same-role fragment fully contained in the running range (`next.end <=
304///   end`) is dropped: its content is already covered by the enclosing
305///   fragment (e.g. a symbol-level "function" extraction and a hunk-level
306///   "chunk" both covering the same edited lines), so keeping it is pure
307///   duplication, not additional information.
308/// - a same-role fragment that is line-contiguous with the running range
309///   (`next.start == end + 1`) is merged into it.
310/// Both are lossless on line coverage and remove the per-fragment scaffolding
311/// tax that dominates output on one-line/near-duplicate snippets.
312fn merge_file_fragments(
313    rel_path: &str,
314    frags: &[&Fragment],
315    core_ids: &FxHashSet<FragmentId>,
316) -> Vec<(bool, u32, FragmentEntry)> {
317    let mut out: Vec<(bool, u32, FragmentEntry)> = Vec::new();
318    let mut i = 0;
319    while i < frags.len() {
320        let first = frags[i];
321        let role_changed = carries_changed_role(first, core_ids);
322        let mut end = first.end_line();
323        let mut parts: Vec<&str> = vec![first.content.trim_end_matches('\n')];
324        let mut uniform_kind = true;
325        let mut j = i + 1;
326        while j < frags.len() {
327            let next = frags[j];
328            if carries_changed_role(next, core_ids) != role_changed {
329                break;
330            }
331            if next.end_line() <= end {
332                // Fully contained in the range covered so far - redundant.
333                j += 1;
334            } else if next.start_line() == end + 1 {
335                parts.push(next.content.trim_end_matches('\n'));
336                uniform_kind &= next.kind == first.kind;
337                end = next.end_line();
338                j += 1;
339            } else {
340                break;
341            }
342        }
343
344        let mut entry = create_fragment_entry(first, rel_path);
345        if j > i + 1 {
346            entry.lines = format!("{}-{}", first.start_line(), end);
347            let merged = parts.join("\n");
348            entry.content = if merged.is_empty() {
349                None
350            } else {
351                Some(Arc::from(merged.as_str()))
352            };
353            // The merged span is no longer what `first` was, and the kind has
354            // to stop claiming otherwise. A one-line `function_signature`
355            // followed by contiguous body chunks was emitted as a
356            // `function_signature` carrying the whole 101-line function — the
357            // exact opposite of what a signature means, since it exists as the
358            // cheap stand-in when the full fragment misses the budget (#184).
359            // `chunk` is the vocabulary's name for a span of lines with no
360            // single semantic identity, which is precisely what a mixed run is.
361            if !uniform_kind {
362                entry.kind = crate::types::FragmentKind::Chunk.as_str().to_string();
363            }
364        }
365        entry.role = role_changed.then(|| "changed".to_string());
366        out.push((role_changed, first.start_line(), entry));
367        i = j;
368    }
369    out
370}
371
372pub fn build_diff_context_output(
373    repo_root: &Path,
374    selected: &[Fragment],
375    no_content: bool,
376    core_ids: &FxHashSet<FragmentId>,
377    rel_scores: &FxHashMap<FragmentId, f64>,
378    change: ChangeSummary,
379) -> DiffContextOutput {
380    let mut by_path: FxHashMap<String, Vec<&Fragment>> = FxHashMap::default();
381    for frag in selected {
382        by_path
383            .entry(get_relative_path(frag, repo_root))
384            .or_default()
385            .push(frag);
386    }
387
388    // Changed code first (the answer to "what changed"), then supporting
389    // context ordered by descending per-file relevance so the reader's primacy
390    // attention lands on the most relevant material, not on alphabetical noise.
391    let mut changed: Vec<(String, u32, FragmentEntry)> = Vec::new();
392    let mut context: Vec<(f64, String, u32, FragmentEntry)> = Vec::new();
393    for (rel_path, frags) in &by_path {
394        let mut sorted: Vec<&Fragment> = frags.clone();
395        // Tie-break by descending end line so, among same-start fragments, the
396        // widest range sorts first and containment-absorption below (which scans
397        // forward from the first entry of a run) sees the enclosing range before
398        // any of its nested sub-fragments.
399        sorted.sort_by_key(|f| (f.start_line(), std::cmp::Reverse(f.end_line())));
400        let file_rel = sorted
401            .iter()
402            .map(|f| rel_scores.get(&f.id).copied().unwrap_or(0.0))
403            .fold(0.0_f64, f64::max);
404        for (role_changed, start, mut entry) in merge_file_fragments(rel_path, &sorted, core_ids) {
405            if no_content {
406                entry.content = None;
407            }
408            if role_changed {
409                changed.push((rel_path.clone(), start, entry));
410            } else {
411                context.push((file_rel, rel_path.clone(), start, entry));
412            }
413        }
414    }
415
416    changed.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)));
417    context.sort_by(|a, b| {
418        b.0.partial_cmp(&a.0)
419            .unwrap_or(std::cmp::Ordering::Equal)
420            .then(a.1.cmp(&b.1))
421            .then(a.2.cmp(&b.2))
422    });
423
424    let mut fragments_out: Vec<FragmentEntry> = Vec::with_capacity(changed.len() + context.len());
425    fragments_out.extend(changed.into_iter().map(|(_, _, e)| e));
426    fragments_out.extend(context.into_iter().map(|(_, _, _, e)| e));
427
428    let resolved = repo_root
429        .canonicalize()
430        .unwrap_or_else(|_| repo_root.to_path_buf());
431    let name = resolved
432        .file_name()
433        .map(|n| n.to_string_lossy().to_string())
434        .unwrap_or_else(|| resolved.to_string_lossy().to_string());
435
436    DiffContextOutput {
437        name,
438        output_type: "diff_context".to_string(),
439        commit_message: change.commit_message,
440        changed_files: change.changed_files,
441        deleted_files: change.deleted_files,
442        renamed_files: change.renamed_files,
443        lockfile_changes: change.lockfile_changes,
444        ignored_changes: change.ignored_changes,
445        policy_excluded_count: change.policy_excluded_count,
446        fragment_count: fragments_out.len(),
447        fragments: fragments_out,
448        latency: None,
449    }
450}
451
452#[cfg(test)]
453mod tests {
454    use super::*;
455
456    fn empty_output(renamed_files: Vec<(String, String)>) -> DiffContextOutput {
457        DiffContextOutput {
458            name: "repo".to_string(),
459            output_type: "diff_context".to_string(),
460            commit_message: None,
461            changed_files: Vec::new(),
462            deleted_files: Vec::new(),
463            renamed_files,
464            lockfile_changes: Vec::new(),
465            ignored_changes: Vec::new(),
466            policy_excluded_count: 0,
467            fragment_count: 0,
468            fragments: Vec::new(),
469            latency: None,
470        }
471    }
472
473    #[test]
474    fn renamed_files_serialize_as_labelled_from_to_in_yaml() {
475        let out = empty_output(vec![("old.py".to_string(), "new.py".to_string())]);
476        let yaml = serde_yaml::to_string(&out).unwrap();
477        assert!(
478            yaml.contains("from: old.py"),
479            "expected labelled `from:` entry, got:\n{yaml}"
480        );
481        assert!(
482            yaml.contains("to: new.py"),
483            "expected labelled `to:` entry, got:\n{yaml}"
484        );
485        // Guards against serde's default tuple-as-two-element-sequence shape
486        // (`- - old.py\n  - new.py`), which drops the from/to labels.
487        assert!(!yaml.contains("- - old.py"));
488    }
489
490    #[test]
491    fn renamed_files_serialize_as_labelled_from_to_in_json() {
492        let out = empty_output(vec![("old.py".to_string(), "new.py".to_string())]);
493        let json = serde_json::to_value(&out).unwrap();
494        let renamed = json["renamed_files"]
495            .as_array()
496            .expect("renamed_files must serialize as an array");
497        assert_eq!(renamed.len(), 1);
498        assert_eq!(renamed[0]["from"], "old.py");
499        assert_eq!(renamed[0]["to"], "new.py");
500        assert!(
501            renamed[0].is_object(),
502            "must not serialize as a positional [old, new] tuple: {renamed:?}"
503        );
504    }
505
506    #[test]
507    fn renamed_files_empty_is_omitted_from_output() {
508        let out = empty_output(Vec::new());
509        let json = serde_json::to_value(&out).unwrap();
510        assert!(json.get("renamed_files").is_none());
511    }
512
513    fn frag_at(path: &str) -> Fragment {
514        Fragment {
515            id: FragmentId::new(Arc::from(path), 1, 5),
516            kind: FragmentKind::Function,
517            content: Arc::from(""),
518            identifiers: FxHashSet::default(),
519            token_count: 1,
520            symbol_name: None,
521        }
522    }
523
524    #[cfg(unix)]
525    #[test]
526    fn get_relative_path_posix_backslash_in_filename_round_trips_unchanged() {
527        // On POSIX `\` is a legal filename character: `src\utils.py` is one
528        // file, not `src/utils.py` in a subdirectory. Rewriting the
529        // separator here would report a path that does not exist.
530        let frag = frag_at("src\\utils.py");
531        let root = Path::new("/repo");
532        let rel = get_relative_path(&frag, root);
533        assert_eq!(rel, "src\\utils.py");
534    }
535
536    #[cfg(unix)]
537    #[test]
538    fn get_relative_path_strips_repo_root_on_posix() {
539        let frag = frag_at("/repo/src/lib.rs");
540        let root = Path::new("/repo");
541        let rel = get_relative_path(&frag, root);
542        assert_eq!(rel, "src/lib.rs");
543    }
544}
545
546#[cfg(test)]
547mod merge_kind_tests {
548    use super::*;
549    use crate::types::{FragmentId, FragmentKind};
550
551    fn frag(start: u32, end: u32, kind: FragmentKind, body: &str) -> Fragment {
552        Fragment {
553            id: FragmentId::new(Arc::from("a.py"), start, end),
554            kind,
555            content: Arc::from(body),
556            identifiers: FxHashSet::default(),
557            token_count: 10,
558            symbol_name: None,
559        }
560    }
561
562    /// The defect this guards (#184): a one-line signature followed by
563    /// contiguous body chunks was emitted as a `function_signature` carrying the
564    /// whole function. A signature exists to be the cheap stand-in when the full
565    /// fragment misses the budget, so one that holds the body is the opposite of
566    /// its own contract — and `drop_redundant_signatures` decides using that
567    /// contract.
568    #[test]
569    fn a_merged_run_of_mixed_kinds_does_not_claim_the_first_kind() {
570        let frags = vec![
571            frag(1, 1, FragmentKind::FunctionSignature, "def big(a):"),
572            frag(2, 3, FragmentKind::Chunk, "    x = 1\n    y = 2"),
573        ];
574        let refs: Vec<&Fragment> = frags.iter().collect();
575        let out = merge_file_fragments("a.py", &refs, &FxHashSet::default());
576
577        assert_eq!(out.len(), 1, "contiguous fragments should merge into one");
578        assert_eq!(out[0].2.kind, "chunk");
579        assert_eq!(out[0].2.lines, "1-3");
580    }
581
582    /// A run that is genuinely all one kind keeps it — the rule is about the
583    /// label becoming false, not about merging itself.
584    #[test]
585    fn a_merged_run_of_one_kind_keeps_it() {
586        let frags = vec![
587            frag(1, 2, FragmentKind::Chunk, "a\nb"),
588            frag(3, 4, FragmentKind::Chunk, "c\nd"),
589        ];
590        let refs: Vec<&Fragment> = frags.iter().collect();
591        let out = merge_file_fragments("a.py", &refs, &FxHashSet::default());
592
593        assert_eq!(out.len(), 1);
594        assert_eq!(out[0].2.kind, "chunk");
595    }
596
597    /// An unmerged fragment is untouched: nothing about it became untrue.
598    #[test]
599    fn a_lone_fragment_keeps_its_kind() {
600        let frags = vec![frag(1, 1, FragmentKind::FunctionSignature, "def big(a):")];
601        let refs: Vec<&Fragment> = frags.iter().collect();
602        let out = merge_file_fragments("a.py", &refs, &FxHashSet::default());
603
604        assert_eq!(out[0].2.kind, "function_signature");
605    }
606}