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#[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}
22
23fn serialize_renames<S>(renames: &[(String, String)], serializer: S) -> Result<S::Ok, S::Error>
24where
25 S: serde::Serializer,
26{
27 use serde::ser::SerializeSeq;
28 let mut seq = serializer.serialize_seq(Some(renames.len()))?;
29 for (from, to) in renames {
30 let mut m = std::collections::BTreeMap::new();
31 m.insert("from", from);
32 m.insert("to", to);
33 seq.serialize_element(&m)?;
34 }
35 seq.end()
36}
37
38#[derive(Serialize)]
39pub struct DiffContextOutput {
40 pub name: String,
41 #[serde(rename = "type")]
42 pub output_type: String,
43 #[serde(skip_serializing_if = "Option::is_none")]
44 pub commit_message: Option<String>,
45 #[serde(skip_serializing_if = "Vec::is_empty")]
46 pub changed_files: Vec<String>,
47 #[serde(skip_serializing_if = "Vec::is_empty")]
48 pub deleted_files: Vec<String>,
49 #[serde(
50 skip_serializing_if = "Vec::is_empty",
51 serialize_with = "serialize_renames"
52 )]
53 pub renamed_files: Vec<(String, String)>,
54 pub fragment_count: usize,
55 pub fragments: Vec<FragmentEntry>,
56 #[serde(skip)]
57 pub latency: Option<LatencyBreakdown>,
58}
59
60pub struct LatencyBreakdown {
61 pub parse_changed_ms: f64,
62 pub universe_walk_ms: f64,
63 pub discovery_ms: f64,
64 pub parse_discovered_ms: f64,
65 pub tokenization_ms: f64,
66 pub graph_build_ms: f64,
71 pub scoring_selection_ms: f64,
75 pub total_ms: f64,
76 pub scoring_ms: f64,
80 pub selection_ms: f64,
82 pub candidate_count: usize,
89 pub edge_count: usize,
92 pub greedy_iters: usize,
96 pub edges_before_cap: usize,
98 pub edges_dropped_by_cap: usize,
100 pub nodes_capped: usize,
102 pub max_out_edges_per_node: usize,
104 pub ppr_truncated: bool,
109 pub ppr_forward_pushes: usize,
110 pub ppr_backward_pushes: usize,
111 pub stopping_certificate: f64,
116 pub peak_rss_bytes: u64,
119 pub edge_emissions_by_category: Vec<(&'static str, u64, u64)>,
124}
125
126#[derive(Serialize, Clone)]
127pub struct FragmentEntry {
128 pub path: String,
129 pub lines: String,
130 #[serde(skip_serializing_if = "Option::is_none")]
134 pub role: Option<String>,
135 pub kind: String,
136 #[serde(skip_serializing_if = "Option::is_none")]
137 pub symbol: Option<String>,
138 #[serde(skip_serializing_if = "Option::is_none")]
139 pub content: Option<Arc<str>>,
140}
141
142struct SymbolPatterns {
143 function: Vec<Regex>,
144 class: Vec<Regex>,
145 r#struct: Vec<Regex>,
146 interface: Vec<Regex>,
147 r#enum: Vec<Regex>,
148 r#impl: Vec<Regex>,
149 r#type: Vec<Regex>,
150 module: Vec<Regex>,
151 section: Vec<Regex>,
152}
153
154static SYMBOL_PATTERNS: Lazy<SymbolPatterns> = Lazy::new(|| {
155 SymbolPatterns {
156 function: vec![
157 Regex::new(r"(?m)^\s*(?:async\s+)?def\s+(\w+)\s*\(").unwrap(),
158 Regex::new(r"(?m)^\s*(?:export\s+)?(?:async\s+)?function\s+(\w+)\s*[\(<]").unwrap(),
159 Regex::new(r"(?m)^\s*(?:export\s+)?(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s+)?(?:\([^)]*\)|\w)\s*=>").unwrap(),
160 Regex::new(r"(?m)^func\s+(?:\([^)]+\)\s+)?(\w+)\s*[\(\[]").unwrap(),
161 Regex::new(r"(?m)^\s*(?:pub\s+)?(?:async\s+)?fn\s+(\w+)\s*[\(<]").unwrap(),
162 Regex::new(r"(?m)^\s*(?:(?:public|private|protected|static)\s+)*\w[\w<>\[\],]*\s+(\w+)\s*\(").unwrap(),
163 ],
164 class: vec![
165 Regex::new(r"(?m)^\s*class\s+(\w+)\s*[:\({\s]").unwrap(),
166 Regex::new(r"(?m)^\s*(?:export\s+)?(?:abstract\s+)?class\s+(\w+)").unwrap(),
167 ],
168 r#struct: vec![
169 Regex::new(r"(?m)^\s*(?:pub\s+)?struct\s+(\w+)").unwrap(),
170 Regex::new(r"(?m)^\s*type\s+(\w+)\s+struct\s*\{").unwrap(),
171 ],
172 interface: vec![
173 Regex::new(r"(?m)^\s*(?:export\s+)?interface\s+(\w+)").unwrap(),
174 Regex::new(r"(?m)^\s*type\s+(\w+)\s+interface\s*\{").unwrap(),
175 Regex::new(r"(?m)^\s*(?:pub\s+)?trait\s+(\w+)").unwrap(),
176 ],
177 r#enum: vec![
178 Regex::new(r"(?m)^\s*(?:pub\s+)?enum\s+(\w+)").unwrap(),
179 Regex::new(r"(?m)^\s*class\s+(\w+)\s*\(.*Enum\)").unwrap(),
180 ],
181 r#impl: vec![
182 Regex::new(r"(?m)^\s*impl(?:<[^>]+>)?\s+(\w+)").unwrap(),
183 ],
184 r#type: vec![
185 Regex::new(r"(?m)^\s*(?:export\s+)?type\s+(\w+)").unwrap(),
186 Regex::new(r"(?m)^\s*type\s+(\w+)\s").unwrap(),
187 ],
188 module: vec![
189 Regex::new(r"(?m)^\s*(?:pub\s+)?mod\s+(\w+)").unwrap(),
190 Regex::new(r"(?m)^\s*package\s+(\w+)").unwrap(),
191 ],
192 section: vec![
193 Regex::new(r"(?m)^#{1,6}\s+(\S[^\n]*)$").unwrap(),
194 ],
195}
196});
197
198fn extract_symbol(frag: &Fragment) -> Option<String> {
199 let patterns = match frag.kind {
200 FragmentKind::Function | FragmentKind::FunctionSignature => &SYMBOL_PATTERNS.function,
201 FragmentKind::Class | FragmentKind::ClassSignature => &SYMBOL_PATTERNS.class,
202 FragmentKind::Struct | FragmentKind::StructSignature => &SYMBOL_PATTERNS.r#struct,
203 FragmentKind::Interface | FragmentKind::InterfaceSignature => &SYMBOL_PATTERNS.interface,
204 FragmentKind::Enum | FragmentKind::EnumSignature => &SYMBOL_PATTERNS.r#enum,
205 FragmentKind::Impl => &SYMBOL_PATTERNS.r#impl,
206 FragmentKind::Type => &SYMBOL_PATTERNS.r#type,
207 FragmentKind::Module => &SYMBOL_PATTERNS.module,
208 FragmentKind::Section => &SYMBOL_PATTERNS.section,
209 _ => return None,
210 };
211
212 for pattern in patterns {
213 if let Some(caps) = pattern.captures(&frag.content) {
214 if let Some(m) = caps.get(1) {
215 let result = m.as_str().trim();
216 return Some(if frag.kind == FragmentKind::Section {
217 result
218 .chars()
219 .take(RENDER.section_symbol_max_chars)
220 .collect()
221 } else {
222 result.to_string()
223 });
224 }
225 }
226 }
227 None
228}
229
230fn get_relative_path(frag: &Fragment, repo_root: &Path) -> String {
231 let frag_path = Path::new(frag.path());
232 if !frag_path.is_absolute() {
233 return frag_path.to_string_lossy().replace('\\', "/");
234 }
235 frag_path
236 .strip_prefix(repo_root)
237 .unwrap_or(frag_path)
238 .to_string_lossy()
239 .replace('\\', "/")
240}
241
242fn create_fragment_entry(frag: &Fragment, path_str: &str) -> FragmentEntry {
243 let symbol = frag.symbol_name.clone().or_else(|| extract_symbol(frag));
244 let content = if frag.content.is_empty() {
245 None
246 } else {
247 Some(Arc::clone(&frag.content))
248 };
249
250 FragmentEntry {
251 path: path_str.to_string(),
252 lines: format!("{}-{}", frag.start_line(), frag.end_line()),
253 role: None,
254 kind: frag.kind.as_str().to_string(),
255 symbol,
256 content,
257 }
258}
259
260fn merge_file_fragments(
272 rel_path: &str,
273 frags: &[&Fragment],
274 core_ids: &FxHashSet<FragmentId>,
275) -> Vec<(bool, u32, FragmentEntry)> {
276 let mut out: Vec<(bool, u32, FragmentEntry)> = Vec::new();
277 let mut i = 0;
278 while i < frags.len() {
279 let first = frags[i];
280 let role_changed = core_ids.contains(&first.id);
281 let mut end = first.end_line();
282 let mut parts: Vec<&str> = vec![first.content.trim_end_matches('\n')];
283 let mut j = i + 1;
284 while j < frags.len() {
285 let next = frags[j];
286 if core_ids.contains(&next.id) != role_changed {
287 break;
288 }
289 if next.end_line() <= end {
290 j += 1;
292 } else if next.start_line() == end + 1 {
293 parts.push(next.content.trim_end_matches('\n'));
294 end = next.end_line();
295 j += 1;
296 } else {
297 break;
298 }
299 }
300
301 let mut entry = create_fragment_entry(first, rel_path);
302 if j > i + 1 {
303 entry.lines = format!("{}-{}", first.start_line(), end);
304 let merged = parts.join("\n");
305 entry.content = if merged.is_empty() {
306 None
307 } else {
308 Some(Arc::from(merged.as_str()))
309 };
310 }
311 entry.role = role_changed.then(|| "changed".to_string());
312 out.push((role_changed, first.start_line(), entry));
313 i = j;
314 }
315 out
316}
317
318pub fn build_diff_context_output(
319 repo_root: &Path,
320 selected: &[Fragment],
321 no_content: bool,
322 core_ids: &FxHashSet<FragmentId>,
323 rel_scores: &FxHashMap<FragmentId, f64>,
324 change: ChangeSummary,
325) -> DiffContextOutput {
326 let mut by_path: FxHashMap<String, Vec<&Fragment>> = FxHashMap::default();
327 for frag in selected {
328 by_path
329 .entry(get_relative_path(frag, repo_root))
330 .or_default()
331 .push(frag);
332 }
333
334 let mut changed: Vec<(String, u32, FragmentEntry)> = Vec::new();
338 let mut context: Vec<(f64, String, u32, FragmentEntry)> = Vec::new();
339 for (rel_path, frags) in &by_path {
340 let mut sorted: Vec<&Fragment> = frags.clone();
341 sorted.sort_by_key(|f| (f.start_line(), std::cmp::Reverse(f.end_line())));
346 let file_rel = sorted
347 .iter()
348 .map(|f| rel_scores.get(&f.id).copied().unwrap_or(0.0))
349 .fold(0.0_f64, f64::max);
350 for (role_changed, start, mut entry) in merge_file_fragments(rel_path, &sorted, core_ids) {
351 if no_content {
352 entry.content = None;
353 }
354 if role_changed {
355 changed.push((rel_path.clone(), start, entry));
356 } else {
357 context.push((file_rel, rel_path.clone(), start, entry));
358 }
359 }
360 }
361
362 changed.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)));
363 context.sort_by(|a, b| {
364 b.0.partial_cmp(&a.0)
365 .unwrap_or(std::cmp::Ordering::Equal)
366 .then(a.1.cmp(&b.1))
367 .then(a.2.cmp(&b.2))
368 });
369
370 let mut fragments_out: Vec<FragmentEntry> = Vec::with_capacity(changed.len() + context.len());
371 fragments_out.extend(changed.into_iter().map(|(_, _, e)| e));
372 fragments_out.extend(context.into_iter().map(|(_, _, _, e)| e));
373
374 let resolved = repo_root
375 .canonicalize()
376 .unwrap_or_else(|_| repo_root.to_path_buf());
377 let name = resolved
378 .file_name()
379 .map(|n| n.to_string_lossy().to_string())
380 .unwrap_or_else(|| resolved.to_string_lossy().to_string());
381
382 DiffContextOutput {
383 name,
384 output_type: "diff_context".to_string(),
385 commit_message: change.commit_message,
386 changed_files: change.changed_files,
387 deleted_files: change.deleted_files,
388 renamed_files: change.renamed_files,
389 fragment_count: fragments_out.len(),
390 fragments: fragments_out,
391 latency: None,
392 }
393}