use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::PathBuf;
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct PendingItem {
pub id: Uuid,
pub source: ItemSource,
pub files: Vec<PendingFile>,
pub created_at: DateTime<Utc>,
pub status: PendingStatus,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum ItemSource {
DragDrop { paths: Vec<PathBuf> },
Clipboard { mime_type: String },
ShareUrl { url: String, kind: ShareKind },
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum ShareKind {
WebPage,
File,
Unknown,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct PendingFile {
pub path: PathBuf,
pub mime_type: String,
pub size_bytes: u64,
pub thumbnail_path: Option<PathBuf>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum PendingStatus {
AwaitingSelection,
Selected { action_id: String },
Expired,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Task {
pub id: Uuid,
pub pending_item_id: Uuid,
pub action: Action,
pub state: TaskState,
pub steps: Vec<TaskStep>,
pub created_at: DateTime<Utc>,
pub started_at: Option<DateTime<Utc>>,
pub completed_at: Option<DateTime<Utc>>,
pub timeout_seconds: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Action {
pub id: String, pub label: String,
pub user_prompt: Option<String>, }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum TaskState {
Queued,
Running,
Paused,
Completed { outcome: TaskOutcome },
Cancelled,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum TaskOutcome {
Success { outputs: Vec<ActionOutput> },
PartialSuccess { succeeded: u32, failed: u32 },
Failed { error: String },
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ActionOutput {
pub kind: OutputKind,
pub file_path: Option<PathBuf>,
pub chat_content: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum OutputKind {
File,
ChatMessage,
Both,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct TaskStep {
pub index: u32,
pub label: String,
pub state: StepState,
pub started_at: Option<DateTime<Utc>>,
pub completed_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum StepState {
Pending,
Running,
Done,
Failed,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct TrashEntry {
pub id: Uuid,
pub original_path: PathBuf,
pub trash_path: Option<PathBuf>,
pub file_size_bytes: u64,
pub created_by_task_id: Uuid,
pub created_at: DateTime<Utc>,
pub execute_at: DateTime<Utc>,
pub retention_until: Option<DateTime<Utc>>,
pub status: TrashStatus,
pub restore_metadata: RestoreMeta,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum TrashStatus {
PendingDelete,
Retained,
Expired,
Restored,
PermDeleted,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct RestoreMeta {
pub owner_uid: u32,
pub owner_gid: u32,
pub permissions: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum PermDeleteReason {
UserEmpty,
UserNow,
CapacityEviction,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "event", rename_all = "snake_case")]
pub enum ActionEvent {
ItemIngested {
item: PendingItem,
},
ItemSelected {
item_id: Uuid,
action: Action,
},
ItemExpired {
item_id: Uuid,
},
TaskEnqueued {
task: Task,
},
TaskStarted {
task_id: Uuid,
},
TaskStepUpdated {
task_id: Uuid,
step: TaskStep,
},
TaskPaused {
task_id: Uuid,
reason: String,
},
TaskResumed {
task_id: Uuid,
},
TaskCompleted {
task_id: Uuid,
outcome: TaskOutcome,
},
TaskCancelled {
task_id: Uuid,
},
DeletionPending {
entry: TrashEntry,
},
DeletionCancelled {
entry_id: Uuid,
},
TrashCreated {
entry: TrashEntry,
},
TrashRestored {
entry_id: Uuid,
},
TrashExpired {
entry_id: Uuid,
},
TrashPermDeleted {
entry_id: Uuid,
reason: PermDeleteReason,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct FileAction {
pub id: String,
#[serde(default)]
pub label: BTreeMap<String, String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub mime_types: Vec<String>, }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ActionPipelineConfig {
#[serde(default)]
pub deletion: DeletionConfig,
#[serde(default)]
pub queue: QueueConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct DeletionConfig {
#[serde(default = "default_true")]
pub trash_enabled: bool,
#[serde(default = "default_cancel_window")]
pub cancel_window_minutes: u32,
#[serde(default = "default_retention_days")]
pub trash_retention_days: u32,
#[serde(default = "default_trash_max_mb")]
pub trash_max_mb: u64,
#[serde(default = "default_max_batch")]
pub max_batch: u32,
#[serde(default)]
pub auto_permanent_delete: bool, #[serde(default)]
pub trusted_paths: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct QueueConfig {
#[serde(default = "default_max_concurrent")]
pub max_concurrent: u32,
#[serde(default = "default_timeout_minutes")]
pub default_timeout_minutes: u32,
#[serde(default = "default_pending_ttl_minutes")]
pub pending_item_ttl_minutes: u32,
}
fn default_true() -> bool {
true
}
fn default_cancel_window() -> u32 {
10
}
fn default_retention_days() -> u32 {
30
}
fn default_trash_max_mb() -> u64 {
1024
}
fn default_max_batch() -> u32 {
50
}
fn default_max_concurrent() -> u32 {
3
}
fn default_timeout_minutes() -> u32 {
30
}
fn default_pending_ttl_minutes() -> u32 {
60
}
impl Default for ActionPipelineConfig {
fn default() -> Self {
Self {
deletion: DeletionConfig {
trash_enabled: true,
cancel_window_minutes: 10,
trash_retention_days: 30,
trash_max_mb: 1024,
max_batch: 50,
auto_permanent_delete: false,
trusted_paths: vec![],
},
queue: QueueConfig {
max_concurrent: 3,
default_timeout_minutes: 30,
pending_item_ttl_minutes: 60,
},
}
}
}
impl Default for DeletionConfig {
fn default() -> Self {
ActionPipelineConfig::default().deletion
}
}
impl Default for QueueConfig {
fn default() -> Self {
ActionPipelineConfig::default().queue
}
}
impl FileAction {
pub fn label_for(&self, locale: &str) -> &str {
if let Some(v) = self.label.get(locale) {
return v.as_str();
}
if let Some(prefix) = locale.split('-').next()
&& let Some(v) = self.label.get(prefix)
{
return v.as_str();
}
if let Some(v) = self.label.get("en") {
return v.as_str();
}
self.label
.values()
.next()
.map(|s| s.as_str())
.unwrap_or(&self.id)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn action_event_serde_roundtrip() {
let item = PendingItem {
id: Uuid::now_v7(),
source: ItemSource::DragDrop {
paths: vec![PathBuf::from("/tmp/test.pdf")],
},
files: vec![PendingFile {
path: PathBuf::from("/tmp/test.pdf"),
mime_type: "application/pdf".into(),
size_bytes: 1024,
thumbnail_path: None,
}],
created_at: Utc::now(),
status: PendingStatus::AwaitingSelection,
};
let event = ActionEvent::ItemIngested { item };
let json = serde_json::to_string(&event).unwrap();
let back: ActionEvent = serde_json::from_str(&json).unwrap();
match back {
ActionEvent::ItemIngested { item } => {
assert_eq!(item.files[0].mime_type, "application/pdf");
}
_ => panic!("wrong variant"),
}
}
#[test]
fn file_action_label_resolution() {
let mut labels = BTreeMap::new();
labels.insert("zh".into(), "摘要".into());
labels.insert("en".into(), "Summarize".into());
let fa = FileAction {
id: "summarize".into(),
label: labels,
description: None,
mime_types: vec!["text/*".into()],
};
assert_eq!(fa.label_for("zh-TW"), "摘要"); assert_eq!(fa.label_for("zh-CN"), "摘要"); assert_eq!(fa.label_for("en"), "Summarize");
assert_eq!(fa.label_for("ja"), "Summarize"); }
#[test]
fn action_pipeline_config_defaults() {
let cfg = ActionPipelineConfig::default();
assert_eq!(cfg.deletion.cancel_window_minutes, 10);
assert_eq!(cfg.deletion.trash_retention_days, 30);
assert_eq!(cfg.queue.max_concurrent, 3);
}
#[test]
fn trash_entry_serde_roundtrip() {
let entry = TrashEntry {
id: Uuid::now_v7(),
original_path: PathBuf::from("/tmp/file.txt"),
trash_path: None,
file_size_bytes: 100,
created_by_task_id: Uuid::now_v7(),
created_at: Utc::now(),
execute_at: Utc::now(),
retention_until: None,
status: TrashStatus::PendingDelete,
restore_metadata: RestoreMeta {
owner_uid: 501,
owner_gid: 20,
permissions: 0o644,
},
};
let json = serde_json::to_string(&entry).unwrap();
let back: TrashEntry = serde_json::from_str(&json).unwrap();
assert_eq!(back.original_path, PathBuf::from("/tmp/file.txt"));
assert_eq!(back.status, TrashStatus::PendingDelete);
}
#[test]
fn deletion_config_yaml_roundtrip() {
let yaml = r#"
trash_enabled: true
cancel_window_minutes: 5
trash_retention_days: 14
trash_max_mb: 512
max_batch: 25
auto_permanent_delete: false
trusted_paths: []
"#;
let cfg: DeletionConfig = serde_yaml_ng::from_str(yaml).unwrap();
assert_eq!(cfg.cancel_window_minutes, 5);
assert_eq!(cfg.trash_retention_days, 14);
let out = serde_yaml_ng::to_string(&cfg).unwrap();
let back: DeletionConfig = serde_yaml_ng::from_str(&out).unwrap();
assert_eq!(back.cancel_window_minutes, 5);
}
#[test]
fn queue_config_defaults_deserialize() {
let yaml =
"max_concurrent: 5\ndefault_timeout_minutes: 60\npending_item_ttl_minutes: 120\n";
let cfg: QueueConfig = serde_yaml_ng::from_str(yaml).unwrap();
assert_eq!(cfg.max_concurrent, 5);
assert_eq!(cfg.default_timeout_minutes, 60);
assert_eq!(cfg.pending_item_ttl_minutes, 120);
}
}