1const SEPARATOR_HEAVY: &str = "======================================";
24const SEPARATOR_LIGHT: &str = "--------------------------------------";
25
26pub 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 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 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 pub fn section(mut self, title: &str) -> Self {
61 self.lines.push(BannerLine::Section(title.to_string()));
62 self
63 }
64
65 pub fn line(mut self, text: &str) -> Self {
67 self.lines.push(BannerLine::Line(text.to_string()));
68 self
69 }
70
71 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
90pub 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
121pub 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 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}