Skip to main content

lean_ctx_server/tools/
ctx_dedup.rs

1// SPDX-License-Identifier: Apache-2.0
2use 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        // Shared blocks are derived from, and registered with, the content
28        // the cache's current-rights accessor releases NOW (G04) — never the
29        // raw entry.
30        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    // Every entry is taken once, as the cache's current-rights accessor
99    // releases it NOW (G04); the analysis below quotes import lines and block
100    // previews, so it must not see a raw entry.
101    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    // TF-IDF cosine similarity analysis for semantic duplicates
210    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    /// G04: the analysis quoted import lines and block previews straight from
257    /// raw cache entries — also of a source that no longer exists. It now
258    /// sees only what the cache's current-rights accessor releases.
259    #[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}