1use std::collections::{BTreeMap, BTreeSet, HashMap};
2use std::path::{Path, PathBuf};
3use std::sync::atomic::{AtomicU64, Ordering};
4use std::sync::{Arc, Mutex};
5use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
6
7use crossbeam_channel::{after, bounded, select, Receiver, Sender};
8use serde::Deserialize;
9use serde_json::{json, Value};
10
11use super::cache::{InspectCache, Tier2ContributionUpdates};
12use super::dispatch::{default_worker, start_dispatch_loop, InspectWorker};
13use super::freshness::{verify_contribution_file, ContributionFreshness};
14use super::job::{
15 normalize_path, CallgraphExport, CallgraphOutboundCall, CallgraphSnapshot, FileContribution,
16 InspectCategory, InspectJob, InspectResult, InspectScanSuccess, InspectSnapshot, JobKey,
17 JobOutcome, JobScope,
18};
19use crate::cache_freshness::FileFreshness;
20use crate::callgraph::{is_bare_callee, resolve_symbol_query_in_data, CallGraph, EdgeResolution};
21use crate::symbols::SymbolKind;
22
23const DEFAULT_SOFT_DEADLINE: Duration = Duration::from_secs(1);
24
25type WaiterTx = Sender<JobOutcome>;
26
27#[derive(Clone)]
28struct Waiter {
29 tx: WaiterTx,
30}
31
32struct CachedContributionFreshness {
33 file_path: PathBuf,
34 freshness: FileFreshness,
35}
36
37#[derive(PartialEq, Eq)]
38struct ContributionFingerprint {
39 count: usize,
40 set_hash: String,
41}
42
43pub struct InspectManager {
44 request_tx: Sender<InspectJob>,
45 result_rx: Receiver<InspectResult>,
46 #[allow(dead_code)]
47 pool: Arc<rayon::ThreadPool>,
48 in_flight: Mutex<HashMap<JobKey, Vec<Waiter>>>,
49 caches: Mutex<HashMap<PathBuf, Arc<InspectCache>>>,
50 soft_deadline: Duration,
51 next_job_id: AtomicU64,
52}
53
54impl InspectManager {
55 pub fn new() -> Self {
56 Self::with_worker(default_worker(), DEFAULT_SOFT_DEADLINE)
57 }
58
59 #[doc(hidden)]
60 pub fn with_worker(worker: InspectWorker, soft_deadline: Duration) -> Self {
61 let handles = start_dispatch_loop(worker);
62 Self {
63 request_tx: handles.request_tx,
64 result_rx: handles.result_rx,
65 pool: handles.pool,
66 in_flight: Mutex::new(HashMap::new()),
67 caches: Mutex::new(HashMap::new()),
68 soft_deadline,
69 next_job_id: AtomicU64::new(1),
70 }
71 }
72
73 pub fn submit_category(
74 &self,
75 snapshot: InspectSnapshot,
76 category: InspectCategory,
77 caller_scope: JobScope,
78 ) -> JobOutcome {
79 self.submit_category_with_callgraph(snapshot, category, caller_scope, None)
80 }
81
82 pub fn submit_category_with_callgraph(
83 &self,
84 snapshot: InspectSnapshot,
85 category: InspectCategory,
86 caller_scope: JobScope,
87 callgraph_snapshot: Option<Arc<CallgraphSnapshot>>,
88 ) -> JobOutcome {
89 if !category.is_active() {
90 return JobOutcome::Failed {
91 message: format!("inspect category '{category}' is disabled in v0.33"),
92 };
93 }
94
95 let cache = match self.cache_for_snapshot(&snapshot) {
96 Ok(cache) => cache,
97 Err(message) => return JobOutcome::Failed { message },
98 };
99 let key = JobKey::for_category_scope(category, &caller_scope);
100 let (waiter_tx, waiter_rx) = bounded(1);
101
102 let wait_snapshot = snapshot.clone();
103 match self.enqueue_with_waiter(
104 snapshot,
105 category,
106 caller_scope.clone(),
107 key.clone(),
108 waiter_tx,
109 callgraph_snapshot,
110 ) {
111 Ok(()) => self.wait_for_outcome(key, caller_scope, cache, waiter_rx, wait_snapshot),
112 Err(message) => JobOutcome::Failed { message },
113 }
114 }
115
116 pub fn submit_background(
117 &self,
118 snapshot: InspectSnapshot,
119 category: InspectCategory,
120 caller_scope: JobScope,
121 ) -> Result<JobKey, String> {
122 self.submit_background_with_callgraph(snapshot, category, caller_scope, None)
123 }
124
125 pub fn submit_background_with_callgraph(
126 &self,
127 snapshot: InspectSnapshot,
128 category: InspectCategory,
129 caller_scope: JobScope,
130 callgraph_snapshot: Option<Arc<CallgraphSnapshot>>,
131 ) -> Result<JobKey, String> {
132 if !category.is_active() {
133 return Err(format!(
134 "inspect category '{category}' is disabled in v0.33"
135 ));
136 }
137 let key = JobKey::for_category_scope(category, &caller_scope);
138 self.enqueue_without_waiter(
139 snapshot,
140 category,
141 caller_scope,
142 key.clone(),
143 callgraph_snapshot,
144 )?;
145 Ok(key)
146 }
147
148 pub fn submit_tier2_run_with_reuse_background(
149 self: &Arc<Self>,
150 snapshot: InspectSnapshot,
151 category: InspectCategory,
152 ) -> Result<JobKey, String> {
153 if !category.is_active() {
154 return Err(format!(
155 "inspect category '{category}' is disabled in v0.33"
156 ));
157 }
158 if !category.is_tier2() {
159 return Err(format!(
160 "inspect category '{category}' is not a Tier 2 category"
161 ));
162 }
163
164 let job = self.tier2_reuse_job(snapshot, category, None);
165 let key = job.key.clone();
166 let mut in_flight = self
167 .in_flight
168 .lock()
169 .map_err(|_| "inspect in-flight map lock poisoned".to_string())?;
170 if in_flight.contains_key(&key) {
171 return Ok(key);
172 }
173 in_flight.insert(key.clone(), Vec::new());
174 drop(in_flight);
175
176 let manager = Arc::clone(self);
177 let pool = Arc::clone(&self.pool);
178 pool.spawn(move || {
179 let result = manager.tier2_run_with_reuse_job_result(job);
180 manager.route_tier2_reuse_completion(result);
181 });
182
183 Ok(key)
184 }
185
186 pub fn drain_completions(&self) -> usize {
187 let mut drained = 0usize;
188 while let Ok(result) = self.result_rx.try_recv() {
189 self.route_completion(result);
190 drained += 1;
191 }
192 drained
193 }
194
195 pub fn cache_for_snapshot(
196 &self,
197 snapshot: &InspectSnapshot,
198 ) -> Result<Arc<InspectCache>, String> {
199 self.cache_for_paths(snapshot.inspect_dir.clone(), snapshot.project_root.clone())
200 }
201
202 pub fn cache_for_paths(
203 &self,
204 inspect_dir: PathBuf,
205 project_root: PathBuf,
206 ) -> Result<Arc<InspectCache>, String> {
207 let project_key = crate::search_index::project_cache_key(&project_root);
208 let sqlite_path = inspect_dir.join(format!("{project_key}.sqlite"));
209 let mut caches = self
210 .caches
211 .lock()
212 .map_err(|_| "inspect manager cache map lock poisoned".to_string())?;
213 if let Some(cache) = caches.get(&sqlite_path) {
214 return Ok(Arc::clone(cache));
215 }
216 let cache = Arc::new(
217 InspectCache::open(inspect_dir, project_root)
218 .map_err(|error| format!("failed to open inspect cache: {error}"))?,
219 );
220 caches.insert(sqlite_path, Arc::clone(&cache));
221 Ok(cache)
222 }
223
224 pub fn tier2_run_with_reuse(
225 &self,
226 snapshot: InspectSnapshot,
227 category: InspectCategory,
228 caller_scope: JobScope,
229 callgraph_snapshot: Option<Arc<CallgraphSnapshot>>,
230 ) -> JobOutcome {
231 let result =
232 self.tier2_run_with_reuse_result(snapshot.clone(), category, callgraph_snapshot);
233 let outcome = match result.outcome {
234 Ok(success) => JobOutcome::Fresh {
235 payload: success.aggregate,
236 },
237 Err(message) => JobOutcome::Failed { message },
238 };
239 match self.cache_for_snapshot(&snapshot) {
240 Ok(cache) => filter_outcome_for_scope_with_contributions(
241 outcome,
242 &snapshot,
243 category,
244 cache.as_ref(),
245 &caller_scope,
246 ),
247 Err(message) => JobOutcome::Failed { message },
248 }
249 }
250
251 pub fn tier2_read_cached(
258 &self,
259 snapshot: InspectSnapshot,
260 category: InspectCategory,
261 caller_scope: JobScope,
262 ) -> JobOutcome {
263 if !category.is_active() {
264 return JobOutcome::Failed {
265 message: format!("inspect category '{category}' is disabled in v0.33"),
266 };
267 }
268 if !category.is_tier2() {
269 return JobOutcome::Failed {
270 message: format!("inspect category '{category}' is not a Tier 2 category"),
271 };
272 }
273
274 let cache = match self.cache_for_snapshot(&snapshot) {
275 Ok(cache) => cache,
276 Err(message) => return JobOutcome::Failed { message },
277 };
278 let key = JobKey::for_project_category(category);
279 let in_flight = self
280 .in_flight
281 .lock()
282 .map(|guard| guard.contains_key(&key))
283 .unwrap_or(false);
284 match cache.get_aggregated(&key) {
285 Ok(Some(payload)) => {
286 match self.tier2_cached_aggregate_is_fresh(&snapshot, category, cache.as_ref()) {
287 Ok(true) => filter_outcome_for_scope_with_contributions(
288 JobOutcome::Fresh { payload },
289 &snapshot,
290 category,
291 cache.as_ref(),
292 &caller_scope,
293 ),
294 Ok(false) => filter_outcome_for_scope_with_contributions(
295 JobOutcome::Stale {
296 cached: Some(payload),
297 in_flight,
298 },
299 &snapshot,
300 category,
301 cache.as_ref(),
302 &caller_scope,
303 ),
304 Err(message) => JobOutcome::Failed { message },
305 }
306 }
307 Ok(None) => match cache.latest_aggregate_any_hash(category) {
308 Ok(Some(payload)) => filter_outcome_for_scope_with_contributions(
309 JobOutcome::Stale {
310 cached: Some(payload),
311 in_flight,
312 },
313 &snapshot,
314 category,
315 cache.as_ref(),
316 &caller_scope,
317 ),
318 Ok(None) => JobOutcome::Pending { in_flight },
319 Err(error) => JobOutcome::Failed {
320 message: error.to_string(),
321 },
322 },
323 Err(error) => JobOutcome::Failed {
324 message: error.to_string(),
325 },
326 }
327 }
328
329 fn tier2_cached_aggregate_is_fresh(
330 &self,
331 snapshot: &InspectSnapshot,
332 category: InspectCategory,
333 cache: &InspectCache,
334 ) -> Result<bool, String> {
335 let cached_records = load_contribution_freshness(cache, category)?;
336 let project_scope = JobScope::for_project(snapshot.project_root.clone());
337 let project_files = scope_files(&snapshot.project_root, &project_scope);
338 let current_by_relative = current_project_files(&snapshot.project_root, &project_files);
339 let cached_relative = cached_records
340 .iter()
341 .map(freshness_record_relative_key)
342 .collect::<BTreeSet<_>>();
343
344 for record in &cached_records {
345 let relative = freshness_record_relative_key(record);
346 if !current_by_relative.contains_key(&relative) {
347 return Ok(false);
348 }
349
350 let absolute = if record.file_path.is_absolute() {
351 record.file_path.clone()
352 } else {
353 snapshot.project_root.join(&record.file_path)
354 };
355 match verify_contribution_file(&absolute, &record.freshness) {
356 ContributionFreshness::Fresh {
357 metadata_changed,
358 freshness,
359 } => {
360 if metadata_changed {
361 cache
362 .update_content_fresh_metadata(
363 category,
364 &PathBuf::from(&relative),
365 &freshness,
366 )
367 .map_err(|error| error.to_string())?;
368 }
369 }
370 ContributionFreshness::Stale | ContributionFreshness::Deleted => return Ok(false),
371 }
372 }
373
374 Ok(current_by_relative
375 .keys()
376 .all(|relative| cached_relative.contains(relative)))
377 }
378
379 #[doc(hidden)]
380 pub fn tier2_run_with_reuse_result(
381 &self,
382 snapshot: InspectSnapshot,
383 category: InspectCategory,
384 callgraph_snapshot: Option<Arc<CallgraphSnapshot>>,
385 ) -> InspectResult {
386 let job = self.tier2_reuse_job(snapshot, category, callgraph_snapshot);
387 self.tier2_run_with_reuse_job_result(job)
388 }
389
390 fn tier2_run_with_reuse_job_result(&self, mut job: InspectJob) -> InspectResult {
391 let started = Instant::now();
392 if !job.category.is_active() {
393 return InspectResult::failed(
394 &job,
395 format!("inspect category '{}' is disabled in v0.33", job.category),
396 started.elapsed(),
397 );
398 }
399 if !job.category.is_tier2() {
400 return InspectResult::failed(
401 &job,
402 format!(
403 "inspect category '{}' is not a Tier 2 category",
404 job.category
405 ),
406 started.elapsed(),
407 );
408 }
409
410 let project_scope = JobScope::for_project(job.project_root.clone());
411 job.scope_files = scope_files(&job.project_root, &project_scope);
412 let cache = match self.cache_for_paths(job.inspect_dir.clone(), job.project_root.clone()) {
413 Ok(cache) => cache,
414 Err(message) => return InspectResult::failed(&job, message, started.elapsed()),
415 };
416 if let Ok(Some(success)) = self.tier2_quick_reuse_success(&job, cache.as_ref()) {
417 return InspectResult::success(&job, success, started.elapsed());
418 }
419
420 match self.tier2_run_with_reuse_job(&job, &cache) {
421 Ok(success) => InspectResult::success(&job, success, started.elapsed()),
422 Err(message) => InspectResult::failed(&job, message, started.elapsed()),
423 }
424 }
425
426 fn tier2_reuse_job(
427 &self,
428 snapshot: InspectSnapshot,
429 category: InspectCategory,
430 callgraph_snapshot: Option<Arc<CallgraphSnapshot>>,
431 ) -> InspectJob {
432 InspectJob {
433 job_id: self.next_job_id.fetch_add(1, Ordering::Relaxed),
434 key: JobKey::for_project_category(category),
435 category,
436 scope_files: Vec::new(),
437 project_root: snapshot.project_root,
438 inspect_dir: snapshot.inspect_dir,
439 config: snapshot.config,
440 symbol_cache: snapshot.symbol_cache,
441 callgraph_snapshot,
442 }
443 }
444
445 fn tier2_quick_reuse_success(
446 &self,
447 job: &InspectJob,
448 cache: &InspectCache,
449 ) -> Result<Option<InspectScanSuccess>, String> {
450 let Some(aggregate) = cache
451 .get_aggregated(&job.key)
452 .map_err(|error| error.to_string())?
453 else {
454 return Ok(None);
455 };
456 let cached = load_contribution_fingerprint(cache, job.category)?;
457 let current = current_file_fingerprint(&job.project_root, &job.scope_files)?;
458 if cached != current {
459 return Ok(None);
460 }
461
462 cache
463 .touch_tier2_last_full_run(job.category)
464 .map_err(|error| error.to_string())?;
465 Ok(Some(InspectScanSuccess {
466 scanned_files: Vec::new(),
467 contributions: Vec::new(),
468 aggregate,
469 }))
470 }
471
472 fn tier2_run_with_reuse_job(
473 &self,
474 job: &InspectJob,
475 cache: &InspectCache,
476 ) -> Result<InspectScanSuccess, String> {
477 let cached_records = load_contribution_freshness(cache, job.category)?;
478 let current_by_relative = current_project_files(&job.project_root, &job.scope_files);
479 let cached_relative = cached_records
480 .iter()
481 .map(freshness_record_relative_key)
482 .collect::<BTreeSet<_>>();
483 #[cfg(debug_assertions)]
484 let cold_cache = cached_relative.is_empty();
485
486 let mut updates = Tier2ContributionUpdates::default();
487 let mut scan_by_relative = BTreeMap::<String, PathBuf>::new();
488 let mut aggregate_job = job.clone();
489
490 for record in cached_records {
491 let relative = freshness_record_relative_key(&record);
492 let relative_path = PathBuf::from(&relative);
493 let Some(current_file) = current_by_relative.get(&relative) else {
494 updates.deletes.push(relative_path);
495 continue;
496 };
497
498 let absolute = job.project_root.join(&record.file_path);
499 match verify_contribution_file(&absolute, &record.freshness) {
500 ContributionFreshness::Fresh {
501 metadata_changed,
502 freshness,
503 } => {
504 if metadata_changed {
505 updates.metadata_updates.push((relative_path, freshness));
506 }
507 }
508 ContributionFreshness::Stale => {
509 updates.deletes.push(relative_path);
510 scan_by_relative.insert(relative, current_file.clone());
511 }
512 ContributionFreshness::Deleted => {
513 updates.deletes.push(relative_path);
514 }
515 }
516 }
517
518 for (relative, file) in ¤t_by_relative {
519 if !cached_relative.contains(relative) {
520 scan_by_relative.insert(relative.clone(), file.clone());
521 }
522 }
523
524 let mut scan_files = scan_by_relative.into_values().collect::<Vec<_>>();
525 if !scan_files.is_empty() {
526 let mut scan_job = job.clone();
527 scan_job.job_id = self.next_job_id.fetch_add(1, Ordering::Relaxed);
528 scan_job.scope_files = scan_files.clone();
529 if scan_job.category == InspectCategory::DeadCode
530 && scan_job.callgraph_snapshot.is_none()
531 {
532 scan_job.callgraph_snapshot =
533 Some(build_tier2_callgraph_snapshot(&scan_job.project_root));
534 }
535 aggregate_job.callgraph_snapshot = scan_job.callgraph_snapshot.clone();
536 #[cfg(debug_assertions)]
537 if cold_cache {
538 std::thread::sleep(Duration::from_millis(10));
539 }
540 let scan_result = run_tier2_scan(&scan_job);
541 let scan_success = scan_result.outcome.map_err(|message| {
542 format!("{} incremental scan failed: {message}", job.category)
543 })?;
544 updates.upserts.extend(scan_success.contributions);
545 }
546
547 let has_updates = !updates.upserts.is_empty()
548 || !updates.deletes.is_empty()
549 || !updates.metadata_updates.is_empty();
550 if !has_updates {
551 if let Some(aggregate) = cache
552 .get_aggregated(&job.key)
553 .map_err(|error| error.to_string())?
554 {
555 cache
556 .touch_tier2_last_full_run(job.category)
557 .map_err(|error| error.to_string())?;
558 return Ok(InspectScanSuccess {
559 scanned_files: scan_files,
560 contributions: Vec::new(),
561 aggregate,
562 });
563 }
564 }
565
566 let mut contribution_set_hash = if has_updates {
567 cache
568 .apply_contribution_updates(job.category, updates)
569 .map_err(|error| error.to_string())?
570 } else {
571 cache
572 .contribution_set_hash(job.category)
573 .map_err(|error| error.to_string())?
574 };
575
576 if let Some(aggregate) = cache
577 .load_aggregate_if_hash_matches(job.category, &contribution_set_hash)
578 .map_err(|error| error.to_string())?
579 {
580 cache
581 .touch_tier2_last_full_run(job.category)
582 .map_err(|error| error.to_string())?;
583 let contributions = load_contributions(cache, job)?;
584 return Ok(InspectScanSuccess {
585 scanned_files: scan_files,
586 contributions,
587 aggregate,
588 });
589 }
590
591 if category_contributions_depend_on_entry_points(job.category) {
592 let full_scan_files = current_by_relative.into_values().collect::<Vec<_>>();
597 if !full_scan_files.is_empty() {
598 let mut rescan_job = job.clone();
599 rescan_job.job_id = self.next_job_id.fetch_add(1, Ordering::Relaxed);
600 rescan_job.scope_files = full_scan_files.clone();
601 if rescan_job.category == InspectCategory::DeadCode
602 && rescan_job.callgraph_snapshot.is_none()
603 {
604 rescan_job.callgraph_snapshot =
605 Some(build_tier2_callgraph_snapshot(&rescan_job.project_root));
606 }
607 let scan_result = run_tier2_scan(&rescan_job);
608 let scan_success = scan_result.outcome.map_err(|message| {
609 format!(
610 "{} full rescan after entry-point cache miss failed: {message}",
611 job.category
612 )
613 })?;
614 let rescan_updates = Tier2ContributionUpdates {
615 upserts: scan_success.contributions,
616 ..Tier2ContributionUpdates::default()
617 };
618 contribution_set_hash = cache
619 .apply_contribution_updates(job.category, rescan_updates)
620 .map_err(|error| error.to_string())?;
621 aggregate_job.callgraph_snapshot = rescan_job.callgraph_snapshot.clone();
622 scan_files = full_scan_files;
623
624 if let Some(aggregate) = cache
625 .load_aggregate_if_hash_matches(job.category, &contribution_set_hash)
626 .map_err(|error| error.to_string())?
627 {
628 cache
629 .touch_tier2_last_full_run(job.category)
630 .map_err(|error| error.to_string())?;
631 let contributions = load_contributions(cache, job)?;
632 return Ok(InspectScanSuccess {
633 scanned_files: scan_files,
634 contributions,
635 aggregate,
636 });
637 }
638 }
639 }
640
641 if aggregate_job.category == InspectCategory::DeadCode
642 && aggregate_job.callgraph_snapshot.is_none()
643 {
644 aggregate_job.callgraph_snapshot =
645 Some(build_tier2_callgraph_snapshot(&aggregate_job.project_root));
646 }
647 let contributions = load_contributions(cache, &aggregate_job)?;
648 let aggregate = roll_up_tier2_contributions(&aggregate_job, &contributions);
649 cache
650 .store_tier2_aggregate(job.key.clone(), &contribution_set_hash, aggregate.clone())
651 .map_err(|error| error.to_string())?;
652
653 Ok(InspectScanSuccess {
654 scanned_files: scan_files,
655 contributions,
656 aggregate,
657 })
658 }
659
660 fn enqueue_with_waiter(
661 &self,
662 snapshot: InspectSnapshot,
663 category: InspectCategory,
664 caller_scope: JobScope,
665 key: JobKey,
666 waiter_tx: WaiterTx,
667 callgraph_snapshot: Option<Arc<CallgraphSnapshot>>,
668 ) -> Result<(), String> {
669 let mut in_flight = self
670 .in_flight
671 .lock()
672 .map_err(|_| "inspect in-flight map lock poisoned".to_string())?;
673 if let Some(waiters) = in_flight.get_mut(&key) {
674 waiters.push(Waiter { tx: waiter_tx });
675 return Ok(());
676 }
677
678 in_flight.insert(key.clone(), vec![Waiter { tx: waiter_tx }]);
679 drop(in_flight);
680
681 if let Err(message) = self.enqueue_new_job(
682 snapshot,
683 category,
684 caller_scope,
685 key.clone(),
686 callgraph_snapshot,
687 ) {
688 if let Ok(mut in_flight) = self.in_flight.lock() {
689 in_flight.remove(&key);
690 }
691 return Err(message);
692 }
693 Ok(())
694 }
695
696 fn enqueue_without_waiter(
697 &self,
698 snapshot: InspectSnapshot,
699 category: InspectCategory,
700 caller_scope: JobScope,
701 key: JobKey,
702 callgraph_snapshot: Option<Arc<CallgraphSnapshot>>,
703 ) -> Result<(), String> {
704 let mut in_flight = self
705 .in_flight
706 .lock()
707 .map_err(|_| "inspect in-flight map lock poisoned".to_string())?;
708 if in_flight.contains_key(&key) {
709 return Ok(());
710 }
711 in_flight.insert(key.clone(), Vec::new());
712 drop(in_flight);
713
714 if let Err(message) = self.enqueue_new_job(
715 snapshot,
716 category,
717 caller_scope,
718 key.clone(),
719 callgraph_snapshot,
720 ) {
721 if let Ok(mut in_flight) = self.in_flight.lock() {
722 in_flight.remove(&key);
723 }
724 return Err(message);
725 }
726 Ok(())
727 }
728
729 fn enqueue_new_job(
730 &self,
731 snapshot: InspectSnapshot,
732 category: InspectCategory,
733 caller_scope: JobScope,
734 key: JobKey,
735 callgraph_snapshot: Option<Arc<CallgraphSnapshot>>,
736 ) -> Result<(), String> {
737 let scan_scope = if category.is_tier2() {
738 JobScope::for_project(snapshot.project_root.clone())
739 } else {
740 caller_scope
741 };
742 let scope_files = scope_files(&snapshot.project_root, &scan_scope);
743 let job = InspectJob {
744 job_id: self.next_job_id.fetch_add(1, Ordering::Relaxed),
745 key,
746 category,
747 scope_files,
748 project_root: snapshot.project_root,
749 inspect_dir: snapshot.inspect_dir,
750 config: snapshot.config,
751 symbol_cache: snapshot.symbol_cache,
752 callgraph_snapshot,
753 };
754 self.request_tx
755 .send(job)
756 .map_err(|_| "inspect dispatch loop is unavailable".to_string())
757 }
758
759 fn wait_for_outcome(
760 &self,
761 key: JobKey,
762 caller_scope: JobScope,
763 cache: Arc<InspectCache>,
764 waiter_rx: Receiver<JobOutcome>,
765 snapshot: InspectSnapshot,
766 ) -> JobOutcome {
767 let timeout = after(self.soft_deadline);
768 let result_rx = self.result_rx.clone();
769 loop {
770 select! {
771 recv(waiter_rx) -> outcome => {
772 return match outcome {
773 Ok(outcome) => filter_outcome_for_scope_with_contributions(
774 outcome,
775 &snapshot,
776 key.category,
777 cache.as_ref(),
778 &caller_scope,
779 ),
780 Err(_) => self.timeout_outcome(&key, &caller_scope, &cache, &snapshot),
781 };
782 }
783 recv(result_rx) -> result => {
784 match result {
785 Ok(result) => self.route_completion(result),
786 Err(_) => return self.timeout_outcome(&key, &caller_scope, &cache, &snapshot),
787 }
788 }
789 recv(timeout) -> _ => {
790 return self.timeout_outcome(&key, &caller_scope, &cache, &snapshot);
791 }
792 }
793 }
794 }
795
796 fn timeout_outcome(
797 &self,
798 key: &JobKey,
799 caller_scope: &JobScope,
800 cache: &InspectCache,
801 snapshot: &InspectSnapshot,
802 ) -> JobOutcome {
803 match cache.get_aggregated(key) {
804 Ok(Some(cached)) => filter_outcome_for_scope_with_contributions(
805 JobOutcome::Stale {
806 cached: Some(cached),
807 in_flight: true,
808 },
809 snapshot,
810 key.category,
811 cache,
812 caller_scope,
813 ),
814 Ok(None) => JobOutcome::Pending { in_flight: true },
815 Err(error) => JobOutcome::Failed {
816 message: error.to_string(),
817 },
818 }
819 }
820
821 fn route_completion(&self, result: InspectResult) {
822 let outcome = self.completion_outcome(result.clone());
823 let waiters = self
824 .in_flight
825 .lock()
826 .ok()
827 .and_then(|mut in_flight| in_flight.remove(&result.key))
828 .unwrap_or_default();
829 for waiter in waiters {
830 let _ = waiter.tx.send(outcome.clone());
831 }
832 }
833
834 fn route_tier2_reuse_completion(&self, result: InspectResult) {
835 let outcome = match result.outcome.clone() {
836 Ok(success) => JobOutcome::Fresh {
837 payload: success.aggregate,
838 },
839 Err(message) => JobOutcome::Failed { message },
840 };
841 let waiters = self
842 .in_flight
843 .lock()
844 .ok()
845 .and_then(|mut in_flight| in_flight.remove(&result.key))
846 .unwrap_or_default();
847 for waiter in waiters {
848 let _ = waiter.tx.send(outcome.clone());
849 }
850 }
851
852 fn completion_outcome(&self, result: InspectResult) -> JobOutcome {
853 let cache =
854 match self.cache_for_paths(result.inspect_dir.clone(), result.project_root.clone()) {
855 Ok(cache) => cache,
856 Err(message) => return JobOutcome::Failed { message },
857 };
858
859 match result.outcome {
860 Ok(success) => {
861 let store_result = if result.category.is_tier2() {
862 cache.store_tier2_result(
863 result.key.clone(),
864 &success.scanned_files,
865 &success.contributions,
866 success.aggregate.clone(),
867 )
868 } else {
869 cache.store_aggregated(result.key, success.aggregate.clone())
870 };
871
872 match store_result {
873 Ok(()) => JobOutcome::Fresh {
874 payload: success.aggregate,
875 },
876 Err(error) => JobOutcome::Failed {
877 message: error.to_string(),
878 },
879 }
880 }
881 Err(message) => JobOutcome::Failed { message },
882 }
883 }
884}
885
886impl Default for InspectManager {
887 fn default() -> Self {
888 Self::new()
889 }
890}
891
892fn scope_files(project_root: &Path, scope: &JobScope) -> Vec<PathBuf> {
893 let mut files = crate::callgraph::walk_project_files(project_root)
894 .filter(|path| scope.contains(path))
895 .collect::<Vec<_>>();
896 files.sort();
897 files
898}
899
900fn current_project_files(project_root: &Path, files: &[PathBuf]) -> BTreeMap<String, PathBuf> {
901 files
902 .iter()
903 .map(|file| (relative_cache_key(project_root, file), file.clone()))
904 .collect()
905}
906
907fn build_tier2_callgraph_snapshot(project_root: &Path) -> Arc<CallgraphSnapshot> {
908 let mut graph = CallGraph::new(project_root.to_path_buf());
909 let graph_files = graph.project_files().to_vec();
910 let files = graph_files
911 .iter()
912 .map(canonicalize_for_snapshot)
913 .collect::<Vec<_>>();
914 let resolved_entry_points = super::entry_points::resolve_entry_points(project_root);
915
916 let mut exported_symbols = Vec::new();
917 let mut outbound_calls = Vec::new();
918 let mut entry_points = BTreeSet::new();
919
920 for file in &graph_files {
921 let snapshot_file = canonicalize_for_snapshot(file);
922 if is_entry_point_file(&resolved_entry_points, &snapshot_file) {
923 entry_points.insert(snapshot_file.clone());
924 }
925
926 let file_data = match graph.build_file(file) {
927 Ok(file_data) => file_data.clone(),
928 Err(_) => continue,
929 };
930
931 for symbol in &file_data.exported_symbols {
932 let metadata = file_data.symbol_metadata.get(symbol);
933 exported_symbols.push(CallgraphExport {
934 file: snapshot_file.clone(),
935 symbol: symbol.clone(),
936 kind: metadata
937 .map(|metadata| symbol_kind_name(&metadata.kind))
938 .unwrap_or("unknown")
939 .to_string(),
940 line: metadata.map(|metadata| metadata.line).unwrap_or(1),
941 });
942 }
943
944 for (caller_symbol, calls) in &file_data.calls_by_symbol {
945 for call in calls {
946 let target = match graph.resolve_cross_file_edge(
947 &call.full_callee,
948 &call.callee_name,
949 file,
950 &file_data.import_block,
951 ) {
952 EdgeResolution::Resolved { file, symbol } => {
953 let file = canonicalize_for_snapshot(&file);
954 format!("{}::{symbol}", file.display())
955 }
956 EdgeResolution::Unresolved { callee_name } => {
966 if is_bare_callee(&call.full_callee, &callee_name) {
967 match resolve_symbol_query_in_data(&file_data, file, &callee_name) {
968 Ok(symbol) => {
969 format!("{}::{symbol}", snapshot_file.display())
970 }
971 Err(_) => callee_name,
972 }
973 } else {
974 callee_name
975 }
976 }
977 };
978 outbound_calls.push(CallgraphOutboundCall {
979 caller_file: snapshot_file.clone(),
980 caller_symbol: caller_symbol.clone(),
981 target,
982 line: call.line,
983 });
984 }
985 }
986 }
987
988 Arc::new(CallgraphSnapshot {
989 generated_at: Some(SystemTime::now()),
990 files,
991 exported_symbols,
992 outbound_calls,
993 entry_points,
994 })
995}
996
997fn canonicalize_for_snapshot(path: &PathBuf) -> PathBuf {
998 std::fs::canonicalize(path).unwrap_or_else(|_| normalize_path(path))
999}
1000
1001fn is_entry_point_file(entry_points: &super::entry_points::EntryPointSet, file: &Path) -> bool {
1002 entry_points.is_entry_point(file)
1003}
1004
1005fn symbol_kind_name(kind: &SymbolKind) -> &'static str {
1006 match kind {
1007 SymbolKind::Function => "function",
1008 SymbolKind::Method => "method",
1009 SymbolKind::Class => "class",
1010 SymbolKind::Struct => "struct",
1011 SymbolKind::Interface => "interface",
1012 SymbolKind::Enum => "enum",
1013 SymbolKind::TypeAlias => "type_alias",
1014 SymbolKind::Variable => "variable",
1015 SymbolKind::Heading => "heading",
1016 SymbolKind::FileSummary => "file_summary",
1017 }
1018}
1019
1020fn load_contribution_fingerprint(
1021 cache: &InspectCache,
1022 category: InspectCategory,
1023) -> Result<ContributionFingerprint, String> {
1024 let (count, set_hash) = cache
1025 .contribution_fingerprint(category)
1026 .map_err(|error| error.to_string())?;
1027 Ok(ContributionFingerprint { count, set_hash })
1028}
1029
1030fn current_file_fingerprint(
1031 project_root: &Path,
1032 files: &[PathBuf],
1033) -> Result<ContributionFingerprint, String> {
1034 let mut entries = Vec::with_capacity(files.len());
1035 for file in files {
1036 let metadata = std::fs::metadata(file)
1037 .map_err(|error| format!("failed to stat {}: {error}", file.display()))?;
1038 let relative_path = relative_cache_key(project_root, file);
1039 let mtime_ns = system_time_to_ns_i64(metadata.modified().unwrap_or(UNIX_EPOCH));
1040 entries.push((relative_path, mtime_ns, metadata.len()));
1041 }
1042 entries.sort_by(|left, right| left.0.cmp(&right.0));
1043
1044 let mut hasher = blake3::Hasher::new();
1045 for (relative_path, mtime_ns, file_size) in &entries {
1046 update_contribution_fingerprint_hash(&mut hasher, relative_path, *mtime_ns, *file_size);
1047 }
1048
1049 Ok(ContributionFingerprint {
1050 count: entries.len(),
1051 set_hash: hasher.finalize().to_hex().to_string(),
1052 })
1053}
1054
1055fn update_contribution_fingerprint_hash(
1056 hasher: &mut blake3::Hasher,
1057 relative_path: &str,
1058 mtime_ns: i64,
1059 file_size: u64,
1060) {
1061 hasher.update(relative_path.as_bytes());
1062 hasher.update(&[0]);
1063 hasher.update(&mtime_ns.to_le_bytes());
1064 hasher.update(&file_size.to_le_bytes());
1065}
1066
1067fn load_contribution_freshness(
1068 cache: &InspectCache,
1069 category: InspectCategory,
1070) -> Result<Vec<CachedContributionFreshness>, String> {
1071 cache
1072 .contribution_freshness(category)
1073 .map_err(|error| error.to_string())
1074 .map(|records| {
1075 records
1076 .into_iter()
1077 .map(|(file_path, freshness)| CachedContributionFreshness {
1078 file_path,
1079 freshness,
1080 })
1081 .collect()
1082 })
1083}
1084
1085fn freshness_record_relative_key(record: &CachedContributionFreshness) -> String {
1086 record.file_path.to_string_lossy().to_string()
1087}
1088
1089fn system_time_to_ns_i64(time: SystemTime) -> i64 {
1090 let nanos = time
1091 .duration_since(UNIX_EPOCH)
1092 .unwrap_or_else(|_| Duration::from_secs(0))
1093 .as_nanos();
1094 nanos.min(i64::MAX as u128) as i64
1095}
1096
1097fn relative_cache_key(project_root: &Path, path: &Path) -> String {
1098 path.strip_prefix(project_root)
1099 .unwrap_or(path)
1100 .to_string_lossy()
1101 .to_string()
1102}
1103
1104fn load_contributions(
1105 cache: &InspectCache,
1106 job: &InspectJob,
1107) -> Result<Vec<FileContribution>, String> {
1108 cache
1109 .load_tier2_contributions(job.category)
1110 .map_err(|error| error.to_string())
1111 .map(|records| {
1112 records
1113 .into_iter()
1114 .map(|record| contribution_from_record(&job.project_root, record))
1115 .collect()
1116 })
1117}
1118
1119fn contribution_from_record(
1120 project_root: &Path,
1121 record: super::cache::ContributionRecord,
1122) -> FileContribution {
1123 FileContribution::new(
1124 record.category,
1125 project_root.join(record.file_path),
1126 record.freshness,
1127 record.contribution,
1128 )
1129}
1130
1131fn run_tier2_scan(job: &InspectJob) -> InspectResult {
1132 use super::scanners;
1133
1134 match job.category {
1135 InspectCategory::DeadCode => scanners::dead_code::run_dead_code_scan(job),
1136 InspectCategory::UnusedExports => scanners::unused_exports::run_unused_exports_scan(job),
1137 InspectCategory::Duplicates => scanners::duplicates::run_duplicates_scan(job),
1138 other => InspectResult::failed(
1139 job,
1140 format!("inspect category '{other}' is not an active Tier 2 scanner"),
1141 Duration::from_secs(0),
1142 ),
1143 }
1144}
1145
1146fn roll_up_tier2_contributions(job: &InspectJob, contributions: &[FileContribution]) -> Value {
1147 roll_up_tier2_contributions_with_limit(job, contributions, Some(MAX_DRILL_DOWN_ITEMS))
1148}
1149
1150fn roll_up_tier2_contributions_with_limit(
1151 job: &InspectJob,
1152 contributions: &[FileContribution],
1153 drill_down_limit: Option<usize>,
1154) -> Value {
1155 match job.category {
1156 InspectCategory::DeadCode => {
1157 roll_up_dead_code_contributions(job, contributions, drill_down_limit)
1158 }
1159 InspectCategory::UnusedExports => {
1160 roll_up_unused_exports_contributions(job, contributions, drill_down_limit)
1161 }
1162 InspectCategory::Duplicates => {
1163 roll_up_duplicate_contributions(job, contributions, drill_down_limit)
1164 }
1165 _ => json!({
1166 "count": 0,
1167 "items": [],
1168 "scanned_files": contributions.len(),
1169 }),
1170 }
1171}
1172
1173fn scoped_tier2_payload_from_contributions(
1174 snapshot: &InspectSnapshot,
1175 category: InspectCategory,
1176 cache: &InspectCache,
1177 project_payload: Value,
1178 scope: &JobScope,
1179) -> Result<Value, String> {
1180 if scope.is_project_wide() {
1181 return Ok(project_payload);
1182 }
1183
1184 let project_scope = JobScope::for_project(snapshot.project_root.clone());
1185 let rollup_job = scoped_tier2_rollup_job(snapshot, category, &project_scope);
1186 let contributions = load_contributions(cache, &rollup_job)?;
1187 let full_payload = roll_up_tier2_contributions_with_limit(&rollup_job, &contributions, None);
1188 let scoped_payload = filter_payload_for_scope(full_payload, scope);
1189 Ok(cap_payload_drill_down(scoped_payload, MAX_DRILL_DOWN_ITEMS))
1190}
1191
1192fn scoped_tier2_rollup_job(
1193 snapshot: &InspectSnapshot,
1194 category: InspectCategory,
1195 scope: &JobScope,
1196) -> InspectJob {
1197 InspectJob {
1198 job_id: 0,
1199 key: JobKey::for_project_category(category),
1200 category,
1201 scope_files: scope_files(&snapshot.project_root, scope),
1202 project_root: snapshot.project_root.clone(),
1203 inspect_dir: snapshot.inspect_dir.clone(),
1204 config: Arc::clone(&snapshot.config),
1205 symbol_cache: Arc::clone(&snapshot.symbol_cache),
1206 callgraph_snapshot: (category == InspectCategory::DeadCode)
1207 .then(|| Arc::new(CallgraphSnapshot::default())),
1208 }
1209}
1210
1211fn roll_up_dead_code_contributions(
1212 job: &InspectJob,
1213 contributions: &[FileContribution],
1214 drill_down_limit: Option<usize>,
1215) -> Value {
1216 if job.callgraph_snapshot.is_none() {
1217 return super::scanners::dead_code::callgraph_unavailable_aggregate(job.scope_files.len());
1218 }
1219
1220 let public_api_files = super::scanners::dead_code::collect_public_api_files(&job.project_root);
1221 super::scanners::dead_code::aggregate_dead_code_contributions_with_limit(
1222 contributions,
1223 &public_api_files,
1224 drill_down_limit,
1225 )
1226}
1227
1228fn roll_up_unused_exports_contributions(
1229 job: &InspectJob,
1230 contributions: &[FileContribution],
1231 drill_down_limit: Option<usize>,
1232) -> Value {
1233 let parsed = contributions
1234 .iter()
1235 .filter_map(|contribution| {
1236 serde_json::from_value::<UnusedExportsContribution>(contribution.contribution.clone())
1237 .ok()
1238 })
1239 .collect::<Vec<_>>();
1240
1241 let (public_api_entries, package_warnings) = unused_public_api_entries(&job.project_root);
1242 let mut imported_by: BTreeMap<(String, String), BTreeSet<String>> = BTreeMap::new();
1243 let mut uncertain_by: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
1244 for scan in &parsed {
1245 for import in &scan.imports {
1246 let Some(resolved_file) = &import.resolved_file else {
1247 continue;
1248 };
1249 for name in &import.named {
1250 if name == "*" {
1251 uncertain_by
1252 .entry(resolved_file.clone())
1253 .or_default()
1254 .insert(scan.file.clone());
1255 } else {
1256 imported_by
1257 .entry((resolved_file.clone(), name.clone()))
1258 .or_default()
1259 .insert(scan.file.clone());
1260 }
1261 }
1262 }
1263 }
1264
1265 let mut count = 0usize;
1266 let mut items = Vec::new();
1267 let mut uncertain_count = 0usize;
1268 let mut uncertain_items = Vec::new();
1269 for scan in &parsed {
1270 if public_api_entries.contains(&scan.file) {
1271 continue;
1272 }
1273
1274 for export in &scan.exports {
1275 let imported = imported_by
1276 .get(&(scan.file.clone(), export.symbol.clone()))
1277 .map(|files| !files.is_empty())
1278 .unwrap_or(false);
1279 let uncertain = uncertain_by
1280 .get(&scan.file)
1281 .map(|files| !files.is_empty())
1282 .unwrap_or(false);
1283
1284 if imported {
1285 continue;
1286 }
1287 if uncertain {
1288 uncertain_count += 1;
1289 if drill_down_limit.is_none_or(|limit| uncertain_items.len() < limit) {
1290 uncertain_items.push(json!({
1291 "file": scan.file,
1292 "symbol": export.symbol,
1293 "kind": export.kind,
1294 "line": export.line,
1295 "reason": "wildcard_import",
1296 }));
1297 }
1298 continue;
1299 }
1300
1301 count += 1;
1302 if drill_down_limit.is_none_or(|limit| items.len() < limit) {
1303 items.push(json!({
1304 "file": scan.file,
1305 "symbol": export.symbol,
1306 "kind": export.kind,
1307 "line": export.line,
1308 }));
1309 }
1310 }
1311 }
1312
1313 let mut aggregate = json!({
1314 "count": count,
1315 "items": items,
1316 "drill_down_capped": drill_down_limit.is_some_and(|limit| count > limit),
1317 "scanned_files": parsed.len(),
1318 "languages_skipped": skipped_languages(&job.scope_files, LanguageSkipMode::UnusedExports),
1319 "uncertain_count": uncertain_count,
1320 "uncertain_items": uncertain_items,
1321 });
1322 if !package_warnings.is_empty() {
1323 aggregate["note"] = Value::String(package_warnings.join("; "));
1324 }
1325 aggregate
1326}
1327
1328fn roll_up_duplicate_contributions(
1329 job: &InspectJob,
1330 contributions: &[FileContribution],
1331 drill_down_limit: Option<usize>,
1332) -> Value {
1333 super::scanners::duplicates::aggregate_duplicate_contributions_with_limit(
1334 contributions,
1335 skipped_languages(&job.scope_files, LanguageSkipMode::Duplicates),
1336 drill_down_limit,
1337 )
1338}
1339
1340fn cap_payload_drill_down(mut payload: Value, limit: usize) -> Value {
1341 let mut capped = false;
1342 if let Some(items) = payload.get_mut("items").and_then(Value::as_array_mut) {
1343 capped |= items.len() > limit;
1344 items.truncate(limit);
1345 }
1346 if let Some(groups) = payload.get_mut("groups").and_then(Value::as_array_mut) {
1347 capped |= groups.len() > limit;
1348 groups.truncate(limit);
1349 }
1350 if let Some(object) = payload.as_object_mut() {
1351 object.insert("drill_down_capped".to_string(), json!(capped));
1352 }
1353 payload
1354}
1355
1356const MAX_DRILL_DOWN_ITEMS: usize = 100;
1357
1358#[derive(Debug, Clone, Deserialize)]
1359struct ExportContribution {
1360 symbol: String,
1361 kind: String,
1362 line: u32,
1363}
1364
1365#[derive(Debug, Clone, Deserialize)]
1366struct UnusedExportsContribution {
1367 file: String,
1368 exports: Vec<ExportContribution>,
1369 imports: Vec<ImportContribution>,
1370}
1371
1372#[derive(Debug, Clone, Deserialize)]
1373struct ImportContribution {
1374 resolved_file: Option<String>,
1375 named: Vec<String>,
1376}
1377
1378#[derive(Debug, Clone, Copy)]
1379enum LanguageSkipMode {
1380 Duplicates,
1381 UnusedExports,
1382}
1383
1384fn category_contributions_depend_on_entry_points(category: InspectCategory) -> bool {
1385 matches!(
1386 category,
1387 InspectCategory::DeadCode | InspectCategory::UnusedExports
1388 )
1389}
1390
1391fn skipped_languages(files: &[PathBuf], mode: LanguageSkipMode) -> Vec<String> {
1392 files
1393 .iter()
1394 .filter_map(|file| skipped_language(file, mode))
1395 .collect::<BTreeSet<_>>()
1396 .into_iter()
1397 .collect()
1398}
1399
1400fn skipped_language(file: &Path, mode: LanguageSkipMode) -> Option<String> {
1401 let Some(language) = crate::parser::detect_language(file) else {
1402 return match mode {
1403 LanguageSkipMode::Duplicates => Some("unknown".to_string()),
1404 LanguageSkipMode::UnusedExports => None,
1405 };
1406 };
1407
1408 let skipped = match mode {
1409 LanguageSkipMode::Duplicates => !duplicates_supports_language(language),
1410 LanguageSkipMode::UnusedExports => !is_js_ts_language(language),
1411 };
1412 skipped.then(|| language_name(language).to_string())
1413}
1414
1415fn duplicates_supports_language(language: crate::parser::LangId) -> bool {
1416 !matches!(
1417 language,
1418 crate::parser::LangId::Bash
1419 | crate::parser::LangId::Html
1420 | crate::parser::LangId::Json
1421 | crate::parser::LangId::Scala
1422 | crate::parser::LangId::Solidity
1423 | crate::parser::LangId::Vue
1424 | crate::parser::LangId::Markdown
1425 | crate::parser::LangId::Java
1426 | crate::parser::LangId::Ruby
1427 | crate::parser::LangId::Kotlin
1428 | crate::parser::LangId::Swift
1429 | crate::parser::LangId::Php
1430 | crate::parser::LangId::Lua
1431 | crate::parser::LangId::Perl
1432 )
1433}
1434
1435fn is_js_ts_language(language: crate::parser::LangId) -> bool {
1436 matches!(
1437 language,
1438 crate::parser::LangId::TypeScript
1439 | crate::parser::LangId::Tsx
1440 | crate::parser::LangId::JavaScript
1441 )
1442}
1443
1444fn language_name(language: crate::parser::LangId) -> &'static str {
1445 match language {
1446 crate::parser::LangId::TypeScript => "typescript",
1447 crate::parser::LangId::Tsx => "tsx",
1448 crate::parser::LangId::JavaScript => "javascript",
1449 crate::parser::LangId::Python => "python",
1450 crate::parser::LangId::Rust => "rust",
1451 crate::parser::LangId::Go => "go",
1452 crate::parser::LangId::C => "c",
1453 crate::parser::LangId::Cpp => "cpp",
1454 crate::parser::LangId::Zig => "zig",
1455 crate::parser::LangId::CSharp => "csharp",
1456 crate::parser::LangId::Bash => "bash",
1457 crate::parser::LangId::Html => "html",
1458 crate::parser::LangId::Markdown => "markdown",
1459 crate::parser::LangId::Solidity => "solidity",
1460 crate::parser::LangId::Vue => "vue",
1461 crate::parser::LangId::Json => "json",
1462 crate::parser::LangId::Scala => "scala",
1463 crate::parser::LangId::Java => "java",
1464 crate::parser::LangId::Ruby => "ruby",
1465 crate::parser::LangId::Kotlin => "kotlin",
1466 crate::parser::LangId::Swift => "swift",
1467 crate::parser::LangId::Php => "php",
1468 crate::parser::LangId::Lua => "lua",
1469 crate::parser::LangId::Perl => "perl",
1470 }
1471}
1472
1473fn unused_public_api_entries(project_root: &Path) -> (BTreeSet<String>, Vec<String>) {
1474 let entry_points = super::entry_points::resolve_entry_points(project_root);
1475 (
1476 entry_points.public_api_files_relative(project_root),
1477 entry_points.warnings().to_vec(),
1478 )
1479}
1480
1481fn filter_outcome_for_scope_with_contributions(
1482 outcome: JobOutcome,
1483 snapshot: &InspectSnapshot,
1484 category: InspectCategory,
1485 cache: &InspectCache,
1486 scope: &JobScope,
1487) -> JobOutcome {
1488 if !category.is_tier2() || scope.is_project_wide() {
1489 return filter_outcome_for_scope(outcome, scope);
1490 }
1491
1492 match outcome {
1493 JobOutcome::Fresh { payload } => {
1494 match scoped_tier2_payload_from_contributions(snapshot, category, cache, payload, scope)
1495 {
1496 Ok(payload) => JobOutcome::Fresh { payload },
1497 Err(message) => JobOutcome::Failed { message },
1498 }
1499 }
1500 JobOutcome::Stale { cached, in_flight } => match cached {
1501 Some(payload) => {
1502 match scoped_tier2_payload_from_contributions(
1503 snapshot, category, cache, payload, scope,
1504 ) {
1505 Ok(payload) => JobOutcome::Stale {
1506 cached: Some(payload),
1507 in_flight,
1508 },
1509 Err(message) => JobOutcome::Failed { message },
1510 }
1511 }
1512 None => JobOutcome::Stale {
1513 cached: None,
1514 in_flight,
1515 },
1516 },
1517 JobOutcome::Pending { in_flight } => JobOutcome::Pending { in_flight },
1518 JobOutcome::Failed { message } => JobOutcome::Failed { message },
1519 }
1520}
1521
1522fn filter_outcome_for_scope(outcome: JobOutcome, scope: &JobScope) -> JobOutcome {
1523 match outcome {
1524 JobOutcome::Fresh { payload } => JobOutcome::Fresh {
1525 payload: filter_payload_for_scope(payload, scope),
1526 },
1527 JobOutcome::Stale { cached, in_flight } => JobOutcome::Stale {
1528 cached: cached.map(|payload| filter_payload_for_scope(payload, scope)),
1529 in_flight,
1530 },
1531 JobOutcome::Pending { in_flight } => JobOutcome::Pending { in_flight },
1532 JobOutcome::Failed { message } => JobOutcome::Failed { message },
1533 }
1534}
1535
1536fn filter_payload_for_scope(mut payload: serde_json::Value, scope: &JobScope) -> serde_json::Value {
1537 if scope.is_project_wide() {
1538 return payload;
1539 }
1540
1541 if let Some(items) = payload
1545 .get_mut("items")
1546 .and_then(|value| value.as_array_mut())
1547 {
1548 let count = filter_values_for_scope(items, scope);
1549 if let Some(object) = payload.as_object_mut() {
1550 object.insert("count".to_string(), serde_json::json!(count));
1551 if object.contains_key("total_groups") {
1552 object.insert("total_groups".to_string(), serde_json::json!(count));
1553 }
1554 if object.contains_key("groups_count") {
1555 object.insert("groups_count".to_string(), serde_json::json!(count));
1556 }
1557 }
1558 }
1559
1560 if let Some(groups) = payload
1561 .get_mut("groups")
1562 .and_then(|value| value.as_array_mut())
1563 {
1564 let count = filter_values_for_scope(groups, scope);
1565 if let Some(object) = payload.as_object_mut() {
1566 object.insert("count".to_string(), serde_json::json!(count));
1567 object.insert("total_groups".to_string(), serde_json::json!(count));
1568 if object.contains_key("groups_count") {
1569 object.insert("groups_count".to_string(), serde_json::json!(count));
1570 }
1571 }
1572 }
1573
1574 payload
1575}
1576
1577fn filter_values_for_scope(values: &mut Vec<serde_json::Value>, scope: &JobScope) -> usize {
1578 values.retain_mut(|value| prune_value_for_scope(value, scope));
1579 values.len()
1580}
1581
1582fn prune_value_for_scope(value: &mut serde_json::Value, scope: &JobScope) -> bool {
1583 if let Some(file) = value.get("file").and_then(|file| file.as_str()) {
1584 return scope.contains_display_path(file);
1585 }
1586
1587 let first_scoped_occurrence = if let Some(files) = value
1588 .get_mut("files")
1589 .and_then(|files| files.as_array_mut())
1590 {
1591 files.retain(|file| {
1592 file.as_str()
1593 .is_some_and(|file| scope.contains_display_path(display_file_from_occurrence(file)))
1594 });
1595 if files.len() < 2 {
1596 return false;
1597 }
1598 files.first().and_then(Value::as_str).map(str::to_string)
1599 } else {
1600 None
1601 };
1602
1603 if let Some(occurrence) = first_scoped_occurrence {
1604 update_duplicate_group_sample(value, &occurrence);
1605 }
1606
1607 true
1608}
1609
1610fn update_duplicate_group_sample(value: &mut serde_json::Value, occurrence: &str) {
1611 let Some((file, start_line, end_line)) = parse_duplicate_occurrence(occurrence) else {
1612 return;
1613 };
1614 let Some(object) = value.as_object_mut() else {
1615 return;
1616 };
1617
1618 if object.contains_key("sample_file") {
1619 object.insert("sample_file".to_string(), json!(file));
1620 }
1621 if object.contains_key("sample_start_line") {
1622 object.insert("sample_start_line".to_string(), json!(start_line));
1623 }
1624 if object.contains_key("sample_end_line") {
1625 object.insert("sample_end_line".to_string(), json!(end_line));
1626 }
1627}
1628
1629fn parse_duplicate_occurrence(value: &str) -> Option<(&str, u64, u64)> {
1630 let (file, range) = value.rsplit_once(':')?;
1631 let (start, end) = range.split_once('-')?;
1632 if !start.chars().all(|char| char.is_ascii_digit())
1633 || !end.chars().all(|char| char.is_ascii_digit())
1634 {
1635 return None;
1636 }
1637
1638 Some((file, start.parse().ok()?, end.parse().ok()?))
1639}
1640
1641fn display_file_from_occurrence(value: &str) -> &str {
1642 let Some((file, range)) = value.rsplit_once(':') else {
1643 return value;
1644 };
1645 let Some((start, end)) = range.split_once('-') else {
1646 return value;
1647 };
1648 if start.chars().all(|char| char.is_ascii_digit())
1649 && end.chars().all(|char| char.is_ascii_digit())
1650 {
1651 file
1652 } else {
1653 value
1654 }
1655}
1656
1657#[allow(dead_code)]
1658fn normalize_scope_root(path: &Path) -> PathBuf {
1659 std::fs::canonicalize(path).unwrap_or_else(|_| normalize_path(path))
1660}