1use std::{
38 collections::BTreeMap,
39 path::{Path, PathBuf},
40 time::Instant,
41};
42
43use objects::{
44 object::{ChangeId, SemanticChange, State},
45 store::ObjectStore,
46};
47
48use crate::{
49 cache::SemanticParseCache,
50 diff::{SemanticDiffOptions, semantic_diff_with_cache},
51};
52
53#[derive(Copy, Clone, Debug, Eq, PartialEq)]
60pub enum HotSpotKey {
61 File,
62 Function,
63}
64
65#[derive(Copy, Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
69pub enum HotEventKind {
70 FileAdded,
71 FileDeleted,
72 FileModified,
73 FileRenamed,
74 FunctionExtracted,
75 FunctionDeleted,
76 FunctionRenamed,
77 FunctionModified,
78 FunctionMoved,
79 SignatureChanged,
80 DependencyChanged,
81}
82
83impl HotEventKind {
84 fn classify(change: &SemanticChange) -> Option<Self> {
85 Some(match change {
86 SemanticChange::FileAdded { .. } => HotEventKind::FileAdded,
87 SemanticChange::FileDeleted { .. } => HotEventKind::FileDeleted,
88 SemanticChange::FileModified { .. } => HotEventKind::FileModified,
89 SemanticChange::FileRenamed { .. } => HotEventKind::FileRenamed,
90 SemanticChange::FunctionAdded { .. } | SemanticChange::FunctionExtracted { .. } => {
91 HotEventKind::FunctionExtracted
92 }
93 SemanticChange::FunctionDeleted { .. } => HotEventKind::FunctionDeleted,
94 SemanticChange::FunctionRenamed { .. } => HotEventKind::FunctionRenamed,
95 SemanticChange::FunctionModified { .. } => HotEventKind::FunctionModified,
96 SemanticChange::FunctionMoved { .. } => HotEventKind::FunctionMoved,
97 SemanticChange::SignatureChanged { .. } => HotEventKind::SignatureChanged,
98 SemanticChange::DependencyAdded { .. } | SemanticChange::DependencyRemoved { .. } => {
99 HotEventKind::DependencyChanged
100 }
101 SemanticChange::Custom { .. } => return None,
104 })
105 }
106}
107
108#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd)]
113pub enum HotSpotKeyValue {
114 File { path: PathBuf },
115 Function { path: PathBuf, name: String },
116}
117
118impl HotSpotKeyValue {
119 pub fn path(&self) -> &Path {
121 match self {
122 HotSpotKeyValue::File { path } => path,
123 HotSpotKeyValue::Function { path, .. } => path,
124 }
125 }
126
127 pub fn function_name(&self) -> Option<&str> {
129 match self {
130 HotSpotKeyValue::Function { name, .. } => Some(name),
131 HotSpotKeyValue::File { .. } => None,
132 }
133 }
134}
135
136#[derive(Clone, Debug)]
142pub struct HotSpot {
143 pub key: HotSpotKeyValue,
144 pub event_count: usize,
145 pub state_count: usize,
146 pub first_seen: ChangeId,
147 pub last_seen: ChangeId,
148 pub by_kind: BTreeMap<HotEventKind, usize>,
150 pub by_actor: Option<BTreeMap<String, usize>>,
154}
155
156#[derive(Clone, Debug)]
158pub struct HotSpotParams {
159 pub limit_states: Option<usize>,
163 pub group_by: HotSpotKey,
165 pub include_kinds: Vec<HotEventKind>,
168 pub include_paths: Vec<String>,
173 pub exclude_paths: Vec<String>,
174 pub top_n: usize,
176 pub include_actors: bool,
180 pub diff_options: SemanticDiffOptions,
182}
183
184impl Default for HotSpotParams {
185 fn default() -> Self {
186 Self {
187 limit_states: Some(200),
188 group_by: HotSpotKey::File,
189 include_kinds: Vec::new(),
190 include_paths: Vec::new(),
191 exclude_paths: Vec::new(),
192 top_n: 20,
193 include_actors: false,
194 diff_options: SemanticDiffOptions::default(),
195 }
196 }
197}
198
199#[derive(Clone, Debug, Default)]
201pub struct HotSpotsReport {
202 pub spots: Vec<HotSpot>,
203 pub states_walked: usize,
205 pub total_events: usize,
209}
210
211pub fn analyze_hot_spots(
219 store: &impl ObjectStore,
220 walk_from: ChangeId,
221 params: &HotSpotParams,
222) -> Result<HotSpotsReport, anyhow::Error> {
223 let started = Instant::now();
224 let cache = SemanticParseCache::shared();
225 let limit = params.limit_states.unwrap_or(usize::MAX);
226
227 let mut slots: BTreeMap<HotSpotKeyValue, SlotAccumulator> = BTreeMap::new();
230 let mut total_events = 0usize;
231 let mut states_walked = 0usize;
232
233 let mut current_id = walk_from;
234 let mut current = match store.get_state(¤t_id)? {
235 Some(s) => s,
236 None => return Ok(HotSpotsReport::default()),
237 };
238
239 while states_walked < limit {
240 let Some(parent_id) = current.first_parent().copied() else {
241 break;
242 };
243 let parent = match store.get_state(&parent_id)? {
244 Some(s) => s,
245 None => break,
246 };
247
248 let diff = semantic_diff_with_cache(
253 store,
254 &parent.tree,
255 ¤t.tree,
256 ¶ms.diff_options,
257 cache,
258 )?;
259
260 let actor_label = if params.include_actors {
261 Some(current.attribution.to_string())
262 } else {
263 None
264 };
265
266 let mut touched_this_state: std::collections::BTreeSet<HotSpotKeyValue> =
271 Default::default();
272
273 for change in &diff.changes {
274 let Some(kind) = HotEventKind::classify(change) else {
275 continue;
276 };
277 if !params.include_kinds.is_empty() && !params.include_kinds.contains(&kind) {
278 continue;
279 }
280 let key = match (params.group_by, change_to_key(change)) {
284 (HotSpotKey::File, Some((path, _))) => HotSpotKeyValue::File { path },
285 (HotSpotKey::Function, Some((path, Some(name)))) => {
286 HotSpotKeyValue::Function { path, name }
287 }
288 _ => continue,
289 };
290
291 if !path_passes_filter(key.path(), ¶ms.include_paths, ¶ms.exclude_paths) {
292 continue;
293 }
294
295 total_events += 1;
296
297 let slot = slots
298 .entry(key.clone())
299 .or_insert_with(|| SlotAccumulator::new(current_id));
300 slot.event_count += 1;
301 slot.last_seen = current_id;
302 *slot.by_kind.entry(kind).or_insert(0) += 1;
303 if let Some(actor) = &actor_label {
304 let by_actor = slot.by_actor.get_or_insert_with(BTreeMap::new);
305 *by_actor.entry(actor.clone()).or_insert(0) += 1;
306 }
307 touched_this_state.insert(key);
308 }
309 for key in touched_this_state {
310 if let Some(slot) = slots.get_mut(&key) {
311 slot.state_count += 1;
312 }
313 }
314
315 states_walked += 1;
316 current_id = parent_id;
317 current = parent;
318 }
319
320 let _ = started; let mut ranked: Vec<(HotSpotKeyValue, SlotAccumulator)> = slots.into_iter().collect();
326 ranked.sort_by(|a, b| {
327 b.1.event_count
328 .cmp(&a.1.event_count)
329 .then(b.1.state_count.cmp(&a.1.state_count))
330 .then(a.0.cmp(&b.0))
331 });
332
333 let spots = ranked
334 .into_iter()
335 .take(params.top_n)
336 .map(|(key, slot)| HotSpot {
337 key,
338 event_count: slot.event_count,
339 state_count: slot.state_count,
340 first_seen: slot.first_seen,
341 last_seen: slot.last_seen,
342 by_kind: slot.by_kind,
343 by_actor: slot.by_actor,
344 })
345 .collect();
346
347 Ok(HotSpotsReport {
348 spots,
349 states_walked,
350 total_events,
351 })
352}
353
354struct SlotAccumulator {
356 event_count: usize,
357 state_count: usize,
358 first_seen: ChangeId,
359 last_seen: ChangeId,
360 by_kind: BTreeMap<HotEventKind, usize>,
361 by_actor: Option<BTreeMap<String, usize>>,
362}
363
364impl SlotAccumulator {
365 fn new(seen: ChangeId) -> Self {
366 Self {
367 event_count: 0,
368 state_count: 0,
369 first_seen: seen,
370 last_seen: seen,
371 by_kind: BTreeMap::new(),
372 by_actor: None,
373 }
374 }
375}
376
377fn change_to_key(change: &SemanticChange) -> Option<(PathBuf, Option<String>)> {
386 match change {
387 SemanticChange::FileAdded { path }
388 | SemanticChange::FileDeleted { path }
389 | SemanticChange::FileModified { path, .. } => Some((path.clone(), None)),
390 SemanticChange::FileRenamed { to, .. } => Some((to.clone(), None)),
391 SemanticChange::FunctionAdded { file, name, .. }
392 | SemanticChange::FunctionExtracted { file, name, .. } => {
393 Some((file.clone(), Some(name.clone())))
394 }
395 SemanticChange::FunctionDeleted { file, name, .. } => {
396 Some((file.clone(), Some(name.clone())))
397 }
398 SemanticChange::FunctionRenamed { file, new_name, .. } => {
399 Some((file.clone(), Some(new_name.clone())))
400 }
401 SemanticChange::FunctionModified { file, name, .. } => {
402 Some((file.clone(), Some(name.clone())))
403 }
404 SemanticChange::FunctionMoved { file, name, .. } => {
405 Some((file.clone(), Some(name.clone())))
406 }
407 SemanticChange::SignatureChanged { file, name, .. } => {
408 Some((file.clone(), Some(name.clone())))
409 }
410 SemanticChange::DependencyAdded { .. }
411 | SemanticChange::DependencyRemoved { .. }
412 | SemanticChange::Custom { .. } => None,
413 }
414}
415
416fn path_passes_filter(path: &Path, includes: &[String], excludes: &[String]) -> bool {
419 let s = path.to_string_lossy();
420 if !includes.is_empty() && !includes.iter().any(|inc| s.contains(inc.as_str())) {
421 return false;
422 }
423 if excludes.iter().any(|exc| s.contains(exc.as_str())) {
424 return false;
425 }
426 true
427}
428
429pub fn analyze_actor_histogram(
435 store: &impl ObjectStore,
436 walk_from: ChangeId,
437 limit_states: Option<usize>,
438) -> Result<BTreeMap<String, usize>, anyhow::Error> {
439 let limit = limit_states.unwrap_or(usize::MAX);
440 let mut histogram: BTreeMap<String, usize> = BTreeMap::new();
441 let mut steps = 0usize;
442
443 let Some(mut current) = store.get_state(&walk_from)? else {
444 return Ok(histogram);
445 };
446
447 *histogram
448 .entry(current.attribution.to_string())
449 .or_insert(0) += 1;
450 steps += 1;
451
452 while steps < limit {
453 let Some(parent_id) = current.first_parent().copied() else {
454 break;
455 };
456 let Some(parent) = store.get_state(&parent_id)? else {
457 break;
458 };
459 *histogram.entry(parent.attribution.to_string()).or_insert(0) += 1;
460 steps += 1;
461 current = parent;
462 }
463
464 Ok(histogram)
465}
466
467#[allow(dead_code)]
473fn _state_anchor(_: &State) {}
474
475#[cfg(test)]
476mod tests {
477 use objects::{
478 object::{Attribution, ChangeId, Principal, State, Tree, TreeEntry},
479 store::InMemoryStore,
480 };
481
482 use super::*;
483
484 fn principal(label: &str) -> Principal {
485 Principal::new(label.to_string(), format!("{label}@example.com"))
486 }
487
488 fn build_three_state_chain() -> (ChangeId, InMemoryStore) {
492 let store = InMemoryStore::new();
493
494 let blob_a = store
495 .put_blob(&objects::object::Blob::from_slice(
496 b"fn one() {}\nfn two() {}\n",
497 ))
498 .unwrap();
499 let tree_a = store
500 .put_tree(&Tree::from_entries(vec![
501 TreeEntry::file("lib.rs".to_string(), blob_a, false).unwrap(),
502 ]))
503 .unwrap();
504 let attrib_a = Attribution::human(principal("alice"));
505 let state_a = State::new(tree_a, Vec::new(), attrib_a);
506 store.put_state(&state_a).unwrap();
507 let id_a = state_a.change_id;
508
509 let blob_b = store
510 .put_blob(&objects::object::Blob::from_slice(
511 b"fn one() { println!(\"hi\"); }\nfn two() {}\n",
512 ))
513 .unwrap();
514 let tree_b = store
515 .put_tree(&Tree::from_entries(vec![
516 TreeEntry::file("lib.rs".to_string(), blob_b, false).unwrap(),
517 ]))
518 .unwrap();
519 let state_b = State::new(tree_b, vec![id_a], Attribution::human(principal("bob")));
520 store.put_state(&state_b).unwrap();
521 let id_b = state_b.change_id;
522
523 let blob_c = store
524 .put_blob(&objects::object::Blob::from_slice(
525 b"fn one() { println!(\"hello\"); }\nfn two() {}\nfn three() {}\n",
526 ))
527 .unwrap();
528 let tree_c = store
529 .put_tree(&Tree::from_entries(vec![
530 TreeEntry::file("lib.rs".to_string(), blob_c, false).unwrap(),
531 ]))
532 .unwrap();
533 let state_c = State::new(tree_c, vec![id_b], Attribution::human(principal("carol")));
534 store.put_state(&state_c).unwrap();
535 let id_c = state_c.change_id;
536
537 (id_c, store)
538 }
539
540 #[test]
541 fn walks_first_parent_chain_to_root() {
542 let (head, store) = build_three_state_chain();
543 let report = analyze_hot_spots(&store, head, &HotSpotParams::default()).unwrap();
544
545 assert_eq!(report.states_walked, 2);
547 let lib_path: PathBuf = "lib.rs".into();
549 let file_spot = report
550 .spots
551 .iter()
552 .find(|s| matches!(&s.key, HotSpotKeyValue::File { path } if path == &lib_path))
553 .expect("expected lib.rs hot-spot");
554 assert!(file_spot.event_count >= 2);
555 assert_eq!(file_spot.state_count, 2);
556 }
557
558 #[test]
559 fn limit_states_caps_the_walk() {
560 let (head, store) = build_three_state_chain();
561 let params = HotSpotParams {
562 limit_states: Some(1),
563 ..HotSpotParams::default()
564 };
565 let report = analyze_hot_spots(&store, head, ¶ms).unwrap();
566 assert_eq!(
567 report.states_walked, 1,
568 "limit_states=1 should walk one pair"
569 );
570 }
571
572 #[test]
573 fn group_by_function_skips_pure_file_events() {
574 let (head, store) = build_three_state_chain();
575 let params = HotSpotParams {
576 group_by: HotSpotKey::Function,
577 ..HotSpotParams::default()
578 };
579 let report = analyze_hot_spots(&store, head, ¶ms).unwrap();
580
581 for spot in &report.spots {
587 assert!(
588 matches!(&spot.key, HotSpotKeyValue::Function { .. }),
589 "group_by=Function should only emit Function keys, got {:?}",
590 spot.key
591 );
592 }
593 }
594
595 #[test]
596 fn include_actors_populates_per_actor_histogram() {
597 let (head, store) = build_three_state_chain();
598 let params = HotSpotParams {
599 include_actors: true,
600 ..HotSpotParams::default()
601 };
602 let report = analyze_hot_spots(&store, head, ¶ms).unwrap();
603
604 let any = report.spots.first().expect("expected at least one spot");
605 let actors = any
606 .by_actor
607 .as_ref()
608 .expect("include_actors=true should populate by_actor");
609 assert!(
613 actors
614 .keys()
615 .any(|k| k.contains("bob") || k.contains("carol")),
616 "expected bob or carol in actor histogram, got {:?}",
617 actors.keys().collect::<Vec<_>>()
618 );
619 }
620
621 #[test]
622 fn path_filter_excludes_substring_match() {
623 let (head, store) = build_three_state_chain();
624 let params = HotSpotParams {
625 exclude_paths: vec!["lib.rs".to_string()],
626 ..HotSpotParams::default()
627 };
628 let report = analyze_hot_spots(&store, head, ¶ms).unwrap();
629 assert!(
630 report.spots.is_empty(),
631 "exclude path 'lib.rs' should remove every spot, got {:?}",
632 report.spots
633 );
634 }
635
636 #[test]
637 fn actor_histogram_walks_chain_independently_of_diff_path() {
638 let (head, store) = build_three_state_chain();
639 let hist = analyze_actor_histogram(&store, head, Some(10)).unwrap();
640 assert_eq!(hist.values().sum::<usize>(), 3);
643 assert_eq!(hist.len(), 3);
644 }
645
646 #[test]
647 fn empty_chain_returns_empty_report() {
648 let store = InMemoryStore::new();
650 let blob = store
651 .put_blob(&objects::object::Blob::from_slice(b"fn solo() {}"))
652 .unwrap();
653 let tree = store
654 .put_tree(&Tree::from_entries(vec![
655 TreeEntry::file("solo.rs".to_string(), blob, false).unwrap(),
656 ]))
657 .unwrap();
658 let state = State::new(tree, Vec::new(), Attribution::human(principal("alice")));
659 store.put_state(&state).unwrap();
660
661 let report = analyze_hot_spots(&store, state.change_id, &HotSpotParams::default()).unwrap();
662 assert_eq!(report.states_walked, 0);
663 assert_eq!(report.total_events, 0);
664 assert!(report.spots.is_empty());
665 }
666}