1use clap::{Args, Parser, Subcommand, ValueEnum};
45use std::fmt;
46
47use crate::io::DEFAULT_BLOCK_SIZE;
48use crate::tile::{DEFAULT_JPEG_QUALITY, DEFAULT_TILE_CACHE_CAPACITY};
49
50pub const DEFAULT_HOST: &str = "0.0.0.0";
56
57pub const DEFAULT_PORT: u16 = 3000;
59
60pub const DEFAULT_REGION: &str = "us-east-1";
62
63pub const DEFAULT_SLIDE_CACHE_CAPACITY: usize = 100;
65
66pub const DEFAULT_BLOCK_CACHE_CAPACITY: usize = 100;
68
69pub const DEFAULT_CACHE_MAX_AGE: u32 = 3600;
71
72pub const DEFAULT_SIGN_TTL: u64 = 3600;
74
75#[derive(Parser, Debug)]
84#[command(name = "wsi-streamer")]
85#[command(author, version, about, long_about = None)]
86#[command(propagate_version = true)]
87#[command(after_help = "\
88EXAMPLES:
89 # Start serving slides from an S3 bucket
90 wsi-streamer s3://my-slides-bucket
91
92 # Use a custom port
93 wsi-streamer s3://my-slides --port 8080
94
95 # With MinIO (local S3-compatible storage)
96 wsi-streamer s3://slides --s3-endpoint http://localhost:9000
97
98 # Enable authentication for production
99 wsi-streamer s3://my-slides --auth-enabled --auth-secret $SECRET
100
101 # Check S3 connectivity and list slides
102 wsi-streamer check s3://my-slides --list-slides
103
104 # Generate a signed URL for a tile
105 wsi-streamer sign --path /tiles/slide.svs/0/0/0.jpg --secret $SECRET
106")]
107pub struct Cli {
108 #[command(subcommand)]
109 pub command: Option<Command>,
110
111 #[command(flatten)]
112 pub serve: ServeConfig,
113}
114
115impl Cli {
116 pub fn into_command(self) -> Command {
118 self.command.unwrap_or(Command::Serve(self.serve))
119 }
120}
121
122#[derive(Subcommand, Debug, Clone)]
123pub enum Command {
124 Serve(ServeConfig),
126
127 Sign(SignConfig),
129
130 Check(CheckConfig),
132}
133
134#[derive(Args, Debug, Clone)]
140pub struct ServeConfig {
141 #[arg(value_name = "S3_URI")]
144 pub s3_uri: Option<String>,
145
146 #[arg(long, default_value = DEFAULT_HOST, env = "WSI_HOST")]
151 pub host: String,
152
153 #[arg(short, long, default_value_t = DEFAULT_PORT, env = "WSI_PORT")]
155 pub port: u16,
156
157 #[arg(long, env = "WSI_S3_BUCKET")]
163 pub s3_bucket: Option<String>,
164
165 #[arg(long, env = "WSI_S3_ENDPOINT")]
169 pub s3_endpoint: Option<String>,
170
171 #[arg(long, default_value = DEFAULT_REGION, env = "WSI_S3_REGION")]
173 pub s3_region: String,
174
175 #[arg(long, env = "WSI_AUTH_SECRET")]
182 pub auth_secret: Option<String>,
183
184 #[arg(long, default_value_t = false, env = "WSI_AUTH_ENABLED")]
189 pub auth_enabled: bool,
190
191 #[arg(long, default_value_t = DEFAULT_SLIDE_CACHE_CAPACITY, env = "WSI_CACHE_SLIDES")]
196 pub cache_slides: usize,
197
198 #[arg(long, default_value_t = DEFAULT_BLOCK_CACHE_CAPACITY, env = "WSI_CACHE_BLOCKS")]
200 pub cache_blocks: usize,
201
202 #[arg(long, default_value_t = DEFAULT_TILE_CACHE_CAPACITY, env = "WSI_CACHE_TILES")]
204 pub cache_tiles: usize,
205
206 #[arg(long, default_value_t = DEFAULT_BLOCK_SIZE, env = "WSI_BLOCK_SIZE")]
208 pub block_size: usize,
209
210 #[arg(long, default_value_t = DEFAULT_JPEG_QUALITY, env = "WSI_JPEG_QUALITY")]
215 pub jpeg_quality: u8,
216
217 #[arg(long, default_value_t = DEFAULT_CACHE_MAX_AGE, env = "WSI_CACHE_MAX_AGE")]
219 pub cache_max_age: u32,
220
221 #[arg(long, env = "WSI_CORS_ORIGINS", value_delimiter = ',')]
228 pub cors_origins: Option<Vec<String>>,
229
230 #[arg(short, long, default_value_t = false)]
235 pub verbose: bool,
236
237 #[arg(long, default_value_t = false)]
239 pub no_tracing: bool,
240}
241
242impl ServeConfig {
243 pub fn resolve_bucket(&self) -> Result<String, String> {
245 if let Some(ref uri) = self.s3_uri {
247 return parse_s3_uri(uri);
248 }
249
250 if let Some(ref bucket) = self.s3_bucket {
252 if bucket.is_empty() {
253 return Err("S3 bucket name cannot be empty".to_string());
254 }
255 return Ok(bucket.clone());
256 }
257
258 Err(
259 "S3 bucket is required. Use: wsi-streamer s3://bucket-name or --s3-bucket=name"
260 .to_string(),
261 )
262 }
263
264 pub fn validate(&self) -> Result<(), String> {
266 self.resolve_bucket()?;
268
269 if self.auth_enabled && self.auth_secret.is_none() {
271 return Err("Authentication is enabled but no secret provided. \
272 Set --auth-secret or WSI_AUTH_SECRET, or disable auth with --auth-enabled=false"
273 .to_string());
274 }
275
276 if self.cache_slides == 0 {
278 return Err("cache_slides must be greater than 0".to_string());
279 }
280 if self.cache_blocks == 0 {
281 return Err("cache_blocks must be greater than 0".to_string());
282 }
283 if self.cache_tiles == 0 {
284 return Err("cache_tiles must be greater than 0".to_string());
285 }
286
287 if self.jpeg_quality == 0 || self.jpeg_quality > 100 {
289 return Err("jpeg_quality must be between 1 and 100".to_string());
290 }
291
292 if self.block_size < 1024 || self.block_size > 16 * 1024 * 1024 {
294 return Err("block_size must be between 1KB and 16MB".to_string());
295 }
296
297 Ok(())
298 }
299
300 pub fn bind_address(&self) -> String {
302 format!("{}:{}", self.host, self.port)
303 }
304
305 pub fn auth_secret_or_empty(&self) -> &str {
307 self.auth_secret.as_deref().unwrap_or("")
308 }
309
310 pub fn bucket(&self) -> String {
312 self.resolve_bucket()
313 .expect("bucket should be validated before calling this method")
314 }
315}
316
317#[derive(Debug, Clone, Copy, Default, ValueEnum)]
323pub enum SignOutputFormat {
324 #[default]
326 Url,
327 Json,
329 Signature,
331}
332
333impl fmt::Display for SignOutputFormat {
334 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
335 match self {
336 SignOutputFormat::Url => write!(f, "url"),
337 SignOutputFormat::Json => write!(f, "json"),
338 SignOutputFormat::Signature => write!(f, "signature"),
339 }
340 }
341}
342
343#[derive(Args, Debug, Clone)]
345pub struct SignConfig {
346 #[arg(short, long)]
348 pub path: String,
349
350 #[arg(short, long, env = "WSI_AUTH_SECRET")]
353 pub secret: String,
354
355 #[arg(short, long, default_value_t = DEFAULT_SIGN_TTL)]
357 pub ttl: u64,
358
359 #[arg(short, long)]
361 pub base_url: Option<String>,
362
363 #[arg(short = 'P', long, value_delimiter = ',')]
365 pub params: Option<Vec<String>>,
366
367 #[arg(short, long, default_value = "url")]
369 pub format: SignOutputFormat,
370}
371
372impl SignConfig {
373 pub fn parse_params(&self) -> Result<Vec<(String, String)>, String> {
375 let Some(ref params) = self.params else {
376 return Ok(Vec::new());
377 };
378
379 params
380 .iter()
381 .map(|p| {
382 let parts: Vec<&str> = p.splitn(2, '=').collect();
383 if parts.len() != 2 {
384 Err(format!(
385 "Invalid parameter format '{}'. Expected key=value",
386 p
387 ))
388 } else {
389 Ok((parts[0].to_string(), parts[1].to_string()))
390 }
391 })
392 .collect()
393 }
394
395 pub fn validate(&self) -> Result<(), String> {
397 if self.path.is_empty() {
398 return Err("Path cannot be empty".to_string());
399 }
400
401 if self.secret.is_empty() {
402 return Err("Secret cannot be empty. Set --secret or WSI_AUTH_SECRET".to_string());
403 }
404
405 if self.ttl == 0 {
406 return Err("TTL must be greater than 0".to_string());
407 }
408
409 self.parse_params()?;
411
412 Ok(())
413 }
414}
415
416#[derive(Args, Debug, Clone)]
422pub struct CheckConfig {
423 #[arg(value_name = "S3_URI")]
425 pub s3_uri: Option<String>,
426
427 #[arg(long, env = "WSI_S3_BUCKET")]
429 pub s3_bucket: Option<String>,
430
431 #[arg(long, env = "WSI_S3_ENDPOINT")]
433 pub s3_endpoint: Option<String>,
434
435 #[arg(long, default_value = DEFAULT_REGION, env = "WSI_S3_REGION")]
437 pub s3_region: String,
438
439 #[arg(long)]
441 pub test_slide: Option<String>,
442
443 #[arg(long, default_value_t = false)]
445 pub list_slides: bool,
446
447 #[arg(short, long, default_value_t = false)]
449 pub verbose: bool,
450}
451
452impl CheckConfig {
453 pub fn resolve_bucket(&self) -> Result<String, String> {
455 if let Some(ref uri) = self.s3_uri {
456 return parse_s3_uri(uri);
457 }
458
459 if let Some(ref bucket) = self.s3_bucket {
460 if bucket.is_empty() {
461 return Err("S3 bucket name cannot be empty".to_string());
462 }
463 return Ok(bucket.clone());
464 }
465
466 Err(
467 "S3 bucket is required. Use: wsi-streamer check s3://bucket-name or --s3-bucket=name"
468 .to_string(),
469 )
470 }
471}
472
473fn parse_s3_uri(uri: &str) -> Result<String, String> {
479 let uri = uri.trim();
481
482 if let Some(path) = uri.strip_prefix("s3://") {
483 let bucket = path.split('/').next().unwrap_or("");
484 if bucket.is_empty() {
485 return Err(format!(
486 "Invalid S3 URI '{}'. Expected format: s3://bucket-name",
487 uri
488 ));
489 }
490 Ok(bucket.to_string())
491 } else if uri.contains("://") {
492 Err(format!(
493 "Invalid URI scheme in '{}'. Expected s3:// or plain bucket name",
494 uri
495 ))
496 } else {
497 if uri.is_empty() {
499 return Err("Bucket name cannot be empty".to_string());
500 }
501 Ok(uri.to_string())
502 }
503}
504
505pub type Config = ServeConfig;
512
513#[cfg(test)]
518mod tests {
519 use super::*;
520
521 fn test_serve_config() -> ServeConfig {
522 ServeConfig {
523 s3_uri: None,
524 host: "127.0.0.1".to_string(),
525 port: 8080,
526 s3_bucket: Some("test-bucket".to_string()),
527 s3_endpoint: None,
528 s3_region: "us-west-2".to_string(),
529 auth_secret: Some("test-secret".to_string()),
530 auth_enabled: true,
531 cache_slides: 50,
532 cache_blocks: 100,
533 cache_tiles: 500,
534 block_size: DEFAULT_BLOCK_SIZE,
535 jpeg_quality: 85,
536 cache_max_age: 7200,
537 cors_origins: None,
538 verbose: false,
539 no_tracing: false,
540 }
541 }
542
543 #[test]
544 fn test_valid_config() {
545 let config = test_serve_config();
546 assert!(config.validate().is_ok());
547 }
548
549 #[test]
550 fn test_missing_auth_secret() {
551 let mut config = test_serve_config();
552 config.auth_secret = None;
553 config.auth_enabled = true;
554
555 let result = config.validate();
556 assert!(result.is_err());
557 assert!(result.unwrap_err().contains("secret"));
558 }
559
560 #[test]
561 fn test_auth_disabled_no_secret_ok() {
562 let mut config = test_serve_config();
563 config.auth_secret = None;
564 config.auth_enabled = false;
565
566 assert!(config.validate().is_ok());
567 }
568
569 #[test]
570 fn test_empty_bucket() {
571 let mut config = test_serve_config();
572 config.s3_bucket = Some(String::new());
573 config.s3_uri = None;
574
575 let result = config.validate();
576 assert!(result.is_err());
577 }
578
579 #[test]
580 fn test_missing_bucket() {
581 let mut config = test_serve_config();
582 config.s3_bucket = None;
583 config.s3_uri = None;
584
585 let result = config.validate();
586 assert!(result.is_err());
587 assert!(result.unwrap_err().contains("bucket"));
588 }
589
590 #[test]
591 fn test_s3_uri_parsing() {
592 assert_eq!(parse_s3_uri("s3://my-bucket").unwrap(), "my-bucket");
594 assert_eq!(
595 parse_s3_uri("s3://my-bucket/prefix/path").unwrap(),
596 "my-bucket"
597 );
598 assert_eq!(parse_s3_uri("my-bucket").unwrap(), "my-bucket");
599
600 assert!(parse_s3_uri("s3://").is_err());
602 assert!(parse_s3_uri("http://bucket").is_err());
603 assert!(parse_s3_uri("").is_err());
604 }
605
606 #[test]
607 fn test_s3_uri_takes_precedence() {
608 let mut config = test_serve_config();
609 config.s3_uri = Some("s3://uri-bucket".to_string());
610 config.s3_bucket = Some("flag-bucket".to_string());
611
612 assert_eq!(config.resolve_bucket().unwrap(), "uri-bucket");
613 }
614
615 #[test]
616 fn test_invalid_cache_sizes() {
617 let mut config = test_serve_config();
618 config.cache_slides = 0;
619 assert!(config.validate().is_err());
620
621 let mut config = test_serve_config();
622 config.cache_blocks = 0;
623 assert!(config.validate().is_err());
624
625 let mut config = test_serve_config();
626 config.cache_tiles = 0;
627 assert!(config.validate().is_err());
628 }
629
630 #[test]
631 fn test_invalid_jpeg_quality() {
632 let mut config = test_serve_config();
633 config.jpeg_quality = 0;
634 assert!(config.validate().is_err());
635
636 let mut config = test_serve_config();
637 config.jpeg_quality = 101;
638 assert!(config.validate().is_err());
639 }
640
641 #[test]
642 fn test_bind_address() {
643 let config = test_serve_config();
644 assert_eq!(config.bind_address(), "127.0.0.1:8080");
645 }
646
647 #[test]
648 fn test_auth_secret_or_empty() {
649 let config = test_serve_config();
650 assert_eq!(config.auth_secret_or_empty(), "test-secret");
651
652 let mut config = test_serve_config();
653 config.auth_secret = None;
654 assert_eq!(config.auth_secret_or_empty(), "");
655 }
656
657 #[test]
658 fn test_cors_origins() {
659 let mut config = test_serve_config();
660 config.cors_origins = Some(vec![
661 "https://example.com".to_string(),
662 "https://other.com".to_string(),
663 ]);
664 assert!(config.validate().is_ok());
665 assert_eq!(config.cors_origins.as_ref().unwrap().len(), 2);
666 }
667
668 #[test]
669 fn test_sign_config_parse_params() {
670 let config = SignConfig {
671 path: "/tiles/test.svs/0/0/0.jpg".to_string(),
672 secret: "secret".to_string(),
673 ttl: 3600,
674 base_url: None,
675 params: Some(vec!["quality=90".to_string(), "format=jpg".to_string()]),
676 format: SignOutputFormat::Url,
677 };
678
679 let params = config.parse_params().unwrap();
680 assert_eq!(params.len(), 2);
681 assert_eq!(params[0], ("quality".to_string(), "90".to_string()));
682 assert_eq!(params[1], ("format".to_string(), "jpg".to_string()));
683 }
684
685 #[test]
686 fn test_sign_config_invalid_params() {
687 let config = SignConfig {
688 path: "/tiles/test.svs/0/0/0.jpg".to_string(),
689 secret: "secret".to_string(),
690 ttl: 3600,
691 base_url: None,
692 params: Some(vec!["invalid_param".to_string()]),
693 format: SignOutputFormat::Url,
694 };
695
696 assert!(config.parse_params().is_err());
697 }
698
699 #[test]
700 fn test_check_config_resolve_bucket() {
701 let config = CheckConfig {
702 s3_uri: Some("s3://check-bucket".to_string()),
703 s3_bucket: None,
704 s3_endpoint: None,
705 s3_region: DEFAULT_REGION.to_string(),
706 test_slide: None,
707 list_slides: false,
708 verbose: false,
709 };
710
711 assert_eq!(config.resolve_bucket().unwrap(), "check-bucket");
712 }
713}