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