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 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/// Complete set of graph analyses for all edge kinds
40#[derive(Debug)]
41pub struct GraphAnalyses {
42    /// CSR adjacency matrix for all edge kinds
43    pub adjacency: CsrAdjacency,
44    /// Strongly connected components for call edges
45    pub scc_calls: SccData,
46    /// Strongly connected components for import edges
47    pub scc_imports: SccData,
48    /// Strongly connected components for reference edges
49    pub scc_references: SccData,
50    /// Strongly connected components for inheritance edges
51    pub scc_inherits: SccData,
52    /// Condensation DAG for call edges
53    pub cond_calls: CondensationDag,
54    /// Condensation DAG for import edges
55    pub cond_imports: CondensationDag,
56    /// Condensation DAG for reference edges
57    pub cond_references: CondensationDag,
58    /// Condensation DAG for inheritance edges
59    pub cond_inherits: CondensationDag,
60}
61
62impl GraphAnalyses {
63    /// Build all analyses from a compacted graph snapshot
64    /// Returns an error if the operation fails.
65    ///
66    /// # Errors
67    ///
68    pub fn build_all(snapshot: &CompactionSnapshot) -> Result<Self> {
69        Self::build_all_with_budget(snapshot, &LabelBudgetConfig::default())
70    }
71
72    /// Build all analyses from a compacted graph snapshot with configurable label budget.
73    ///
74    /// # Errors
75    ///
76    /// Returns an error if analysis building fails.
77    ///
78    /// # Panics
79    ///
80    /// Panics if downstream label builders encounter an internal invariant
81    /// violation while materializing analysis data.
82    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    /// Builds all graph analyses from a pre-built `CsrAdjacency`.
91    ///
92    /// This is the preferred entry point when the caller already has a
93    /// `CsrAdjacency` (e.g., reused from a pre-save compaction step).
94    /// Avoids rebuilding the adjacency matrix from a `CompactionSnapshot`.
95    ///
96    /// # Errors
97    ///
98    /// Returns an error if SCC computation or condensation DAG construction
99    /// fails for any edge kind.
100    ///
101    /// # Panics
102    ///
103    /// Panics if downstream label builders encounter an internal invariant
104    /// violation while materializing analysis data.
105    pub fn build_all_from_adjacency_with_budget(
106        adjacency: CsrAdjacency,
107        label_budget: &LabelBudgetConfig,
108    ) -> Result<Self> {
109        // Per-kind pipeline — SCC → condensation DAG + 2-hop labels.
110        // Each edge kind flows independently with no cross-kind barriers.
111        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        // Unpack in declaration order: calls, imports, references, inherits
135        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    /// Persist all analyses to disk using paths from [`GraphStorage`](crate::graph::unified::persistence::GraphStorage).
155    ///
156    /// Creates the analysis directory if it doesn't exist and writes all
157    /// artifacts (CSR, SCC, condensation DAGs) with identity validation headers.
158    ///
159    /// # Errors
160    ///
161    /// Returns an error if directory creation or file writing fails.
162    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
215/// Resolve analysis label-budget configuration with precedence:
216/// CLI overrides > config file > environment > compiled defaults.
217///
218/// # Errors
219///
220/// Returns an error if configured numeric values exceed `usize` range or if an
221/// explicit policy override is invalid.
222pub 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 layers in increasing precedence so later sources override earlier ones.
231    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
352/// Helper to reconstruct paths using precomputed analyses
353pub struct PathReconstructor<'a> {
354    csr: &'a CsrAdjacency,
355    scc_data: &'a SccData,
356    cond_dag: &'a CondensationDag,
357}
358
359impl<'a> PathReconstructor<'a> {
360    /// Create a new path reconstructor for a specific edge kind's analyses.
361    #[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    /// Reconstruct node-level path from one node to another
375    /// Returns an error if the operation fails.
376    ///
377    /// # Errors
378    ///
379    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]?; // Safe: if None, no path.
581        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    /// Create a simple test graph with known structure
597    ///
598    /// Graph: 0 -> 1 -> 2 -> 3
599    ///            \-> 4 -/
600    ///        5 -> 6 (separate component)
601    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            // Main component
611            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            // Separate component
617            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        // Check node 0's neighbors
637        let neighbors = csr.neighbors(NodeId::new(0, 0));
638        assert_eq!(neighbors.len(), 1);
639        assert_eq!(neighbors[0], 1);
640
641        // Check node 1's neighbors (has 2 outgoing edges)
642        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        // All nodes should be in trivial SCCs (no cycles)
709        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        // Create a graph with a cycle: 0 -> 1 -> 2 -> 0
717        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        // Should detect one non-trivial SCC containing nodes 0, 1, 2
746        assert_eq!(scc.scc_count, 1);
747        assert_eq!(scc.non_trivial_count, 1);
748        assert_eq!(scc.max_scc_size, 3);
749
750        // All three nodes should be in the same SCC
751        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        // Should have 7 SCCs (all trivial)
772        assert_eq!(dag.scc_count, 7);
773
774        // Should have 6 edges (same as original DAG since no cycles)
775        assert_eq!(dag.edge_count, 6);
776
777        // Topological order should exist (no cycles)
778        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        // Check reachability: 0 can reach 3
795        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        // Check non-reachability: 3 cannot reach 0
800        assert!(!dag.can_reach(scc_3, scc_0));
801
802        // Separate component: 0 cannot reach 6
803        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        // Create temp directory
823        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        // Persist
831        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        // Load back
836        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        // Verify
841        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    // ── Helper function tests ──────────────────────────────────────────
914
915    mod env_helpers {
916        use super::*;
917        use serial_test::serial;
918
919        // ── parse_env_usize ──────────────────────────────────────────
920
921        #[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        // ── env_flag_is_true ─────────────────────────────────────────
969
970        #[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            // Policy should remain unchanged on error
1062            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            // Config source with unknown value should warn but not error
1069            let result = apply_budget_policy_override(&mut policy, "unknown_policy", "config");
1070            assert!(result.is_ok());
1071            // Policy should remain unchanged (warning, not applied)
1072            assert_eq!(policy, BudgetExceededPolicy::Degrade);
1073        }
1074    }
1075
1076    mod label_budget_env_overrides {
1077        use super::*;
1078        use serial_test::serial;
1079
1080        /// Helper to clean up test env vars
1081        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}