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}
23
24fn serialize_renames<S>(renames: &[(String, String)], serializer: S) -> Result<S::Ok, S::Error>
25where
26    S: serde::Serializer,
27{
28    use serde::ser::SerializeSeq;
29    let mut seq = serializer.serialize_seq(Some(renames.len()))?;
30    for (from, to) in renames {
31        let mut m = std::collections::BTreeMap::new();
32        m.insert("from", from);
33        m.insert("to", to);
34        seq.serialize_element(&m)?;
35    }
36    seq.end()
37}
38
39#[derive(Serialize)]
40pub struct DiffContextOutput {
41    pub name: String,
42    #[serde(rename = "type")]
43    pub output_type: String,
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub commit_message: Option<String>,
46    #[serde(skip_serializing_if = "Vec::is_empty")]
47    pub changed_files: Vec<String>,
48    #[serde(skip_serializing_if = "Vec::is_empty")]
49    pub deleted_files: Vec<String>,
50    #[serde(
51        skip_serializing_if = "Vec::is_empty",
52        serialize_with = "serialize_renames"
53    )]
54    pub renamed_files: Vec<(String, String)>,
55    /// Lock files touched by the range. Paths only — the raw hunks are
56    /// thousands of tokens of checksum churn for one line of signal (#112).
57    #[serde(skip_serializing_if = "Vec::is_empty")]
58    pub lockfile_changes: Vec<String>,
59    pub fragment_count: usize,
60    pub fragments: Vec<FragmentEntry>,
61    #[serde(skip)]
62    pub latency: Option<LatencyBreakdown>,
63}
64
65pub struct LatencyBreakdown {
66    pub parse_changed_ms: f64,
67    pub universe_walk_ms: f64,
68    pub discovery_ms: f64,
69    pub parse_discovered_ms: f64,
70    pub tokenization_ms: f64,
71    /// Typed dependency graph construction: edge builders + dedup + hub
72    /// suppression + per-source cap. Carved out of `scoring_ms` (which
73    /// used to absorb it) so the cost distribution is truthful. Zero for
74    /// BM25 mode (no graph built).
75    pub graph_build_ms: f64,
76    /// Combined graph build + scoring + selection time. Kept for
77    /// backward compatibility with the existing checkpoint schema; the
78    /// split values below are the new diagnostic signal.
79    pub scoring_selection_ms: f64,
80    pub total_ms: f64,
81    /// Heavy-phase rank computation only (PPR/EGO/BM25 + relevance
82    /// filtering). Graph construction is reported in `graph_build_ms`;
83    /// the selection stage is excluded.
84    pub scoring_ms: f64,
85    /// Selection stage only (lazy greedy / Boltzmann + post-passes).
86    pub selection_ms: f64,
87    /// Size of the candidate fragment universe handed to the scoring
88    /// strategy (after fragment generation + signature variants but
89    /// before per-strategy filtering). Surfaces blowup on large repos —
90    /// pathological scoring time is correlated with this number, not
91    /// with `fragment_count` (which is the *output* size after
92    /// selection).
93    pub candidate_count: usize,
94    /// Edge count of the typed dependency graph used by PPR/EGO. Zero
95    /// for BM25 mode (no graph built).
96    pub edge_count: usize,
97    /// Number of greedy iterations actually executed (selected non-core
98    /// fragments). Bounded by `selected.len() - core.len()`. Pairs with
99    /// `selection_ms` to spot lazy-heap blowup vs. genuine large output.
100    pub greedy_iters: usize,
101    /// Edge count after merge + hub suppression, before per-source cap.
102    pub edges_before_cap: usize,
103    /// Edges discarded by the per-source top-K cap.
104    pub edges_dropped_by_cap: usize,
105    /// Source nodes whose outgoing edge list was truncated by the cap.
106    pub nodes_capped: usize,
107    /// The K value applied for the per-source cap.
108    pub max_out_edges_per_node: usize,
109    /// PPR push iteration was truncated by `max_pushes_cap` before
110    /// convergence. When true, `rel_scores` are biased toward seeds
111    /// and absolute file_recall on this instance should be flagged
112    /// in post-analysis. Always false for non-PPR scoring modes.
113    pub ppr_truncated: bool,
114    pub ppr_forward_pushes: usize,
115    pub ppr_backward_pushes: usize,
116    /// Additive stopping certificate: upper bound
117    /// (`tau * peak_density * remaining_budget`) on utility foregone by
118    /// adaptive stopping. 0 when the greedy loop ended for another
119    /// reason (budget exhausted, no candidates, singleton override).
120    pub stopping_certificate: f64,
121    /// Lifetime peak physical memory of the process, sampled in-process
122    /// at the end of the run. 0 when the platform query fails.
123    pub peak_rss_bytes: u64,
124    /// Per-category (raw, first-seen deduped) edge emission counts from
125    /// pass 1 of the two-pass edge build, sorted by category name. Names
126    /// the builder category behind near-dense emission blowups (#116).
127    /// Empty for BM25 mode (no graph built).
128    pub edge_emissions_by_category: Vec<(&'static str, u64, u64)>,
129}
130
131#[derive(Serialize, Clone)]
132pub struct FragmentEntry {
133    pub path: String,
134    pub lines: String,
135    /// `Some("changed")` for fragments overlapping the diff hunks; omitted
136    /// (treated as supporting context) otherwise. This is the single signal a
137    /// reader needs to tell the change apart from its context.
138    #[serde(skip_serializing_if = "Option::is_none")]
139    pub role: Option<String>,
140    pub kind: String,
141    #[serde(skip_serializing_if = "Option::is_none")]
142    pub symbol: Option<String>,
143    #[serde(skip_serializing_if = "Option::is_none")]
144    pub content: Option<Arc<str>>,
145}
146
147struct SymbolPatterns {
148    function: Vec<Regex>,
149    class: Vec<Regex>,
150    r#struct: Vec<Regex>,
151    interface: Vec<Regex>,
152    r#enum: Vec<Regex>,
153    r#impl: Vec<Regex>,
154    r#type: Vec<Regex>,
155    module: Vec<Regex>,
156    section: Vec<Regex>,
157}
158
159static SYMBOL_PATTERNS: Lazy<SymbolPatterns> = Lazy::new(|| {
160    SymbolPatterns {
161    function: vec![
162        Regex::new(r"(?m)^\s*(?:async\s+)?def\s+(\w+)\s*\(").unwrap(),
163        Regex::new(r"(?m)^\s*(?:export\s+)?(?:async\s+)?function\s+(\w+)\s*[\(<]").unwrap(),
164        Regex::new(r"(?m)^\s*(?:export\s+)?(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s+)?(?:\([^)]*\)|\w)\s*=>").unwrap(),
165        Regex::new(r"(?m)^func\s+(?:\([^)]+\)\s+)?(\w+)\s*[\(\[]").unwrap(),
166        Regex::new(r"(?m)^\s*(?:pub\s+)?(?:async\s+)?fn\s+(\w+)\s*[\(<]").unwrap(),
167        Regex::new(r"(?m)^\s*(?:(?:public|private|protected|static)\s+)*\w[\w<>\[\],]*\s+(\w+)\s*\(").unwrap(),
168    ],
169    class: vec![
170        Regex::new(r"(?m)^\s*class\s+(\w+)\s*[:\({\s]").unwrap(),
171        Regex::new(r"(?m)^\s*(?:export\s+)?(?:abstract\s+)?class\s+(\w+)").unwrap(),
172    ],
173    r#struct: vec![
174        Regex::new(r"(?m)^\s*(?:pub\s+)?struct\s+(\w+)").unwrap(),
175        Regex::new(r"(?m)^\s*type\s+(\w+)\s+struct\s*\{").unwrap(),
176    ],
177    interface: vec![
178        Regex::new(r"(?m)^\s*(?:export\s+)?interface\s+(\w+)").unwrap(),
179        Regex::new(r"(?m)^\s*type\s+(\w+)\s+interface\s*\{").unwrap(),
180        Regex::new(r"(?m)^\s*(?:pub\s+)?trait\s+(\w+)").unwrap(),
181    ],
182    r#enum: vec![
183        Regex::new(r"(?m)^\s*(?:pub\s+)?enum\s+(\w+)").unwrap(),
184        Regex::new(r"(?m)^\s*class\s+(\w+)\s*\(.*Enum\)").unwrap(),
185    ],
186    r#impl: vec![
187        Regex::new(r"(?m)^\s*impl(?:<[^>]+>)?\s+(\w+)").unwrap(),
188    ],
189    r#type: vec![
190        Regex::new(r"(?m)^\s*(?:export\s+)?type\s+(\w+)").unwrap(),
191        Regex::new(r"(?m)^\s*type\s+(\w+)\s").unwrap(),
192    ],
193    module: vec![
194        Regex::new(r"(?m)^\s*(?:pub\s+)?mod\s+(\w+)").unwrap(),
195        Regex::new(r"(?m)^\s*package\s+(\w+)").unwrap(),
196    ],
197    section: vec![
198        Regex::new(r"(?m)^#{1,6}\s+(\S[^\n]*)$").unwrap(),
199    ],
200}
201});
202
203fn extract_symbol(frag: &Fragment) -> Option<String> {
204    let patterns = match frag.kind {
205        FragmentKind::Function | FragmentKind::FunctionSignature => &SYMBOL_PATTERNS.function,
206        FragmentKind::Class | FragmentKind::ClassSignature => &SYMBOL_PATTERNS.class,
207        FragmentKind::Struct | FragmentKind::StructSignature => &SYMBOL_PATTERNS.r#struct,
208        FragmentKind::Interface | FragmentKind::InterfaceSignature => &SYMBOL_PATTERNS.interface,
209        FragmentKind::Enum | FragmentKind::EnumSignature => &SYMBOL_PATTERNS.r#enum,
210        FragmentKind::Impl => &SYMBOL_PATTERNS.r#impl,
211        FragmentKind::Type => &SYMBOL_PATTERNS.r#type,
212        FragmentKind::Module => &SYMBOL_PATTERNS.module,
213        FragmentKind::Section => &SYMBOL_PATTERNS.section,
214        _ => return None,
215    };
216
217    for pattern in patterns {
218        if let Some(caps) = pattern.captures(&frag.content) {
219            if let Some(m) = caps.get(1) {
220                let result = m.as_str().trim();
221                return Some(if frag.kind == FragmentKind::Section {
222                    result
223                        .chars()
224                        .take(RENDER.section_symbol_max_chars)
225                        .collect()
226                } else {
227                    result.to_string()
228                });
229            }
230        }
231    }
232    None
233}
234
235// Windows paths use `\` as the component separator, so a rendered path must
236// be normalized to `/` for cross-platform-readable output. On POSIX, `\` is
237// a legal filename character (`src\utils.py` is one file, not a directory
238// separator) — rewriting it there would report a path that does not exist
239// and silently merge two distinct files under one heading in `by_path`.
240#[cfg(windows)]
241fn normalize_path_separators(s: std::borrow::Cow<'_, str>) -> String {
242    s.replace('\\', "/")
243}
244
245#[cfg(not(windows))]
246fn normalize_path_separators(s: std::borrow::Cow<'_, str>) -> String {
247    s.into_owned()
248}
249
250pub(crate) fn get_relative_path(frag: &Fragment, repo_root: &Path) -> String {
251    let frag_path = Path::new(frag.path());
252    if !frag_path.is_absolute() {
253        return normalize_path_separators(frag_path.to_string_lossy());
254    }
255    normalize_path_separators(
256        frag_path
257            .strip_prefix(repo_root)
258            .unwrap_or(frag_path)
259            .to_string_lossy(),
260    )
261}
262
263fn create_fragment_entry(frag: &Fragment, path_str: &str) -> FragmentEntry {
264    let symbol = frag.symbol_name.clone().or_else(|| extract_symbol(frag));
265    let content = if frag.content.is_empty() {
266        None
267    } else {
268        Some(Arc::clone(&frag.content))
269    };
270
271    FragmentEntry {
272        path: path_str.to_string(),
273        lines: format!("{}-{}", frag.start_line(), frag.end_line()),
274        role: None,
275        kind: frag.kind.as_str().to_string(),
276        symbol,
277        content,
278    }
279}
280
281/// Collapse a file's fragments (sorted by start line, ties by descending end
282/// line) into the rendered entries. Two behaviors:
283/// - a same-role fragment fully contained in the running range (`next.end <=
284///   end`) is dropped: its content is already covered by the enclosing
285///   fragment (e.g. a symbol-level "function" extraction and a hunk-level
286///   "chunk" both covering the same edited lines), so keeping it is pure
287///   duplication, not additional information.
288/// - a same-role fragment that is line-contiguous with the running range
289///   (`next.start == end + 1`) is merged into it.
290/// Both are lossless on line coverage and remove the per-fragment scaffolding
291/// tax that dominates output on one-line/near-duplicate snippets.
292fn merge_file_fragments(
293    rel_path: &str,
294    frags: &[&Fragment],
295    core_ids: &FxHashSet<FragmentId>,
296) -> Vec<(bool, u32, FragmentEntry)> {
297    let mut out: Vec<(bool, u32, FragmentEntry)> = Vec::new();
298    let mut i = 0;
299    while i < frags.len() {
300        let first = frags[i];
301        let role_changed = core_ids.contains(&first.id);
302        let mut end = first.end_line();
303        let mut parts: Vec<&str> = vec![first.content.trim_end_matches('\n')];
304        let mut j = i + 1;
305        while j < frags.len() {
306            let next = frags[j];
307            if core_ids.contains(&next.id) != role_changed {
308                break;
309            }
310            if next.end_line() <= end {
311                // Fully contained in the range covered so far - redundant.
312                j += 1;
313            } else if next.start_line() == end + 1 {
314                parts.push(next.content.trim_end_matches('\n'));
315                end = next.end_line();
316                j += 1;
317            } else {
318                break;
319            }
320        }
321
322        let mut entry = create_fragment_entry(first, rel_path);
323        if j > i + 1 {
324            entry.lines = format!("{}-{}", first.start_line(), end);
325            let merged = parts.join("\n");
326            entry.content = if merged.is_empty() {
327                None
328            } else {
329                Some(Arc::from(merged.as_str()))
330            };
331        }
332        entry.role = role_changed.then(|| "changed".to_string());
333        out.push((role_changed, first.start_line(), entry));
334        i = j;
335    }
336    out
337}
338
339pub fn build_diff_context_output(
340    repo_root: &Path,
341    selected: &[Fragment],
342    no_content: bool,
343    core_ids: &FxHashSet<FragmentId>,
344    rel_scores: &FxHashMap<FragmentId, f64>,
345    change: ChangeSummary,
346) -> DiffContextOutput {
347    let mut by_path: FxHashMap<String, Vec<&Fragment>> = FxHashMap::default();
348    for frag in selected {
349        by_path
350            .entry(get_relative_path(frag, repo_root))
351            .or_default()
352            .push(frag);
353    }
354
355    // Changed code first (the answer to "what changed"), then supporting
356    // context ordered by descending per-file relevance so the reader's primacy
357    // attention lands on the most relevant material, not on alphabetical noise.
358    let mut changed: Vec<(String, u32, FragmentEntry)> = Vec::new();
359    let mut context: Vec<(f64, String, u32, FragmentEntry)> = Vec::new();
360    for (rel_path, frags) in &by_path {
361        let mut sorted: Vec<&Fragment> = frags.clone();
362        // Tie-break by descending end line so, among same-start fragments, the
363        // widest range sorts first and containment-absorption below (which scans
364        // forward from the first entry of a run) sees the enclosing range before
365        // any of its nested sub-fragments.
366        sorted.sort_by_key(|f| (f.start_line(), std::cmp::Reverse(f.end_line())));
367        let file_rel = sorted
368            .iter()
369            .map(|f| rel_scores.get(&f.id).copied().unwrap_or(0.0))
370            .fold(0.0_f64, f64::max);
371        for (role_changed, start, mut entry) in merge_file_fragments(rel_path, &sorted, core_ids) {
372            if no_content {
373                entry.content = None;
374            }
375            if role_changed {
376                changed.push((rel_path.clone(), start, entry));
377            } else {
378                context.push((file_rel, rel_path.clone(), start, entry));
379            }
380        }
381    }
382
383    changed.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)));
384    context.sort_by(|a, b| {
385        b.0.partial_cmp(&a.0)
386            .unwrap_or(std::cmp::Ordering::Equal)
387            .then(a.1.cmp(&b.1))
388            .then(a.2.cmp(&b.2))
389    });
390
391    let mut fragments_out: Vec<FragmentEntry> = Vec::with_capacity(changed.len() + context.len());
392    fragments_out.extend(changed.into_iter().map(|(_, _, e)| e));
393    fragments_out.extend(context.into_iter().map(|(_, _, _, e)| e));
394
395    let resolved = repo_root
396        .canonicalize()
397        .unwrap_or_else(|_| repo_root.to_path_buf());
398    let name = resolved
399        .file_name()
400        .map(|n| n.to_string_lossy().to_string())
401        .unwrap_or_else(|| resolved.to_string_lossy().to_string());
402
403    DiffContextOutput {
404        name,
405        output_type: "diff_context".to_string(),
406        commit_message: change.commit_message,
407        changed_files: change.changed_files,
408        deleted_files: change.deleted_files,
409        renamed_files: change.renamed_files,
410        lockfile_changes: change.lockfile_changes,
411        fragment_count: fragments_out.len(),
412        fragments: fragments_out,
413        latency: None,
414    }
415}
416
417#[cfg(test)]
418mod tests {
419    use super::*;
420
421    fn empty_output(renamed_files: Vec<(String, String)>) -> DiffContextOutput {
422        DiffContextOutput {
423            name: "repo".to_string(),
424            output_type: "diff_context".to_string(),
425            commit_message: None,
426            changed_files: Vec::new(),
427            deleted_files: Vec::new(),
428            renamed_files,
429            lockfile_changes: Vec::new(),
430            fragment_count: 0,
431            fragments: Vec::new(),
432            latency: None,
433        }
434    }
435
436    #[test]
437    fn renamed_files_serialize_as_labelled_from_to_in_yaml() {
438        let out = empty_output(vec![("old.py".to_string(), "new.py".to_string())]);
439        let yaml = serde_yaml::to_string(&out).unwrap();
440        assert!(
441            yaml.contains("from: old.py"),
442            "expected labelled `from:` entry, got:\n{yaml}"
443        );
444        assert!(
445            yaml.contains("to: new.py"),
446            "expected labelled `to:` entry, got:\n{yaml}"
447        );
448        // Guards against serde's default tuple-as-two-element-sequence shape
449        // (`- - old.py\n  - new.py`), which drops the from/to labels.
450        assert!(!yaml.contains("- - old.py"));
451    }
452
453    #[test]
454    fn renamed_files_serialize_as_labelled_from_to_in_json() {
455        let out = empty_output(vec![("old.py".to_string(), "new.py".to_string())]);
456        let json = serde_json::to_value(&out).unwrap();
457        let renamed = json["renamed_files"]
458            .as_array()
459            .expect("renamed_files must serialize as an array");
460        assert_eq!(renamed.len(), 1);
461        assert_eq!(renamed[0]["from"], "old.py");
462        assert_eq!(renamed[0]["to"], "new.py");
463        assert!(
464            renamed[0].is_object(),
465            "must not serialize as a positional [old, new] tuple: {renamed:?}"
466        );
467    }
468
469    #[test]
470    fn renamed_files_empty_is_omitted_from_output() {
471        let out = empty_output(Vec::new());
472        let json = serde_json::to_value(&out).unwrap();
473        assert!(json.get("renamed_files").is_none());
474    }
475
476    fn frag_at(path: &str) -> Fragment {
477        Fragment {
478            id: FragmentId::new(Arc::from(path), 1, 5),
479            kind: FragmentKind::Function,
480            content: Arc::from(""),
481            identifiers: FxHashSet::default(),
482            token_count: 1,
483            symbol_name: None,
484        }
485    }
486
487    #[cfg(unix)]
488    #[test]
489    fn get_relative_path_posix_backslash_in_filename_round_trips_unchanged() {
490        // On POSIX `\` is a legal filename character: `src\utils.py` is one
491        // file, not `src/utils.py` in a subdirectory. Rewriting the
492        // separator here would report a path that does not exist.
493        let frag = frag_at("src\\utils.py");
494        let root = Path::new("/repo");
495        let rel = get_relative_path(&frag, root);
496        assert_eq!(rel, "src\\utils.py");
497    }
498
499    #[cfg(unix)]
500    #[test]
501    fn get_relative_path_strips_repo_root_on_posix() {
502        let frag = frag_at("/repo/src/lib.rs");
503        let root = Path::new("/repo");
504        let rel = get_relative_path(&frag, root);
505        assert_eq!(rel, "src/lib.rs");
506    }
507}