1use percent_encoding::{percent_decode_str, utf8_percent_encode, NON_ALPHANUMERIC};
24
25use crate::{
26 error::AppError,
27 ids::{BucketId, ObjectKey, ProfileId},
28};
29
30use super::{DisplayPath, S3Location};
31
32pub fn to_canonical_uri(loc: &S3Location) -> String {
45 let encoded_bucket = encode_no_slash(loc.bucket.as_str());
47
48 let key_str = loc
50 .key
51 .as_ref()
52 .map(|k| k.as_str())
53 .unwrap_or(loc.prefix.as_str());
54 let encoded_key = encode_preserve_slash(key_str);
55
56 format!(
57 "brows3r://{}/{}/{}",
58 loc.profile_id.as_str(),
59 encoded_bucket,
60 encoded_key
61 )
62}
63
64pub fn from_canonical_uri(uri: &str) -> Result<S3Location, AppError> {
71 let rest = uri
73 .strip_prefix("brows3r://")
74 .ok_or_else(|| AppError::Validation {
75 field: "uri".to_string(),
76 hint: "URI must begin with brows3r://".to_string(),
77 })?;
78
79 let mut parts = rest.splitn(3, '/');
81
82 let profile_id_raw = parts.next().unwrap_or(""); if profile_id_raw.is_empty() {
84 return Err(AppError::Validation {
85 field: "uri".to_string(),
86 hint: "profile_id must not be empty".to_string(),
87 });
88 }
89
90 let bucket_raw = parts.next().unwrap_or("");
91 if bucket_raw.is_empty() {
92 return Err(AppError::Validation {
93 field: "uri".to_string(),
94 hint: "bucket must not be empty".to_string(),
95 });
96 }
97
98 let key_raw = parts.next().unwrap_or("");
100
101 let bucket_decoded = decode_component(bucket_raw).map_err(|_| AppError::Validation {
103 field: "uri".to_string(),
104 hint: "bucket segment contains invalid percent-encoding".to_string(),
105 })?;
106
107 let key_decoded = decode_component(key_raw).map_err(|_| AppError::Validation {
108 field: "uri".to_string(),
109 hint: "key segment contains invalid percent-encoding".to_string(),
110 })?;
111
112 let key = if key_decoded.is_empty() {
113 None
114 } else {
115 Some(ObjectKey::new(key_decoded))
116 };
117
118 Ok(S3Location {
119 profile_id: ProfileId::new(profile_id_raw),
120 bucket: BucketId::new(bucket_decoded),
121 prefix: String::new(),
122 key,
123 })
124}
125
126pub fn to_display_path(loc: &S3Location, profile_display_name: &str) -> DisplayPath {
137 let raw = loc
138 .key
139 .as_ref()
140 .map(|k| k.as_str())
141 .unwrap_or(loc.prefix.as_str());
142
143 let segments: Vec<String> = raw
144 .split('/')
145 .filter(|s| !s.is_empty())
146 .map(str::to_owned)
147 .collect();
148
149 DisplayPath {
150 profile_display_name: profile_display_name.to_owned(),
151 bucket: loc.bucket.as_str().to_owned(),
152 segments,
153 }
154}
155
156pub fn from_display_path(
161 profile_id: ProfileId,
162 bucket: BucketId,
163 segments: &[String],
164) -> S3Location {
165 let key = if segments.is_empty() {
166 None
167 } else {
168 Some(ObjectKey::new(segments.join("/")))
169 };
170
171 S3Location {
172 profile_id,
173 bucket,
174 prefix: String::new(),
175 key,
176 }
177}
178
179pub fn to_clipboard_string(loc: &S3Location, _profile_display_name: &str) -> String {
188 let key_str = loc
189 .key
190 .as_ref()
191 .map(|k| k.as_str())
192 .unwrap_or(loc.prefix.as_str());
193
194 if key_str.is_empty() {
195 format!("s3://{}/", loc.bucket.as_str())
196 } else {
197 format!("s3://{}/{}", loc.bucket.as_str(), key_str)
198 }
199}
200
201fn encode_no_slash(input: &str) -> String {
208 utf8_percent_encode(input, NON_ALPHANUMERIC).to_string()
209}
210
211fn encode_preserve_slash(input: &str) -> String {
214 let encoded = utf8_percent_encode(input, NON_ALPHANUMERIC).to_string();
217 encoded.replace("%2F", "/").replace("%2f", "/")
219}
220
221fn decode_component(input: &str) -> Result<String, ()> {
223 percent_decode_str(input)
224 .decode_utf8()
225 .map(|s| s.into_owned())
226 .map_err(|_| ())
227}
228
229#[cfg(test)]
234mod tests {
235 use super::*;
236
237 fn make_loc(profile_id: &str, bucket: &str, prefix: &str, key: Option<&str>) -> S3Location {
238 S3Location {
239 profile_id: ProfileId::new(profile_id),
240 bucket: BucketId::new(bucket),
241 prefix: prefix.to_owned(),
242 key: key.map(ObjectKey::new),
243 }
244 }
245
246 #[test]
251 fn duplicate_display_names_produce_distinct_canonical_uris() {
252 let profile_id_a = "11111111-1111-1111-1111-111111111111";
254 let profile_id_b = "22222222-2222-2222-2222-222222222222";
255
256 let loc_a = make_loc(profile_id_a, "my-bucket", "", Some("data/file.csv"));
257 let loc_b = make_loc(profile_id_b, "my-bucket", "", Some("data/file.csv"));
258
259 let uri_a = to_canonical_uri(&loc_a);
260 let uri_b = to_canonical_uri(&loc_b);
261
262 assert_ne!(
263 uri_a, uri_b,
264 "duplicate display names must produce distinct URIs"
265 );
266 assert!(
267 uri_a.contains(profile_id_a),
268 "URI must embed the profile_id"
269 );
270 assert!(uri_b.contains(profile_id_b));
271 }
272
273 #[test]
278 fn unicode_key_round_trips() {
279 let loc = make_loc("prof-1", "bucket", "", Some("café/menu.pdf"));
280
281 let uri = to_canonical_uri(&loc);
282 let restored = from_canonical_uri(&uri).expect("must parse");
283
284 assert_eq!(
285 restored.key.as_ref().map(|k| k.as_str()),
286 Some("café/menu.pdf"),
287 "unicode key must survive round-trip"
288 );
289 assert!(
291 uri.contains('/'),
292 "URI must preserve path-separator slashes"
293 );
294 }
295
296 #[test]
301 fn special_chars_round_trip() {
302 let key = "path/with?query#hash%percent/end";
303 let loc = make_loc("prof-1", "my-bucket", "", Some(key));
304
305 let uri = to_canonical_uri(&loc);
306
307 assert!(
309 !uri.contains('?'),
310 "? must be percent-encoded in canonical URI"
311 );
312 assert!(
313 !uri.contains('#'),
314 "# must be percent-encoded in canonical URI"
315 );
316
317 let restored = from_canonical_uri(&uri).expect("must parse");
318 assert_eq!(
319 restored.key.as_ref().map(|k| k.as_str()),
320 Some(key),
321 "special-char key must round-trip losslessly"
322 );
323 }
324
325 #[test]
326 fn slash_preserved_in_key_encoding() {
327 let loc = make_loc("prof-1", "bucket", "", Some("a/b/c.txt"));
328 let uri = to_canonical_uri(&loc);
329 let restored = from_canonical_uri(&uri).expect("must parse");
334 assert_eq!(
335 restored.key.as_ref().map(|k| k.as_str()),
336 Some("a/b/c.txt"),
337 "key must round-trip losslessly"
338 );
339 assert!(
341 uri.contains("a/b/"),
342 "path slashes must be preserved as literal '/': got {uri}"
343 );
344 }
345
346 #[test]
351 fn clipboard_string_formats_s3_uri() {
352 let loc = make_loc("prof-1", "my-bucket", "", Some("folder/file.txt"));
353 let clip = to_clipboard_string(&loc, "prod");
354 assert_eq!(clip, "s3://my-bucket/folder/file.txt");
355 }
356
357 #[test]
358 fn clipboard_string_bucket_root() {
359 let loc = make_loc("prof-1", "my-bucket", "", None);
360 let clip = to_clipboard_string(&loc, "prod");
361 assert_eq!(clip, "s3://my-bucket/");
362 }
363
364 #[test]
369 fn rejects_wrong_scheme() {
370 let result = from_canonical_uri("https://example.com/bucket/key");
371 assert!(
372 matches!(result, Err(AppError::Validation { .. })),
373 "wrong scheme must return Validation error"
374 );
375 }
376
377 #[test]
378 fn rejects_empty_profile_id() {
379 let result = from_canonical_uri("brows3r:///bucket/key");
381 assert!(
382 matches!(result, Err(AppError::Validation { .. })),
383 "empty profile_id must be rejected"
384 );
385 }
386
387 #[test]
388 fn rejects_missing_bucket() {
389 let result = from_canonical_uri("brows3r://prof-1");
391 assert!(
392 matches!(result, Err(AppError::Validation { .. })),
393 "missing bucket must be rejected"
394 );
395 }
396
397 #[test]
398 fn rejects_empty_bucket() {
399 let result = from_canonical_uri("brows3r://prof-1//key");
400 assert!(
401 matches!(result, Err(AppError::Validation { .. })),
402 "empty bucket must be rejected"
403 );
404 }
405
406 #[test]
411 fn to_display_path_splits_segments() {
412 let loc = make_loc("prof-1", "my-bucket", "", Some("folder/sub/file.txt"));
413 let dp = to_display_path(&loc, "production");
414 assert_eq!(dp.profile_display_name, "production");
415 assert_eq!(dp.bucket, "my-bucket");
416 assert_eq!(dp.segments, vec!["folder", "sub", "file.txt"]);
417 }
418
419 #[test]
420 fn to_display_path_bucket_root() {
421 let loc = make_loc("prof-1", "my-bucket", "", None);
422 let dp = to_display_path(&loc, "prod");
423 assert_eq!(dp.segments, Vec::<String>::new());
424 }
425
426 #[test]
427 fn from_display_path_joins_segments() {
428 let profile_id = ProfileId::new("prof-1");
429 let bucket = BucketId::new("my-bucket");
430 let segments = vec!["folder".to_owned(), "sub".to_owned(), "file.txt".to_owned()];
431 let loc = from_display_path(profile_id.clone(), bucket.clone(), &segments);
432
433 assert_eq!(loc.profile_id, profile_id);
434 assert_eq!(loc.bucket, bucket);
435 assert_eq!(
436 loc.key.as_ref().map(|k| k.as_str()),
437 Some("folder/sub/file.txt")
438 );
439 }
440
441 #[test]
442 fn from_display_path_empty_segments_is_bucket_root() {
443 let loc = from_display_path(ProfileId::new("p"), BucketId::new("b"), &[]);
444 assert!(loc.key.is_none());
445 }
446}