1use std::collections::HashMap;
9use std::path::PathBuf;
10
11use serde::{Deserialize, Serialize};
12
13use crate::types::Value;
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
17#[serde(tag = "type", rename_all = "snake_case")]
18pub enum Hook {
19 OnNavigate {
22 from: PathBuf,
24 to: PathBuf,
26 },
27
28 OnScanStart {
31 path: PathBuf,
33 },
34
35 OnScanComplete {
37 path: PathBuf,
39 total_files: u64,
41 total_dirs: u64,
43 total_size: u64,
45 },
46
47 OnScanError {
49 path: PathBuf,
51 error: String,
53 },
54
55 OnDeleteStart {
58 items: Vec<PathBuf>,
60 use_trash: bool,
62 },
63
64 OnDeleteComplete {
66 deleted: usize,
68 failed: usize,
70 bytes_freed: u64,
72 },
73
74 OnCopyComplete {
76 succeeded: usize,
78 failed: usize,
80 bytes_copied: u64,
82 },
83
84 OnMoveComplete {
86 succeeded: usize,
88 failed: usize,
90 },
91
92 OnRenameComplete {
94 source: PathBuf,
96 new_path: PathBuf,
98 },
99
100 OnDuplicatesFound {
103 group_count: usize,
105 wasted_bytes: u64,
107 },
108
109 OnAgeAnalysisComplete {
111 stale_dirs: usize,
113 files: u64,
115 average_age_secs: u64,
117 },
118
119 OnStartup,
122
123 OnShutdown,
125}
126
127impl Hook {
128 pub fn name(&self) -> &'static str {
130 match self {
131 Self::OnNavigate { .. } => "on_navigate",
132 Self::OnScanStart { .. } => "on_scan_start",
133 Self::OnScanComplete { .. } => "on_scan_complete",
134 Self::OnScanError { .. } => "on_scan_error",
135 Self::OnDeleteStart { .. } => "on_delete_start",
136 Self::OnDeleteComplete { .. } => "on_delete_complete",
137 Self::OnCopyComplete { .. } => "on_copy_complete",
138 Self::OnMoveComplete { .. } => "on_move_complete",
139 Self::OnRenameComplete { .. } => "on_rename_complete",
140 Self::OnDuplicatesFound { .. } => "on_duplicates_found",
141 Self::OnAgeAnalysisComplete { .. } => "on_age_analysis_complete",
142 Self::OnStartup => "on_startup",
143 Self::OnShutdown => "on_shutdown",
144 }
145 }
146}
147
148#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
150#[serde(rename_all = "lowercase")]
151pub enum NoticeLevel {
152 #[default]
153 Info,
154 Warn,
155 Error,
156}
157
158impl NoticeLevel {
159 pub fn parse(name: &str) -> Self {
161 match name {
162 "warn" | "warning" => Self::Warn,
163 "error" => Self::Error,
164 _ => Self::Info,
165 }
166 }
167}
168
169#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
171pub struct Notice {
172 pub level: NoticeLevel,
173 pub message: String,
174}
175
176impl Notice {
177 pub const MAX_PER_CALL: usize = 8;
179 pub const MAX_BYTES: usize = 500;
181
182 pub fn new(level: NoticeLevel, message: &str) -> Self {
184 let mut end = message.len().min(Self::MAX_BYTES);
185 while !message.is_char_boundary(end) {
186 end -= 1;
187 }
188 Self {
189 level,
190 message: message[..end].to_string(),
191 }
192 }
193}
194
195#[derive(Debug, Clone, Default)]
197pub struct HookContext {
198 pub data: HashMap<String, Value>,
200
201 pub cwd: Option<PathBuf>,
203
204 pub view_root: Option<PathBuf>,
206
207 pub theme: Option<String>,
209}
210
211impl HookContext {
212 pub fn new() -> Self {
214 Self::default()
215 }
216
217 pub fn set(&mut self, key: impl Into<String>, value: impl Into<Value>) -> &mut Self {
219 self.data.insert(key.into(), value.into());
220 self
221 }
222
223 pub fn get(&self, key: &str) -> Option<&Value> {
225 self.data.get(key)
226 }
227
228 pub fn with_cwd(mut self, cwd: PathBuf) -> Self {
230 self.cwd = Some(cwd);
231 self
232 }
233
234 pub fn with_view_root(mut self, view_root: PathBuf) -> Self {
236 self.view_root = Some(view_root);
237 self
238 }
239
240 pub fn with_theme(mut self, theme: impl Into<String>) -> Self {
242 self.theme = Some(theme.into());
243 self
244 }
245}
246
247#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
249pub struct HookResult {
250 pub handled: bool,
252
253 pub prevent_default: bool,
255
256 pub stop_propagation: bool,
258
259 pub value: Option<Value>,
261
262 pub error: Option<String>,
264
265 #[serde(default)]
267 pub notices: Vec<Notice>,
268
269 #[serde(default)]
271 pub plugin: Option<String>,
272}
273
274impl HookResult {
275 pub fn ok() -> Self {
277 Self {
278 handled: true,
279 ..Default::default()
280 }
281 }
282
283 pub fn with_value(value: impl Into<Value>) -> Self {
285 Self {
286 handled: true,
287 value: Some(value.into()),
288 ..Default::default()
289 }
290 }
291
292 pub fn error(message: impl Into<String>) -> Self {
294 Self {
295 handled: true,
296 error: Some(message.into()),
297 ..Default::default()
298 }
299 }
300
301 pub fn prevent_default(mut self) -> Self {
303 self.prevent_default = true;
304 self
305 }
306
307 pub fn stop_propagation(mut self) -> Self {
309 self.stop_propagation = true;
310 self
311 }
312
313 pub fn is_ok(&self) -> bool {
315 self.error.is_none()
316 }
317
318 pub fn is_err(&self) -> bool {
320 self.error.is_some()
321 }
322}
323
324#[cfg(test)]
325mod tests {
326 use super::*;
327
328 fn every_hook() -> Vec<Hook> {
331 let all = vec![
332 Hook::OnNavigate {
333 from: PathBuf::new(),
334 to: PathBuf::new(),
335 },
336 Hook::OnScanStart {
337 path: PathBuf::new(),
338 },
339 Hook::OnScanComplete {
340 path: PathBuf::new(),
341 total_files: 0,
342 total_dirs: 0,
343 total_size: 0,
344 },
345 Hook::OnScanError {
346 path: PathBuf::new(),
347 error: String::new(),
348 },
349 Hook::OnDeleteStart {
350 items: Vec::new(),
351 use_trash: true,
352 },
353 Hook::OnDeleteComplete {
354 deleted: 0,
355 failed: 0,
356 bytes_freed: 0,
357 },
358 Hook::OnCopyComplete {
359 succeeded: 0,
360 failed: 0,
361 bytes_copied: 0,
362 },
363 Hook::OnMoveComplete {
364 succeeded: 0,
365 failed: 0,
366 },
367 Hook::OnRenameComplete {
368 source: PathBuf::new(),
369 new_path: PathBuf::new(),
370 },
371 Hook::OnDuplicatesFound {
372 group_count: 0,
373 wasted_bytes: 0,
374 },
375 Hook::OnAgeAnalysisComplete {
376 stale_dirs: 0,
377 files: 0,
378 average_age_secs: 0,
379 },
380 Hook::OnStartup,
381 Hook::OnShutdown,
382 ];
383 for hook in &all {
384 match hook {
385 Hook::OnNavigate { .. }
386 | Hook::OnScanStart { .. }
387 | Hook::OnScanComplete { .. }
388 | Hook::OnScanError { .. }
389 | Hook::OnDeleteStart { .. }
390 | Hook::OnDeleteComplete { .. }
391 | Hook::OnCopyComplete { .. }
392 | Hook::OnMoveComplete { .. }
393 | Hook::OnRenameComplete { .. }
394 | Hook::OnDuplicatesFound { .. }
395 | Hook::OnAgeAnalysisComplete { .. }
396 | Hook::OnStartup
397 | Hook::OnShutdown => {}
398 }
399 }
400 all
401 }
402
403 #[test]
404 fn plugin_discovery_knows_every_hook_and_nothing_else() {
405 let names: Vec<&str> = every_hook().iter().map(Hook::name).collect();
406 assert_eq!(names, crate::runtime::HOOK_NAMES);
407 }
408
409 #[test]
410 fn long_notices_are_cut_at_a_character_boundary() {
411 let notice = Notice::new(NoticeLevel::Info, &"é".repeat(Notice::MAX_BYTES));
412 assert!(notice.message.len() <= Notice::MAX_BYTES);
413 assert!(notice.message.chars().all(|c| c == 'é'));
414 assert_eq!(NoticeLevel::parse("warning"), NoticeLevel::Warn);
415 assert_eq!(NoticeLevel::parse("loud"), NoticeLevel::Info);
416 }
417}