1use crate::commands::CustomCommand;
4use crate::config::PluginConfig;
5use crate::context::{ContextGuard, PluginContext};
6use crate::error::{PluginError, Result};
7use crate::hooks::{HookEvent, HookResult};
8use std::collections::HashMap;
9use std::path::{Path, PathBuf};
10use std::time::Instant;
11use steel::steel_vm::engine::Engine;
12use tracing::{debug, info, warn};
13
14pub trait PluginSystem {
19 fn initialize(&mut self) -> Result<()>;
21
22 fn load_plugin(&mut self, path: &Path) -> Result<()>;
24
25 fn run_hook(&mut self, event: HookEvent, ctx: &PluginContext) -> Result<HookResult>;
27
28 fn run_command(&mut self, name: &str, args: &[String]) -> Result<i32>;
30
31 fn register_api(&mut self) -> Result<()>;
33
34 fn commands(&self) -> &HashMap<String, CustomCommand>;
36}
37
38pub struct SteelEngine {
40 engine: Engine,
42
43 loaded_plugins: Vec<PathBuf>,
45
46 commands: HashMap<String, CustomCommand>,
48
49 config: PluginConfig,
51}
52
53fn ensure_steel_home() {
61 if let Some(dirs) = directories::BaseDirs::new() {
62 let _ = std::fs::create_dir_all(dirs.data_local_dir());
63 }
64}
65
66impl SteelEngine {
67 pub fn new(config: PluginConfig) -> Self {
69 ensure_steel_home();
70 SteelEngine {
71 engine: Engine::new(),
72 loaded_plugins: Vec::new(),
73 commands: HashMap::new(),
74 config,
75 }
76 }
77
78 pub fn with_defaults() -> Self {
80 Self::new(PluginConfig::new())
81 }
82
83 pub fn config(&self) -> &PluginConfig {
85 &self.config
86 }
87
88 pub fn is_loaded(&self, path: &Path) -> bool {
90 self.loaded_plugins.iter().any(|p| p == path)
91 }
92
93 pub fn loaded_plugins(&self) -> &[PathBuf] {
95 &self.loaded_plugins
96 }
97
98 pub fn eval(&mut self, code: String) -> Result<()> {
100 self.engine
101 .run(code)
102 .map_err(|e| PluginError::runtime("eval", e.to_string()))?;
103 Ok(())
104 }
105
106 fn load_file(&mut self, path: &Path) -> Result<()> {
108 let content = std::fs::read_to_string(path).map_err(|e| {
109 PluginError::io(format!("failed to read plugin file: {}", path.display()), e)
110 })?;
111
112 self.engine.run(content).map_err(|e| {
113 PluginError::load(path.to_path_buf(), format!("Steel evaluation error: {}", e))
114 })?;
115
116 Ok(())
117 }
118
119 fn has_function(&mut self, name: &str) -> bool {
121 engine_has_function(&mut self.engine, name)
122 }
123
124 fn call_function(&mut self, name: &str) -> Result<()> {
126 engine_call_function(&mut self.engine, name)
127 }
128}
129
130fn engine_has_function(engine: &mut Engine, name: &str) -> bool {
132 let check_code = format!("(if (defined? '{}) #t #f)", name);
133 match engine.run(check_code) {
134 Ok(results) => {
135 if let Some(result) = results.into_iter().next() {
136 matches!(result, steel::SteelVal::BoolV(true))
137 } else {
138 false
139 }
140 }
141 Err(_) => false,
142 }
143}
144
145fn engine_call_function(engine: &mut Engine, name: &str) -> Result<()> {
147 let call_code = format!("({})", name);
148 engine
149 .run(call_code)
150 .map_err(|e| PluginError::runtime(name, e.to_string()))?;
151 Ok(())
152}
153
154fn new_initialized_engine() -> Result<Engine> {
156 ensure_steel_home();
157 let mut engine = Engine::new();
158 crate::api::register_all(&mut engine)?;
159 engine
160 .run(include_str!("prelude.scm").to_string())
161 .map_err(|e| PluginError::runtime("prelude", e.to_string()))?;
162 Ok(engine)
163}
164
165fn run_hook_isolated(
171 scripts: &[PathBuf],
172 hook_fn: &str,
173 ctx: &PluginContext,
174 continue_on_error: bool,
175) -> Result<HookResult> {
176 let _guard = ContextGuard::new(ctx.clone());
177 let start = Instant::now();
178 let mut engine = new_initialized_engine()?;
179
180 for path in scripts {
181 let content = std::fs::read_to_string(path).map_err(|e| {
182 PluginError::io(format!("failed to read plugin file: {}", path.display()), e)
183 })?;
184 engine.run(content).map_err(|e| {
185 PluginError::load(path.clone(), format!("Steel evaluation error: {}", e))
186 })?;
187
188 if engine_has_function(&mut engine, hook_fn) {
189 match engine_call_function(&mut engine, hook_fn) {
190 Ok(()) => debug!("Hook {} completed successfully", hook_fn),
191 Err(e) => {
192 warn!("Hook {} failed: {}", hook_fn, e);
193 if !continue_on_error {
194 return Ok(HookResult::failure(start.elapsed(), e.to_string()));
195 }
196 }
197 }
198 }
199 }
200
201 Ok(HookResult::success(start.elapsed()))
202}
203
204impl PluginSystem for SteelEngine {
205 fn initialize(&mut self) -> Result<()> {
206 info!("Initializing Steel plugin engine");
207
208 self.register_api()?;
210
211 let prelude = include_str!("prelude.scm").to_string();
213 self.eval(prelude)?;
214
215 debug!("Steel engine initialized");
216 Ok(())
217 }
218
219 fn load_plugin(&mut self, path: &Path) -> Result<()> {
220 if !path.exists() {
221 return Err(PluginError::not_found(path.to_path_buf()));
222 }
223
224 if self.is_loaded(path) {
225 debug!("Plugin already loaded: {}", path.display());
226 return Ok(());
227 }
228
229 info!("Loading plugin: {}", path.display());
230 self.load_file(path)?;
231 self.loaded_plugins.push(path.to_path_buf());
232
233 Ok(())
234 }
235
236 fn run_hook(&mut self, event: HookEvent, ctx: &PluginContext) -> Result<HookResult> {
237 let scripts: Vec<String> = self.config.scripts_for_hook(event).to_vec();
239
240 if scripts.is_empty() {
241 return Ok(HookResult::skipped());
242 }
243
244 debug!("Running {} hook with {} scripts", event, scripts.len());
245
246 let project_root = ctx.project_root.clone();
247
248 let mut resolved = Vec::with_capacity(scripts.len());
250 for script in &scripts {
251 resolved.push(self.find_script(script, &project_root)?);
252 }
253
254 let timeout = self.config.hook_timeout();
255 let hook_fn = event.scheme_function();
256
257 if timeout.is_zero() {
259 let _guard = ContextGuard::new(ctx.clone());
260 let start = Instant::now();
261
262 for script_path in &resolved {
263 if !self.is_loaded(script_path) {
264 self.load_plugin(script_path)?;
265 }
266
267 if self.has_function(hook_fn) {
268 match self.call_function(hook_fn) {
269 Ok(()) => {
270 debug!("Hook {} completed successfully", hook_fn);
271 }
272 Err(e) => {
273 let duration = start.elapsed();
274 warn!("Hook {} failed: {}", hook_fn, e);
275
276 if !self.config.continue_on_error {
277 return Ok(HookResult::failure(duration, e.to_string()));
278 }
279 }
280 }
281 }
282 }
283
284 return Ok(HookResult::success(start.elapsed()));
285 }
286
287 let (tx, rx) = std::sync::mpsc::channel();
290 let ctx_clone = ctx.clone();
291 let hook_fn = hook_fn.to_string();
292 let continue_on_error = self.config.continue_on_error;
293 let start = Instant::now();
294
295 std::thread::Builder::new()
296 .name("hx-plugin-hook".into())
297 .spawn(move || {
298 let result = run_hook_isolated(&resolved, &hook_fn, &ctx_clone, continue_on_error);
299 let _ = tx.send(result);
300 })
301 .map_err(|e| PluginError::io("failed to spawn hook thread".to_string(), e))?;
302
303 match rx.recv_timeout(timeout) {
304 Ok(result) => result,
305 Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
306 warn!(
307 "{} hook timed out after {}ms; abandoning hook thread",
308 event,
309 timeout.as_millis()
310 );
311 Ok(HookResult::failure(
312 start.elapsed(),
313 format!(
314 "hook timed out after {}ms (configure [plugins].hook_timeout_ms to adjust)",
315 timeout.as_millis()
316 ),
317 ))
318 }
319 Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => Err(PluginError::runtime(
320 event.scheme_function(),
321 "hook thread terminated unexpectedly".to_string(),
322 )),
323 }
324 }
325
326 fn run_command(&mut self, name: &str, args: &[String]) -> Result<i32> {
327 if !self.commands.contains_key(name) {
328 return Err(PluginError::unknown_command(name));
329 }
330
331 let args_list = args
333 .iter()
334 .map(|a| format!("\"{}\"", a.replace('\\', "\\\\").replace('"', "\\\"")))
335 .collect::<Vec<_>>()
336 .join(" ");
337
338 let call_code = format!("(hx/run-command \"{}\" (list {}))", name, args_list);
339
340 match self.engine.run(call_code) {
341 Ok(results) => {
342 if let Some(steel::SteelVal::IntV(code)) = results.into_iter().next() {
344 return Ok(code as i32);
345 }
346 Ok(0)
347 }
348 Err(e) => Err(PluginError::runtime(name, e.to_string())),
349 }
350 }
351
352 fn register_api(&mut self) -> Result<()> {
353 crate::api::register_all(&mut self.engine)?;
356 Ok(())
357 }
358
359 fn commands(&self) -> &HashMap<String, CustomCommand> {
360 &self.commands
361 }
362}
363
364impl SteelEngine {
365 fn find_script(&self, name: &str, project_root: &Path) -> Result<PathBuf> {
367 let paths = self.config.all_paths(project_root);
368
369 for base_path in &paths {
370 let script_path = base_path.join(name);
371 if script_path.exists() {
372 return Ok(script_path);
373 }
374 }
375
376 Err(PluginError::not_found(PathBuf::from(name)))
377 }
378
379 pub fn register_command(&mut self, cmd: CustomCommand) {
381 info!("Registering custom command: {}", cmd.name);
382 self.commands.insert(cmd.name.clone(), cmd);
383 }
384}
385
386#[cfg(test)]
387mod tests {
388 use super::*;
389
390 #[test]
391 fn test_engine_creation() {
392 let engine = SteelEngine::with_defaults();
393 assert!(engine.loaded_plugins().is_empty());
394 assert!(engine.commands().is_empty());
395 }
396}