lean_ctx/cli/
explore_cmd.rs1use crate::tools::CrpMode;
10use crate::tools::ctx_explore::{self, Citation, ExploreOptions};
11
12#[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 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
100fn 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}