Skip to main content

at_jet/
startup.rs

1//! Startup utilities for application bootstrapping
2//!
3//! Provides a [`StartupBanner`] builder for printing structured startup information
4//! to stderr before the tracing/logging system is initialized. This is useful in
5//! containerized environments (k8s) where you need visible output early.
6//!
7//! Also provides masking utilities for safe logging of credentials.
8//!
9//! # Example
10//!
11//! ```
12//! use at_jet::startup::{StartupBanner, mask_url_credentials, mask_secret};
13//!
14//! StartupBanner::new("my-service", "1.0.0")
15//!   .kv("environment", "prod")
16//!   .kv("server_address", "0.0.0.0:8080")
17//!   .kv("mysql_url", &mask_url_credentials("mysql://user:pass@host:3306/db"))
18//!   .section("Secrets")
19//!   .kv("  api_key", &mask_secret("abcdefghijklmnop"))
20//!   .print();
21//! ```
22
23const SEPARATOR_HEAVY: &str = "======================================";
24const SEPARATOR_LIGHT: &str = "--------------------------------------";
25
26/// Builder for structured startup banners printed to stderr.
27///
28/// Collects lines and prints them as a formatted block with header, sections,
29/// and footer separators. All output goes to stderr so it is visible in k8s
30/// logs even before the tracing subscriber is initialized.
31pub struct StartupBanner {
32  lines:        Vec<BannerLine>,
33  service_name: String,
34  version:      String,
35}
36
37enum BannerLine {
38  Kv(String, String),
39  Section(String),
40  Line(String),
41}
42
43impl StartupBanner {
44  /// Create a new banner with service name and version.
45  pub fn new(service_name: &str, version: &str) -> Self {
46    Self {
47      lines:        Vec::new(),
48      service_name: service_name.to_string(),
49      version:      version.to_string(),
50    }
51  }
52
53  /// Add a key-value pair line.
54  pub fn kv(mut self, key: &str, value: &str) -> Self {
55    self.lines.push(BannerLine::Kv(key.to_string(), value.to_string()));
56    self
57  }
58
59  /// Add a section divider with a title.
60  pub fn section(mut self, title: &str) -> Self {
61    self.lines.push(BannerLine::Section(title.to_string()));
62    self
63  }
64
65  /// Add a free-form line.
66  pub fn line(mut self, text: &str) -> Self {
67    self.lines.push(BannerLine::Line(text.to_string()));
68    self
69  }
70
71  /// Print the banner to stderr.
72  pub fn print(&self) {
73    eprintln!("{}", SEPARATOR_HEAVY);
74    eprintln!("{} v{}", self.service_name, self.version);
75    eprintln!("{}", SEPARATOR_HEAVY);
76    for line in &self.lines {
77      match line {
78        | BannerLine::Kv(key, value) => eprintln!("{}: {}", key, value),
79        | BannerLine::Section(title) => {
80          eprintln!("{}", SEPARATOR_LIGHT);
81          eprintln!("{}:", title);
82        }
83        | BannerLine::Line(text) => eprintln!("{}", text),
84      }
85    }
86    eprintln!("{}", SEPARATOR_HEAVY);
87  }
88}
89
90/// Mask credentials in a URL for safe logging.
91///
92/// Replaces `user:password` with `***:***` in URLs that contain credentials.
93///
94/// # Examples
95///
96/// ```
97/// use at_jet::startup::mask_url_credentials;
98///
99/// assert_eq!(
100///   mask_url_credentials("mysql://root:secret@host:3306/db"),
101///   "mysql://***:***@host:3306/db"
102/// );
103///
104/// // URLs without credentials are returned unchanged
105/// assert_eq!(
106///   mask_url_credentials("https://example.com/api"),
107///   "https://example.com/api"
108/// );
109/// ```
110pub fn mask_url_credentials(url: &str) -> String {
111  if let Some(at_pos) = url.find('@') {
112    if let Some(proto_end) = url.find("://") {
113      let proto = &url[.. proto_end + 3];
114      let after_at = &url[at_pos ..];
115      return format!("{}***:***{}", proto, after_at);
116    }
117  }
118  url.to_string()
119}
120
121/// Mask a secret value for safe logging, showing only the first 8 characters.
122///
123/// Returns `"(empty)"` for empty strings, or the first 8 characters followed by `***`.
124/// If the secret is 8 characters or shorter, shows only the first 4 characters.
125///
126/// # Examples
127///
128/// ```
129/// use at_jet::startup::mask_secret;
130///
131/// assert_eq!(mask_secret("abcdefghijklmnop"), "abcdefgh***");
132/// assert_eq!(mask_secret("short"), "shor***");
133/// assert_eq!(mask_secret("ab"), "ab***");
134/// assert_eq!(mask_secret(""), "(empty)");
135/// ```
136pub fn mask_secret(secret: &str) -> String {
137  if secret.is_empty() {
138    return "(empty)".to_string();
139  }
140  let show = if secret.len() > 8 {
141    8
142  } else {
143    secret.len().min(4).max(secret.len().min(2))
144  };
145  let visible: String = secret.chars().take(show).collect();
146  format!("{}***", visible)
147}
148
149#[cfg(test)]
150#[allow(clippy::unwrap_used, clippy::expect_used)]
151mod tests {
152  use super::*;
153
154  #[test]
155  fn test_mask_url_credentials_with_credentials() {
156    assert_eq!(
157      mask_url_credentials("mysql://user:pass@host:3306/db"),
158      "mysql://***:***@host:3306/db"
159    );
160  }
161
162  #[test]
163  fn test_mask_url_credentials_amqp() {
164    assert_eq!(
165      mask_url_credentials("amqp://admin:secret@localhost:5672"),
166      "amqp://***:***@localhost:5672"
167    );
168  }
169
170  #[test]
171  fn test_mask_url_credentials_no_credentials() {
172    assert_eq!(
173      mask_url_credentials("https://example.com/api"),
174      "https://example.com/api"
175    );
176  }
177
178  #[test]
179  fn test_mask_url_credentials_no_protocol() {
180    assert_eq!(mask_url_credentials("host:3306/db"), "host:3306/db");
181  }
182
183  #[test]
184  fn test_mask_secret_long() {
185    assert_eq!(mask_secret("abcdefghijklmnop"), "abcdefgh***");
186  }
187
188  #[test]
189  fn test_mask_secret_short() {
190    assert_eq!(mask_secret("short"), "shor***");
191  }
192
193  #[test]
194  fn test_mask_secret_very_short() {
195    assert_eq!(mask_secret("ab"), "ab***");
196  }
197
198  #[test]
199  fn test_mask_secret_empty() {
200    assert_eq!(mask_secret(""), "(empty)");
201  }
202
203  #[test]
204  fn test_banner_prints_without_panic() {
205    // Just verify it doesn't panic — output goes to stderr
206    StartupBanner::new("test-service", "0.1.0")
207      .kv("env", "test")
208      .section("Database")
209      .kv("  url", "mysql://***:***@localhost:3306/db")
210      .line("extra info")
211      .print();
212  }
213}