1pub mod cache;
12pub mod centrality;
13pub mod community;
14pub mod condensation;
15pub mod csr;
16pub mod hotspots;
17pub mod persistence;
18pub mod reachability;
19pub mod scc;
20pub mod subsystems;
21
22pub use cache::AnalysisCache;
23pub use centrality::{HubMetric, HubOpts, HubRank, KindMask, rank_hubs};
24pub use community::{
25 Community, CommunityGate, CommunityOpts, CommunityOutcome, CommunityReport, GateVerdict,
26 Resolution, detect_communities,
27};
28pub use condensation::{
29 BudgetExceededPolicy, CondensationDag, Interval, LabelBudgetConfig, ReachabilityStrategy,
30};
31pub use csr::{CsrAdjacency, EdgeKindDiscriminant};
32pub use hotspots::{HotspotRank, rank_hotspots};
33pub use persistence::{
34 AnalysisIdentity, compute_manifest_hash, compute_node_id_hash, load_condensation,
35 load_condensation_checked, load_csr, load_csr_checked, load_scc, load_scc_checked,
36 persist_condensation, persist_csr, persist_scc, try_load_path_analysis, try_load_scc,
37 try_load_scc_and_condensation,
38};
39pub use scc::SccData;
40pub use subsystems::{Coupling, CouplingEdgeKind, Subsystem, SubsystemOpts, aggregate_subsystems};
41
42use crate::graph::unified::compaction::CompactionSnapshot;
43use crate::graph::unified::edge::{EdgeKind, ResolvedVia};
44use crate::graph::unified::node::NodeId;
45use anyhow::{Context, Result, bail};
46use rayon::prelude::*;
47use std::collections::VecDeque;
48use std::path::Path;
49
50#[derive(Debug)]
52pub struct GraphAnalyses {
53 pub adjacency: CsrAdjacency,
55 pub scc_calls: SccData,
57 pub scc_imports: SccData,
59 pub scc_references: SccData,
61 pub scc_inherits: SccData,
63 pub cond_calls: CondensationDag,
65 pub cond_imports: CondensationDag,
67 pub cond_references: CondensationDag,
69 pub cond_inherits: CondensationDag,
71}
72
73impl GraphAnalyses {
74 pub fn build_all(snapshot: &CompactionSnapshot) -> Result<Self> {
80 Self::build_all_with_budget(snapshot, &LabelBudgetConfig::default())
81 }
82
83 pub fn build_all_with_budget(
94 snapshot: &CompactionSnapshot,
95 label_budget: &LabelBudgetConfig,
96 ) -> Result<Self> {
97 let adjacency = CsrAdjacency::build_from_snapshot(snapshot)?;
98 Self::build_all_from_adjacency_with_budget(adjacency, label_budget)
99 }
100
101 pub fn build_all_from_adjacency_with_budget(
117 adjacency: CsrAdjacency,
118 label_budget: &LabelBudgetConfig,
119 ) -> Result<Self> {
120 let edge_kinds = [
123 EdgeKind::Calls {
124 argument_count: 0,
125 is_async: false,
126 resolved_via: ResolvedVia::Direct,
127 },
128 EdgeKind::Imports {
129 alias: None,
130 is_wildcard: false,
131 },
132 EdgeKind::References,
133 EdgeKind::Inherits,
134 ];
135
136 let results: Vec<(SccData, CondensationDag)> = edge_kinds
137 .into_par_iter()
138 .map(|kind| {
139 let scc = SccData::compute_tarjan(&adjacency, &kind)?;
140 let cond = CondensationDag::build_with_budget(&scc, &adjacency, label_budget)?;
141 Ok((scc, cond))
142 })
143 .collect::<Result<Vec<_>>>()?;
144
145 let mut results = results.into_iter();
147 let (scc_calls, cond_calls) = results.next().expect("4 edge kinds");
148 let (scc_imports, cond_imports) = results.next().expect("4 edge kinds");
149 let (scc_references, cond_references) = results.next().expect("4 edge kinds");
150 let (scc_inherits, cond_inherits) = results.next().expect("4 edge kinds");
151
152 Ok(Self {
153 adjacency,
154 scc_calls,
155 scc_imports,
156 scc_references,
157 scc_inherits,
158 cond_calls,
159 cond_imports,
160 cond_references,
161 cond_inherits,
162 })
163 }
164
165 pub fn persist_all(
174 &self,
175 storage: &crate::graph::unified::persistence::GraphStorage,
176 identity: &AnalysisIdentity,
177 ) -> Result<()> {
178 std::fs::create_dir_all(storage.analysis_dir())?;
179
180 persist_csr(&self.adjacency, identity, &storage.analysis_csr_path())?;
181 persist_scc(
182 &self.scc_calls,
183 identity,
184 &storage.analysis_scc_path("calls"),
185 )?;
186 persist_scc(
187 &self.scc_imports,
188 identity,
189 &storage.analysis_scc_path("imports"),
190 )?;
191 persist_scc(
192 &self.scc_references,
193 identity,
194 &storage.analysis_scc_path("references"),
195 )?;
196 persist_scc(
197 &self.scc_inherits,
198 identity,
199 &storage.analysis_scc_path("inherits"),
200 )?;
201 persist_condensation(
202 &self.cond_calls,
203 identity,
204 &storage.analysis_cond_path("calls"),
205 )?;
206 persist_condensation(
207 &self.cond_imports,
208 identity,
209 &storage.analysis_cond_path("imports"),
210 )?;
211 persist_condensation(
212 &self.cond_references,
213 identity,
214 &storage.analysis_cond_path("references"),
215 )?;
216 persist_condensation(
217 &self.cond_inherits,
218 identity,
219 &storage.analysis_cond_path("inherits"),
220 )?;
221
222 Ok(())
223 }
224}
225
226pub fn resolve_label_budget_config(
234 index_root: &Path,
235 cli_label_budget: Option<u64>,
236 cli_density_threshold: Option<u64>,
237 cli_policy: Option<&str>,
238 cli_no_labels: bool,
239) -> Result<LabelBudgetConfig> {
240 let mut config = LabelBudgetConfig::default();
241 apply_label_budget_env_overrides(&mut config);
243 apply_label_budget_config_overrides(index_root, &mut config)?;
244 apply_label_budget_cli_overrides(
245 &mut config,
246 cli_label_budget,
247 cli_density_threshold,
248 cli_policy,
249 cli_no_labels,
250 )?;
251 Ok(config)
252}
253
254fn apply_label_budget_env_overrides(config: &mut LabelBudgetConfig) {
255 if let Some(label_budget) = parse_env_usize("SQRY_LABEL_BUDGET") {
256 config.budget_per_kind = label_budget;
257 }
258 if env_flag_is_true("SQRY_LABEL_BUDGET_FAIL") {
259 config.on_exceeded = BudgetExceededPolicy::Fail;
260 }
261 if let Some(density_threshold) = parse_env_usize("SQRY_DENSITY_GATE_THRESHOLD") {
262 config.density_gate_threshold = density_threshold;
263 }
264 if env_flag_is_true("SQRY_NO_LABELS") {
265 config.skip_labels = true;
266 }
267}
268
269fn apply_label_budget_config_overrides(
270 index_root: &Path,
271 config: &mut LabelBudgetConfig,
272) -> Result<()> {
273 let Ok(store) = crate::config::GraphConfigStore::new(index_root) else {
274 return Ok(());
275 };
276 if !store.is_initialized() {
277 return Ok(());
278 }
279
280 let persistence = crate::config::ConfigPersistence::new(&store);
281 let Ok((graph_config, report)) = persistence.load() else {
282 return Ok(());
283 };
284 for warning in &report.warnings {
285 log::warn!("Config load: {warning}");
286 }
287
288 config.budget_per_kind =
289 usize::try_from(graph_config.config.limits.analysis_label_budget_per_kind)
290 .context("analysis_label_budget_per_kind exceeds usize range")?;
291 config.density_gate_threshold =
292 usize::try_from(graph_config.config.limits.analysis_density_gate_threshold)
293 .context("analysis_density_gate_threshold exceeds usize range")?;
294 apply_budget_policy_override(
295 &mut config.on_exceeded,
296 graph_config
297 .config
298 .limits
299 .analysis_budget_exceeded_policy
300 .as_str(),
301 "config",
302 )?;
303
304 Ok(())
305}
306
307fn apply_label_budget_cli_overrides(
308 config: &mut LabelBudgetConfig,
309 cli_label_budget: Option<u64>,
310 cli_density_threshold: Option<u64>,
311 cli_policy: Option<&str>,
312 cli_no_labels: bool,
313) -> Result<()> {
314 if let Some(label_budget) = cli_label_budget {
315 config.budget_per_kind =
316 usize::try_from(label_budget).context("--label-budget value exceeds usize range")?;
317 }
318 if let Some(density_threshold) = cli_density_threshold {
319 config.density_gate_threshold = usize::try_from(density_threshold)
320 .context("--density-threshold value exceeds usize range")?;
321 }
322 if cli_no_labels {
323 config.skip_labels = true;
324 }
325 if let Some(policy) = cli_policy {
326 apply_budget_policy_override(&mut config.on_exceeded, policy, "cli")?;
327 }
328
329 Ok(())
330}
331
332fn parse_env_usize(key: &str) -> Option<usize> {
333 std::env::var(key)
334 .ok()
335 .and_then(|value| value.parse::<usize>().ok())
336}
337
338fn env_flag_is_true(key: &str) -> bool {
339 std::env::var(key)
340 .ok()
341 .is_some_and(|value| value == "1" || value.eq_ignore_ascii_case("true"))
342}
343
344fn apply_budget_policy_override(
345 target: &mut BudgetExceededPolicy,
346 policy: &str,
347 source: &str,
348) -> Result<()> {
349 match policy {
350 "fail" => *target = BudgetExceededPolicy::Fail,
351 "degrade" => *target = BudgetExceededPolicy::Degrade,
352 other if source == "config" => {
353 log::warn!("Unknown analysis_budget_exceeded_policy '{other}' in config, ignoring");
354 }
355 other => {
356 bail!("Invalid --budget-exceeded-policy: '{other}' (expected: degrade or fail)")
357 }
358 }
359
360 Ok(())
361}
362
363pub struct PathReconstructor<'a> {
365 csr: &'a CsrAdjacency,
366 scc_data: &'a SccData,
367 cond_dag: &'a CondensationDag,
368}
369
370impl<'a> PathReconstructor<'a> {
371 #[must_use]
373 pub fn new(
374 csr: &'a CsrAdjacency,
375 scc_data: &'a SccData,
376 cond_dag: &'a CondensationDag,
377 ) -> Self {
378 Self {
379 csr,
380 scc_data,
381 cond_dag,
382 }
383 }
384
385 pub fn reconstruct_path(&self, from: NodeId, to: NodeId) -> Result<Option<Vec<NodeId>>> {
391 let from_idx = from.index();
392 let to_idx = to.index();
393 if from_idx >= self.csr.node_count || to_idx >= self.csr.node_count {
394 return Ok(None);
395 }
396
397 let from_scc = self
398 .scc_data
399 .scc_of(from)
400 .ok_or_else(|| anyhow::anyhow!("Invalid from node ID: {from:?}"))?;
401 let to_scc = self
402 .scc_data
403 .scc_of(to)
404 .ok_or_else(|| anyhow::anyhow!("Invalid to node ID: {to:?}"))?;
405
406 if !self.cond_dag.can_reach(from_scc, to_scc) {
407 return Ok(None);
408 }
409
410 if from_scc == to_scc {
411 return Ok(self
412 .reconstruct_intra_scc_path(from_idx, to_idx, from_scc)
413 .map(|path| path.into_iter().map(|idx| NodeId::new(idx, 0)).collect()));
414 }
415
416 let Some(scc_path) = self.reconstruct_scc_path(from_scc, to_scc) else {
417 return Ok(None);
418 };
419
420 let Some(node_path) = self.expand_scc_path_to_nodes(&scc_path, from_idx, to_idx, to_scc)
421 else {
422 return Ok(None);
423 };
424
425 Ok(Some(
426 node_path
427 .into_iter()
428 .map(|idx| NodeId::new(idx, 0))
429 .collect(),
430 ))
431 }
432
433 fn reconstruct_intra_scc_path(&self, from: u32, to: u32, scc_id: u32) -> Option<Vec<u32>> {
434 if from == to {
435 return Some(vec![from]);
436 }
437
438 let node_count = self.csr.node_count as usize;
439 let mut parents = vec![None; node_count];
440 let mut queue = VecDeque::new();
441 parents[from as usize] = Some(from);
442 queue.push_back(from);
443
444 while let Some(current) = queue.pop_front() {
445 for neighbor in self
446 .csr
447 .neighbors_filtered(NodeId::new(current, 0), &self.scc_data.edge_kind)
448 {
449 let neighbor_scc = self
450 .scc_data
451 .scc_of(NodeId::new(neighbor, 0))
452 .unwrap_or(u32::MAX);
453 if neighbor_scc != scc_id {
454 continue;
455 }
456 if parents[neighbor as usize].is_some() {
457 continue;
458 }
459
460 parents[neighbor as usize] = Some(current);
461 if neighbor == to {
462 return reconstruct_path_from_parents(&parents, from, to);
463 }
464 queue.push_back(neighbor);
465 }
466 }
467
468 None
469 }
470
471 fn reconstruct_scc_path(&self, from_scc: u32, to_scc: u32) -> Option<Vec<u32>> {
472 if from_scc == to_scc {
473 return Some(vec![from_scc]);
474 }
475
476 let scc_count = self.cond_dag.scc_count as usize;
477 let mut parents = vec![None; scc_count];
478 let mut queue = VecDeque::new();
479 parents[from_scc as usize] = Some(from_scc);
480 queue.push_back(from_scc);
481
482 while let Some(current) = queue.pop_front() {
483 for &successor in self.cond_dag.successors(current) {
484 if parents[successor as usize].is_some() {
485 continue;
486 }
487 parents[successor as usize] = Some(current);
488 if successor == to_scc {
489 break;
490 }
491 queue.push_back(successor);
492 }
493 }
494
495 reconstruct_path_from_parents(&parents, from_scc, to_scc)
496 }
497
498 fn expand_scc_path_to_nodes(
499 &self,
500 scc_path: &[u32],
501 from: u32,
502 to: u32,
503 to_scc: u32,
504 ) -> Option<Vec<u32>> {
505 if scc_path.is_empty() {
506 return None;
507 }
508
509 let mut full_path: Vec<u32> = Vec::new();
510 let mut current_node = from;
511
512 for window in scc_path.windows(2) {
513 let current_scc = window[0];
514 let next_scc = window[1];
515
516 let (segment, entry_node) = self.find_exit_path(current_node, current_scc, next_scc)?;
517
518 if full_path.is_empty() {
519 full_path.extend(segment);
520 } else {
521 full_path.extend(segment.into_iter().skip(1));
522 }
523
524 full_path.push(entry_node);
525 current_node = entry_node;
526 }
527
528 let tail = self.reconstruct_intra_scc_path(current_node, to, to_scc)?;
529 if full_path.is_empty() {
530 full_path = tail;
531 } else {
532 full_path.extend(tail.into_iter().skip(1));
533 }
534
535 Some(full_path)
536 }
537
538 fn find_exit_path(
539 &self,
540 start: u32,
541 current_scc: u32,
542 next_scc: u32,
543 ) -> Option<(Vec<u32>, u32)> {
544 use std::collections::VecDeque;
545 let node_count = self.csr.node_count as usize;
546 let mut parents = vec![None; node_count];
547 let mut queue = VecDeque::new();
548 parents[start as usize] = Some(start);
549 queue.push_back(start);
550
551 while let Some(current) = queue.pop_front() {
552 for neighbor in self
553 .csr
554 .neighbors_filtered(NodeId::new(current, 0), &self.scc_data.edge_kind)
555 {
556 let neighbor_scc = self
557 .scc_data
558 .scc_of(NodeId::new(neighbor, 0))
559 .unwrap_or(u32::MAX);
560 if neighbor_scc == next_scc {
561 let segment = reconstruct_path_from_parents(&parents, start, current)?;
562 return Some((segment, neighbor));
563 }
564 if neighbor_scc != current_scc {
565 continue;
566 }
567 if parents[neighbor as usize].is_some() {
568 continue;
569 }
570 parents[neighbor as usize] = Some(current);
571 queue.push_back(neighbor);
572 }
573 }
574
575 None
576 }
577}
578
579fn reconstruct_path_from_parents(
580 parents: &[Option<u32>],
581 start: u32,
582 goal: u32,
583) -> Option<Vec<u32>> {
584 if parents.get(goal as usize)?.is_none() {
585 return None;
586 }
587
588 let mut path = vec![goal];
589 let mut current = goal;
590 while current != start {
591 let parent = parents[current as usize]?; current = parent;
593 path.push(current);
594 }
595 path.reverse();
596 Some(path)
597}
598
599#[cfg(test)]
600mod tests {
601 use super::*;
602 use crate::graph::unified::compaction::{CompactionSnapshot, MergedEdge};
603 use crate::graph::unified::edge::{DeltaEdge, DeltaOp, EdgeKind};
604 use crate::graph::unified::file::FileId;
605 use crate::graph::unified::node::NodeId;
606
607 fn create_test_snapshot() -> CompactionSnapshot {
613 let file = FileId::new(0);
614 let kind = EdgeKind::Calls {
615 argument_count: 0,
616 is_async: false,
617 resolved_via: ResolvedVia::Direct,
618 };
619
620 let edges = vec![
621 MergedEdge::new(NodeId::new(0, 0), NodeId::new(1, 0), kind.clone(), 1, file),
623 MergedEdge::new(NodeId::new(1, 0), NodeId::new(2, 0), kind.clone(), 2, file),
624 MergedEdge::new(NodeId::new(2, 0), NodeId::new(3, 0), kind.clone(), 3, file),
625 MergedEdge::new(NodeId::new(1, 0), NodeId::new(4, 0), kind.clone(), 4, file),
626 MergedEdge::new(NodeId::new(4, 0), NodeId::new(3, 0), kind.clone(), 5, file),
627 MergedEdge::new(NodeId::new(5, 0), NodeId::new(6, 0), kind.clone(), 6, file),
629 ];
630
631 CompactionSnapshot {
632 csr_edges: edges,
633 delta_edges: Vec::new(),
634 node_count: 7,
635 csr_version: 0,
636 }
637 }
638
639 #[test]
640 fn test_csr_construction() {
641 let snapshot = create_test_snapshot();
642 let csr = CsrAdjacency::build_from_snapshot(&snapshot).unwrap();
643
644 assert_eq!(csr.node_count, 7);
645 assert_eq!(csr.edge_count, 6);
646
647 let neighbors = csr.neighbors(NodeId::new(0, 0));
649 assert_eq!(neighbors.len(), 1);
650 assert_eq!(neighbors[0], 1);
651
652 let neighbors = csr.neighbors(NodeId::new(1, 0));
654 assert_eq!(neighbors.len(), 2);
655 assert!(neighbors.contains(&2));
656 assert!(neighbors.contains(&4));
657 }
658
659 #[test]
660 fn test_csr_merges_lww_and_tombstones() {
661 let file = FileId::new(0);
662 let kind = EdgeKind::Calls {
663 argument_count: 0,
664 is_async: false,
665 resolved_via: ResolvedVia::Direct,
666 };
667
668 let csr_edges = vec![MergedEdge::new(
669 NodeId::new(0, 0),
670 NodeId::new(1, 0),
671 kind.clone(),
672 1,
673 file,
674 )];
675
676 let delta_edges = vec![
677 DeltaEdge::new(
678 NodeId::new(0, 0),
679 NodeId::new(1, 0),
680 kind.clone(),
681 2,
682 DeltaOp::Remove,
683 file,
684 ),
685 DeltaEdge::new(
686 NodeId::new(0, 0),
687 NodeId::new(2, 0),
688 kind.clone(),
689 3,
690 DeltaOp::Add,
691 file,
692 ),
693 ];
694
695 let snapshot = CompactionSnapshot {
696 csr_edges,
697 delta_edges,
698 node_count: 3,
699 csr_version: 0,
700 };
701
702 let csr = CsrAdjacency::build_from_snapshot(&snapshot).unwrap();
703 assert_eq!(csr.edge_count, 1);
704 assert_eq!(csr.neighbors(NodeId::new(0, 0)), &[2]);
705 }
706
707 #[test]
708 fn test_scc_computation() {
709 let snapshot = create_test_snapshot();
710 let csr = CsrAdjacency::build_from_snapshot(&snapshot).unwrap();
711
712 let kind = EdgeKind::Calls {
713 argument_count: 0,
714 is_async: false,
715 resolved_via: ResolvedVia::Direct,
716 };
717 let scc = SccData::compute_tarjan(&csr, &kind).unwrap();
718
719 assert_eq!(scc.scc_count, 7);
721 assert_eq!(scc.non_trivial_count, 0);
722 assert_eq!(scc.max_scc_size, 1);
723 }
724
725 #[test]
726 fn test_scc_with_cycle() {
727 let file = FileId::new(0);
729 let kind = EdgeKind::Calls {
730 argument_count: 0,
731 is_async: false,
732 resolved_via: ResolvedVia::Direct,
733 };
734
735 let edges = vec![
736 MergedEdge::new(NodeId::new(0, 0), NodeId::new(1, 0), kind.clone(), 1, file),
737 MergedEdge::new(NodeId::new(1, 0), NodeId::new(2, 0), kind.clone(), 2, file),
738 MergedEdge::new(NodeId::new(2, 0), NodeId::new(0, 0), kind.clone(), 3, file),
739 ];
740
741 let snapshot = CompactionSnapshot {
742 csr_edges: edges,
743 delta_edges: Vec::new(),
744 node_count: 3,
745 csr_version: 0,
746 };
747
748 let csr = CsrAdjacency::build_from_snapshot(&snapshot).unwrap();
749 let kind = EdgeKind::Calls {
750 argument_count: 0,
751 is_async: false,
752 resolved_via: ResolvedVia::Direct,
753 };
754 let scc = SccData::compute_tarjan(&csr, &kind).unwrap();
755
756 assert_eq!(scc.scc_count, 1);
758 assert_eq!(scc.non_trivial_count, 1);
759 assert_eq!(scc.max_scc_size, 3);
760
761 let scc_0 = scc.scc_of(NodeId::new(0, 0)).unwrap();
763 let scc_1 = scc.scc_of(NodeId::new(1, 0)).unwrap();
764 let scc_2 = scc.scc_of(NodeId::new(2, 0)).unwrap();
765 assert_eq!(scc_0, scc_1);
766 assert_eq!(scc_1, scc_2);
767 }
768
769 #[test]
770 fn test_condensation_dag() {
771 let snapshot = create_test_snapshot();
772 let csr = CsrAdjacency::build_from_snapshot(&snapshot).unwrap();
773
774 let kind = EdgeKind::Calls {
775 argument_count: 0,
776 is_async: false,
777 resolved_via: ResolvedVia::Direct,
778 };
779 let scc = SccData::compute_tarjan(&csr, &kind).unwrap();
780 let dag = CondensationDag::build(&scc, &csr).unwrap();
781
782 assert_eq!(dag.scc_count, 7);
784
785 assert_eq!(dag.edge_count, 6);
787
788 assert_eq!(dag.topo_order.len(), 7);
790 }
791
792 #[test]
793 fn test_2hop_reachability() {
794 let snapshot = create_test_snapshot();
795 let csr = CsrAdjacency::build_from_snapshot(&snapshot).unwrap();
796
797 let kind = EdgeKind::Calls {
798 argument_count: 0,
799 is_async: false,
800 resolved_via: ResolvedVia::Direct,
801 };
802 let scc = SccData::compute_tarjan(&csr, &kind).unwrap();
803 let dag = CondensationDag::build(&scc, &csr).unwrap();
804
805 let scc_0 = scc.scc_of(NodeId::new(0, 0)).unwrap();
807 let scc_3 = scc.scc_of(NodeId::new(3, 0)).unwrap();
808 assert!(dag.can_reach(scc_0, scc_3));
809
810 assert!(!dag.can_reach(scc_3, scc_0));
812
813 let scc_6 = scc.scc_of(NodeId::new(6, 0)).unwrap();
815 assert!(!dag.can_reach(scc_0, scc_6));
816 }
817
818 #[test]
819 fn test_persistence_roundtrip() {
820 use tempfile::TempDir;
821
822 let snapshot = create_test_snapshot();
823 let csr = CsrAdjacency::build_from_snapshot(&snapshot).unwrap();
824
825 let kind = EdgeKind::Calls {
826 argument_count: 0,
827 is_async: false,
828 resolved_via: ResolvedVia::Direct,
829 };
830 let scc = SccData::compute_tarjan(&csr, &kind).unwrap();
831 let dag = CondensationDag::build(&scc, &csr).unwrap();
832
833 let temp_dir = TempDir::new().unwrap();
835 let csr_path = temp_dir.path().join("test.csr");
836 let scc_path = temp_dir.path().join("test.scc");
837 let dag_path = temp_dir.path().join("test.dag");
838
839 let identity = AnalysisIdentity::new("manifest".to_string(), [42u8; 32]);
840
841 persistence::persist_csr(&csr, &identity, &csr_path).unwrap();
843 persistence::persist_scc(&scc, &identity, &scc_path).unwrap();
844 persistence::persist_condensation(&dag, &identity, &dag_path).unwrap();
845
846 let (csr_loaded, identity_loaded) = persistence::load_csr(&csr_path).unwrap();
848 let (scc_loaded, identity_loaded_scc) = persistence::load_scc(&scc_path).unwrap();
849 let (dag_loaded, identity_loaded_dag) = persistence::load_condensation(&dag_path).unwrap();
850
851 assert_eq!(csr_loaded.node_count, csr.node_count);
853 assert_eq!(csr_loaded.edge_count, csr.edge_count);
854 assert_eq!(scc_loaded.scc_count, scc.scc_count);
855 assert_eq!(dag_loaded.scc_count, dag.scc_count);
856 assert_eq!(dag_loaded.edge_count, dag.edge_count);
857 assert_eq!(identity_loaded, identity);
858 assert_eq!(identity_loaded_scc, identity);
859 assert_eq!(identity_loaded_dag, identity);
860 }
861
862 #[test]
863 fn test_persistence_identity_mismatch_rejected() {
864 use tempfile::TempDir;
865
866 let snapshot = create_test_snapshot();
867 let csr = CsrAdjacency::build_from_snapshot(&snapshot).unwrap();
868
869 let temp_dir = TempDir::new().unwrap();
870 let csr_path = temp_dir.path().join("test.csr");
871
872 let identity = AnalysisIdentity::new("manifest".to_string(), [1u8; 32]);
873 let wrong_identity = AnalysisIdentity::new("other".to_string(), [2u8; 32]);
874
875 persistence::persist_csr(&csr, &identity, &csr_path).unwrap();
876
877 let result = persistence::load_csr_checked(&csr_path, &wrong_identity);
878 assert!(result.is_err());
879 }
880
881 #[test]
882 fn test_path_reconstruction_across_sccs() {
883 let snapshot = create_test_snapshot();
884 let csr = CsrAdjacency::build_from_snapshot(&snapshot).unwrap();
885
886 let kind = EdgeKind::Calls {
887 argument_count: 0,
888 is_async: false,
889 resolved_via: ResolvedVia::Direct,
890 };
891 let scc = SccData::compute_tarjan(&csr, &kind).unwrap();
892 let dag = CondensationDag::build(&scc, &csr).unwrap();
893
894 let recon = PathReconstructor::new(&csr, &scc, &dag);
895 let path = recon
896 .reconstruct_path(NodeId::new(0, 0), NodeId::new(3, 0))
897 .unwrap()
898 .unwrap();
899
900 assert_eq!(path.first(), Some(&NodeId::new(0, 0)));
901 assert_eq!(path.last(), Some(&NodeId::new(3, 0)));
902 }
903
904 #[test]
905 fn test_path_reconstruction_unreachable() {
906 let snapshot = create_test_snapshot();
907 let csr = CsrAdjacency::build_from_snapshot(&snapshot).unwrap();
908
909 let kind = EdgeKind::Calls {
910 argument_count: 0,
911 is_async: false,
912 resolved_via: ResolvedVia::Direct,
913 };
914 let scc = SccData::compute_tarjan(&csr, &kind).unwrap();
915 let dag = CondensationDag::build(&scc, &csr).unwrap();
916
917 let recon = PathReconstructor::new(&csr, &scc, &dag);
918 let path = recon
919 .reconstruct_path(NodeId::new(0, 0), NodeId::new(6, 0))
920 .unwrap();
921 assert!(path.is_none());
922 }
923
924 mod env_helpers {
927 use super::*;
928 use serial_test::serial;
929
930 #[test]
933 #[serial]
934 fn parse_env_usize_valid() {
935 unsafe { std::env::set_var("SQRY_TEST_PARSE_USIZE", "42") };
936 assert_eq!(parse_env_usize("SQRY_TEST_PARSE_USIZE"), Some(42));
937 unsafe { std::env::remove_var("SQRY_TEST_PARSE_USIZE") };
938 }
939
940 #[test]
941 #[serial]
942 fn parse_env_usize_zero() {
943 unsafe { std::env::set_var("SQRY_TEST_PARSE_USIZE", "0") };
944 assert_eq!(parse_env_usize("SQRY_TEST_PARSE_USIZE"), Some(0));
945 unsafe { std::env::remove_var("SQRY_TEST_PARSE_USIZE") };
946 }
947
948 #[test]
949 #[serial]
950 fn parse_env_usize_invalid_string() {
951 unsafe { std::env::set_var("SQRY_TEST_PARSE_USIZE", "not_a_number") };
952 assert_eq!(parse_env_usize("SQRY_TEST_PARSE_USIZE"), None);
953 unsafe { std::env::remove_var("SQRY_TEST_PARSE_USIZE") };
954 }
955
956 #[test]
957 #[serial]
958 fn parse_env_usize_negative() {
959 unsafe { std::env::set_var("SQRY_TEST_PARSE_USIZE", "-1") };
960 assert_eq!(parse_env_usize("SQRY_TEST_PARSE_USIZE"), None);
961 unsafe { std::env::remove_var("SQRY_TEST_PARSE_USIZE") };
962 }
963
964 #[test]
965 #[serial]
966 fn parse_env_usize_missing() {
967 unsafe { std::env::remove_var("SQRY_TEST_PARSE_USIZE") };
968 assert_eq!(parse_env_usize("SQRY_TEST_PARSE_USIZE"), None);
969 }
970
971 #[test]
972 #[serial]
973 fn parse_env_usize_empty_string() {
974 unsafe { std::env::set_var("SQRY_TEST_PARSE_USIZE", "") };
975 assert_eq!(parse_env_usize("SQRY_TEST_PARSE_USIZE"), None);
976 unsafe { std::env::remove_var("SQRY_TEST_PARSE_USIZE") };
977 }
978
979 #[test]
982 #[serial]
983 fn env_flag_is_true_with_1() {
984 unsafe { std::env::set_var("SQRY_TEST_FLAG", "1") };
985 assert!(env_flag_is_true("SQRY_TEST_FLAG"));
986 unsafe { std::env::remove_var("SQRY_TEST_FLAG") };
987 }
988
989 #[test]
990 #[serial]
991 fn env_flag_is_true_with_true_lowercase() {
992 unsafe { std::env::set_var("SQRY_TEST_FLAG", "true") };
993 assert!(env_flag_is_true("SQRY_TEST_FLAG"));
994 unsafe { std::env::remove_var("SQRY_TEST_FLAG") };
995 }
996
997 #[test]
998 #[serial]
999 fn env_flag_is_true_with_true_titlecase() {
1000 unsafe { std::env::set_var("SQRY_TEST_FLAG", "True") };
1001 assert!(env_flag_is_true("SQRY_TEST_FLAG"));
1002 unsafe { std::env::remove_var("SQRY_TEST_FLAG") };
1003 }
1004
1005 #[test]
1006 #[serial]
1007 fn env_flag_is_true_with_true_uppercase() {
1008 unsafe { std::env::set_var("SQRY_TEST_FLAG", "TRUE") };
1009 assert!(env_flag_is_true("SQRY_TEST_FLAG"));
1010 unsafe { std::env::remove_var("SQRY_TEST_FLAG") };
1011 }
1012
1013 #[test]
1014 #[serial]
1015 fn env_flag_is_false_with_0() {
1016 unsafe { std::env::set_var("SQRY_TEST_FLAG", "0") };
1017 assert!(!env_flag_is_true("SQRY_TEST_FLAG"));
1018 unsafe { std::env::remove_var("SQRY_TEST_FLAG") };
1019 }
1020
1021 #[test]
1022 #[serial]
1023 fn env_flag_is_false_with_false_string() {
1024 unsafe { std::env::set_var("SQRY_TEST_FLAG", "false") };
1025 assert!(!env_flag_is_true("SQRY_TEST_FLAG"));
1026 unsafe { std::env::remove_var("SQRY_TEST_FLAG") };
1027 }
1028
1029 #[test]
1030 #[serial]
1031 fn env_flag_is_false_when_missing() {
1032 unsafe { std::env::remove_var("SQRY_TEST_FLAG") };
1033 assert!(!env_flag_is_true("SQRY_TEST_FLAG"));
1034 }
1035
1036 #[test]
1037 #[serial]
1038 fn env_flag_is_false_with_random_string() {
1039 unsafe { std::env::set_var("SQRY_TEST_FLAG", "yes") };
1040 assert!(!env_flag_is_true("SQRY_TEST_FLAG"));
1041 unsafe { std::env::remove_var("SQRY_TEST_FLAG") };
1042 }
1043 }
1044
1045 mod budget_policy_override {
1046 use super::*;
1047
1048 #[test]
1049 fn apply_policy_fail() {
1050 let mut policy = BudgetExceededPolicy::Degrade;
1051 apply_budget_policy_override(&mut policy, "fail", "cli").unwrap();
1052 assert_eq!(policy, BudgetExceededPolicy::Fail);
1053 }
1054
1055 #[test]
1056 fn apply_policy_degrade() {
1057 let mut policy = BudgetExceededPolicy::Fail;
1058 apply_budget_policy_override(&mut policy, "degrade", "cli").unwrap();
1059 assert_eq!(policy, BudgetExceededPolicy::Degrade);
1060 }
1061
1062 #[test]
1063 fn apply_policy_invalid_cli_source_errors() {
1064 let mut policy = BudgetExceededPolicy::Degrade;
1065 let result = apply_budget_policy_override(&mut policy, "invalid", "cli");
1066 assert!(result.is_err());
1067 let err = result.unwrap_err().to_string();
1068 assert!(
1069 err.contains("invalid"),
1070 "Error should mention the bad value: {err}"
1071 );
1072 assert_eq!(policy, BudgetExceededPolicy::Degrade);
1074 }
1075
1076 #[test]
1077 fn apply_policy_invalid_config_source_warns_but_succeeds() {
1078 let mut policy = BudgetExceededPolicy::Degrade;
1079 let result = apply_budget_policy_override(&mut policy, "unknown_policy", "config");
1081 assert!(result.is_ok());
1082 assert_eq!(policy, BudgetExceededPolicy::Degrade);
1084 }
1085 }
1086
1087 mod label_budget_env_overrides {
1088 use super::*;
1089 use serial_test::serial;
1090
1091 fn cleanup_env() {
1093 unsafe {
1094 std::env::remove_var("SQRY_LABEL_BUDGET");
1095 std::env::remove_var("SQRY_LABEL_BUDGET_FAIL");
1096 std::env::remove_var("SQRY_DENSITY_GATE_THRESHOLD");
1097 std::env::remove_var("SQRY_NO_LABELS");
1098 }
1099 }
1100
1101 #[test]
1102 #[serial]
1103 fn env_overrides_budget_per_kind() {
1104 cleanup_env();
1105 unsafe { std::env::set_var("SQRY_LABEL_BUDGET", "500") };
1106 let mut config = LabelBudgetConfig::default();
1107 apply_label_budget_env_overrides(&mut config);
1108 assert_eq!(config.budget_per_kind, 500);
1109 cleanup_env();
1110 }
1111
1112 #[test]
1113 #[serial]
1114 fn env_overrides_fail_policy() {
1115 cleanup_env();
1116 unsafe { std::env::set_var("SQRY_LABEL_BUDGET_FAIL", "1") };
1117 let mut config = LabelBudgetConfig::default();
1118 apply_label_budget_env_overrides(&mut config);
1119 assert_eq!(config.on_exceeded, BudgetExceededPolicy::Fail);
1120 cleanup_env();
1121 }
1122
1123 #[test]
1124 #[serial]
1125 fn env_overrides_density_gate_threshold() {
1126 cleanup_env();
1127 unsafe { std::env::set_var("SQRY_DENSITY_GATE_THRESHOLD", "128") };
1128 let mut config = LabelBudgetConfig::default();
1129 apply_label_budget_env_overrides(&mut config);
1130 assert_eq!(config.density_gate_threshold, 128);
1131 cleanup_env();
1132 }
1133
1134 #[test]
1135 #[serial]
1136 fn env_overrides_skip_labels() {
1137 cleanup_env();
1138 unsafe { std::env::set_var("SQRY_NO_LABELS", "true") };
1139 let mut config = LabelBudgetConfig::default();
1140 apply_label_budget_env_overrides(&mut config);
1141 assert!(config.skip_labels);
1142 cleanup_env();
1143 }
1144
1145 #[test]
1146 #[serial]
1147 fn env_overrides_no_change_when_vars_absent() {
1148 cleanup_env();
1149 let mut config = LabelBudgetConfig::default();
1150 let default = LabelBudgetConfig::default();
1151 apply_label_budget_env_overrides(&mut config);
1152 assert_eq!(config.budget_per_kind, default.budget_per_kind);
1153 assert_eq!(config.on_exceeded, default.on_exceeded);
1154 assert_eq!(
1155 config.density_gate_threshold,
1156 default.density_gate_threshold
1157 );
1158 assert_eq!(config.skip_labels, default.skip_labels);
1159 }
1160
1161 #[test]
1162 #[serial]
1163 fn env_overrides_multiple_vars_combined() {
1164 cleanup_env();
1165 unsafe {
1166 std::env::set_var("SQRY_LABEL_BUDGET", "1000");
1167 std::env::set_var("SQRY_LABEL_BUDGET_FAIL", "true");
1168 std::env::set_var("SQRY_DENSITY_GATE_THRESHOLD", "256");
1169 std::env::set_var("SQRY_NO_LABELS", "1");
1170 }
1171 let mut config = LabelBudgetConfig::default();
1172 apply_label_budget_env_overrides(&mut config);
1173 assert_eq!(config.budget_per_kind, 1000);
1174 assert_eq!(config.on_exceeded, BudgetExceededPolicy::Fail);
1175 assert_eq!(config.density_gate_threshold, 256);
1176 assert!(config.skip_labels);
1177 cleanup_env();
1178 }
1179 }
1180
1181 mod label_budget_cli_overrides {
1182 use super::*;
1183
1184 #[test]
1185 fn cli_overrides_budget() {
1186 let mut config = LabelBudgetConfig::default();
1187 apply_label_budget_cli_overrides(&mut config, Some(999), None, None, false).unwrap();
1188 assert_eq!(config.budget_per_kind, 999);
1189 }
1190
1191 #[test]
1192 fn cli_overrides_density_threshold() {
1193 let mut config = LabelBudgetConfig::default();
1194 apply_label_budget_cli_overrides(&mut config, None, Some(200), None, false).unwrap();
1195 assert_eq!(config.density_gate_threshold, 200);
1196 }
1197
1198 #[test]
1199 fn cli_overrides_no_labels() {
1200 let mut config = LabelBudgetConfig::default();
1201 apply_label_budget_cli_overrides(&mut config, None, None, None, true).unwrap();
1202 assert!(config.skip_labels);
1203 }
1204
1205 #[test]
1206 fn cli_overrides_policy_fail() {
1207 let mut config = LabelBudgetConfig::default();
1208 apply_label_budget_cli_overrides(&mut config, None, None, Some("fail"), false).unwrap();
1209 assert_eq!(config.on_exceeded, BudgetExceededPolicy::Fail);
1210 }
1211
1212 #[test]
1213 fn cli_overrides_policy_degrade() {
1214 let mut config = LabelBudgetConfig {
1215 on_exceeded: BudgetExceededPolicy::Fail,
1216 ..LabelBudgetConfig::default()
1217 };
1218 apply_label_budget_cli_overrides(&mut config, None, None, Some("degrade"), false)
1219 .unwrap();
1220 assert_eq!(config.on_exceeded, BudgetExceededPolicy::Degrade);
1221 }
1222
1223 #[test]
1224 fn cli_overrides_invalid_policy_errors() {
1225 let mut config = LabelBudgetConfig::default();
1226 let result =
1227 apply_label_budget_cli_overrides(&mut config, None, None, Some("bad"), false);
1228 assert!(result.is_err());
1229 }
1230
1231 #[test]
1232 fn cli_overrides_no_args_no_change() {
1233 let mut config = LabelBudgetConfig::default();
1234 let default = LabelBudgetConfig::default();
1235 apply_label_budget_cli_overrides(&mut config, None, None, None, false).unwrap();
1236 assert_eq!(config.budget_per_kind, default.budget_per_kind);
1237 assert_eq!(config.on_exceeded, default.on_exceeded);
1238 assert_eq!(
1239 config.density_gate_threshold,
1240 default.density_gate_threshold
1241 );
1242 assert_eq!(config.skip_labels, default.skip_labels);
1243 }
1244
1245 #[test]
1246 fn cli_overrides_all_args_combined() {
1247 let mut config = LabelBudgetConfig::default();
1248 apply_label_budget_cli_overrides(&mut config, Some(77), Some(33), Some("fail"), true)
1249 .unwrap();
1250 assert_eq!(config.budget_per_kind, 77);
1251 assert_eq!(config.density_gate_threshold, 33);
1252 assert_eq!(config.on_exceeded, BudgetExceededPolicy::Fail);
1253 assert!(config.skip_labels);
1254 }
1255 }
1256
1257 mod config_file_overrides {
1258 use super::*;
1259
1260 #[test]
1261 fn config_overrides_nonexistent_dir_is_ok() {
1262 let result = apply_label_budget_config_overrides(
1263 Path::new("/nonexistent/path/that/does/not/exist"),
1264 &mut LabelBudgetConfig::default(),
1265 );
1266 assert!(result.is_ok());
1267 }
1268
1269 #[test]
1270 fn config_overrides_uninitialized_dir_is_ok() {
1271 let temp = tempfile::TempDir::new().unwrap();
1272 let mut config = LabelBudgetConfig::default();
1273 let default = LabelBudgetConfig::default();
1274 let result = apply_label_budget_config_overrides(temp.path(), &mut config);
1275 assert!(result.is_ok());
1276 assert_eq!(config.budget_per_kind, default.budget_per_kind);
1277 }
1278 }
1279
1280 #[test]
1281 fn test_path_reconstruction_intra_scc() {
1282 let file = FileId::new(0);
1283 let kind = EdgeKind::Calls {
1284 argument_count: 0,
1285 is_async: false,
1286 resolved_via: ResolvedVia::Direct,
1287 };
1288
1289 let edges = vec![
1290 MergedEdge::new(NodeId::new(0, 0), NodeId::new(1, 0), kind.clone(), 1, file),
1291 MergedEdge::new(NodeId::new(1, 0), NodeId::new(0, 0), kind.clone(), 2, file),
1292 ];
1293
1294 let snapshot = CompactionSnapshot {
1295 csr_edges: edges,
1296 delta_edges: Vec::new(),
1297 node_count: 2,
1298 csr_version: 0,
1299 };
1300
1301 let csr = CsrAdjacency::build_from_snapshot(&snapshot).unwrap();
1302 let scc = SccData::compute_tarjan(&csr, &kind).unwrap();
1303 let dag = CondensationDag::build(&scc, &csr).unwrap();
1304
1305 let recon = PathReconstructor::new(&csr, &scc, &dag);
1306 let path = recon
1307 .reconstruct_path(NodeId::new(0, 0), NodeId::new(1, 0))
1308 .unwrap()
1309 .unwrap();
1310
1311 assert_eq!(path.first(), Some(&NodeId::new(0, 0)));
1312 assert_eq!(path.last(), Some(&NodeId::new(1, 0)));
1313 }
1314}