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