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