lean_ctx_server/tools/
ctx_dedup.rs1use std::collections::{HashMap, HashSet};
3
4use lean_ctx::core::cache::{SessionCache, SharedBlock};
5use lean_ctx::core::codebook;
6use lean_ctx::core::tokens::count_tokens;
7
8pub fn handle(cache: &SessionCache) -> String {
9 analyze(cache)
10}
11
12pub fn handle_action(cache: &mut SessionCache, action: &str) -> String {
13 match action {
14 "apply" => apply_dedup(cache),
15 _ => analyze(cache),
16 }
17}
18
19fn apply_dedup(cache: &mut SessionCache) -> String {
20 let entries = cache.get_all_entries();
21 if entries.len() < 2 {
22 return "Need at least 2 cached files for cross-file dedup.".to_string();
23 }
24
25 let mut block_occurrences: HashMap<String, Vec<(String, usize)>> = HashMap::new();
26 for (path, _) in &entries {
27 let Some(full_content) =
31 crate::tools::admitted_sources::released_cache_content(cache, path)
32 else {
33 continue;
34 };
35 let lines: Vec<&str> = full_content.lines().collect();
36 for (idx, chunk) in lines.chunks(5).enumerate() {
37 if chunk.len() == 5 {
38 let block = chunk.join("\n");
39 let trimmed = block.trim().to_string();
40 if !trimmed.is_empty() && count_tokens(&trimmed) > 10 {
41 block_occurrences
42 .entry(trimmed)
43 .or_default()
44 .push(((*path).clone(), idx * 5 + 1));
45 }
46 }
47 }
48 }
49
50 let mut shared = Vec::new();
51 for (content, occurrences) in &block_occurrences {
52 let unique_files: HashSet<&str> = occurrences.iter().map(|(p, _)| p.as_str()).collect();
53 if unique_files.len() >= 2 {
54 let (canonical_path, start_line) = &occurrences[0];
55 let ref_label = cache
56 .file_ref_map()
57 .get(canonical_path)
58 .cloned()
59 .unwrap_or_else(|| "F?".to_string());
60 shared.push(SharedBlock {
61 canonical_path: canonical_path.clone(),
62 canonical_ref: ref_label,
63 start_line: *start_line,
64 end_line: start_line + 4,
65 content: content.clone(),
66 });
67 }
68 }
69
70 let count = shared.len();
71 let savings: usize = shared
72 .iter()
73 .map(|b| {
74 let occurrences = block_occurrences.get(&b.content).map_or(0, |o| {
75 let unique: HashSet<&str> = o.iter().map(|(p, _)| p.as_str()).collect();
76 unique.len() - 1
77 });
78 count_tokens(&b.content) * occurrences
79 })
80 .sum();
81
82 cache.set_shared_blocks(shared);
83
84 format!(
85 "Applied cross-file dedup: {count} shared blocks registered (~{savings} tokens saveable)"
86 )
87}
88
89fn analyze(cache: &SessionCache) -> String {
90 let entries = cache.get_all_entries();
91 if entries.len() < 2 {
92 return "Need at least 2 cached files for cross-file deduplication analysis.".to_string();
93 }
94
95 let mut import_patterns: HashMap<String, Vec<String>> = HashMap::new();
96 let mut boilerplate_blocks: HashMap<String, Vec<String>> = HashMap::new();
97
98 let released: Vec<(String, String)> = entries
102 .iter()
103 .filter_map(|(path, _)| {
104 let content = crate::tools::admitted_sources::released_cache_content(cache, path)?;
105 Some(((*path).clone(), content))
106 })
107 .collect();
108
109 for (path, full_content) in &released {
110 let lines: Vec<&str> = full_content.lines().collect();
111
112 let imports: Vec<&str> = lines
113 .iter()
114 .copied()
115 .filter(|l| {
116 let t = l.trim();
117 t.starts_with("import ")
118 || t.starts_with("use ")
119 || t.starts_with("from ")
120 || t.starts_with("require(")
121 || t.starts_with("#include")
122 })
123 .collect();
124
125 for imp in &imports {
126 let key = imp.trim().to_string();
127 import_patterns
128 .entry(key)
129 .or_default()
130 .push((*path).clone());
131 }
132
133 for chunk in lines.chunks(5) {
134 if chunk.len() == 5 {
135 let block = chunk.join("\n");
136 let block_trimmed = block.trim().to_string();
137 if !block_trimmed.is_empty() && count_tokens(&block_trimmed) > 10 {
138 boilerplate_blocks
139 .entry(block_trimmed)
140 .or_default()
141 .push((*path).clone());
142 }
143 }
144 }
145 }
146
147 let shared_imports: Vec<_> = import_patterns
148 .iter()
149 .filter(|(_, files)| files.len() >= 2)
150 .collect();
151
152 let shared_blocks: Vec<_> = boilerplate_blocks
153 .iter()
154 .filter(|(_, files)| {
155 let unique: std::collections::HashSet<_> = files.iter().collect();
156 unique.len() >= 2
157 })
158 .collect();
159
160 let mut result = Vec::new();
161 result.push(format!(
162 "Cross-file deduplication analysis ({} cached files):",
163 entries.len()
164 ));
165
166 if !shared_imports.is_empty() {
167 let total_import_tokens: usize = shared_imports
168 .iter()
169 .map(|(imp, files)| count_tokens(imp) * (files.len() - 1))
170 .sum();
171
172 result.push(format!(
173 "\nShared imports ({}, ~{total_import_tokens} redundant tokens):",
174 shared_imports.len()
175 ));
176 for (imp, files) in shared_imports.iter().take(10) {
177 let short_files: Vec<String> = files
178 .iter()
179 .map(|f| lean_ctx::core::protocol::shorten_path(f))
180 .collect();
181 result.push(format!(" {imp}"));
182 result.push(format!(" in: {}", short_files.join(", ")));
183 }
184 if shared_imports.len() > 10 {
185 result.push(format!(" ... +{} more", shared_imports.len() - 10));
186 }
187 }
188
189 if !shared_blocks.is_empty() {
190 let total_block_tokens: usize = shared_blocks
191 .iter()
192 .map(|(block, files)| {
193 let unique: std::collections::HashSet<_> = files.iter().collect();
194 count_tokens(block) * (unique.len() - 1)
195 })
196 .sum();
197
198 result.push(format!(
199 "\nShared code blocks ({}, ~{total_block_tokens} redundant tokens):",
200 shared_blocks.len()
201 ));
202 for (block, files) in shared_blocks.iter().take(5) {
203 let unique: std::collections::HashSet<_> = files.iter().collect();
204 let preview = block.lines().next().unwrap_or("...");
205 result.push(format!(" \"{preview}...\" (in {} files)", unique.len()));
206 }
207 }
208
209 let semantic_dups = codebook::find_semantic_duplicates(&released, 0.75);
211 if !semantic_dups.is_empty() {
212 result.push(format!(
213 "\nSemantic duplicates (TF-IDF cosine > 0.75, {} pairs):",
214 semantic_dups.len()
215 ));
216 for (a, b, sim) in semantic_dups.iter().take(8) {
217 result.push(format!(
218 " {:.0}% similar: {} ↔ {}",
219 sim * 100.0,
220 lean_ctx::core::protocol::shorten_path(a),
221 lean_ctx::core::protocol::shorten_path(b)
222 ));
223 }
224 if semantic_dups.len() > 8 {
225 result.push(format!(" ... +{} more pairs", semantic_dups.len() - 8));
226 }
227 }
228
229 if shared_imports.is_empty() && shared_blocks.is_empty() && semantic_dups.is_empty() {
230 result.push("\nNo significant cross-file duplication detected.".to_string());
231 } else {
232 let total_savings: usize = shared_imports
233 .iter()
234 .map(|(imp, files)| count_tokens(imp) * (files.len() - 1))
235 .sum::<usize>()
236 + shared_blocks
237 .iter()
238 .map(|(block, files)| {
239 let unique: std::collections::HashSet<_> = files.iter().collect();
240 count_tokens(block) * (unique.len() - 1)
241 })
242 .sum::<usize>();
243
244 result.push(format!(
245 "\nTotal potential savings: ~{total_savings} tokens"
246 ));
247 }
248
249 result.join("\n")
250}
251
252#[cfg(test)]
253mod tests {
254 use super::*;
255
256 #[test]
260 fn analysis_quotes_only_released_cache_entries() {
261 let _data = lean_ctx::core::data_dir::isolated_data_dir();
262 let _community = lean_ctx::core::policy::runtime::TestPolicyOverride::set(None);
263 let project = tempfile::tempdir().unwrap();
264 let source = "use widget_core::PlanMarker;\nfn body() {}\n";
265 let mut cache = SessionCache::new();
266 let mut paths = Vec::new();
267 for name in ["a.rs", "b.rs"] {
268 let path = project.path().join(name);
269 std::fs::write(&path, source).unwrap();
270 let path = path.to_string_lossy().into_owned();
271 cache.store(&path, source);
272 paths.push(path);
273 }
274
275 let out = handle(&cache);
276 assert!(out.contains("use widget_core::PlanMarker;"), "{out}");
277
278 std::fs::remove_file(&paths[1]).unwrap();
279 let out = handle(&cache);
280 assert!(!out.contains("widget_core::PlanMarker"), "{out}");
281 assert_eq!(
282 handle_action(&mut cache, "apply"),
283 "Applied cross-file dedup: 0 shared blocks registered (~0 tokens saveable)"
284 );
285 }
286}