Skip to main content

lean_ctx/cli/
explore_cmd.rs

1//! `lean-ctx explore` — FastContext-style iterative code exploration (CLI).
2//!
3//! Exposes the same deterministic loop as the `ctx_explore` MCP tool: a bounded
4//! multi-turn search (BM25 + static call/import graph + AST symbols) that returns
5//! compact `path:start-end` citations rather than file bodies. `--json` emits the
6//! citation list for editor/script consumption; `--citation` prints only the
7//! `<final_answer>` block.
8
9use crate::tools::CrpMode;
10use crate::tools::ctx_explore::{self, Citation, ExploreOptions};
11
12/// Parsed `explore` invocation. Separated from execution so flag handling is
13/// unit-testable without running a real search.
14#[derive(Debug, PartialEq)]
15struct Args {
16    query: Option<String>,
17    path: String,
18    max_turns: Option<usize>,
19    citation: bool,
20    json: bool,
21    help: bool,
22}
23
24impl Default for Args {
25    fn default() -> Self {
26        Self {
27            query: None,
28            path: ".".to_string(),
29            max_turns: None,
30            citation: false,
31            json: false,
32            help: false,
33        }
34    }
35}
36
37fn parse_args(args: &[String]) -> Args {
38    let mut parsed = Args::default();
39    let mut i = 0;
40    while i < args.len() {
41        match args[i].as_str() {
42            "--json" => parsed.json = true,
43            "--citation" | "--citations" => parsed.citation = true,
44            "--help" | "-h" => parsed.help = true,
45            "--query" | "-q" => {
46                i += 1;
47                parsed.query = args.get(i).cloned();
48            }
49            "--path" | "-p" => {
50                i += 1;
51                if let Some(v) = args.get(i) {
52                    parsed.path.clone_from(v);
53                }
54            }
55            "--max-turns" | "-t" => {
56                i += 1;
57                parsed.max_turns = args.get(i).and_then(|s| s.parse::<usize>().ok());
58            }
59            // First bare token is the query; later bare tokens are ignored.
60            other if !other.starts_with('-') && parsed.query.is_none() => {
61                parsed.query = Some(other.to_string());
62            }
63            _ => {}
64        }
65        i += 1;
66    }
67    parsed
68}
69
70pub(crate) fn cmd_explore(args: &[String]) {
71    let parsed = parse_args(args);
72
73    if parsed.help {
74        print_help();
75        return;
76    }
77
78    let Some(query) = parsed.query.filter(|q| !q.trim().is_empty()) else {
79        eprintln!(
80            "usage: lean-ctx explore <query> [--citation] [--json] [--max-turns N] [--path DIR]"
81        );
82        std::process::exit(2);
83    };
84
85    let opts = ExploreOptions::new(parsed.max_turns, parsed.citation);
86    let outcome = ctx_explore::handle(&query, &parsed.path, CrpMode::Off, &opts);
87
88    if outcome.text.starts_with("ERROR") {
89        eprintln!("explore: {}", outcome.text.trim_start_matches("ERROR: "));
90        std::process::exit(1);
91    }
92
93    if parsed.json {
94        println!("{}", to_json(&outcome.citations));
95    } else {
96        println!("{}", outcome.text);
97    }
98}
99
100/// Serialize citations with stable field names for editors/scripts.
101fn to_json(citations: &[Citation]) -> String {
102    #[derive(serde::Serialize)]
103    struct Cite<'a> {
104        file: &'a str,
105        start: usize,
106        end: usize,
107        label: &'a str,
108    }
109    let out: Vec<Cite> = citations
110        .iter()
111        .map(|c| Cite {
112            file: &c.file,
113            start: c.start,
114            end: c.end,
115            label: &c.label,
116        })
117        .collect();
118    serde_json::to_string(&out).unwrap_or_else(|_| "[]".to_string())
119}
120
121fn print_help() {
122    println!(
123        "lean-ctx explore — iterative code exploration → file:line citations\n\n\
124         USAGE:\n  lean-ctx explore <query> [OPTIONS]\n\n\
125         OPTIONS:\n\
126         \x20 -q, --query <text>     Question or symbol names (or pass as the first argument)\n\
127         \x20 -t, --max-turns <N>    Exploration depth (1-8, default 3)\n\
128         \x20 -p, --path <dir>       Project root to explore (default: cwd)\n\
129         \x20     --citation         Print only the <final_answer> block\n\
130         \x20     --json             Emit JSON array [{{file,start,end,label}}]\n\
131         \x20 -h, --help             Show this help\n\n\
132         vs semantic-search: explore follows the call/import graph over multiple turns;\n\
133         vs compose: explore returns citations (cheap), compose inlines bodies (one shot)."
134    );
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    fn args(parts: &[&str]) -> Vec<String> {
142        parts.iter().map(|s| (*s).to_string()).collect()
143    }
144
145    #[test]
146    fn parses_positional_query_with_defaults() {
147        let a = parse_args(&args(&["how does caching work"]));
148        assert_eq!(a.query.as_deref(), Some("how does caching work"));
149        assert_eq!(a.path, ".");
150        assert_eq!(a.max_turns, None);
151        assert!(!a.citation);
152        assert!(!a.json);
153    }
154
155    #[test]
156    fn parses_flags() {
157        let a = parse_args(&args(&[
158            "--query",
159            "auth flow",
160            "--max-turns",
161            "5",
162            "--path",
163            "/tmp/p",
164            "--citation",
165            "--json",
166        ]));
167        assert_eq!(a.query.as_deref(), Some("auth flow"));
168        assert_eq!(a.max_turns, Some(5));
169        assert_eq!(a.path, "/tmp/p");
170        assert!(a.citation);
171        assert!(a.json);
172    }
173
174    #[test]
175    fn explicit_query_flag_beats_bare_token() {
176        let a = parse_args(&args(&["--query", "real", "ignored"]));
177        assert_eq!(a.query.as_deref(), Some("real"));
178    }
179
180    #[test]
181    fn json_serializes_citation_fields() {
182        let cites = vec![Citation {
183            file: "src/main.rs".to_string(),
184            start: 12,
185            end: 20,
186            label: "main (fn)".to_string(),
187        }];
188        let v: serde_json::Value = serde_json::from_str(&to_json(&cites)).unwrap();
189        assert_eq!(v[0]["file"], "src/main.rs");
190        assert_eq!(v[0]["start"], 12);
191        assert_eq!(v[0]["end"], 20);
192        assert_eq!(v[0]["label"], "main (fn)");
193    }
194
195    #[test]
196    fn empty_citations_serialize_as_empty_array() {
197        assert_eq!(to_json(&[]), "[]");
198    }
199}