Skip to main content

sqry_cli/commands/
cycles.rs

1//! Cycles command implementation.
2//!
3//! Provides the CLI interface for finding circular dependencies in the
4//! codebase.
5//!
6//! # Dispatch path (DB19)
7//!
8//! `cycles` is a **name-keyed predicate** under the Phase 3C dispatch
9//! taxonomy: the question is "which strongly connected components match
10//! this edge kind and these bounds", which is the planner-canonical
11//! contract that sqry-db's [`sqry_db::queries::CyclesQuery`] caches
12//! (keyed on [`sqry_db::queries::CyclesKey`]). The CLI handler acquires
13//! a per-call [`sqry_db::QueryDb`] via
14//! [`sqry_db::queries::dispatch::make_query_db`], dispatches
15//! `CyclesQuery`, and materializes the returned
16//! [`sqry_core::graph::unified::node::NodeId`] vectors into the
17//! qualified-name shape the JSON / text output uses.
18//!
19//! This mirrors the MCP `execute_find_cycles` pattern exactly so CLI
20//! and MCP share one cache behavior on the same snapshot. The legacy
21//! `find_all_cycles_graph` call path was removed in DB19 (2026-04-15).
22
23use 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/// Cycle for output.
35#[derive(Debug, Serialize)]
36struct Cycle {
37    /// Cycle depth (number of nodes).
38    depth: usize,
39    /// Nodes in the cycle (forms a ring: last connects to first).
40    nodes: Vec<String>,
41}
42
43/// Materialize a cycle `NodeId` list into the qualified-name strings
44/// the JSON / text output uses.
45///
46/// Mirrors `sqry_mcp::execution::tools::analysis::materialize_cycle_node_ids`.
47/// Qualified names are preferred; nodes without a qualified name fall back
48/// to their simple name. Nodes whose entries cannot be resolved (stale
49/// `NodeId`s post-tombstone) are skipped silently — they are never in a
50/// live cycle because sqry-db's Tarjan walk only visits arena-live nodes
51/// per `SccQuery`.
52fn 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
77/// Run the cycles command.
78///
79/// # Errors
80/// Returns an error if the graph cannot be loaded.
81pub 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    // Parse cycle type.
93    let circular_type = CircularType::try_parse(cycle_type).with_context(|| {
94        format!("Invalid cycle type: {cycle_type}. Use: calls, imports, modules")
95    })?;
96
97    // Find index.
98    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    // Load unified graph.
111    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    // Route through sqry-db: `CyclesQuery` is the name-keyed cycle
116    // predicate in the planner taxonomy, cached per-snapshot.
117    // `--include-self` maps directly onto
118    // `CycleBounds::should_include_self_loops`, matching the pre-DB19
119    // `CircularConfig::should_include_self_loops` semantic exactly
120    // (`include_self` at the CLI ⇒ size-1 SCCs with a self-edge count).
121    let snapshot = Arc::new(graph.snapshot());
122    // PN3 CLIENT_LOAD: opportunistic cold-load from workspace companion file.
123    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    // Convert to output format.
137    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    // Output.
147    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        // Text output.
153        let output = format_cycles_text(&output_cycles, circular_type);
154        streams.write_result(&output)?;
155    }
156
157    Ok(())
158}
159
160/// Format cycles as human-readable text.
161fn 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        // Format as chain: A → B → C → A
177        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}