lean_ctx/tools/
ctx_overview.rs1use crate::core::cache::SessionCache;
2use crate::core::task_relevance::{compute_relevance, parse_task_hints};
3use crate::core::tokens::count_tokens;
4use crate::tools::CrpMode;
5
6pub fn handle(
17 cache: &SessionCache,
18 task: Option<&str>,
19 path: Option<&str>,
20 _crp_mode: CrpMode,
21) -> String {
22 let project_root = path
23 .map(|p| p.to_string())
24 .unwrap_or_else(|| ".".to_string());
25
26 let index = crate::core::graph_index::load_or_build(&project_root);
27
28 let (task_files, task_keywords) = if let Some(task_desc) = task {
29 parse_task_hints(task_desc)
30 } else {
31 (vec![], vec![])
32 };
33
34 let has_task = !task_files.is_empty() || !task_keywords.is_empty();
35
36 let mut output = Vec::new();
37
38 if has_task {
39 let relevance = compute_relevance(&index, &task_files, &task_keywords);
40
41 if let Some(task_desc) = task {
42 let file_context: Vec<(String, usize)> = relevance
43 .iter()
44 .filter(|r| r.score >= 0.3)
45 .take(8)
46 .filter_map(|r| {
47 std::fs::read_to_string(&r.path)
48 .ok()
49 .map(|c| (r.path.clone(), c.lines().count()))
50 })
51 .collect();
52 let briefing = crate::core::task_briefing::build_briefing(task_desc, &file_context);
53 output.push(crate::core::task_briefing::format_briefing(&briefing));
54 }
55
56 let high: Vec<&_> = relevance.iter().filter(|r| r.score >= 0.8).collect();
57 let medium: Vec<&_> = relevance
58 .iter()
59 .filter(|r| r.score >= 0.3 && r.score < 0.8)
60 .collect();
61 let low: Vec<&_> = relevance.iter().filter(|r| r.score < 0.3).collect();
62
63 output.push(format!(
64 "PROJECT OVERVIEW {} files task-filtered",
65 index.files.len()
66 ));
67 output.push(String::new());
68
69 if !high.is_empty() {
70 output.push("▸ DIRECTLY RELEVANT (use ctx_read full):".to_string());
71 for r in &high {
72 let line_count = file_line_count(&r.path);
73 let ref_id = cache.get_file_ref_readonly(&r.path);
74 let ref_str = ref_id.map_or(String::new(), |r| format!("{r}="));
75 output.push(format!(
76 " {ref_str}{} {line_count}L score={:.1}",
77 short_path(&r.path),
78 r.score
79 ));
80 }
81 output.push(String::new());
82 }
83
84 if !medium.is_empty() {
85 output.push("▸ CONTEXT (use ctx_read signatures/map):".to_string());
86 for r in medium.iter().take(20) {
87 let line_count = file_line_count(&r.path);
88 output.push(format!(
89 " {} {line_count}L mode={}",
90 short_path(&r.path),
91 r.recommended_mode
92 ));
93 }
94 if medium.len() > 20 {
95 output.push(format!(" ... +{} more", medium.len() - 20));
96 }
97 output.push(String::new());
98 }
99
100 if !low.is_empty() {
101 output.push(format!(
102 "▸ DISTANT ({} files, not loaded unless needed)",
103 low.len()
104 ));
105 for r in low.iter().take(10) {
106 output.push(format!(" {}", short_path(&r.path)));
107 }
108 if low.len() > 10 {
109 output.push(format!(" ... +{} more", low.len() - 10));
110 }
111 }
112 } else {
113 output.push(format!(
115 "PROJECT OVERVIEW {} files {} edges",
116 index.files.len(),
117 index.edges.len()
118 ));
119 output.push(String::new());
120
121 let mut by_dir: std::collections::BTreeMap<String, Vec<String>> =
123 std::collections::BTreeMap::new();
124
125 for file_entry in index.files.values() {
126 let dir = std::path::Path::new(&file_entry.path)
127 .parent()
128 .map(|p| p.to_string_lossy().to_string())
129 .unwrap_or_else(|| ".".to_string());
130 by_dir
131 .entry(dir)
132 .or_default()
133 .push(short_path(&file_entry.path));
134 }
135
136 for (dir, files) in &by_dir {
137 let dir_display = if dir.len() > 50 {
138 format!("...{}", &dir[dir.len() - 47..])
139 } else {
140 dir.clone()
141 };
142
143 if files.len() <= 5 {
144 output.push(format!("{dir_display}/ {}", files.join(" ")));
145 } else {
146 output.push(format!(
147 "{dir_display}/ {} +{} more",
148 files[..3].join(" "),
149 files.len() - 3
150 ));
151 }
152 }
153
154 output.push(String::new());
156 let mut connection_counts: std::collections::HashMap<&str, usize> =
157 std::collections::HashMap::new();
158 for edge in &index.edges {
159 *connection_counts.entry(&edge.from).or_insert(0) += 1;
160 *connection_counts.entry(&edge.to).or_insert(0) += 1;
161 }
162 let mut hubs: Vec<(&&str, &usize)> = connection_counts.iter().collect();
163 hubs.sort_by(|a, b| b.1.cmp(a.1));
164
165 if !hubs.is_empty() {
166 output.push("HUB FILES (most connected):".to_string());
167 for (path, count) in hubs.iter().take(8) {
168 output.push(format!(" {} ({count} edges)", short_path(path)));
169 }
170 }
171 }
172
173 let original = count_tokens(&format!("{} files", index.files.len())) * index.files.len();
174 let compressed = count_tokens(&output.join("\n"));
175 output.push(String::new());
176 output.push(crate::core::protocol::format_savings(original, compressed));
177
178 output.join("\n")
179}
180
181fn short_path(path: &str) -> String {
182 let parts: Vec<&str> = path.split('/').collect();
183 if parts.len() <= 2 {
184 return path.to_string();
185 }
186 parts[parts.len() - 2..].join("/")
187}
188
189fn file_line_count(path: &str) -> usize {
190 std::fs::read_to_string(path)
191 .map(|c| c.lines().count())
192 .unwrap_or(0)
193}