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