1use std::path::PathBuf;
2
3use miette::IntoDiagnostic as _;
4use serde::Deserialize;
5
6use crate::api::issues::UserRef;
7use crate::api::{Client, Parent, Projection};
8use crate::error::{Result, YtError};
9use crate::fields::{choices, usage};
10
11#[derive(Clone, Debug, Deserialize)]
12#[serde(rename_all = "camelCase")]
13pub struct Attachment {
14 pub id: String,
15 pub name: String,
16 pub size: Option<i64>,
17 pub mime_type: Option<String>,
18 pub author: Option<UserRef>,
19 pub created: Option<i64>,
20 pub url: Option<String>,
21}
22
23impl Projection for Attachment {
24 const FIELDS: &'static str = "id,name,size,mimeType,author(login,fullName),created,url";
25}
26
27pub async fn list(client: &Client, parent: &Parent) -> Result<Vec<Attachment>> {
28 client
29 .get_all(&format!("{}/attachments", parent.path()))
30 .await
31}
32
33pub async fn upload(
35 client: &Client,
36 parent: &Parent,
37 files: &[PathBuf],
38) -> Result<Vec<Attachment>> {
39 let paths = files.to_vec();
40 let parts = tokio::task::spawn_blocking(move || -> Result<Vec<(String, Vec<u8>)>> {
41 paths
42 .iter()
43 .map(|path| {
44 let data = std::fs::read(path)
45 .map_err(|error| usage(format!("cannot read {}: {error}", path.display())))?;
46 let name = path
47 .file_name()
48 .map(|name| name.to_string_lossy().into_owned())
49 .unwrap_or_else(|| "file".into());
50 Ok((name, data))
51 })
52 .collect()
53 })
54 .await
55 .into_diagnostic()??;
56 client
57 .upload(&format!("{}/attachments", parent.path()), &parts)
58 .await
59}
60
61pub async fn download(client: &Client, attachment: &Attachment) -> Result<Vec<u8>> {
63 let url = attachment.url.as_deref().ok_or_else(|| {
64 usage(format!(
65 "YouTrack gave no download address for {}",
66 attachment.name
67 ))
68 })?;
69 let mut response = client.download(url).await?;
70 let mut data = Vec::new();
71 while let Some(chunk) = response
72 .chunk()
73 .await
74 .map_err(|error| YtError::Network(crate::redact::redact(&error.to_string())))?
75 {
76 data.extend_from_slice(&chunk);
77 }
78 Ok(data)
79}
80
81pub async fn delete(client: &Client, parent: &Parent, attachment: &str) -> Result<()> {
82 if !attachment
83 .chars()
84 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '.' | '_'))
85 {
86 return Err(usage(format!("'{attachment}' is not an attachment ID")));
87 }
88 client
89 .delete(&format!("{}/attachments/{attachment}", parent.path()))
90 .await
91}
92
93pub fn find<'a>(
95 attachments: &'a [Attachment],
96 name_or_id: &str,
97 parent: &Parent,
98) -> Result<&'a Attachment> {
99 if let Some(found) = attachments
100 .iter()
101 .find(|attachment| attachment.id == name_or_id)
102 {
103 return Ok(found);
104 }
105 let named: Vec<&Attachment> = attachments
106 .iter()
107 .filter(|attachment| attachment.name == name_or_id)
108 .collect();
109 match named.as_slice() {
110 [one] => Ok(one),
111 [] => {
112 let names: Vec<String> = attachments
113 .iter()
114 .map(|attachment| attachment.name.clone())
115 .collect();
116 Err(usage(if names.is_empty() {
117 format!(
118 "there is no attachment '{name_or_id}'; the {} has none",
119 parent.noun()
120 )
121 } else {
122 format!(
123 "there is no attachment '{name_or_id}'; attachments: {}",
124 choices(&names)
125 )
126 }))
127 }
128 several => Err(usage(format!(
129 "{} attachments are named '{name_or_id}'; pick one by ID: {}",
130 several.len(),
131 several
132 .iter()
133 .map(|attachment| attachment.id.as_str())
134 .collect::<Vec<_>>()
135 .join(", ")
136 ))),
137 }
138}
139
140#[cfg(test)]
141mod tests {
142 use serde_json::json;
143 use wiremock::matchers::{body_string_contains, header_regex, method, path};
144 use wiremock::{Mock, MockServer, ResponseTemplate};
145
146 use super::*;
147 use crate::api::test_client;
148
149 fn attachment(id: &str, name: &str) -> Attachment {
150 Attachment {
151 id: id.into(),
152 name: name.into(),
153 size: Some(5),
154 mime_type: None,
155 author: None,
156 created: None,
157 url: Some(format!("/api/files/{id}?sign=x")),
158 }
159 }
160
161 #[tokio::test]
162 async fn files_upload_in_one_multipart_request() {
163 let server = MockServer::start().await;
164 Mock::given(method("POST"))
165 .and(path("/api/issues/WEB-1/attachments"))
166 .and(header_regex("content-type", "^multipart/form-data"))
167 .and(body_string_contains("hello attachment"))
168 .respond_with(
169 ResponseTemplate::new(200)
170 .set_body_json(json!([{"id": "1-1", "name": "log.txt", "size": 16}])),
171 )
172 .expect(1)
173 .mount(&server)
174 .await;
175 let dir = tempfile::tempdir().unwrap();
176 let file = dir.path().join("log.txt");
177 std::fs::write(&file, "hello attachment").unwrap();
178 let client = test_client(&server.uri());
179 let uploaded = upload(&client, &Parent::Issue("WEB-1".into()), &[file])
180 .await
181 .unwrap();
182 assert_eq!(uploaded[0].name, "log.txt");
183 assert!(client.has_written());
184 }
185
186 #[tokio::test]
187 async fn a_missing_file_names_its_path() {
188 let client = test_client("https://acme.youtrack.cloud");
189 let error = upload(
190 &client,
191 &Parent::Issue("WEB-1".into()),
192 &[PathBuf::from("/nonexistent/x.bin")],
193 )
194 .await
195 .unwrap_err();
196 assert!(error.to_string().contains("/nonexistent/x.bin"), "{error}");
197 }
198
199 #[tokio::test]
200 async fn downloads_keep_every_byte() {
201 let server = MockServer::start().await;
202 let bytes = vec![0_u8, 159, 146, 150, 255, 10, 13];
203 Mock::given(method("GET"))
204 .and(path("/api/files/1-1"))
205 .respond_with(ResponseTemplate::new(200).set_body_bytes(bytes.clone()))
206 .mount(&server)
207 .await;
208 let data = download(&test_client(&server.uri()), &attachment("1-1", "data.bin"))
209 .await
210 .unwrap();
211 assert_eq!(data, bytes);
212 }
213
214 #[tokio::test]
215 async fn a_slow_download_outlasts_the_request_timeout() {
216 let server = MockServer::start().await;
217 Mock::given(method("GET"))
218 .and(path("/api/files/1-1"))
219 .respond_with(
220 ResponseTemplate::new(200)
221 .set_body_bytes(vec![1_u8, 2, 3])
222 .set_delay(std::time::Duration::from_millis(1500)),
223 )
224 .mount(&server)
225 .await;
226 let http = crate::http::client(&crate::http::HttpConfig {
227 timeout: std::time::Duration::from_millis(500),
228 retries: 0,
229 ca_file: None,
230 })
231 .unwrap();
232 let token = std::sync::Arc::new(crate::auth::token_source::StaticToken::new(
233 secrecy::SecretString::from("perm-c2xvdw==.dGVzdA==.secret123456".to_string()),
234 "acme",
235 ));
236 let client = Client::new(
237 http,
238 crate::instance::InstanceUrl::parse(&server.uri()).unwrap(),
239 token,
240 0,
241 );
242 assert_eq!(
243 download(&client, &attachment("1-1", "data.bin"))
244 .await
245 .unwrap(),
246 [1, 2, 3]
247 );
248 }
249
250 #[test]
251 fn attachments_are_found_by_id_or_unique_name() {
252 let attachments = vec![
253 attachment("1-1", "a.txt"),
254 attachment("1-2", "b.txt"),
255 attachment("1-3", "b.txt"),
256 ];
257 assert_eq!(
258 find(&attachments, "a.txt", &Parent::Issue("WEB-1".into()))
259 .unwrap()
260 .id,
261 "1-1"
262 );
263 assert_eq!(
264 find(&attachments, "1-3", &Parent::Issue("WEB-1".into()))
265 .unwrap()
266 .id,
267 "1-3"
268 );
269 let error = find(&attachments, "b.txt", &Parent::Issue("WEB-1".into())).unwrap_err();
270 assert!(error.to_string().contains("1-2, 1-3"), "{error}");
271 assert!(find(&attachments, "c.txt", &Parent::Issue("WEB-1".into())).is_err());
272 }
273}