1use serde::Deserialize;
2
3use crate::error::{Error, Result};
4
5#[non_exhaustive]
7#[derive(Debug, Clone, Deserialize)]
8#[serde(default)]
9pub struct BucketConfig {
10 pub name: String,
12 pub bucket: String,
14 pub region: Option<String>,
16 pub endpoint: String,
18 pub access_key: String,
20 pub secret_key: String,
22 pub public_url: Option<String>,
24 pub max_file_size: Option<String>,
26 pub path_style: bool,
29}
30
31impl Default for BucketConfig {
32 fn default() -> Self {
33 Self {
34 name: String::new(),
35 bucket: String::new(),
36 region: None,
37 endpoint: String::new(),
38 access_key: String::new(),
39 secret_key: String::new(),
40 public_url: None,
41 max_file_size: None,
42 path_style: true,
43 }
44 }
45}
46
47impl BucketConfig {
48 pub(crate) fn validate(&self) -> Result<()> {
51 if self.bucket.is_empty() {
52 return Err(Error::internal("bucket name is required"));
53 }
54 if self.endpoint.is_empty() {
55 return Err(Error::internal("endpoint is required"));
56 }
57 if let Some(ref size_str) = self.max_file_size {
58 parse_size(size_str)?; }
60 Ok(())
61 }
62
63 pub(crate) fn normalized_public_url(&self) -> Option<String> {
65 self.public_url
66 .as_deref()
67 .map(str::trim)
68 .filter(|s| !s.is_empty())
69 .map(|s| s.trim_end_matches('/').to_string())
70 }
71
72 pub(crate) fn max_file_size_bytes(&self) -> Result<Option<usize>> {
74 match &self.max_file_size {
75 Some(s) => Ok(Some(parse_size(s)?)),
76 None => Ok(None),
77 }
78 }
79}
80
81pub(crate) fn parse_size(s: &str) -> Result<usize> {
86 let s = s.trim().to_ascii_lowercase();
87 if s.is_empty() {
88 return Err(Error::internal("empty size string"));
89 }
90
91 let (num_str, multiplier) = if let Some(n) = s.strip_suffix("gb") {
92 (n, 1024 * 1024 * 1024)
93 } else if let Some(n) = s.strip_suffix("mb") {
94 (n, 1024 * 1024)
95 } else if let Some(n) = s.strip_suffix("kb") {
96 (n, 1024)
97 } else if let Some(n) = s.strip_suffix('b') {
98 (n, 1)
99 } else {
100 (s.as_str(), 1)
101 };
102
103 let num: usize = num_str
104 .trim()
105 .parse()
106 .map_err(|_| Error::internal(format!("invalid size string: \"{s}\"")))?;
107
108 let result = num
109 .checked_mul(multiplier)
110 .ok_or_else(|| Error::internal(format!("size value overflows: \"{s}\"")))?;
111 if result == 0 {
112 return Err(Error::internal(format!(
113 "size must be greater than 0: \"{s}\""
114 )));
115 }
116
117 Ok(result)
118}
119
120pub fn kb(n: usize) -> usize {
122 n * 1024
123}
124
125pub fn mb(n: usize) -> usize {
127 n * 1024 * 1024
128}
129
130pub fn gb(n: usize) -> usize {
132 n * 1024 * 1024 * 1024
133}
134
135#[cfg(test)]
136mod tests {
137 use super::*;
138
139 #[test]
142 fn parse_size_mb() {
143 assert_eq!(parse_size("10mb").unwrap(), 10 * 1024 * 1024);
144 }
145
146 #[test]
147 fn parse_size_kb() {
148 assert_eq!(parse_size("500kb").unwrap(), 500 * 1024);
149 }
150
151 #[test]
152 fn parse_size_gb() {
153 assert_eq!(parse_size("1gb").unwrap(), 1024 * 1024 * 1024);
154 }
155
156 #[test]
157 fn parse_size_bytes_with_suffix() {
158 assert_eq!(parse_size("1024b").unwrap(), 1024);
159 }
160
161 #[test]
162 fn parse_size_bare_number() {
163 assert_eq!(parse_size("1024").unwrap(), 1024);
164 }
165
166 #[test]
167 fn parse_size_case_insensitive() {
168 assert_eq!(parse_size("10MB").unwrap(), 10 * 1024 * 1024);
169 assert_eq!(parse_size("5Kb").unwrap(), 5 * 1024);
170 }
171
172 #[test]
173 fn parse_size_with_whitespace() {
174 assert_eq!(parse_size(" 10mb ").unwrap(), 10 * 1024 * 1024);
175 }
176
177 #[test]
178 fn parse_size_empty_string() {
179 assert!(parse_size("").is_err());
180 }
181
182 #[test]
183 fn parse_size_invalid() {
184 assert!(parse_size("abc").is_err());
185 assert!(parse_size("mb").is_err());
186 }
187
188 #[test]
189 fn parse_size_zero_rejected() {
190 assert!(parse_size("0mb").is_err());
191 assert!(parse_size("0").is_err());
192 }
193
194 #[test]
195 fn parse_size_overflow() {
196 assert!(parse_size("999999999999gb").is_err());
197 assert!(parse_size("99999999999999999999").is_err());
198 }
199
200 #[test]
201 fn parse_size_negative_rejected() {
202 assert!(parse_size("-1mb").is_err());
203 }
204
205 #[test]
206 fn parse_size_single_byte() {
207 assert_eq!(parse_size("1b").unwrap(), 1);
208 }
209
210 #[test]
213 fn size_helpers() {
214 assert_eq!(kb(1), 1024);
215 assert_eq!(mb(1), 1024 * 1024);
216 assert_eq!(gb(1), 1024 * 1024 * 1024);
217 assert_eq!(mb(5), 5 * 1024 * 1024);
218 }
219
220 #[test]
223 fn valid_config() {
224 let config = BucketConfig {
225 bucket: "test".into(),
226 endpoint: "https://s3.example.com".into(),
227 ..Default::default()
228 };
229 config.validate().unwrap();
230 }
231
232 #[test]
233 fn rejects_empty_bucket() {
234 let config = BucketConfig {
235 endpoint: "https://s3.example.com".into(),
236 ..Default::default()
237 };
238 assert!(config.validate().is_err());
239 }
240
241 #[test]
242 fn rejects_empty_endpoint() {
243 let config = BucketConfig {
244 bucket: "test".into(),
245 ..Default::default()
246 };
247 assert!(config.validate().is_err());
248 }
249
250 #[test]
251 fn rejects_invalid_max_file_size() {
252 let config = BucketConfig {
253 bucket: "test".into(),
254 endpoint: "https://s3.example.com".into(),
255 max_file_size: Some("not-a-size".into()),
256 ..Default::default()
257 };
258 assert!(config.validate().is_err());
259 }
260
261 #[test]
262 fn rejects_zero_max_file_size() {
263 let config = BucketConfig {
264 bucket: "test".into(),
265 endpoint: "https://s3.example.com".into(),
266 max_file_size: Some("0mb".into()),
267 ..Default::default()
268 };
269 assert!(config.validate().is_err());
270 }
271
272 #[test]
273 fn none_max_file_size_is_valid() {
274 let config = BucketConfig {
275 bucket: "test".into(),
276 endpoint: "https://s3.example.com".into(),
277 max_file_size: None,
278 ..Default::default()
279 };
280 config.validate().unwrap();
281 }
282
283 #[test]
284 fn normalized_public_url_strips_trailing_slash() {
285 let config = BucketConfig {
286 public_url: Some("https://cdn.example.com/".into()),
287 ..Default::default()
288 };
289 assert_eq!(
290 config.normalized_public_url(),
291 Some("https://cdn.example.com".into())
292 );
293 }
294
295 #[test]
296 fn normalized_public_url_empty_becomes_none() {
297 let config = BucketConfig {
298 public_url: Some("".into()),
299 ..Default::default()
300 };
301 assert_eq!(config.normalized_public_url(), None);
302 }
303
304 #[test]
305 fn normalized_public_url_whitespace_becomes_none() {
306 let config = BucketConfig {
307 public_url: Some(" ".into()),
308 ..Default::default()
309 };
310 assert_eq!(config.normalized_public_url(), None);
311 }
312
313 #[test]
314 fn normalized_public_url_none_stays_none() {
315 let config = BucketConfig::default();
316 assert_eq!(config.normalized_public_url(), None);
317 }
318
319 #[test]
320 fn default_path_style_is_true() {
321 let config = BucketConfig::default();
322 assert!(config.path_style);
323 }
324
325 #[test]
326 fn default_region_is_none() {
327 let config = BucketConfig::default();
328 assert!(config.region.is_none());
329 }
330}