1use crate::schema::node_col;
21use arrow::array::{Array, RecordBatch, StringArray};
22use std::process::Command;
23use std::time::Duration;
24
25#[derive(Debug, thiserror::Error)]
27pub enum ExecutorError {
28 #[error("Node not found: {0}")]
29 NodeNotFound(String),
30
31 #[error("Node has no body (body_hash is null): {0}")]
32 NoBody(String),
33
34 #[error("Node kind '{kind}' is not executable (only function/method supported): {id}")]
35 NotExecutable { id: String, kind: String },
36
37 #[error("Execution timed out after {0:?}")]
38 Timeout(Duration),
39
40 #[error("Execution failed: {0}")]
41 ExecutionFailed(String),
42
43 #[error("IO error: {0}")]
44 Io(#[from] std::io::Error),
45}
46
47pub type Result<T> = std::result::Result<T, ExecutorError>;
48
49#[derive(Debug, Clone)]
51pub struct ExecutionResult {
52 pub node_id: String,
54 pub stdout: String,
56 pub stderr: String,
58 pub exit_code: i32,
60 pub completed: bool,
62}
63
64const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
66
67pub fn execute_object(
94 nodes_batch: &RecordBatch,
95 node_id: &str,
96 call_args: &str,
97 timeout: Option<Duration>,
98) -> Result<ExecutionResult> {
99 let timeout = timeout.unwrap_or(DEFAULT_TIMEOUT);
100
101 let ids = nodes_batch
102 .column(node_col::ID)
103 .as_any()
104 .downcast_ref::<StringArray>()
105 .expect("id column");
106 let names = nodes_batch
107 .column(node_col::NAME)
108 .as_any()
109 .downcast_ref::<StringArray>()
110 .expect("name column");
111 let signatures = nodes_batch
112 .column(node_col::SIGNATURE)
113 .as_any()
114 .downcast_ref::<StringArray>()
115 .expect("signature column");
116 let body_hashes = nodes_batch
117 .column(node_col::BODY_HASH)
118 .as_any()
119 .downcast_ref::<StringArray>()
120 .expect("body_hash column");
121
122 let kind_col = nodes_batch.column(node_col::KIND);
124 let kind_dict = kind_col
125 .as_any()
126 .downcast_ref::<arrow::array::Int8DictionaryArray>()
127 .expect("kind dict");
128 let kind_values = kind_dict
129 .values()
130 .as_any()
131 .downcast_ref::<StringArray>()
132 .expect("kind values");
133
134 let row_idx = (0..nodes_batch.num_rows())
136 .find(|&i| ids.value(i) == node_id)
137 .ok_or_else(|| ExecutorError::NodeNotFound(node_id.to_string()))?;
138
139 let kind_key = kind_dict.keys().value(row_idx) as usize;
141 let kind_str = kind_values.value(kind_key);
142 if kind_str != "function" && kind_str != "method" && kind_str != "test" {
143 return Err(ExecutorError::NotExecutable {
144 id: node_id.to_string(),
145 kind: kind_str.to_string(),
146 });
147 }
148
149 if body_hashes.is_null(row_idx) {
151 return Err(ExecutorError::NoBody(node_id.to_string()));
152 }
153
154 let name = names.value(row_idx);
155 let signature = if signatures.is_null(row_idx) {
156 format!("def {}():", name)
157 } else {
158 format!("{}:", signatures.value(row_idx))
159 };
160
161 let timeout_secs = timeout.as_secs().max(1);
169 let python_code = format!(
170 "import signal\nsignal.alarm({})\n{}\n pass\n\nresult = {}({})\nif result is not None:\n print(result)",
171 timeout_secs, signature, name, call_args
172 );
173
174 let output = Command::new("python3")
175 .arg("-c")
176 .arg(&python_code)
177 .output()?;
178
179 let exit_code = output.status.code().unwrap_or(-1);
181 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
182 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
183
184 if exit_code == -1 && stderr.contains("AlarmError") {
186 return Err(ExecutorError::Timeout(timeout));
187 }
188
189 Ok(ExecutionResult {
190 node_id: node_id.to_string(),
191 stdout,
192 stderr,
193 exit_code,
194 completed: true,
195 })
196}
197
198#[cfg(test)]
199mod tests {
200 use super::*;
201 use crate::schema::{CodeNode, CodeNodeKind, build_code_nodes_batch};
202
203 fn sample_nodes() -> Vec<CodeNode> {
204 vec![
205 CodeNode {
206 id: "func:math.py::add".into(),
207 kind: CodeNodeKind::Function,
208 parent_id: None,
209 name: "add".into(),
210 signature: Some("def add(a, b)".into()),
211 docstring: None,
212 body_hash: Some("hash_add".into()),
213 body: None,
214 loc: Some(3),
215 cyclomatic_complexity: Some(1),
216 coverage_pct: None,
217 last_modified: None,
218 ..Default::default()
219 },
220 CodeNode {
221 id: "class:store.py::Store".into(),
222 kind: CodeNodeKind::Class,
223 parent_id: None,
224 name: "Store".into(),
225 signature: None,
226 docstring: None,
227 body_hash: Some("hash_store".into()),
228 body: None,
229 loc: Some(50),
230 cyclomatic_complexity: None,
231 coverage_pct: None,
232 last_modified: None,
233 ..Default::default()
234 },
235 CodeNode {
236 id: "func:math.py::no_body".into(),
237 kind: CodeNodeKind::Function,
238 parent_id: None,
239 name: "no_body".into(),
240 signature: Some("def no_body()".into()),
241 docstring: None,
242 body_hash: None, body: None,
244 loc: None,
245 cyclomatic_complexity: None,
246 coverage_pct: None,
247 last_modified: None,
248 ..Default::default()
249 },
250 ]
251 }
252
253 #[test]
254 fn test_execute_function() {
255 let batch = build_code_nodes_batch(&sample_nodes()).unwrap();
256 let result = execute_object(&batch, "func:math.py::add", "1, 2", None).unwrap();
258 assert_eq!(result.node_id, "func:math.py::add");
259 assert!(result.completed);
260 assert_eq!(result.exit_code, 0);
261 }
262
263 #[test]
264 fn test_execute_not_found() {
265 let batch = build_code_nodes_batch(&sample_nodes()).unwrap();
266 let result = execute_object(&batch, "func:nonexistent::foo", "", None);
267 assert!(result.is_err());
268 assert!(matches!(
269 result.unwrap_err(),
270 ExecutorError::NodeNotFound(_)
271 ));
272 }
273
274 #[test]
275 fn test_execute_non_executable_kind() {
276 let batch = build_code_nodes_batch(&sample_nodes()).unwrap();
277 let result = execute_object(&batch, "class:store.py::Store", "", None);
278 assert!(result.is_err());
279 assert!(matches!(
280 result.unwrap_err(),
281 ExecutorError::NotExecutable { .. }
282 ));
283 }
284
285 #[test]
286 fn test_execute_no_body() {
287 let batch = build_code_nodes_batch(&sample_nodes()).unwrap();
288 let result = execute_object(&batch, "func:math.py::no_body", "", None);
289 assert!(result.is_err());
290 assert!(matches!(result.unwrap_err(), ExecutorError::NoBody(_)));
291 }
292
293 #[test]
294 fn test_execution_result_captures_stderr() {
295 let batch = build_code_nodes_batch(&sample_nodes()).unwrap();
296 let result = execute_object(&batch, "func:math.py::add", "", None).unwrap();
298 assert!(result.completed);
299 assert_ne!(result.exit_code, 0);
301 assert!(!result.stderr.is_empty());
302 assert!(result.stderr.contains("TypeError"));
303 }
304}