1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use std::collections::BTreeMap;
4use std::path::PathBuf;
5use uuid::Uuid;
6
7#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
10pub struct PendingItem {
11 pub id: Uuid,
12 pub source: ItemSource,
13 pub files: Vec<PendingFile>,
14 pub created_at: DateTime<Utc>,
15 pub status: PendingStatus,
16}
17
18#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
19pub enum ItemSource {
20 DragDrop { paths: Vec<PathBuf> },
21 Clipboard { mime_type: String },
22 ShareUrl { url: String, kind: ShareKind },
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
26pub enum ShareKind {
27 WebPage,
28 File,
29 Unknown,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
33pub struct PendingFile {
34 pub path: PathBuf,
35 pub mime_type: String,
36 pub size_bytes: u64,
37 pub thumbnail_path: Option<PathBuf>,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
41pub enum PendingStatus {
42 AwaitingSelection,
43 Selected { action_id: String },
44 Expired,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
50pub struct Task {
51 pub id: Uuid,
52 pub pending_item_id: Uuid,
53 pub action: Action,
54 pub state: TaskState,
55 pub steps: Vec<TaskStep>,
56 pub created_at: DateTime<Utc>,
57 pub started_at: Option<DateTime<Utc>>,
58 pub completed_at: Option<DateTime<Utc>>,
59 pub timeout_seconds: u32,
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
63pub struct Action {
64 pub id: String, pub label: String,
66 pub user_prompt: Option<String>, }
68
69#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
70pub enum TaskState {
71 Queued,
72 Running,
73 Paused,
74 Completed { outcome: TaskOutcome },
75 Cancelled,
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
79pub enum TaskOutcome {
80 Success { outputs: Vec<ActionOutput> },
81 PartialSuccess { succeeded: u32, failed: u32 },
82 Failed { error: String },
83}
84
85#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
86pub struct ActionOutput {
87 pub kind: OutputKind,
88 pub file_path: Option<PathBuf>,
89 pub chat_content: Option<String>,
90}
91
92#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
93pub enum OutputKind {
94 File,
95 ChatMessage,
96 Both,
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
100pub struct TaskStep {
101 pub index: u32,
102 pub label: String,
103 pub state: StepState,
104 pub started_at: Option<DateTime<Utc>>,
105 pub completed_at: Option<DateTime<Utc>>,
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
109pub enum StepState {
110 Pending,
111 Running,
112 Done,
113 Failed,
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
119pub struct TrashEntry {
120 pub id: Uuid,
121 pub original_path: PathBuf,
122 pub trash_path: Option<PathBuf>,
123 pub file_size_bytes: u64,
124 pub created_by_task_id: Uuid,
125 pub created_at: DateTime<Utc>,
126 pub execute_at: DateTime<Utc>,
127 pub retention_until: Option<DateTime<Utc>>,
128 pub status: TrashStatus,
129 pub restore_metadata: RestoreMeta,
130}
131
132#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
133pub enum TrashStatus {
134 PendingDelete,
135 Retained,
136 Expired,
137 Restored,
138 PermDeleted,
139}
140
141#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
142pub struct RestoreMeta {
143 pub owner_uid: u32,
144 pub owner_gid: u32,
145 pub permissions: u32,
146}
147
148#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
149pub enum PermDeleteReason {
150 UserEmpty,
151 UserNow,
152 CapacityEviction,
153}
154
155#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
158#[serde(tag = "event", rename_all = "snake_case")]
159pub enum ActionEvent {
160 ItemIngested {
161 item: PendingItem,
162 },
163 ItemSelected {
164 item_id: Uuid,
165 action: Action,
166 },
167 ItemExpired {
168 item_id: Uuid,
169 },
170 TaskEnqueued {
171 task: Task,
172 },
173 TaskStarted {
174 task_id: Uuid,
175 },
176 TaskStepUpdated {
177 task_id: Uuid,
178 step: TaskStep,
179 },
180 TaskPaused {
181 task_id: Uuid,
182 reason: String,
183 },
184 TaskResumed {
185 task_id: Uuid,
186 },
187 TaskCompleted {
188 task_id: Uuid,
189 outcome: TaskOutcome,
190 },
191 TaskCancelled {
192 task_id: Uuid,
193 },
194 DeletionPending {
195 entry: TrashEntry,
196 },
197 DeletionCancelled {
198 entry_id: Uuid,
199 },
200 TrashCreated {
201 entry: TrashEntry,
202 },
203 TrashRestored {
204 entry_id: Uuid,
205 },
206 TrashExpired {
207 entry_id: Uuid,
208 },
209 TrashPermDeleted {
210 entry_id: Uuid,
211 reason: PermDeleteReason,
212 },
213}
214
215#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
218pub struct FileAction {
219 pub id: String,
220 #[serde(default)]
221 pub label: BTreeMap<String, String>,
222 #[serde(default)]
223 pub description: Option<String>,
224 #[serde(default)]
225 pub mime_types: Vec<String>, }
227
228#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
229pub struct ActionPipelineConfig {
230 #[serde(default)]
231 pub deletion: DeletionConfig,
232 #[serde(default)]
233 pub queue: QueueConfig,
234}
235
236#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
237pub struct DeletionConfig {
238 #[serde(default = "default_true")]
239 pub trash_enabled: bool,
240 #[serde(default = "default_cancel_window")]
241 pub cancel_window_minutes: u32,
242 #[serde(default = "default_retention_days")]
243 pub trash_retention_days: u32,
244 #[serde(default = "default_trash_max_mb")]
245 pub trash_max_mb: u64,
246 #[serde(default = "default_max_batch")]
247 pub max_batch: u32,
248 #[serde(default)]
249 pub auto_permanent_delete: bool, #[serde(default)]
251 pub trusted_paths: Vec<String>,
252}
253
254#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
255pub struct QueueConfig {
256 #[serde(default = "default_max_concurrent")]
257 pub max_concurrent: u32,
258 #[serde(default = "default_timeout_minutes")]
259 pub default_timeout_minutes: u32,
260 #[serde(default = "default_pending_ttl_minutes")]
261 pub pending_item_ttl_minutes: u32,
262}
263
264fn default_true() -> bool {
265 true
266}
267fn default_cancel_window() -> u32 {
268 10
269}
270fn default_retention_days() -> u32 {
271 30
272}
273fn default_trash_max_mb() -> u64 {
274 1024
275}
276fn default_max_batch() -> u32 {
277 50
278}
279fn default_max_concurrent() -> u32 {
280 3
281}
282fn default_timeout_minutes() -> u32 {
283 30
284}
285fn default_pending_ttl_minutes() -> u32 {
286 60
287}
288
289impl Default for ActionPipelineConfig {
290 fn default() -> Self {
291 Self {
292 deletion: DeletionConfig {
293 trash_enabled: true,
294 cancel_window_minutes: 10,
295 trash_retention_days: 30,
296 trash_max_mb: 1024,
297 max_batch: 50,
298 auto_permanent_delete: false,
299 trusted_paths: vec![],
300 },
301 queue: QueueConfig {
302 max_concurrent: 3,
303 default_timeout_minutes: 30,
304 pending_item_ttl_minutes: 60,
305 },
306 }
307 }
308}
309
310impl Default for DeletionConfig {
311 fn default() -> Self {
312 ActionPipelineConfig::default().deletion
313 }
314}
315
316impl Default for QueueConfig {
317 fn default() -> Self {
318 ActionPipelineConfig::default().queue
319 }
320}
321
322impl FileAction {
325 pub fn label_for(&self, locale: &str) -> &str {
328 if let Some(v) = self.label.get(locale) {
329 return v.as_str();
330 }
331 if let Some(prefix) = locale.split('-').next()
332 && let Some(v) = self.label.get(prefix)
333 {
334 return v.as_str();
335 }
336 if let Some(v) = self.label.get("en") {
337 return v.as_str();
338 }
339 self.label
340 .values()
341 .next()
342 .map(|s| s.as_str())
343 .unwrap_or(&self.id)
344 }
345}
346
347#[cfg(test)]
348mod tests {
349 use super::*;
350
351 #[test]
352 fn action_event_serde_roundtrip() {
353 let item = PendingItem {
354 id: Uuid::now_v7(),
355 source: ItemSource::DragDrop {
356 paths: vec![PathBuf::from("/tmp/test.pdf")],
357 },
358 files: vec![PendingFile {
359 path: PathBuf::from("/tmp/test.pdf"),
360 mime_type: "application/pdf".into(),
361 size_bytes: 1024,
362 thumbnail_path: None,
363 }],
364 created_at: Utc::now(),
365 status: PendingStatus::AwaitingSelection,
366 };
367 let event = ActionEvent::ItemIngested { item };
368 let json = serde_json::to_string(&event).unwrap();
369 let back: ActionEvent = serde_json::from_str(&json).unwrap();
370 match back {
371 ActionEvent::ItemIngested { item } => {
372 assert_eq!(item.files[0].mime_type, "application/pdf");
373 }
374 _ => panic!("wrong variant"),
375 }
376 }
377
378 #[test]
379 fn file_action_label_resolution() {
380 let mut labels = BTreeMap::new();
381 labels.insert("zh".into(), "摘要".into());
382 labels.insert("en".into(), "Summarize".into());
383 let fa = FileAction {
384 id: "summarize".into(),
385 label: labels,
386 description: None,
387 mime_types: vec!["text/*".into()],
388 };
389 assert_eq!(fa.label_for("zh-TW"), "摘要"); assert_eq!(fa.label_for("zh-CN"), "摘要"); assert_eq!(fa.label_for("en"), "Summarize");
392 assert_eq!(fa.label_for("ja"), "Summarize"); }
394
395 #[test]
396 fn action_pipeline_config_defaults() {
397 let cfg = ActionPipelineConfig::default();
398 assert_eq!(cfg.deletion.cancel_window_minutes, 10);
399 assert_eq!(cfg.deletion.trash_retention_days, 30);
400 assert_eq!(cfg.queue.max_concurrent, 3);
401 }
402
403 #[test]
404 fn trash_entry_serde_roundtrip() {
405 let entry = TrashEntry {
406 id: Uuid::now_v7(),
407 original_path: PathBuf::from("/tmp/file.txt"),
408 trash_path: None,
409 file_size_bytes: 100,
410 created_by_task_id: Uuid::now_v7(),
411 created_at: Utc::now(),
412 execute_at: Utc::now(),
413 retention_until: None,
414 status: TrashStatus::PendingDelete,
415 restore_metadata: RestoreMeta {
416 owner_uid: 501,
417 owner_gid: 20,
418 permissions: 0o644,
419 },
420 };
421 let json = serde_json::to_string(&entry).unwrap();
422 let back: TrashEntry = serde_json::from_str(&json).unwrap();
423 assert_eq!(back.original_path, PathBuf::from("/tmp/file.txt"));
424 assert_eq!(back.status, TrashStatus::PendingDelete);
425 }
426
427 #[test]
428 fn deletion_config_yaml_roundtrip() {
429 let yaml = r#"
430trash_enabled: true
431cancel_window_minutes: 5
432trash_retention_days: 14
433trash_max_mb: 512
434max_batch: 25
435auto_permanent_delete: false
436trusted_paths: []
437"#;
438 let cfg: DeletionConfig = serde_yaml_ng::from_str(yaml).unwrap();
439 assert_eq!(cfg.cancel_window_minutes, 5);
440 assert_eq!(cfg.trash_retention_days, 14);
441 let out = serde_yaml_ng::to_string(&cfg).unwrap();
442 let back: DeletionConfig = serde_yaml_ng::from_str(&out).unwrap();
443 assert_eq!(back.cancel_window_minutes, 5);
444 }
445
446 #[test]
447 fn queue_config_defaults_deserialize() {
448 let yaml =
449 "max_concurrent: 5\ndefault_timeout_minutes: 60\npending_item_ttl_minutes: 120\n";
450 let cfg: QueueConfig = serde_yaml_ng::from_str(yaml).unwrap();
451 assert_eq!(cfg.max_concurrent, 5);
452 assert_eq!(cfg.default_timeout_minutes, 60);
453 assert_eq!(cfg.pending_item_ttl_minutes, 120);
454 }
455}