1use std::collections::HashMap;
6use std::path::{Path, PathBuf};
7use std::process::Command;
8
9use serde::Deserialize;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
13pub enum HookEvent {
14 OnCreate,
16 OnDelete,
18 OnRename,
20 OnCd,
22 OnStart,
24 OnExit,
26}
27
28impl HookEvent {
29 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#[derive(Debug, Clone, Default, Deserialize)]
44#[serde(default)]
45pub struct HooksConfig {
46 pub on_create: Option<String>,
48 pub on_delete: Option<String>,
50 pub on_rename: Option<String>,
52 pub on_cd: Option<String>,
54 pub on_start: Option<String>,
56 pub on_exit: Option<String>,
58}
59
60impl HooksConfig {
61 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#[derive(Debug, Clone, Default)]
76pub struct HookContext {
77 pub path: Option<PathBuf>,
79 pub old_path: Option<PathBuf>,
81 pub dir: Option<PathBuf>,
83 pub selected: Vec<PathBuf>,
85}
86
87pub struct HookExecutor {
89 config: HooksConfig,
90}
91
92impl HookExecutor {
93 pub fn new(config: HooksConfig) -> Self {
95 Self { config }
96 }
97
98 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 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 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 fn build_env(context: &HookContext) -> HashMap<String, String> {
131 let mut env = HashMap::new();
132
133 if let Some(ref path) = context.path {
135 env.insert("FILEVIEW_PATH".to_string(), path.display().to_string());
136 }
137
138 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 if let Some(ref dir) = context.dir {
148 env.insert("FILEVIEW_DIR".to_string(), dir.display().to_string());
149 }
150
151 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 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 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 fn execute_script(script: &str, env: &HashMap<String, String>) -> anyhow::Result<()> {
182 let path = Path::new(script);
183
184 if !path.exists() {
186 return Ok(()); }
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 for (key, value) in env {
201 cmd.env(key, value);
202 }
203
204 let _ = cmd
206 .stdout(std::process::Stdio::null())
207 .stderr(std::process::Stdio::null())
208 .status();
209
210 Ok(())
211 }
212
213 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 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 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 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 if dirs::home_dir().is_some() {
350 assert!(!expanded.starts_with('~'));
351 }
352 }
353}