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
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 #[serde(skip_serializing_if = "Vec::is_empty")]
64 pub lockfile_changes: Vec<String>,
65 #[serde(skip_serializing_if = "Vec::is_empty")]
69 pub ignored_changes: Vec<String>,
70 #[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 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 pub graph_build_ms: f64,
95 pub scoring_selection_ms: f64,
99 pub total_ms: f64,
100 pub scoring_ms: f64,
104 pub selection_ms: f64,
106 pub candidate_count: usize,
113 pub edge_count: usize,
116 pub greedy_iters: usize,
120 pub edges_before_cap: usize,
122 pub edges_dropped_by_cap: usize,
124 pub nodes_capped: usize,
126 pub max_out_edges_per_node: usize,
128 pub ppr_truncated: bool,
133 pub ppr_forward_pushes: usize,
134 pub ppr_backward_pushes: usize,
135 pub stopping_certificate: f64,
140 pub peak_rss_bytes: u64,
143 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 #[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
287fn 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
304pub(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
315fn 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 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 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 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 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 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 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 #[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 #[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 #[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 #[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}