1use std::path::Path;
10
11pub use crate::tools::ctx_read::ReadOutput;
12pub use crate::tools::ctx_search::SearchOutcome;
13pub use lean_ctx::core::protocol::CrpMode;
14
15pub struct OperatorCache {
17 inner: lean_ctx::core::cache::SessionCache,
18}
19
20impl Default for OperatorCache {
21 fn default() -> Self {
22 Self::new()
23 }
24}
25
26impl OperatorCache {
27 pub fn new() -> Self {
28 Self {
29 inner: lean_ctx::core::cache::SessionCache::new(),
30 }
31 }
32
33 pub fn clear(&mut self) -> usize {
35 self.inner.clear()
36 }
37}
38
39#[derive(Debug, Clone, Copy)]
41pub struct ReadRequest<'a> {
42 pub path: &'a Path,
43 pub project_root: &'a Path,
44 pub mode: &'a str,
45 pub fresh: bool,
46 pub crp_mode: CrpMode,
47 pub task: Option<&'a str>,
48 pub aggressiveness: Option<f64>,
49 pub protect: &'a [String],
50}
51
52pub fn read(
54 cache: &mut OperatorCache,
55 request: ReadRequest<'_>,
56) -> Result<ReadOutput, lean_ctx::core::error::PathJailError> {
57 let path = lean_ctx::core::pathjail::jail_path(request.path, request.project_root)?;
58 let path = path.to_string_lossy();
59 let output = if request.fresh {
60 crate::tools::ctx_read::handle_fresh_with_task_resolved_tuned(
61 &mut cache.inner,
62 &path,
63 request.mode,
64 request.crp_mode,
65 request.task,
66 request.aggressiveness,
67 request.protect,
68 )
69 } else {
70 crate::tools::ctx_read::handle_with_task_resolved_tuned(
71 &mut cache.inner,
72 &path,
73 request.mode,
74 request.crp_mode,
75 request.task,
76 request.aggressiveness,
77 request.protect,
78 )
79 };
80 Ok(output)
81}
82
83#[derive(Debug, Clone, Copy)]
85pub struct SearchRequest<'a> {
86 pub pattern: &'a str,
87 pub directory: &'a Path,
88 pub project_root: &'a Path,
89 pub include: Option<&'a str>,
90 pub max_results: usize,
91 pub crp_mode: CrpMode,
92 pub respect_gitignore: bool,
93 pub allow_secret_paths: bool,
94 pub anchored: bool,
95 pub exclude: Option<&'a str>,
96 pub exclude_pattern: Option<&'a str>,
97}
98
99pub fn search(
101 request: SearchRequest<'_>,
102) -> Result<SearchOutcome, lean_ctx::core::error::PathJailError> {
103 let directory = lean_ctx::core::pathjail::jail_path(request.directory, request.project_root)?;
104 let directory = directory.to_string_lossy();
105 Ok(crate::tools::ctx_search::handle_filtered(
106 request.pattern,
107 &directory,
108 request.include,
109 request.max_results,
110 request.crp_mode,
111 request.respect_gitignore,
112 request.allow_secret_paths,
113 request.anchored,
114 request.exclude,
115 request.exclude_pattern,
116 ))
117}
118
119pub fn compress_shell_output(command: &str, output: &str, exit_code: i32) -> String {
121 lean_ctx::shell::compress::engine::compress_for_outcome(command, output, exit_code)
122}
123
124pub fn redact_output(input: &str) -> String {
126 lean_ctx::core::redaction::redact_text(input)
127}
128
129pub fn render_shared_signatures(content: &str, file_ext: &str, tdd: bool) -> String {
131 let (signatures, _) =
132 lean_ctx::core::signatures::extract_signatures_with_backend(content, file_ext);
133 signatures
134 .iter()
135 .map(|signature| {
136 if tdd {
137 signature.to_tdd_located()
138 } else {
139 signature.to_compact_located()
140 }
141 })
142 .collect::<Vec<_>>()
143 .join("\n")
144}
145
146#[cfg(test)]
147mod tests {
148 use super::*;
149
150 fn assert_read_eq(actual: &ReadOutput, expected: &ReadOutput) {
151 assert_eq!(actual.content, expected.content);
152 assert_eq!(actual.resolved_mode, expected.resolved_mode);
153 assert_eq!(actual.output_tokens, expected.output_tokens);
154 assert_eq!(actual.is_cache_hit, expected.is_cache_hit);
155 }
156
157 #[test]
158 fn read_boundary_matches_existing_operator() {
159 let dir = tempfile::tempdir().unwrap();
160 let path = dir.path().join("sample.rs");
161 std::fs::write(&path, "pub fn answer() -> u32 { 42 }\n").unwrap();
162 let request = ReadRequest {
163 path: &path,
164 project_root: dir.path(),
165 mode: "signatures",
166 fresh: false,
167 crp_mode: CrpMode::Off,
168 task: None,
169 aggressiveness: None,
170 protect: &[],
171 };
172 let mut boundary_cache = OperatorCache::new();
173 let mut direct_cache = lean_ctx::core::cache::SessionCache::new();
174
175 let actual = read(&mut boundary_cache, request).unwrap();
176 let path = lean_ctx::core::pathjail::jail_path(request.path, request.project_root).unwrap();
177 let path = path.to_string_lossy();
178 let expected = crate::tools::ctx_read::handle_with_task_resolved_tuned(
179 &mut direct_cache,
180 &path,
181 request.mode,
182 request.crp_mode,
183 request.task,
184 request.aggressiveness,
185 request.protect,
186 );
187
188 assert_read_eq(&actual, &expected);
189 }
190
191 #[test]
192 fn fresh_read_boundary_matches_existing_operator() {
193 let dir = tempfile::tempdir().unwrap();
194 let path = dir.path().join("fresh.txt");
195 std::fs::write(&path, "alpha\nbeta\n").unwrap();
196 let request = ReadRequest {
197 path: &path,
198 project_root: dir.path(),
199 mode: "full",
200 fresh: true,
201 crp_mode: CrpMode::Off,
202 task: Some("alpha"),
203 aggressiveness: None,
204 protect: &[],
205 };
206 let mut boundary_cache = OperatorCache::new();
207 let mut direct_cache = lean_ctx::core::cache::SessionCache::new();
208
209 let actual = read(&mut boundary_cache, request).unwrap();
210 let path = lean_ctx::core::pathjail::jail_path(request.path, request.project_root).unwrap();
211 let path = path.to_string_lossy();
212 let expected = crate::tools::ctx_read::handle_fresh_with_task_resolved_tuned(
213 &mut direct_cache,
214 &path,
215 request.mode,
216 request.crp_mode,
217 request.task,
218 request.aggressiveness,
219 request.protect,
220 );
221
222 assert_read_eq(&actual, &expected);
223 }
224
225 #[test]
226 fn search_boundary_matches_existing_operator() {
227 let dir = tempfile::tempdir().unwrap();
228 std::fs::write(dir.path().join("sample.txt"), "alpha needle omega\n").unwrap();
229 let request = SearchRequest {
230 pattern: "needle",
231 directory: dir.path(),
232 project_root: dir.path(),
233 include: Some("*.txt"),
234 max_results: 10,
235 crp_mode: CrpMode::Off,
236 respect_gitignore: true,
237 allow_secret_paths: false,
238 anchored: false,
239 exclude: None,
240 exclude_pattern: None,
241 };
242
243 let actual = search(request).unwrap();
244 let directory =
245 lean_ctx::core::pathjail::jail_path(request.directory, request.project_root).unwrap();
246 let directory = directory.to_string_lossy();
247 let expected = crate::tools::ctx_search::handle_filtered(
248 request.pattern,
249 &directory,
250 request.include,
251 request.max_results,
252 request.crp_mode,
253 request.respect_gitignore,
254 request.allow_secret_paths,
255 request.anchored,
256 request.exclude,
257 request.exclude_pattern,
258 );
259
260 assert_eq!(actual.text, expected.text);
261 assert_eq!(actual.modeled_baseline, expected.modeled_baseline);
262 assert_eq!(actual.observed_tokens, expected.observed_tokens);
263 }
264
265 #[test]
266 fn shell_boundary_matches_existing_operator() {
267 let output = "running 2 tests\ntest alpha ... ok\ntest beta ... ok\ntest result: ok. 2 passed; 0 failed\n";
268 assert_eq!(
269 compress_shell_output("cargo test", output, 0),
270 lean_ctx::shell::compress::engine::compress_for_outcome("cargo test", output, 0)
271 );
272 }
273
274 #[test]
275 fn failed_shell_boundary_matches_existing_operator() {
276 let output = "step 1 ok\nstep 2 ok\nerror: deployment failed\n";
277 assert_eq!(
278 compress_shell_output("./deploy.sh", output, 13),
279 lean_ctx::shell::compress::engine::compress_for_outcome("./deploy.sh", output, 13)
280 );
281 }
282
283 #[test]
284 fn redaction_boundary_matches_existing_operator() {
285 let input = "Authorization: Bearer abcdefghijklmnopqrstuvwxyz";
286 let actual = redact_output(input);
287 assert_eq!(actual, lean_ctx::core::redaction::redact_text(input));
288 assert!(!actual.contains("abcdefghijklmnopqrstuvwxyz"));
289 }
290
291 #[test]
292 fn cache_boundary_reuses_session_cache_type() {
293 let mut cache = OperatorCache::new();
294 cache
295 .inner
296 .store("virtual.txt", "same cache implementation");
297 assert_eq!(cache.clear(), 1);
298 assert!(cache.inner.get_full_content("virtual.txt").is_none());
299 }
300
301 #[test]
302 fn shared_signature_operator_matches_local_extractor() {
303 let source = "pub fn answer() -> u32 { 42 }\nfn detail() {}\n";
304 let expected = lean_ctx::core::signatures::extract_signatures(source, "rs")
305 .iter()
306 .map(lean_ctx::core::signatures::Signature::to_compact_located)
307 .collect::<Vec<_>>()
308 .join("\n");
309 assert_eq!(render_shared_signatures(source, "rs", false), expected);
310 }
311
312 #[test]
313 #[cfg(not(feature = "no-jail"))]
314 fn read_boundary_rejects_paths_outside_project_root() {
315 let project = tempfile::tempdir().unwrap();
316 let outside = tempfile::NamedTempFile::new().unwrap();
317 let mut cache = OperatorCache::new();
318 let result = read(
319 &mut cache,
320 ReadRequest {
321 path: outside.path(),
322 project_root: project.path(),
323 mode: "full",
324 fresh: false,
325 crp_mode: CrpMode::Off,
326 task: None,
327 aggressiveness: None,
328 protect: &[],
329 },
330 );
331 assert!(result.is_err());
332 }
333
334 #[test]
335 #[cfg(not(feature = "no-jail"))]
336 fn search_boundary_rejects_directories_outside_project_root() {
337 let project = tempfile::tempdir().unwrap();
338 let outside = tempfile::tempdir().unwrap();
339 let result = search(SearchRequest {
340 pattern: "needle",
341 directory: outside.path(),
342 project_root: project.path(),
343 include: None,
344 max_results: 10,
345 crp_mode: CrpMode::Off,
346 respect_gitignore: true,
347 allow_secret_paths: false,
348 anchored: false,
349 exclude: None,
350 exclude_pattern: None,
351 });
352 assert!(result.is_err());
353 }
354}