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 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 #[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 pub graph_build_ms: f64,
76 pub scoring_selection_ms: f64,
80 pub total_ms: f64,
81 pub scoring_ms: f64,
85 pub selection_ms: f64,
87 pub candidate_count: usize,
94 pub edge_count: usize,
97 pub greedy_iters: usize,
101 pub edges_before_cap: usize,
103 pub edges_dropped_by_cap: usize,
105 pub nodes_capped: usize,
107 pub max_out_edges_per_node: usize,
109 pub ppr_truncated: bool,
114 pub ppr_forward_pushes: usize,
115 pub ppr_backward_pushes: usize,
116 pub stopping_certificate: f64,
121 pub peak_rss_bytes: u64,
124 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 #[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#[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
281fn 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 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 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 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 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 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}