Skip to main content

eoka_protocol/
download.rs

1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
4pub struct DownloadStartArgs {
5    pub click: Option<String>,
6    pub url: Option<String>,
7    #[schemars(
8        description = "Absolute output filename. Must not exist; its parent directory must exist."
9    )]
10    pub output: String,
11    pub expect_bytes: Option<u64>,
12    #[serde(default = "default_download_timeout")]
13    #[schemars(range(min = 1, max = 300000))]
14    pub start_timeout_ms: u64,
15}
16
17impl DownloadStartArgs {
18    pub fn validate(&self) -> Result<(), String> {
19        if self.click.is_some() == self.url.is_some() {
20            return Err("Exactly one of click or url is required".into());
21        }
22        if self
23            .click
24            .as_ref()
25            .is_some_and(|value| value.trim().is_empty())
26        {
27            return Err("Click target must not be empty".into());
28        }
29        if !std::path::Path::new(&self.output).is_absolute() {
30            return Err("Download output must be an absolute filename".into());
31        }
32        if self.start_timeout_ms == 0 || self.start_timeout_ms > 300_000 {
33            return Err("start_timeout_ms must be between 1 and 300000".into());
34        }
35        Ok(())
36    }
37}
38
39pub fn default_download_timeout() -> u64 {
40    30_000
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
44pub struct DownloadIdArgs {
45    pub id: String,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
49pub struct DownloadWaitArgs {
50    pub id: String,
51    #[serde(default = "default_download_timeout")]
52    #[schemars(range(min = 1, max = 86400000))]
53    pub timeout_ms: u64,
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
57#[serde(rename_all = "snake_case")]
58pub enum DownloadState {
59    Pending,
60    Downloading,
61    Verifying,
62    Completed,
63    Canceled,
64    Failed,
65}
66
67impl DownloadState {
68    pub fn is_terminal(self) -> bool {
69        matches!(self, Self::Completed | Self::Canceled | Self::Failed)
70    }
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
74pub struct DownloadRecord {
75    pub id: String,
76    pub tab_id: String,
77    pub state: DownloadState,
78    pub output: String,
79    pub published_path: Option<String>,
80    pub staging_path: Option<String>,
81    pub received_bytes: u64,
82    pub total_bytes: Option<u64>,
83    pub suggested_filename: Option<String>,
84    pub source_url: Option<String>,
85    pub created_at_ms: u64,
86    pub updated_at_ms: u64,
87    pub error: Option<crate::ErrorDetail>,
88}
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93
94    #[test]
95    fn download_inputs_reject_ambiguous_triggers_and_relative_paths() {
96        let mut args = DownloadStartArgs {
97            click: Some("@e1".into()),
98            url: None,
99            output: "/tmp/file".into(),
100            expect_bytes: None,
101            start_timeout_ms: 30_000,
102        };
103        assert!(args.validate().is_ok());
104        args.url = Some("https://example.com/file".into());
105        assert!(args.validate().is_err());
106        args.url = None;
107        args.output = "relative".into();
108        assert!(args.validate().is_err());
109    }
110}