Skip to main content

mur_common/
action.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use std::collections::BTreeMap;
4use std::path::PathBuf;
5use uuid::Uuid;
6
7// ── Phase 1: Ingestion ──
8
9#[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// ── Phase 2: Task Queue ──
48
49#[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, // matches file_actions[].id
65    pub label: String,
66    pub user_prompt: Option<String>, // filled for ask_me
67}
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// ── Phase 3: Deletion Safety ──
117
118#[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// ── Shared Ledger Events ──
156
157#[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// ── Profile Schema Types ──
216
217#[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>, // empty ⇒ matches any
226}
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, // MUST stay false
250    #[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
322// ── BCP-47 label resolution ──
323
324impl FileAction {
325    /// Resolve label for a given BCP-47 locale string (e.g. "zh-TW").
326    /// Falls back: exact match → language prefix → "en" → first entry → id.
327    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"), "摘要"); // exact match on "zh" prefix
390        assert_eq!(fa.label_for("zh-CN"), "摘要"); // prefix fallback: zh-CN → zh
391        assert_eq!(fa.label_for("en"), "Summarize");
392        assert_eq!(fa.label_for("ja"), "Summarize"); // fallback to en
393    }
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}