Skip to main content

sqry_cli/commands/
cycles.rs

1//! Cycles command implementation
2//!
3//! Provides CLI interface for finding circular dependencies in the codebase.
4
5use crate::args::Cli;
6use crate::commands::graph::loader::{GraphLoadConfig, load_unified_graph_for_cli};
7use crate::index_discovery::find_nearest_index;
8use crate::output::OutputStreams;
9use anyhow::{Context, Result};
10use serde::Serialize;
11use sqry_core::query::{CircularConfig, CircularType, find_all_cycles_graph};
12
13/// Cycle for output
14#[derive(Debug, Serialize)]
15struct Cycle {
16    /// Cycle depth (number of nodes)
17    depth: usize,
18    /// Nodes in the cycle (forms a ring: last connects to first)
19    nodes: Vec<String>,
20}
21
22/// Run the cycles command.
23///
24/// # Errors
25/// Returns an error if the graph cannot be loaded or cycles cannot be found.
26pub fn run_cycles(
27    cli: &Cli,
28    path: Option<&str>,
29    cycle_type: &str,
30    min_depth: usize,
31    max_depth: Option<usize>,
32    include_self: bool,
33    max_results: usize,
34) -> Result<()> {
35    let mut streams = OutputStreams::new();
36
37    // Parse cycle type
38    let circular_type = CircularType::try_parse(cycle_type).with_context(|| {
39        format!("Invalid cycle type: {cycle_type}. Use: calls, imports, modules")
40    })?;
41
42    // Find index
43    let search_path = path.map_or_else(
44        || std::env::current_dir().unwrap_or_default(),
45        std::path::PathBuf::from,
46    );
47
48    let index_location = find_nearest_index(&search_path);
49    let Some(ref loc) = index_location else {
50        streams
51            .write_diagnostic("No .sqry-index found. Run 'sqry index' first to build the index.")?;
52        return Ok(());
53    };
54
55    // Load unified graph
56    let config = GraphLoadConfig::default();
57    let graph = load_unified_graph_for_cli(&loc.index_root, &config, cli)
58        .context("Failed to load graph. Run 'sqry index' to build the graph.")?;
59
60    // Build config
61    let circular_config = CircularConfig {
62        min_depth,
63        max_depth,
64        max_results,
65        should_include_self_loops: include_self,
66    };
67
68    // Find cycles using graph-based detection
69    let all_cycles = find_all_cycles_graph(circular_type, &graph, &circular_config);
70
71    // Convert to output format
72    let output_cycles: Vec<Cycle> = all_cycles
73        .into_iter()
74        .take(max_results)
75        .map(|nodes| Cycle {
76            depth: nodes.len(),
77            nodes,
78        })
79        .collect();
80
81    // Output
82    if cli.json {
83        let json =
84            serde_json::to_string_pretty(&output_cycles).context("Failed to serialize to JSON")?;
85        streams.write_result(&json)?;
86    } else {
87        // Text output
88        let output = format_cycles_text(&output_cycles, circular_type);
89        streams.write_result(&output)?;
90    }
91
92    Ok(())
93}
94
95/// Format cycles as human-readable text
96fn format_cycles_text(cycles: &[Cycle], cycle_type: CircularType) -> String {
97    let mut lines = Vec::new();
98
99    let type_name = match cycle_type {
100        CircularType::Calls => "call",
101        CircularType::Imports => "import",
102        CircularType::Modules => "module",
103    };
104
105    lines.push(format!("Found {} {} cycles", cycles.len(), type_name));
106    lines.push(String::new());
107
108    for (i, cycle) in cycles.iter().enumerate() {
109        lines.push(format!("Cycle {} (depth {}):", i + 1, cycle.depth));
110
111        // Format as chain: A → B → C → A
112        let mut chain = cycle.nodes.join(" → ");
113        if let Some(first) = cycle.nodes.first() {
114            chain.push_str(" → ");
115            chain.push_str(first);
116        }
117        lines.push(format!("  {chain}"));
118        lines.push(String::new());
119    }
120
121    if cycles.is_empty() {
122        lines.push("No cycles found.".to_string());
123    }
124
125    lines.join("\n")
126}