at-jet 0.7.2

High-performance HTTP + Protobuf API framework for mobile services
Documentation
//! Startup utilities for application bootstrapping
//!
//! Provides a [`StartupBanner`] builder for printing structured startup information
//! to stderr before the tracing/logging system is initialized. This is useful in
//! containerized environments (k8s) where you need visible output early.
//!
//! Also provides masking utilities for safe logging of credentials.
//!
//! # Example
//!
//! ```
//! use at_jet::startup::{StartupBanner, mask_url_credentials, mask_secret};
//!
//! StartupBanner::new("my-service", "1.0.0")
//!   .kv("environment", "prod")
//!   .kv("server_address", "0.0.0.0:8080")
//!   .kv("mysql_url", &mask_url_credentials("mysql://user:pass@host:3306/db"))
//!   .section("Secrets")
//!   .kv("  api_key", &mask_secret("abcdefghijklmnop"))
//!   .print();
//! ```

const SEPARATOR_HEAVY: &str = "======================================";
const SEPARATOR_LIGHT: &str = "--------------------------------------";

/// Builder for structured startup banners printed to stderr.
///
/// Collects lines and prints them as a formatted block with header, sections,
/// and footer separators. All output goes to stderr so it is visible in k8s
/// logs even before the tracing subscriber is initialized.
pub struct StartupBanner {
  lines:        Vec<BannerLine>,
  service_name: String,
  version:      String,
}

enum BannerLine {
  Kv(String, String),
  Section(String),
  Line(String),
}

impl StartupBanner {
  /// Create a new banner with service name and version.
  pub fn new(service_name: &str, version: &str) -> Self {
    Self {
      lines:        Vec::new(),
      service_name: service_name.to_string(),
      version:      version.to_string(),
    }
  }

  /// Add a key-value pair line.
  pub fn kv(mut self, key: &str, value: &str) -> Self {
    self.lines.push(BannerLine::Kv(key.to_string(), value.to_string()));
    self
  }

  /// Add a section divider with a title.
  pub fn section(mut self, title: &str) -> Self {
    self.lines.push(BannerLine::Section(title.to_string()));
    self
  }

  /// Add a free-form line.
  pub fn line(mut self, text: &str) -> Self {
    self.lines.push(BannerLine::Line(text.to_string()));
    self
  }

  /// Print the banner to stderr.
  pub fn print(&self) {
    eprintln!("{}", SEPARATOR_HEAVY);
    eprintln!("{} v{}", self.service_name, self.version);
    eprintln!("{}", SEPARATOR_HEAVY);
    for line in &self.lines {
      match line {
        | BannerLine::Kv(key, value) => eprintln!("{}: {}", key, value),
        | BannerLine::Section(title) => {
          eprintln!("{}", SEPARATOR_LIGHT);
          eprintln!("{}:", title);
        }
        | BannerLine::Line(text) => eprintln!("{}", text),
      }
    }
    eprintln!("{}", SEPARATOR_HEAVY);
  }
}

/// Mask credentials in a URL for safe logging.
///
/// Replaces `user:password` with `***:***` in URLs that contain credentials.
///
/// # Examples
///
/// ```
/// use at_jet::startup::mask_url_credentials;
///
/// assert_eq!(
///   mask_url_credentials("mysql://root:secret@host:3306/db"),
///   "mysql://***:***@host:3306/db"
/// );
///
/// // URLs without credentials are returned unchanged
/// assert_eq!(
///   mask_url_credentials("https://example.com/api"),
///   "https://example.com/api"
/// );
/// ```
pub fn mask_url_credentials(url: &str) -> String {
  if let Some(at_pos) = url.find('@') {
    if let Some(proto_end) = url.find("://") {
      let proto = &url[.. proto_end + 3];
      let after_at = &url[at_pos ..];
      return format!("{}***:***{}", proto, after_at);
    }
  }
  url.to_string()
}

/// Mask a secret value for safe logging, showing only the first 8 characters.
///
/// Returns `"(empty)"` for empty strings, or the first 8 characters followed by `***`.
/// If the secret is 8 characters or shorter, shows only the first 4 characters.
///
/// # Examples
///
/// ```
/// use at_jet::startup::mask_secret;
///
/// assert_eq!(mask_secret("abcdefghijklmnop"), "abcdefgh***");
/// assert_eq!(mask_secret("short"), "shor***");
/// assert_eq!(mask_secret("ab"), "ab***");
/// assert_eq!(mask_secret(""), "(empty)");
/// ```
pub fn mask_secret(secret: &str) -> String {
  if secret.is_empty() {
    return "(empty)".to_string();
  }
  let show = if secret.len() > 8 {
    8
  } else {
    secret.len().min(4).max(secret.len().min(2))
  };
  let visible: String = secret.chars().take(show).collect();
  format!("{}***", visible)
}

#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
  use super::*;

  #[test]
  fn test_mask_url_credentials_with_credentials() {
    assert_eq!(
      mask_url_credentials("mysql://user:pass@host:3306/db"),
      "mysql://***:***@host:3306/db"
    );
  }

  #[test]
  fn test_mask_url_credentials_amqp() {
    assert_eq!(
      mask_url_credentials("amqp://admin:secret@localhost:5672"),
      "amqp://***:***@localhost:5672"
    );
  }

  #[test]
  fn test_mask_url_credentials_no_credentials() {
    assert_eq!(
      mask_url_credentials("https://example.com/api"),
      "https://example.com/api"
    );
  }

  #[test]
  fn test_mask_url_credentials_no_protocol() {
    assert_eq!(mask_url_credentials("host:3306/db"), "host:3306/db");
  }

  #[test]
  fn test_mask_secret_long() {
    assert_eq!(mask_secret("abcdefghijklmnop"), "abcdefgh***");
  }

  #[test]
  fn test_mask_secret_short() {
    assert_eq!(mask_secret("short"), "shor***");
  }

  #[test]
  fn test_mask_secret_very_short() {
    assert_eq!(mask_secret("ab"), "ab***");
  }

  #[test]
  fn test_mask_secret_empty() {
    assert_eq!(mask_secret(""), "(empty)");
  }

  #[test]
  fn test_banner_prints_without_panic() {
    // Just verify it doesn't panic — output goes to stderr
    StartupBanner::new("test-service", "0.1.0")
      .kv("env", "test")
      .section("Database")
      .kv("  url", "mysql://***:***@localhost:3306/db")
      .line("extra info")
      .print();
  }
}