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