Skip to main content

sqry_core/graph/unified/analysis/
mod.rs

1//! Graph analysis precomputation (Pass 5)
2//!
3//! This module provides precomputed graph analyses to eliminate query-time O(V+E) costs:
4//! - CSR adjacency for fast neighbor iteration
5//! - SCC (Strongly Connected Components) for cycle detection
6//! - Condensation DAG for dependency analysis
7//! - 2-hop interval labels for fast reachability queries
8//!
9//! All analyses are persisted to disk and memory-mapped for fast loading.
10
11pub 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/// Complete set of graph analyses for all edge kinds
51#[derive(Debug)]
52pub struct GraphAnalyses {
53    /// CSR adjacency matrix for all edge kinds
54    pub adjacency: CsrAdjacency,
55    /// Strongly connected components for call edges
56    pub scc_calls: SccData,
57    /// Strongly connected components for import edges
58    pub scc_imports: SccData,
59    /// Strongly connected components for reference edges
60    pub scc_references: SccData,
61    /// Strongly connected components for inheritance edges
62    pub scc_inherits: SccData,
63    /// Condensation DAG for call edges
64    pub cond_calls: CondensationDag,
65    /// Condensation DAG for import edges
66    pub cond_imports: CondensationDag,
67    /// Condensation DAG for reference edges
68    pub cond_references: CondensationDag,
69    /// Condensation DAG for inheritance edges
70    pub cond_inherits: CondensationDag,
71}
72
73impl GraphAnalyses {
74    /// Build all analyses from a compacted graph snapshot
75    /// Returns an error if the operation fails.
76    ///
77    /// # Errors
78    ///
79    pub fn build_all(snapshot: &CompactionSnapshot) -> Result<Self> {
80        Self::build_all_with_budget(snapshot, &LabelBudgetConfig::default())
81    }
82
83    /// Build all analyses from a compacted graph snapshot with configurable label budget.
84    ///
85    /// # Errors
86    ///
87    /// Returns an error if analysis building fails.
88    ///
89    /// # Panics
90    ///
91    /// Panics if downstream label builders encounter an internal invariant
92    /// violation while materializing analysis data.
93    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    /// Builds all graph analyses from a pre-built `CsrAdjacency`.
102    ///
103    /// This is the preferred entry point when the caller already has a
104    /// `CsrAdjacency` (e.g., reused from a pre-save compaction step).
105    /// Avoids rebuilding the adjacency matrix from a `CompactionSnapshot`.
106    ///
107    /// # Errors
108    ///
109    /// Returns an error if SCC computation or condensation DAG construction
110    /// fails for any edge kind.
111    ///
112    /// # Panics
113    ///
114    /// Panics if downstream label builders encounter an internal invariant
115    /// violation while materializing analysis data.
116    pub fn build_all_from_adjacency_with_budget(
117        adjacency: CsrAdjacency,
118        label_budget: &LabelBudgetConfig,
119    ) -> Result<Self> {
120        // Per-kind pipeline — SCC → condensation DAG + 2-hop labels.
121        // Each edge kind flows independently with no cross-kind barriers.
122        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        // Unpack in declaration order: calls, imports, references, inherits
146        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    /// Persist all analyses to disk using paths from [`GraphStorage`](crate::graph::unified::persistence::GraphStorage).
166    ///
167    /// Creates the analysis directory if it doesn't exist and writes all
168    /// artifacts (CSR, SCC, condensation DAGs) with identity validation headers.
169    ///
170    /// # Errors
171    ///
172    /// Returns an error if directory creation or file writing fails.
173    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
226/// Resolve analysis label-budget configuration with precedence:
227/// CLI overrides > config file > environment > compiled defaults.
228///
229/// # Errors
230///
231/// Returns an error if configured numeric values exceed `usize` range or if an
232/// explicit policy override is invalid.
233pub 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 layers in increasing precedence so later sources override earlier ones.
242    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
363/// Helper to reconstruct paths using precomputed analyses
364pub struct PathReconstructor<'a> {
365    csr: &'a CsrAdjacency,
366    scc_data: &'a SccData,
367    cond_dag: &'a CondensationDag,
368}
369
370impl<'a> PathReconstructor<'a> {
371    /// Create a new path reconstructor for a specific edge kind's analyses.
372    #[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    /// Reconstruct node-level path from one node to another
386    /// Returns an error if the operation fails.
387    ///
388    /// # Errors
389    ///
390    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]?; // Safe: if None, no path.
592        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    /// Create a simple test graph with known structure
608    ///
609    /// Graph: 0 -> 1 -> 2 -> 3
610    ///            \-> 4 -/
611    ///        5 -> 6 (separate component)
612    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            // Main component
622            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            // Separate component
628            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        // Check node 0's neighbors
648        let neighbors = csr.neighbors(NodeId::new(0, 0));
649        assert_eq!(neighbors.len(), 1);
650        assert_eq!(neighbors[0], 1);
651
652        // Check node 1's neighbors (has 2 outgoing edges)
653        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        // All nodes should be in trivial SCCs (no cycles)
720        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        // Create a graph with a cycle: 0 -> 1 -> 2 -> 0
728        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        // Should detect one non-trivial SCC containing nodes 0, 1, 2
757        assert_eq!(scc.scc_count, 1);
758        assert_eq!(scc.non_trivial_count, 1);
759        assert_eq!(scc.max_scc_size, 3);
760
761        // All three nodes should be in the same SCC
762        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        // Should have 7 SCCs (all trivial)
783        assert_eq!(dag.scc_count, 7);
784
785        // Should have 6 edges (same as original DAG since no cycles)
786        assert_eq!(dag.edge_count, 6);
787
788        // Topological order should exist (no cycles)
789        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        // Check reachability: 0 can reach 3
806        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        // Check non-reachability: 3 cannot reach 0
811        assert!(!dag.can_reach(scc_3, scc_0));
812
813        // Separate component: 0 cannot reach 6
814        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        // Create temp directory
834        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        // Persist
842        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        // Load back
847        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        // Verify
852        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    // ── Helper function tests ──────────────────────────────────────────
925
926    mod env_helpers {
927        use super::*;
928        use serial_test::serial;
929
930        // ── parse_env_usize ──────────────────────────────────────────
931
932        #[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        // ── env_flag_is_true ─────────────────────────────────────────
980
981        #[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            // Policy should remain unchanged on error
1073            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            // Config source with unknown value should warn but not error
1080            let result = apply_budget_policy_override(&mut policy, "unknown_policy", "config");
1081            assert!(result.is_ok());
1082            // Policy should remain unchanged (warning, not applied)
1083            assert_eq!(policy, BudgetExceededPolicy::Degrade);
1084        }
1085    }
1086
1087    mod label_budget_env_overrides {
1088        use super::*;
1089        use serial_test::serial;
1090
1091        /// Helper to clean up test env vars
1092        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}