Skip to main content

fileview/handler/
hooks.rs

1//! Event hooks system
2//!
3//! Executes user-defined scripts in response to file operations and events.
4
5use std::collections::HashMap;
6use std::path::{Path, PathBuf};
7use std::process::Command;
8
9use serde::Deserialize;
10
11/// Hook event types
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
13pub enum HookEvent {
14    /// Triggered after a file is created
15    OnCreate,
16    /// Triggered after a file is deleted
17    OnDelete,
18    /// Triggered after a file is renamed
19    OnRename,
20    /// Triggered when changing directory
21    OnCd,
22    /// Triggered when the application starts
23    OnStart,
24    /// Triggered when the application exits
25    OnExit,
26}
27
28impl HookEvent {
29    /// Get the config key for this hook event
30    pub fn config_key(&self) -> &'static str {
31        match self {
32            HookEvent::OnCreate => "on_create",
33            HookEvent::OnDelete => "on_delete",
34            HookEvent::OnRename => "on_rename",
35            HookEvent::OnCd => "on_cd",
36            HookEvent::OnStart => "on_start",
37            HookEvent::OnExit => "on_exit",
38        }
39    }
40}
41
42/// Hook configuration from config file
43#[derive(Debug, Clone, Default, Deserialize)]
44#[serde(default)]
45pub struct HooksConfig {
46    /// Script to run after file creation
47    pub on_create: Option<String>,
48    /// Script to run after file deletion
49    pub on_delete: Option<String>,
50    /// Script to run after file rename
51    pub on_rename: Option<String>,
52    /// Script to run on directory change
53    pub on_cd: Option<String>,
54    /// Script to run on application start
55    pub on_start: Option<String>,
56    /// Script to run on application exit
57    pub on_exit: Option<String>,
58}
59
60impl HooksConfig {
61    /// Get the script path for a hook event
62    pub fn get(&self, event: HookEvent) -> Option<&str> {
63        match event {
64            HookEvent::OnCreate => self.on_create.as_deref(),
65            HookEvent::OnDelete => self.on_delete.as_deref(),
66            HookEvent::OnRename => self.on_rename.as_deref(),
67            HookEvent::OnCd => self.on_cd.as_deref(),
68            HookEvent::OnStart => self.on_start.as_deref(),
69            HookEvent::OnExit => self.on_exit.as_deref(),
70        }
71    }
72}
73
74/// Context for hook execution
75#[derive(Debug, Clone, Default)]
76pub struct HookContext {
77    /// Target file path
78    pub path: Option<PathBuf>,
79    /// Old path (for rename operations)
80    pub old_path: Option<PathBuf>,
81    /// Current directory
82    pub dir: Option<PathBuf>,
83    /// Selected files (for multi-select operations)
84    pub selected: Vec<PathBuf>,
85}
86
87/// Hook executor
88pub struct HookExecutor {
89    config: HooksConfig,
90}
91
92impl HookExecutor {
93    /// Create a new hook executor with the given configuration
94    pub fn new(config: HooksConfig) -> Self {
95        Self { config }
96    }
97
98    /// Execute a hook if configured
99    ///
100    /// This runs asynchronously (non-blocking) so it doesn't slow down the UI.
101    pub fn execute(&self, event: HookEvent, context: &HookContext) {
102        if let Some(script) = self.config.get(event) {
103            let expanded = Self::expand_script(script, context);
104            Self::run_script_async(&expanded, context);
105        }
106    }
107
108    /// Execute a hook synchronously (blocking)
109    ///
110    /// Use this only when you need to wait for the hook to complete.
111    pub fn execute_sync(&self, event: HookEvent, context: &HookContext) -> anyhow::Result<()> {
112        if let Some(script) = self.config.get(event) {
113            let expanded = Self::expand_script(script, context);
114            Self::run_script_sync(&expanded, context)?;
115        }
116        Ok(())
117    }
118
119    /// Expand path in script (~ to home directory)
120    fn expand_script(script: &str, _context: &HookContext) -> String {
121        if script.starts_with('~') {
122            if let Some(home) = dirs::home_dir() {
123                return script.replacen('~', &home.display().to_string(), 1);
124            }
125        }
126        script.to_string()
127    }
128
129    /// Build environment variables for hook execution
130    fn build_env(context: &HookContext) -> HashMap<String, String> {
131        let mut env = HashMap::new();
132
133        // FILEVIEW_PATH: target file path
134        if let Some(ref path) = context.path {
135            env.insert("FILEVIEW_PATH".to_string(), path.display().to_string());
136        }
137
138        // FILEVIEW_OLD_PATH: old path (for rename)
139        if let Some(ref old_path) = context.old_path {
140            env.insert(
141                "FILEVIEW_OLD_PATH".to_string(),
142                old_path.display().to_string(),
143            );
144        }
145
146        // FILEVIEW_DIR: current directory
147        if let Some(ref dir) = context.dir {
148            env.insert("FILEVIEW_DIR".to_string(), dir.display().to_string());
149        }
150
151        // FILEVIEW_SELECTED: newline-separated list of selected files
152        if !context.selected.is_empty() {
153            let selected: Vec<String> = context
154                .selected
155                .iter()
156                .map(|p| p.display().to_string())
157                .collect();
158            env.insert("FILEVIEW_SELECTED".to_string(), selected.join("\n"));
159        }
160
161        env
162    }
163
164    /// Run a script asynchronously
165    fn run_script_async(script: &str, context: &HookContext) {
166        let script = script.to_string();
167        let env = Self::build_env(context);
168
169        std::thread::spawn(move || {
170            let _ = Self::execute_script(&script, &env);
171        });
172    }
173
174    /// Run a script synchronously
175    fn run_script_sync(script: &str, context: &HookContext) -> anyhow::Result<()> {
176        let env = Self::build_env(context);
177        Self::execute_script(script, &env)
178    }
179
180    /// Execute a script with environment variables
181    fn execute_script(script: &str, env: &HashMap<String, String>) -> anyhow::Result<()> {
182        let path = Path::new(script);
183
184        // Check if script exists and is executable
185        if !path.exists() {
186            return Ok(()); // Silently skip non-existent scripts
187        }
188
189        let mut cmd = if cfg!(target_os = "windows") {
190            let mut c = Command::new("cmd");
191            c.args(["/C", script]);
192            c
193        } else {
194            let mut c = Command::new("sh");
195            c.args(["-c", script]);
196            c
197        };
198
199        // Set environment variables
200        for (key, value) in env {
201            cmd.env(key, value);
202        }
203
204        // Run and ignore output
205        let _ = cmd
206            .stdout(std::process::Stdio::null())
207            .stderr(std::process::Stdio::null())
208            .status();
209
210        Ok(())
211    }
212
213    /// Create a context for a file operation
214    pub fn context_for_file(path: &Path, dir: &Path) -> HookContext {
215        HookContext {
216            path: Some(path.to_path_buf()),
217            old_path: None,
218            dir: Some(dir.to_path_buf()),
219            selected: Vec::new(),
220        }
221    }
222
223    /// Create a context for a rename operation
224    pub fn context_for_rename(old_path: &Path, new_path: &Path, dir: &Path) -> HookContext {
225        HookContext {
226            path: Some(new_path.to_path_buf()),
227            old_path: Some(old_path.to_path_buf()),
228            dir: Some(dir.to_path_buf()),
229            selected: Vec::new(),
230        }
231    }
232
233    /// Create a context for a directory change
234    pub fn context_for_cd(dir: &Path) -> HookContext {
235        HookContext {
236            path: None,
237            old_path: None,
238            dir: Some(dir.to_path_buf()),
239            selected: Vec::new(),
240        }
241    }
242
243    /// Create a context for multi-file operations
244    pub fn context_for_selected(selected: &[PathBuf], dir: &Path) -> HookContext {
245        HookContext {
246            path: selected.first().cloned(),
247            old_path: None,
248            dir: Some(dir.to_path_buf()),
249            selected: selected.to_vec(),
250        }
251    }
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257
258    #[test]
259    fn test_hooks_config_default() {
260        let config = HooksConfig::default();
261        assert!(config.on_create.is_none());
262        assert!(config.on_delete.is_none());
263        assert!(config.on_rename.is_none());
264        assert!(config.on_cd.is_none());
265        assert!(config.on_start.is_none());
266        assert!(config.on_exit.is_none());
267    }
268
269    #[test]
270    fn test_hooks_config_get() {
271        let config = HooksConfig {
272            on_create: Some("/path/to/create.sh".to_string()),
273            on_delete: Some("/path/to/delete.sh".to_string()),
274            ..Default::default()
275        };
276
277        assert_eq!(config.get(HookEvent::OnCreate), Some("/path/to/create.sh"));
278        assert_eq!(config.get(HookEvent::OnDelete), Some("/path/to/delete.sh"));
279        assert_eq!(config.get(HookEvent::OnRename), None);
280    }
281
282    #[test]
283    fn test_hook_context_for_file() {
284        let path = PathBuf::from("/test/file.txt");
285        let dir = PathBuf::from("/test");
286        let context = HookExecutor::context_for_file(&path, &dir);
287
288        assert_eq!(context.path, Some(path));
289        assert_eq!(context.dir, Some(dir));
290        assert!(context.old_path.is_none());
291        assert!(context.selected.is_empty());
292    }
293
294    #[test]
295    fn test_hook_context_for_rename() {
296        let old_path = PathBuf::from("/test/old.txt");
297        let new_path = PathBuf::from("/test/new.txt");
298        let dir = PathBuf::from("/test");
299        let context = HookExecutor::context_for_rename(&old_path, &new_path, &dir);
300
301        assert_eq!(context.path, Some(new_path));
302        assert_eq!(context.old_path, Some(old_path));
303        assert_eq!(context.dir, Some(dir));
304    }
305
306    #[test]
307    fn test_build_env() {
308        let context = HookContext {
309            path: Some(PathBuf::from("/test/file.txt")),
310            old_path: Some(PathBuf::from("/test/old.txt")),
311            dir: Some(PathBuf::from("/test")),
312            selected: vec![PathBuf::from("/test/a.txt"), PathBuf::from("/test/b.txt")],
313        };
314
315        let env = HookExecutor::build_env(&context);
316
317        assert_eq!(
318            env.get("FILEVIEW_PATH"),
319            Some(&"/test/file.txt".to_string())
320        );
321        assert_eq!(
322            env.get("FILEVIEW_OLD_PATH"),
323            Some(&"/test/old.txt".to_string())
324        );
325        assert_eq!(env.get("FILEVIEW_DIR"), Some(&"/test".to_string()));
326        assert_eq!(
327            env.get("FILEVIEW_SELECTED"),
328            Some(&"/test/a.txt\n/test/b.txt".to_string())
329        );
330    }
331
332    #[test]
333    fn test_hook_event_config_key() {
334        assert_eq!(HookEvent::OnCreate.config_key(), "on_create");
335        assert_eq!(HookEvent::OnDelete.config_key(), "on_delete");
336        assert_eq!(HookEvent::OnRename.config_key(), "on_rename");
337        assert_eq!(HookEvent::OnCd.config_key(), "on_cd");
338        assert_eq!(HookEvent::OnStart.config_key(), "on_start");
339        assert_eq!(HookEvent::OnExit.config_key(), "on_exit");
340    }
341
342    #[test]
343    fn test_expand_script_with_tilde() {
344        let script = "~/.config/fileview/hooks/test.sh";
345        let context = HookContext::default();
346        let expanded = HookExecutor::expand_script(script, &context);
347
348        // Should not start with ~ anymore (unless home dir lookup failed)
349        if dirs::home_dir().is_some() {
350            assert!(!expanded.starts_with('~'));
351        }
352    }
353}