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    #[serde(default)]
6    pub operation_id: Option<String>,
7    pub click: Option<String>,
8    pub url: Option<String>,
9    #[schemars(
10        description = "Absolute output filename. Must not exist; its parent directory must exist."
11    )]
12    pub output: String,
13    pub expect_bytes: Option<u64>,
14    #[serde(default = "default_download_timeout")]
15    #[schemars(range(min = 1, max = 300000))]
16    pub start_timeout_ms: u64,
17}
18
19impl DownloadStartArgs {
20    pub fn validate(&self) -> Result<(), String> {
21        if self.click.is_some() == self.url.is_some() {
22            return Err("Exactly one of click or url is required".into());
23        }
24        if self
25            .click
26            .as_ref()
27            .is_some_and(|value| value.trim().is_empty())
28        {
29            return Err("Click target must not be empty".into());
30        }
31        if !std::path::Path::new(&self.output).is_absolute() {
32            return Err("Download output must be an absolute filename".into());
33        }
34        if self.start_timeout_ms == 0 || self.start_timeout_ms > 300_000 {
35            return Err("start_timeout_ms must be between 1 and 300000".into());
36        }
37        Ok(())
38    }
39}
40
41pub fn default_download_timeout() -> u64 {
42    30_000
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
46pub struct DownloadIdArgs {
47    pub id: String,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
51pub struct DownloadWaitArgs {
52    pub id: String,
53    #[serde(default = "default_download_timeout")]
54    #[schemars(range(min = 1, max = 86400000))]
55    pub timeout_ms: u64,
56}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
59#[serde(rename_all = "snake_case")]
60pub enum DownloadState {
61    Pending,
62    Downloading,
63    Verifying,
64    Completed,
65    Canceled,
66    Failed,
67}
68
69impl DownloadState {
70    pub fn is_terminal(self) -> bool {
71        matches!(self, Self::Completed | Self::Canceled | Self::Failed)
72    }
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
76pub struct DownloadRecord {
77    #[serde(default)]
78    pub native_guid: Option<String>,
79    #[serde(default)]
80    pub native_complete: bool,
81    #[serde(default)]
82    pub sha256: Option<String>,
83    pub id: String,
84    pub tab_id: String,
85    pub state: DownloadState,
86    pub output: String,
87    pub published_path: Option<String>,
88    pub staging_path: Option<String>,
89    pub received_bytes: u64,
90    pub total_bytes: Option<u64>,
91    pub suggested_filename: Option<String>,
92    pub source_url: Option<String>,
93    pub created_at_ms: u64,
94    pub updated_at_ms: u64,
95    pub error: Option<crate::ErrorDetail>,
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
99pub struct DownloadResult {
100    #[serde(flatten)]
101    pub download: DownloadRecord,
102    #[serde(skip_serializing_if = "Option::is_none")]
103    pub completion: Option<String>,
104}
105
106impl From<DownloadRecord> for DownloadResult {
107    fn from(download: DownloadRecord) -> Self {
108        let completion = (download.state == DownloadState::Completed
109            && download.native_complete
110            && download.published_path.is_some()
111            && download.sha256.is_some())
112        .then(|| {
113            "File saving is complete and verified. published_path, received_bytes and sha256 describe the saved file. This receipt confirms successful saving; inspect file contents only if the task requires content inspection. Do not download again.".into()
114        });
115        Self {
116            download,
117            completion,
118        }
119    }
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125
126    #[test]
127    fn completion_requires_verified_publication_and_preserves_record_fields() {
128        let record: DownloadRecord = serde_json::from_value(serde_json::json!({
129            "id":"job", "tab_id":"tab", "state":"completed",
130            "output":"/tmp/report.csv", "published_path":"/tmp/report.csv",
131            "received_bytes":36, "created_at_ms":0, "updated_at_ms":1,
132            "native_complete":true, "sha256":"0".repeat(64)
133        }))
134        .unwrap();
135        let response = serde_json::to_value(DownloadResult::from(record.clone())).unwrap();
136        assert!(response["completion"].as_str().is_some());
137        let restored: DownloadRecord = serde_json::from_value(response).unwrap();
138        assert_eq!(
139            serde_json::to_value(restored).unwrap(),
140            serde_json::to_value(&record).unwrap()
141        );
142        for state in [
143            DownloadState::Pending,
144            DownloadState::Downloading,
145            DownloadState::Verifying,
146            DownloadState::Canceled,
147            DownloadState::Failed,
148        ] {
149            let mut incomplete = record.clone();
150            incomplete.state = state;
151            assert!(DownloadResult::from(incomplete).completion.is_none());
152        }
153        let mut no_native_completion = record.clone();
154        no_native_completion.native_complete = false;
155        let mut no_hash = record.clone();
156        no_hash.sha256 = None;
157        let mut unpublished = record;
158        unpublished.published_path = None;
159        for incomplete in [no_native_completion, no_hash, unpublished] {
160            let response = serde_json::to_value(DownloadResult::from(incomplete)).unwrap();
161            assert!(response.get("completion").is_none());
162        }
163    }
164
165    #[test]
166    fn download_inputs_reject_ambiguous_triggers_and_relative_paths() {
167        let mut args = DownloadStartArgs {
168            operation_id: None,
169            click: Some("@e1".into()),
170            url: None,
171            output: "/tmp/file".into(),
172            expect_bytes: None,
173            start_timeout_ms: 30_000,
174        };
175        assert!(args.validate().is_ok());
176        args.url = Some("https://example.com/file".into());
177        assert!(args.validate().is_err());
178        args.url = None;
179        args.output = "relative".into();
180        assert!(args.validate().is_err());
181    }
182}