Skip to main content

lean_ctx/tools/
ctx_execute.rs

1use crate::core::sandbox::{self, SandboxResult};
2use crate::core::tokens::count_tokens;
3
4/// Executes a code snippet in a sandboxed environment and returns formatted output.
5pub fn handle(language: &str, code: &str, intent: Option<&str>, timeout: Option<u64>) -> String {
6    let result = sandbox::execute(language, code, timeout);
7    format_result(&result, intent)
8}
9
10/// Reads a file from disk, detects its language, and executes a processing script.
11///
12/// `project_root` is used for pathjail validation. If `None`, the current
13/// directory is used as the jail root.
14pub fn handle_file(path: &str, intent: Option<&str>, project_root: Option<&str>) -> String {
15    let jail_root = match project_root {
16        Some(r) => std::path::PathBuf::from(r),
17        None => std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")),
18    };
19    let candidate = std::path::Path::new(path);
20    let jailed = match crate::core::pathjail::jail_path(candidate, &jail_root) {
21        Ok(p) => p,
22        Err(e) => return format!("Path rejected: {e}"),
23    };
24    let path_str = jailed.to_string_lossy();
25
26    let cap = crate::core::limits::max_read_bytes();
27    let meta = match std::fs::metadata(&*jailed) {
28        Ok(m) => m,
29        Err(e) => return format!("Error reading {path_str}: {e}"),
30    };
31    if meta.len() > cap as u64 {
32        return format!(
33            "File too large ({} bytes, limit {cap} bytes). Use a line-range read instead.",
34            meta.len()
35        );
36    }
37    let content = match std::fs::read_to_string(&*jailed) {
38        Ok(c) => c,
39        Err(e) => return format!("Error reading {path_str}: {e}"),
40    };
41
42    let language = detect_language_from_extension(path);
43    let code = build_file_processing_script(&language, &content, intent);
44    let result = sandbox::execute(&language, &code, None);
45    format_result(&result, intent)
46}
47
48/// Executes multiple (language, code) pairs in parallel and returns aggregated results.
49pub fn handle_batch(items: &[(String, String)]) -> String {
50    let results = sandbox::batch_execute(items);
51    let mut output = Vec::new();
52
53    for (i, result) in results.iter().enumerate() {
54        let label = format!("[{}/{}] {}", i + 1, results.len(), result.language);
55        if result.exit_code == 0 {
56            let stdout = result.stdout.trim();
57            if stdout.is_empty() {
58                output.push(format!("{label}: (no output) [{} ms]", result.duration_ms));
59            } else {
60                output.push(format!("{label}: {stdout} [{} ms]", result.duration_ms));
61            }
62        } else {
63            let stderr = result.stderr.trim();
64            output.push(format!(
65                "{label}: EXIT {} — {stderr} [{} ms]",
66                result.exit_code, result.duration_ms
67            ));
68        }
69    }
70
71    let total_ms: u64 = results.iter().map(|r| r.duration_ms).sum();
72    output.push(format!("\n{} tasks, {} ms total", results.len(), total_ms));
73    output.join("\n")
74}
75
76fn format_result(result: &SandboxResult, intent: Option<&str>) -> String {
77    let mut parts = Vec::new();
78
79    if result.exit_code == 0 {
80        let stdout = result.stdout.trim();
81        if stdout.is_empty() {
82            parts.push("(no output)".to_string());
83        } else {
84            let raw_tokens = count_tokens(stdout);
85            parts.push(stdout.to_string());
86
87            if let Some(intent_desc) = intent {
88                if raw_tokens > 50 {
89                    parts.push(format!("[intent: {intent_desc}]"));
90                }
91            }
92        }
93    } else {
94        if !result.stdout.is_empty() {
95            parts.push(result.stdout.trim().to_string());
96        }
97        parts.push(format!(
98            "EXIT {} — {}",
99            result.exit_code,
100            result.stderr.trim()
101        ));
102    }
103
104    parts.push(format!("[{} | {} ms]", result.language, result.duration_ms));
105    parts.join("\n")
106}
107
108fn detect_language_from_extension(path: &str) -> String {
109    let ext = path.rsplit('.').next().unwrap_or("");
110    match ext {
111        "js" | "mjs" | "cjs" => "javascript",
112        "ts" | "mts" | "cts" => "typescript",
113        "py" | "json" | "csv" | "log" | "txt" | "xml" | "yaml" | "yml" | "md" | "html" => "python",
114        "rb" => "ruby",
115        "go" => "go",
116        "rs" => "rust",
117        "php" => "php",
118        "pl" => "perl",
119        "r" | "R" => "r",
120        "ex" | "exs" => "elixir",
121        _ => "shell",
122    }
123    .to_string()
124}
125
126fn sanitize_intent(raw: &str) -> String {
127    raw.chars()
128        .filter(|c| c.is_alphanumeric() || *c == ' ' || *c == '-' || *c == '_' || *c == '.')
129        .take(200)
130        .collect()
131}
132
133/// Escape a path for safe embedding inside a Python raw double-quoted string.
134/// Handles embedded double-quotes which would break `r"..."`.
135fn escape_for_python_raw(path: &str) -> String {
136    path.replace('"', r#"\" + '"' + r""#)
137}
138
139/// Escape a path for safe embedding inside a shell double-quoted string.
140/// Handles `$`, backtick, `\`, and `"` which are special inside double quotes.
141fn escape_for_shell_dq(path: &str) -> String {
142    let mut out = String::with_capacity(path.len());
143    for ch in path.chars() {
144        match ch {
145            '$' | '`' | '"' | '\\' => {
146                out.push('\\');
147                out.push(ch);
148            }
149            _ => out.push(ch),
150        }
151    }
152    out
153}
154
155fn build_file_processing_script(language: &str, content: &str, intent: Option<&str>) -> String {
156    let Ok(tmp) = tempfile::Builder::new()
157        .prefix("lean-ctx-exec-")
158        .suffix(".dat")
159        .tempfile()
160    else {
161        return "echo 'lean-ctx: failed to create temp file'".to_string();
162    };
163    let _ = std::fs::write(tmp.path(), content);
164    let tmp_path = tmp.path().to_string_lossy().to_string();
165    let _keep = tmp.into_temp_path();
166    let intent_str = sanitize_intent(intent.unwrap_or("summarize the content"));
167
168    if language == "python" {
169        let py_path = escape_for_python_raw(&tmp_path);
170        format!(
171            r#"
172    import os
173
174    with open(r"{py_path}", "r", encoding="utf-8") as f:
175        data = f.read()
176    os.remove(r"{py_path}")
177
178    lines = data.strip().split('\n')
179    total_lines = len(lines)
180    total_bytes = len(data.encode('utf-8'))
181
182    word_count = sum(len(line.split()) for line in lines)
183
184    print(f"{{total_lines}} lines, {{total_bytes}} bytes, {{word_count}} words")
185    print("Intent: {intent_str}")
186
187    if total_lines > 10:
188        print(f"First 3: {{lines[:3]}}")
189        print(f"Last 3: {{lines[-3:]}}")
190    "#
191        )
192    } else {
193        let sh_path = escape_for_shell_dq(&tmp_path);
194        format!(
195            r#"
196    data=$(cat "{sh_path}")
197    rm -f "{sh_path}"
198    lines=$(echo "$data" | wc -l | tr -d ' ')
199    bytes=$(echo "$data" | wc -c | tr -d ' ')
200    echo "$lines lines, $bytes bytes"
201    echo 'Intent: {intent_str}'
202    echo "$data" | head -3
203    echo "..."
204    echo "$data" | tail -3
205    "#
206        )
207    }
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    #[test]
215    fn handle_simple_python() {
216        let result = handle("python", "print(2 + 2)", None, None);
217        assert!(result.contains('4'));
218        assert!(result.contains("python"));
219    }
220
221    #[test]
222    fn handle_with_intent() {
223        let result = handle(
224            "python",
225            "print('found 5 errors')",
226            Some("count errors"),
227            None,
228        );
229        assert!(result.contains("found 5 errors"));
230    }
231
232    #[test]
233    fn handle_error_shows_stderr() {
234        let result = handle("python", "raise Exception('boom')", None, None);
235        assert!(result.contains("EXIT"));
236        assert!(result.contains("boom"));
237    }
238
239    #[test]
240    fn detect_language_from_path() {
241        assert_eq!(detect_language_from_extension("test.py"), "python");
242        assert_eq!(detect_language_from_extension("test.js"), "javascript");
243        assert_eq!(detect_language_from_extension("test.rs"), "rust");
244        assert_eq!(detect_language_from_extension("test.csv"), "python");
245        assert_eq!(detect_language_from_extension("test.log"), "python");
246    }
247
248    #[test]
249    fn escape_shell_dq_handles_special_chars() {
250        assert_eq!(escape_for_shell_dq(r"C:\tmp\file"), r"C:\\tmp\\file");
251        assert_eq!(escape_for_shell_dq("/tmp/normal"), "/tmp/normal");
252        assert_eq!(escape_for_shell_dq("path with $VAR"), r"path with \$VAR");
253        assert_eq!(escape_for_shell_dq(r#"path"quote"#), r#"path\"quote"#);
254        assert_eq!(escape_for_shell_dq("has `backtick`"), r"has \`backtick\`");
255    }
256
257    #[test]
258    fn escape_python_raw_handles_quotes() {
259        assert_eq!(escape_for_python_raw("/tmp/normal"), "/tmp/normal");
260        assert_eq!(escape_for_python_raw(r"C:\Users\test"), r"C:\Users\test");
261    }
262
263    #[test]
264    fn script_with_spaces_in_path() {
265        let script = build_file_processing_script("shell", "test data", None);
266        let lines: Vec<&str> = script.lines().collect();
267        for line in &lines {
268            if line.contains("cat ") || line.contains("rm -f") {
269                assert!(
270                    line.contains('"'),
271                    "path must be double-quoted in shell script: {line}"
272                );
273            }
274        }
275    }
276
277    #[test]
278    #[cfg(not(target_os = "windows"))]
279    fn batch_multiple_tasks() {
280        let items = vec![
281            ("python".to_string(), "print('task1')".to_string()),
282            ("shell".to_string(), "echo task2".to_string()),
283        ];
284        let result = handle_batch(&items);
285        assert!(result.contains("task1"));
286        assert!(result.contains("task2"));
287        assert!(result.contains("2 tasks"));
288    }
289}