sqry_cli/commands/
cycles.rs1use crate::args::Cli;
24use crate::commands::graph::loader::{GraphLoadConfig, load_unified_graph_for_cli};
25use crate::index_discovery::find_nearest_index;
26use crate::output::OutputStreams;
27use anyhow::{Context, Result};
28use serde::Serialize;
29use sqry_core::graph::unified::concurrent::GraphSnapshot;
30use sqry_core::graph::unified::node::NodeId;
31use sqry_core::query::CircularType;
32use std::sync::Arc;
33
34#[derive(Debug, Serialize)]
36struct Cycle {
37 depth: usize,
39 nodes: Vec<String>,
41}
42
43fn materialize_cycle_node_ids(
53 cycles: &[Vec<NodeId>],
54 snapshot: &GraphSnapshot,
55) -> Vec<Vec<String>> {
56 let strings = snapshot.strings();
57 cycles
58 .iter()
59 .map(|cycle| {
60 cycle
61 .iter()
62 .filter_map(|&node_id| {
63 snapshot.get_node(node_id).and_then(|entry| {
64 entry
65 .qualified_name
66 .and_then(|sid| strings.resolve(sid))
67 .or_else(|| strings.resolve(entry.name))
68 .map(|s| s.to_string())
69 })
70 })
71 .collect()
72 })
73 .filter(|cycle: &Vec<String>| !cycle.is_empty())
74 .collect()
75}
76
77pub fn run_cycles(
82 cli: &Cli,
83 path: Option<&str>,
84 cycle_type: &str,
85 min_depth: usize,
86 max_depth: Option<usize>,
87 include_self: bool,
88 max_results: usize,
89) -> Result<()> {
90 let mut streams = OutputStreams::new();
91
92 let circular_type = CircularType::try_parse(cycle_type).with_context(|| {
94 format!("Invalid cycle type: {cycle_type}. Use: calls, imports, modules")
95 })?;
96
97 let search_path = path.map_or_else(
99 || std::env::current_dir().unwrap_or_default(),
100 std::path::PathBuf::from,
101 );
102
103 let index_location = find_nearest_index(&search_path);
104 let Some(ref loc) = index_location else {
105 streams
106 .write_diagnostic("No .sqry-index found. Run 'sqry index' first to build the index.")?;
107 return Ok(());
108 };
109
110 let config = GraphLoadConfig::default();
112 let graph = load_unified_graph_for_cli(&loc.index_root, &config, cli)
113 .context("Failed to load graph. Run 'sqry index' to build the graph.")?;
114
115 let snapshot = Arc::new(graph.snapshot());
122 let db = sqry_db::queries::dispatch::make_query_db_cold(Arc::clone(&snapshot), &loc.index_root);
124 let key = sqry_db::queries::CyclesKey {
125 circular_type,
126 bounds: sqry_db::queries::CycleBounds {
127 min_depth,
128 max_depth,
129 max_results,
130 should_include_self_loops: include_self,
131 },
132 };
133 let cycle_node_ids = db.get::<sqry_db::queries::CyclesQuery>(&key);
134 let cycles = materialize_cycle_node_ids(&cycle_node_ids, snapshot.as_ref());
135
136 let output_cycles: Vec<Cycle> = cycles
138 .into_iter()
139 .take(max_results)
140 .map(|nodes| Cycle {
141 depth: nodes.len(),
142 nodes,
143 })
144 .collect();
145
146 if cli.json {
148 let json =
149 serde_json::to_string_pretty(&output_cycles).context("Failed to serialize to JSON")?;
150 streams.write_result(&json)?;
151 } else {
152 let output = format_cycles_text(&output_cycles, circular_type);
154 streams.write_result(&output)?;
155 }
156
157 Ok(())
158}
159
160fn format_cycles_text(cycles: &[Cycle], cycle_type: CircularType) -> String {
162 let mut lines = Vec::new();
163
164 let type_name = match cycle_type {
165 CircularType::Calls => "call",
166 CircularType::Imports => "import",
167 CircularType::Modules => "module",
168 };
169
170 lines.push(format!("Found {} {} cycles", cycles.len(), type_name));
171 lines.push(String::new());
172
173 for (i, cycle) in cycles.iter().enumerate() {
174 lines.push(format!("Cycle {} (depth {}):", i + 1, cycle.depth));
175
176 let mut chain = cycle.nodes.join(" → ");
178 if let Some(first) = cycle.nodes.first() {
179 chain.push_str(" → ");
180 chain.push_str(first);
181 }
182 lines.push(format!(" {chain}"));
183 lines.push(String::new());
184 }
185
186 if cycles.is_empty() {
187 lines.push("No cycles found.".to_string());
188 }
189
190 lines.join("\n")
191}