1use serde_json::Value;
6
7pub fn build_json_startup(config: Value, args: Value, env: Value) -> Value {
13 serde_json::json!({"code": "startup", "config": config, "args": args, "env": env})
14}
15
16pub fn build_json_ok(result: Value, trace: Option<Value>) -> Value {
18 match trace {
19 Some(t) => serde_json::json!({"code": "ok", "result": result, "trace": t}),
20 None => serde_json::json!({"code": "ok", "result": result}),
21 }
22}
23
24pub fn build_json_error(message: &str, trace: Option<Value>) -> Value {
26 match trace {
27 Some(t) => serde_json::json!({"code": "error", "error": message, "trace": t}),
28 None => serde_json::json!({"code": "error", "error": message}),
29 }
30}
31
32pub fn build_json(code: &str, fields: Value, trace: Option<Value>) -> Value {
34 let mut obj = match fields {
35 Value::Object(map) => map,
36 _ => serde_json::Map::new(),
37 };
38 obj.insert("code".to_string(), Value::String(code.to_string()));
39 if let Some(t) = trace {
40 obj.insert("trace".to_string(), t);
41 }
42 Value::Object(obj)
43}
44
45pub fn output_json(value: &Value) -> String {
51 let mut v = value.clone();
52 redact_secrets(&mut v);
53 serde_json::to_string(&v).unwrap_or_default()
54}
55
56pub fn output_yaml(value: &Value) -> String {
58 let mut lines = vec!["---".to_string()];
59 render_yaml_processed(value, 0, &mut lines);
60 lines.join("\n")
61}
62
63pub fn output_plain(value: &Value) -> String {
65 let mut pairs: Vec<(String, String)> = Vec::new();
66 collect_plain_pairs(value, "", &mut pairs);
67 pairs.sort_by(|(a, _), (b, _)| a.encode_utf16().cmp(b.encode_utf16()));
68 pairs
69 .into_iter()
70 .map(|(k, v)| {
71 if v.contains(' ') {
72 format!("{}=\"{}\"", k, v)
73 } else {
74 format!("{}={}", k, v)
75 }
76 })
77 .collect::<Vec<_>>()
78 .join(" ")
79}
80
81pub fn internal_redact_secrets(value: &mut Value) {
87 redact_secrets(value);
88}
89
90pub fn parse_size(s: &str) -> Option<u64> {
96 let s = s.trim();
97 if s.is_empty() {
98 return None;
99 }
100 let last = *s.as_bytes().last()?;
101 let (num_str, mult) = match last {
102 b'B' | b'b' => (&s[..s.len() - 1], 1u64),
103 b'K' | b'k' => (&s[..s.len() - 1], 1024),
104 b'M' | b'm' => (&s[..s.len() - 1], 1024 * 1024),
105 b'G' | b'g' => (&s[..s.len() - 1], 1024 * 1024 * 1024),
106 b'T' | b't' => (&s[..s.len() - 1], 1024u64 * 1024 * 1024 * 1024),
107 b'0'..=b'9' | b'.' => (s, 1),
108 _ => return None,
109 };
110 if num_str.is_empty() {
111 return None;
112 }
113 if let Ok(n) = num_str.parse::<u64>() {
114 return n.checked_mul(mult);
115 }
116 let f: f64 = num_str.parse().ok()?;
117 if f < 0.0 || f.is_nan() || f.is_infinite() {
118 return None;
119 }
120 let result = f * mult as f64;
121 if result > u64::MAX as f64 {
122 return None;
123 }
124 Some(result as u64)
125}
126
127fn redact_secrets(value: &mut Value) {
132 match value {
133 Value::Object(map) => {
134 let keys: Vec<String> = map.keys().cloned().collect();
135 for key in keys {
136 if key.ends_with("_secret") || key.ends_with("_SECRET") {
137 match map.get(&key) {
138 Some(Value::Object(_)) | Some(Value::Array(_)) => {
139 }
141 _ => {
142 map.insert(key.clone(), Value::String("***".into()));
143 continue;
144 }
145 }
146 }
147 if let Some(v) = map.get_mut(&key) {
148 redact_secrets(v);
149 }
150 }
151 }
152 Value::Array(arr) => {
153 for v in arr {
154 redact_secrets(v);
155 }
156 }
157 _ => {}
158 }
159}
160
161fn strip_suffix_ci(key: &str, suffix_lower: &str) -> Option<String> {
167 if let Some(s) = key.strip_suffix(suffix_lower) {
168 return Some(s.to_string());
169 }
170 let suffix_upper: String = suffix_lower.chars().map(|c| c.to_ascii_uppercase()).collect();
171 if let Some(s) = key.strip_suffix(&suffix_upper) {
172 return Some(s.to_string());
173 }
174 None
175}
176
177fn try_strip_generic_cents(key: &str) -> Option<(String, String)> {
179 let code = extract_currency_code(key)?;
180 let suffix_len = code.len() + "_cents".len() + 1; let stripped = &key[..key.len() - suffix_len];
182 if stripped.is_empty() {
183 return None;
184 }
185 Some((stripped.to_string(), code.to_string()))
186}
187
188fn try_process_field(key: &str, value: &Value) -> Option<(String, String)> {
191 if let Some(stripped) = strip_suffix_ci(key, "_epoch_ms") {
193 return value.as_i64().map(|ms| (stripped, format_rfc3339_ms(ms)));
194 }
195 if let Some(stripped) = strip_suffix_ci(key, "_epoch_s") {
196 return value.as_i64().map(|s| (stripped, format_rfc3339_ms(s * 1000)));
197 }
198 if let Some(stripped) = strip_suffix_ci(key, "_epoch_ns") {
199 return value
200 .as_i64()
201 .map(|ns| (stripped, format_rfc3339_ms(ns.div_euclid(1_000_000))));
202 }
203
204 if let Some(stripped) = strip_suffix_ci(key, "_usd_cents") {
206 return value
207 .as_u64()
208 .map(|n| (stripped, format!("${}.{:02}", n / 100, n % 100)));
209 }
210 if let Some(stripped) = strip_suffix_ci(key, "_eur_cents") {
211 return value
212 .as_u64()
213 .map(|n| (stripped, format!("€{}.{:02}", n / 100, n % 100)));
214 }
215 if let Some((stripped, code)) = try_strip_generic_cents(key) {
216 return value.as_u64().map(|n| {
217 (
218 stripped,
219 format!("{}.{:02} {}", n / 100, n % 100, code.to_uppercase()),
220 )
221 });
222 }
223
224 if let Some(stripped) = strip_suffix_ci(key, "_rfc3339") {
226 return value.as_str().map(|s| (stripped, s.to_string()));
227 }
228 if let Some(stripped) = strip_suffix_ci(key, "_minutes") {
229 return value
230 .is_number()
231 .then(|| (stripped, format!("{} minutes", number_str(value))));
232 }
233 if let Some(stripped) = strip_suffix_ci(key, "_hours") {
234 return value
235 .is_number()
236 .then(|| (stripped, format!("{} hours", number_str(value))));
237 }
238 if let Some(stripped) = strip_suffix_ci(key, "_days") {
239 return value
240 .is_number()
241 .then(|| (stripped, format!("{} days", number_str(value))));
242 }
243
244 if let Some(stripped) = strip_suffix_ci(key, "_msats") {
246 return value
247 .is_number()
248 .then(|| (stripped, format!("{}msats", number_str(value))));
249 }
250 if let Some(stripped) = strip_suffix_ci(key, "_sats") {
251 return value
252 .is_number()
253 .then(|| (stripped, format!("{}sats", number_str(value))));
254 }
255 if let Some(stripped) = strip_suffix_ci(key, "_bytes") {
256 return value.as_i64().map(|n| (stripped, format_bytes_human(n)));
257 }
258 if let Some(stripped) = strip_suffix_ci(key, "_percent") {
259 return value
260 .is_number()
261 .then(|| (stripped, format!("{}%", number_str(value))));
262 }
263 if let Some(stripped) = strip_suffix_ci(key, "_secret") {
264 return Some((stripped, "***".to_string()));
265 }
266
267 if let Some(stripped) = strip_suffix_ci(key, "_btc") {
269 return value
270 .is_number()
271 .then(|| (stripped, format!("{} BTC", number_str(value))));
272 }
273 if let Some(stripped) = strip_suffix_ci(key, "_jpy") {
274 return value
275 .as_u64()
276 .map(|n| (stripped, format!("¥{}", format_with_commas(n))));
277 }
278 if let Some(stripped) = strip_suffix_ci(key, "_ns") {
279 return value
280 .is_number()
281 .then(|| (stripped, format!("{}ns", number_str(value))));
282 }
283 if let Some(stripped) = strip_suffix_ci(key, "_us") {
284 return value
285 .is_number()
286 .then(|| (stripped, format!("{}μs", number_str(value))));
287 }
288 if let Some(stripped) = strip_suffix_ci(key, "_ms") {
289 return format_ms_value(value).map(|v| (stripped, v));
290 }
291 if let Some(stripped) = strip_suffix_ci(key, "_s") {
292 return value
293 .is_number()
294 .then(|| (stripped, format!("{}s", number_str(value))));
295 }
296
297 None
298}
299
300fn process_object_fields<'a>(
302 map: &'a serde_json::Map<String, Value>,
303) -> Vec<(String, &'a Value, Option<String>)> {
304 let mut entries: Vec<(String, &'a str, &'a Value, Option<String>)> = Vec::new();
305 for (key, value) in map {
306 match try_process_field(key, value) {
307 Some((stripped, formatted)) => {
308 entries.push((stripped, key.as_str(), value, Some(formatted)));
309 }
310 None => {
311 entries.push((key.clone(), key.as_str(), value, None));
312 }
313 }
314 }
315
316 let mut counts: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
318 for (stripped, _, _, _) in &entries {
319 *counts.entry(stripped.clone()).or_insert(0) += 1;
320 }
321
322 let mut result: Vec<(String, &'a Value, Option<String>)> = entries
324 .into_iter()
325 .map(|(stripped, original, value, formatted)| {
326 if counts.get(&stripped).copied().unwrap_or(0) > 1
327 && original != stripped.as_str()
328 {
329 (original.to_string(), value, None)
330 } else {
331 (stripped, value, formatted)
332 }
333 })
334 .collect();
335
336 result.sort_by(|(a, _, _), (b, _, _)| a.encode_utf16().cmp(b.encode_utf16()));
337 result
338}
339
340fn number_str(value: &Value) -> String {
345 match value {
346 Value::Number(n) => n.to_string(),
347 _ => String::new(),
348 }
349}
350
351fn format_ms_as_seconds(ms: f64) -> String {
353 let formatted = format!("{:.3}", ms / 1000.0);
354 let trimmed = formatted.trim_end_matches('0');
355 if trimmed.ends_with('.') {
356 format!("{}0s", trimmed)
357 } else {
358 format!("{}s", trimmed)
359 }
360}
361
362fn format_ms_value(value: &Value) -> Option<String> {
364 let n = value.as_f64()?;
365 if n.abs() >= 1000.0 {
366 Some(format_ms_as_seconds(n))
367 } else if let Some(i) = value.as_i64() {
368 Some(format!("{}ms", i))
369 } else {
370 Some(format!("{}ms", number_str(value)))
371 }
372}
373
374fn format_rfc3339_ms(ms: i64) -> String {
376 use chrono::{DateTime, Utc};
377 let secs = ms.div_euclid(1000);
378 let nanos = (ms.rem_euclid(1000) * 1_000_000) as u32;
379 match DateTime::from_timestamp(secs, nanos) {
380 Some(dt) => dt
381 .with_timezone(&Utc)
382 .to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
383 None => ms.to_string(),
384 }
385}
386
387fn format_bytes_human(bytes: i64) -> String {
389 const KB: f64 = 1024.0;
390 const MB: f64 = KB * 1024.0;
391 const GB: f64 = MB * 1024.0;
392 const TB: f64 = GB * 1024.0;
393
394 let sign = if bytes < 0 { "-" } else { "" };
395 let b = (bytes as f64).abs();
396 if b >= TB {
397 format!("{sign}{:.1}TB", b / TB)
398 } else if b >= GB {
399 format!("{sign}{:.1}GB", b / GB)
400 } else if b >= MB {
401 format!("{sign}{:.1}MB", b / MB)
402 } else if b >= KB {
403 format!("{sign}{:.1}KB", b / KB)
404 } else {
405 format!("{bytes}B")
406 }
407}
408
409fn format_with_commas(n: u64) -> String {
411 let s = n.to_string();
412 let mut result = String::with_capacity(s.len() + s.len() / 3);
413 for (i, c) in s.chars().enumerate() {
414 if i > 0 && (s.len() - i).is_multiple_of(3) {
415 result.push(',');
416 }
417 result.push(c);
418 }
419 result
420}
421
422fn extract_currency_code(key: &str) -> Option<&str> {
424 let without_cents = key
425 .strip_suffix("_cents")
426 .or_else(|| key.strip_suffix("_CENTS"))?;
427 let last_underscore = without_cents.rfind('_')?;
428 let code = &without_cents[last_underscore + 1..];
429 if code.is_empty() {
430 return None;
431 }
432 Some(code)
433}
434
435fn render_yaml_processed(value: &Value, indent: usize, lines: &mut Vec<String>) {
440 let prefix = " ".repeat(indent);
441 match value {
442 Value::Object(map) => {
443 let processed = process_object_fields(map);
444 for (display_key, v, formatted) in processed {
445 if let Some(fv) = formatted {
446 lines.push(format!(
447 "{}{}: \"{}\"",
448 prefix,
449 display_key,
450 escape_yaml_str(&fv)
451 ));
452 } else {
453 match v {
454 Value::Object(inner) if !inner.is_empty() => {
455 lines.push(format!("{}{}:", prefix, display_key));
456 render_yaml_processed(v, indent + 1, lines);
457 }
458 Value::Object(_) => {
459 lines.push(format!("{}{}: {{}}", prefix, display_key));
460 }
461 Value::Array(arr) => {
462 if arr.is_empty() {
463 lines.push(format!("{}{}: []", prefix, display_key));
464 } else {
465 lines.push(format!("{}{}:", prefix, display_key));
466 for item in arr {
467 if item.is_object() {
468 lines.push(format!("{} -", prefix));
469 render_yaml_processed(item, indent + 2, lines);
470 } else {
471 lines.push(format!(
472 "{} - {}",
473 prefix,
474 yaml_scalar(item)
475 ));
476 }
477 }
478 }
479 }
480 _ => {
481 lines.push(format!(
482 "{}{}: {}",
483 prefix,
484 display_key,
485 yaml_scalar(v)
486 ));
487 }
488 }
489 }
490 }
491 }
492 _ => {
493 lines.push(format!("{}{}", prefix, yaml_scalar(value)));
494 }
495 }
496}
497
498fn escape_yaml_str(s: &str) -> String {
499 s.replace('\\', "\\\\")
500 .replace('"', "\\\"")
501 .replace('\n', "\\n")
502 .replace('\r', "\\r")
503 .replace('\t', "\\t")
504}
505
506fn yaml_scalar(value: &Value) -> String {
507 match value {
508 Value::String(s) => {
509 format!("\"{}\"", escape_yaml_str(s))
510 }
511 Value::Null => "null".to_string(),
512 Value::Bool(b) => b.to_string(),
513 Value::Number(n) => n.to_string(),
514 other => format!("\"{}\"", other.to_string().replace('"', "\\\"")),
515 }
516}
517
518fn collect_plain_pairs(value: &Value, prefix: &str, pairs: &mut Vec<(String, String)>) {
523 if let Value::Object(map) = value {
524 let processed = process_object_fields(map);
525 for (display_key, v, formatted) in processed {
526 let full_key = if prefix.is_empty() {
527 display_key
528 } else {
529 format!("{}.{}", prefix, display_key)
530 };
531 if let Some(fv) = formatted {
532 pairs.push((full_key, fv));
533 } else {
534 match v {
535 Value::Object(_) => collect_plain_pairs(v, &full_key, pairs),
536 Value::Array(arr) => {
537 let joined = arr.iter().map(plain_scalar).collect::<Vec<_>>().join(",");
538 pairs.push((full_key, joined));
539 }
540 Value::Null => pairs.push((full_key, String::new())),
541 _ => pairs.push((full_key, plain_scalar(v))),
542 }
543 }
544 }
545 }
546}
547
548fn plain_scalar(value: &Value) -> String {
549 match value {
550 Value::String(s) => s.clone(),
551 Value::Null => "null".to_string(),
552 Value::Bool(b) => b.to_string(),
553 Value::Number(n) => n.to_string(),
554 other => other.to_string(),
555 }
556}
557
558#[cfg(test)]
559mod tests;