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 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 #[serde(skip_serializing_if = "Vec::is_empty")]
68 pub lockfile_changes: Vec<String>,
69 #[serde(skip_serializing_if = "Vec::is_empty")]
73 pub ignored_changes: Vec<String>,
74 #[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 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 pub graph_build_ms: f64,
99 pub scoring_selection_ms: f64,
103 pub total_ms: f64,
104 pub scoring_ms: f64,
108 pub selection_ms: f64,
110 pub candidate_count: usize,
117 pub edge_count: usize,
120 pub greedy_iters: usize,
124 pub edges_before_cap: usize,
126 pub edges_dropped_by_cap: usize,
128 pub nodes_capped: usize,
130 pub max_out_edges_per_node: usize,
132 pub ppr_truncated: bool,
137 pub ppr_forward_pushes: usize,
138 pub ppr_backward_pushes: usize,
139 pub stopping_certificate: f64,
144 pub peak_rss_bytes: u64,
147 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 #[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
291fn carries_changed_role(frag: &Fragment, core_ids: &FxHashSet<FragmentId>) -> bool {
298 core_ids.contains(&frag.id) || frag.kind == FragmentKind::Excerpt
299}
300
301fn 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 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 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 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 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 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 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 #[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 #[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 #[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}