Skip to main content

agent_first_data/
lib.rs

1//! Agent-First Data (AFDATA) output formatting and protocol templates.
2//!
3//! 23 public APIs and 5 types (+ 2 optional help renderers):
4//! - 3 protocol builders: [`build_json_ok`], [`build_json_error`], [`build_json`]
5//! - 3 value-copy redactors: [`redacted_value`], [`redacted_value_with`], [`redacted_value_with_options`]
6//! - 7 output formatters: [`output_json`], [`output_json_with`], [`output_json_with_options`],
7//!   [`output_yaml`], [`output_yaml_with_options`], [`output_plain`], [`output_plain_with_options`]
8//! - 2 in-place value redactors: [`internal_redact_secrets`], [`internal_redact_secrets_with_options`]
9//!   (these redact `_secret` and `_url` fields in a JSON value)
10//! - 2 URL-string redactors: [`redact_url_secrets`], [`redact_url_secrets_with_options`]
11//!   (operate on one URL string; the value redactors above apply these to `_url` fields)
12//! - 1 parse utility: [`parse_size`]
13//! - 5 CLI helpers: [`cli_parse_output`], [`cli_parse_log_filters`], [`cli_output`],
14//!   [`cli_output_with_options`], [`build_cli_error`]
15//! - 5 types: [`OutputFormat`], [`RedactionPolicy`], [`RedactionOptions`],
16//!   [`OutputStyle`], [`OutputOptions`]
17//! - (feature `cli-help`): [`cli_render_help`] — recursive plain-text help for clap commands
18//! - (feature `cli-help-markdown`): [`cli_render_help_markdown`] — recursive Markdown help
19
20#[cfg(feature = "tracing")]
21pub mod afdata_tracing;
22
23use serde_json::Value;
24use std::collections::HashSet;
25
26// ═══════════════════════════════════════════
27// Public API: Protocol Builders
28// ═══════════════════════════════════════════
29
30/// Build `{code: "ok", result: ..., trace?: ...}`.
31pub fn build_json_ok(result: Value, trace: Option<Value>) -> Value {
32    match trace {
33        Some(t) => serde_json::json!({"code": "ok", "result": result, "trace": t}),
34        None => serde_json::json!({"code": "ok", "result": result}),
35    }
36}
37
38/// Build `{code: "error", error: message, hint?: ..., trace?: ...}`.
39pub fn build_json_error(message: &str, hint: Option<&str>, trace: Option<Value>) -> Value {
40    let mut obj = serde_json::Map::new();
41    obj.insert("code".to_string(), Value::String("error".to_string()));
42    obj.insert("error".to_string(), Value::String(message.to_string()));
43    if let Some(h) = hint {
44        obj.insert("hint".to_string(), Value::String(h.to_string()));
45    }
46    if let Some(t) = trace {
47        obj.insert("trace".to_string(), t);
48    }
49    Value::Object(obj)
50}
51
52/// Build `{code: "<custom>", ...fields, trace?: ...}`.
53pub fn build_json(code: &str, fields: Value, trace: Option<Value>) -> Value {
54    let mut obj = match fields {
55        Value::Object(map) => map,
56        _ => serde_json::Map::new(),
57    };
58    obj.insert("code".to_string(), Value::String(code.to_string()));
59    if let Some(t) = trace {
60        obj.insert("trace".to_string(), t);
61    }
62    Value::Object(obj)
63}
64
65// ═══════════════════════════════════════════
66// Public API: Output Formatters
67// ═══════════════════════════════════════════
68
69/// Redaction policy for [`output_json_with`].
70#[derive(Clone, Copy, Debug, PartialEq, Eq)]
71pub enum RedactionPolicy {
72    /// Redact only inside top-level `trace`.
73    RedactionTraceOnly,
74    /// Do not redact any fields.
75    RedactionNone,
76    /// Replace every `_secret` subtree with `"***"`.
77    RedactionStrict,
78}
79
80/// Redaction options for legacy secret field names.
81#[derive(Clone, Debug, Default, PartialEq, Eq)]
82pub struct RedactionOptions {
83    /// Optional scoped policy. `None` means default full redaction.
84    pub policy: Option<RedactionPolicy>,
85    /// Field names to treat as secrets in addition to `_secret` suffixes.
86    ///
87    /// Matching is exact field-name equality at any nesting level. The same
88    /// list also matches URL query-parameter names inside `_url` fields (see
89    /// [`redact_url_secrets`]).
90    pub secret_names: Vec<String>,
91}
92
93/// Rendering style for YAML and plain output.
94#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
95pub enum OutputStyle {
96    /// Human-readable AFDATA rendering: strip suffixes and format values.
97    #[default]
98    Readable,
99    /// Schema-preserving rendering: keep keys and values unchanged after redaction.
100    Raw,
101}
102
103/// Output options combining redaction and rendering style.
104#[derive(Clone, Debug, Default, PartialEq, Eq)]
105pub struct OutputOptions {
106    /// Redaction options applied before rendering.
107    pub redaction: RedactionOptions,
108    /// Rendering style for YAML and plain output.
109    pub style: OutputStyle,
110}
111
112/// Format as single-line JSON with full `_secret` redaction.
113pub fn output_json(value: &Value) -> String {
114    serialize_json_output(&redacted_value(value))
115}
116
117/// Format as single-line JSON with configurable redaction policy.
118pub fn output_json_with(value: &Value, redaction_policy: RedactionPolicy) -> String {
119    serialize_json_output(&redacted_value_with(value, redaction_policy))
120}
121
122/// Format as single-line JSON with configurable output options.
123///
124/// JSON output ignores [`OutputStyle`] and always preserves original keys and values after
125/// redaction.
126pub fn output_json_with_options(value: &Value, output_options: &OutputOptions) -> String {
127    serialize_json_output(&redacted_value_with_options(
128        value,
129        &output_options.redaction,
130    ))
131}
132
133fn serialize_json_output(value: &Value) -> String {
134    match serde_json::to_string(value) {
135        Ok(s) => s,
136        Err(err) => serde_json::json!({
137            "error": "output_json_failed",
138            "detail": err.to_string(),
139        })
140        .to_string(),
141    }
142}
143
144/// Format as multi-line YAML. Keys stripped, values formatted, secrets redacted.
145pub fn output_yaml(value: &Value) -> String {
146    output_yaml_with_options(value, &OutputOptions::default())
147}
148
149/// Format as multi-line YAML with configurable output options.
150pub fn output_yaml_with_options(value: &Value, output_options: &OutputOptions) -> String {
151    let mut lines = vec!["---".to_string()];
152    let v = redacted_value_with_options(value, &output_options.redaction);
153    match output_options.style {
154        OutputStyle::Readable => render_yaml_processed(&v, 0, &mut lines),
155        OutputStyle::Raw => render_yaml_raw(&v, 0, &mut lines),
156    }
157    lines.join("\n")
158}
159
160/// Format as single-line logfmt. Keys stripped, values formatted, secrets redacted.
161pub fn output_plain(value: &Value) -> String {
162    output_plain_with_options(value, &OutputOptions::default())
163}
164
165/// Format as single-line logfmt with configurable output options.
166pub fn output_plain_with_options(value: &Value, output_options: &OutputOptions) -> String {
167    let mut pairs: Vec<(String, String)> = Vec::new();
168    let v = redacted_value_with_options(value, &output_options.redaction);
169    match output_options.style {
170        OutputStyle::Readable => collect_plain_pairs(&v, "", &mut pairs),
171        OutputStyle::Raw => collect_plain_pairs_raw(&v, "", &mut pairs),
172    }
173    pairs.sort_by(|(a, _), (b, _)| a.encode_utf16().cmp(b.encode_utf16()));
174    pairs
175        .into_iter()
176        .map(|(k, v)| format!("{}={}", k, quote_logfmt_value(&v)))
177        .collect::<Vec<_>>()
178        .join(" ")
179}
180
181// ═══════════════════════════════════════════
182// Public API: Redaction & Utility
183// ═══════════════════════════════════════════
184
185/// Redact `_secret` fields in-place.
186pub fn internal_redact_secrets(value: &mut Value) {
187    redact_secrets(value);
188}
189
190/// Redact secret fields in-place using configurable redaction options.
191pub fn internal_redact_secrets_with_options(
192    value: &mut Value,
193    redaction_options: &RedactionOptions,
194) {
195    apply_redaction_options(value, redaction_options);
196}
197
198/// Return a JSON value copy with default `_secret` redaction applied.
199pub fn redacted_value(value: &Value) -> Value {
200    let mut v = value.clone();
201    redact_secrets(&mut v);
202    v
203}
204
205/// Return a JSON value copy with an explicit redaction policy applied.
206pub fn redacted_value_with(value: &Value, redaction_policy: RedactionPolicy) -> Value {
207    let mut v = value.clone();
208    apply_redaction_policy(&mut v, redaction_policy);
209    v
210}
211
212/// Return a JSON value copy with configurable redaction options applied.
213pub fn redacted_value_with_options(value: &Value, redaction_options: &RedactionOptions) -> Value {
214    let mut v = value.clone();
215    apply_redaction_options(&mut v, redaction_options);
216    v
217}
218
219/// Redact secret components of a single URL string, using default options.
220///
221/// Returns `url` with its userinfo password and any `_secret`-suffixed query
222/// parameter values replaced by `***`. See [`redact_url_secrets_with_options`].
223pub fn redact_url_secrets(url: &str) -> String {
224    redact_url_secrets_with_options(url, &RedactionOptions::default())
225}
226
227/// Redact secret components of a single URL string.
228///
229/// A query parameter is redacted iff its (form-decoded) name ends in
230/// `_secret`/`_SECRET` or matches an exact entry in `secret_names`. The
231/// userinfo password (`scheme://user:pass@host`) is always redacted as a
232/// structural rule. Only the secret spans are replaced with `***`; every other
233/// byte is preserved. A string that is not a single, whitespace-free,
234/// scheme-prefixed URL (including a URL embedded in surrounding prose) is
235/// returned unchanged.
236pub fn redact_url_secrets_with_options(url: &str, redaction_options: &RedactionOptions) -> String {
237    let context = RedactionContext::from_options(redaction_options);
238    redact_url_in_str(url, &context).unwrap_or_else(|| url.to_string())
239}
240
241/// Parse a human-readable size string into bytes.
242///
243/// Accepts bare number, or number followed by unit letter
244/// (`B`, `K`, `M`, `G`, `T`). Case-insensitive. Trims whitespace.
245/// Returns `None` for invalid or negative input.
246pub fn parse_size(s: &str) -> Option<u64> {
247    let s = s.trim();
248    if s.is_empty() {
249        return None;
250    }
251    let last = *s.as_bytes().last()?;
252    let (num_str, mult) = match last {
253        b'B' | b'b' => (&s[..s.len() - 1], 1u64),
254        b'K' | b'k' => (&s[..s.len() - 1], 1024),
255        b'M' | b'm' => (&s[..s.len() - 1], 1024 * 1024),
256        b'G' | b'g' => (&s[..s.len() - 1], 1024 * 1024 * 1024),
257        b'T' | b't' => (&s[..s.len() - 1], 1024u64 * 1024 * 1024 * 1024),
258        b'0'..=b'9' | b'.' => (s, 1),
259        _ => return None,
260    };
261    if num_str.is_empty() {
262        return None;
263    }
264    if let Ok(n) = num_str.parse::<u64>() {
265        return n.checked_mul(mult);
266    }
267    // Integer overflow must not silently fall back to float parsing.
268    if !num_str.contains('.') && !num_str.contains('e') && !num_str.contains('E') {
269        return None;
270    }
271    let f: f64 = num_str.parse().ok()?;
272    if f < 0.0 || f.is_nan() || f.is_infinite() {
273        return None;
274    }
275    let result = f * mult as f64;
276    if result >= u64::MAX as f64 {
277        return None;
278    }
279    Some(result as u64)
280}
281
282// ═══════════════════════════════════════════
283// Public API: CLI Helpers
284// ═══════════════════════════════════════════
285
286/// Output format for CLI and pipe/MCP modes.
287#[derive(Clone, Copy, Debug, PartialEq, Eq)]
288pub enum OutputFormat {
289    Json,
290    Yaml,
291    Plain,
292}
293
294/// Parse `--output` flag value into [`OutputFormat`].
295///
296/// Returns `Err` with a message suitable for passing to [`build_cli_error`] on unknown values.
297///
298/// ```
299/// use agent_first_data::{cli_parse_output, OutputFormat};
300/// assert!(matches!(cli_parse_output("json"), Ok(OutputFormat::Json)));
301/// assert!(cli_parse_output("xml").is_err());
302/// ```
303pub fn cli_parse_output(s: &str) -> Result<OutputFormat, String> {
304    match s {
305        "json" => Ok(OutputFormat::Json),
306        "yaml" => Ok(OutputFormat::Yaml),
307        "plain" => Ok(OutputFormat::Plain),
308        _ => Err(format!(
309            "invalid --output format '{s}': expected json, yaml, or plain"
310        )),
311    }
312}
313
314/// Normalize `--log` flag entries: trim, lowercase, deduplicate, remove empty.
315///
316/// Accepts pre-split entries as produced by clap's `value_delimiter = ','`.
317///
318/// ```
319/// use agent_first_data::cli_parse_log_filters;
320/// let f = cli_parse_log_filters(&["Query", " error ", "query"]);
321/// assert_eq!(f, vec!["query", "error"]);
322/// ```
323pub fn cli_parse_log_filters<S: AsRef<str>>(entries: &[S]) -> Vec<String> {
324    let mut out: Vec<String> = Vec::new();
325    for entry in entries {
326        let s = entry.as_ref().trim().to_ascii_lowercase();
327        if !s.is_empty() && !out.contains(&s) {
328            out.push(s);
329        }
330    }
331    out
332}
333
334/// Dispatch output formatting by [`OutputFormat`].
335///
336/// Equivalent to calling [`output_json`], [`output_yaml`], or [`output_plain`] directly.
337///
338/// ```
339/// use agent_first_data::{cli_output, OutputFormat};
340/// let v = serde_json::json!({"code": "ok"});
341/// let s = cli_output(&v, OutputFormat::Plain);
342/// assert!(s.contains("code=ok"));
343/// ```
344pub fn cli_output(value: &Value, format: OutputFormat) -> String {
345    match format {
346        OutputFormat::Json => output_json(value),
347        OutputFormat::Yaml => output_yaml(value),
348        OutputFormat::Plain => output_plain(value),
349    }
350}
351
352/// Dispatch output formatting by [`OutputFormat`] with configurable output options.
353///
354/// JSON output ignores [`OutputStyle`] and always preserves original keys and values after
355/// redaction. YAML and plain output use the requested style.
356pub fn cli_output_with_options(
357    value: &Value,
358    format: OutputFormat,
359    output_options: &OutputOptions,
360) -> String {
361    match format {
362        OutputFormat::Json => output_json_with_options(value, output_options),
363        OutputFormat::Yaml => output_yaml_with_options(value, output_options),
364        OutputFormat::Plain => output_plain_with_options(value, output_options),
365    }
366}
367
368/// Build a standard CLI parse error value.
369///
370/// Use when `Cli::try_parse()` fails or a flag value is invalid.
371/// Print with [`output_json`] and exit with code 2.
372///
373/// ```
374/// let err = agent_first_data::build_cli_error("--output: invalid value 'xml'", None);
375/// assert_eq!(err["code"], "error");
376/// assert_eq!(err["error_code"], "invalid_request");
377/// assert_eq!(err["retryable"], false);
378/// ```
379pub fn build_cli_error(message: &str, hint: Option<&str>) -> Value {
380    let mut obj = serde_json::Map::new();
381    obj.insert("code".to_string(), Value::String("error".to_string()));
382    obj.insert(
383        "error_code".to_string(),
384        Value::String("invalid_request".to_string()),
385    );
386    obj.insert("error".to_string(), Value::String(message.to_string()));
387    if let Some(h) = hint {
388        obj.insert("hint".to_string(), Value::String(h.to_string()));
389    }
390    obj.insert("retryable".to_string(), Value::Bool(false));
391    obj.insert("trace".to_string(), serde_json::json!({"duration_ms": 0}));
392    Value::Object(obj)
393}
394
395// ═══════════════════════════════════════════
396// Public API: CLI Help Rendering (optional)
397// ═══════════════════════════════════════════
398
399/// Render recursive plain-text help for a clap command tree.
400///
401/// Walks to the subcommand identified by `subcommand_path` (empty = root),
402/// then recursively expands all descendant subcommands into a single output.
403/// Agents read `--help` once and get the complete CLI interface.
404///
405/// Requires the `cli-help` feature.
406#[cfg(feature = "cli-help")]
407pub fn cli_render_help(cmd: &clap::Command, subcommand_path: &[&str]) -> String {
408    let target = walk_to_subcommand(cmd, subcommand_path);
409    let mut buf = String::new();
410    render_help_recursive(target, &[], &mut buf, true);
411    buf
412}
413
414/// Render recursive Markdown help for a clap command tree.
415///
416/// Same tree walk as [`cli_render_help`], but outputs Markdown suitable for
417/// documentation generation (`myapp --help-markdown > docs/cli.md`).
418///
419/// Requires the `cli-help-markdown` feature.
420#[cfg(feature = "cli-help-markdown")]
421pub fn cli_render_help_markdown(cmd: &clap::Command, subcommand_path: &[&str]) -> String {
422    let target = walk_to_subcommand(cmd, subcommand_path);
423    let md = clap_markdown::help_markdown_command(target);
424    // Strip the clap-markdown footer (<hr/> + <small>...</small>)
425    md.rfind("\n<hr/>")
426        .map_or(md.clone(), |pos| md[..pos].to_string())
427}
428
429#[cfg(feature = "cli-help")]
430fn walk_to_subcommand<'a>(cmd: &'a clap::Command, path: &[&str]) -> &'a clap::Command {
431    let mut current = cmd;
432    for name in path {
433        current = current.find_subcommand(name).unwrap_or(current);
434    }
435    current
436}
437
438#[cfg(feature = "cli-help")]
439fn render_help_recursive(
440    cmd: &clap::Command,
441    parent_path: &[&str],
442    buf: &mut String,
443    is_root: bool,
444) {
445    use std::fmt::Write;
446
447    // Build the full command path (e.g. "myapp service start")
448    let mut cmd_path = parent_path.to_vec();
449    cmd_path.push(cmd.get_name());
450    let path_str = cmd_path.join(" ");
451
452    // Separator between commands (skip for the first one)
453    if !buf.is_empty() {
454        let _ = writeln!(buf);
455        let _ = writeln!(buf, "{}", "═".repeat(60));
456    }
457
458    // Header: "myapp service start — description"
459    if let Some(about) = cmd.get_about() {
460        let _ = writeln!(buf, "{path_str} — {about}");
461    } else {
462        let _ = writeln!(buf, "{path_str}");
463    }
464    let _ = writeln!(buf);
465
466    // Render clap's built-in help for this command (usage, args, options)
467    let styled = cmd.clone().render_long_help();
468    let help_text = styled.to_string();
469
470    // In root command, insert --help-markdown after the "Print help" line
471    if is_root {
472        let mut found_help = false;
473        for line in help_text.lines() {
474            let _ = writeln!(buf, "{line}");
475            if line.trim_start().starts_with("-h, --help") {
476                found_help = true;
477            } else if found_help && line.contains("Print help") {
478                let _ = writeln!(buf, "      --help-markdown");
479                let _ = writeln!(
480                    buf,
481                    "          Output help as Markdown (for documentation generation)"
482                );
483                found_help = false;
484            } else {
485                found_help = false;
486            }
487        }
488    } else {
489        let _ = write!(buf, "{help_text}");
490    }
491
492    // Recurse into visible subcommands
493    for sub in cmd.get_subcommands() {
494        if sub.get_name() == "help" {
495            continue; // skip clap's auto-generated "help" subcommand
496        }
497        render_help_recursive(sub, &cmd_path, buf, false);
498    }
499}
500
501// ═══════════════════════════════════════════
502// Secret Redaction
503// ═══════════════════════════════════════════
504
505#[derive(Default)]
506struct RedactionContext {
507    secret_names: HashSet<String>,
508}
509
510impl RedactionContext {
511    fn from_options(redaction_options: &RedactionOptions) -> Self {
512        let secret_names = redaction_options.secret_names.iter().cloned().collect();
513        Self { secret_names }
514    }
515
516    fn is_secret_key(&self, key: &str) -> bool {
517        key_has_secret_suffix(key) || self.secret_names.contains(key)
518    }
519}
520
521fn key_has_secret_suffix(key: &str) -> bool {
522    key.ends_with("_secret") || key.ends_with("_SECRET")
523}
524
525fn key_has_url_suffix(key: &str) -> bool {
526    key.ends_with("_url") || key.ends_with("_URL")
527}
528
529fn redact_secrets(value: &mut Value) {
530    let context = RedactionContext::default();
531    redact_secrets_with_context(value, &context);
532}
533
534fn redact_secrets_with_context(value: &mut Value, context: &RedactionContext) {
535    match value {
536        Value::Object(map) => {
537            let keys: Vec<String> = map.keys().cloned().collect();
538            for key in keys {
539                if context.is_secret_key(&key) {
540                    match map.get(&key) {
541                        Some(Value::Object(_)) | Some(Value::Array(_)) => {
542                            // Traverse containers, don't replace
543                        }
544                        _ => {
545                            map.insert(key.clone(), Value::String("***".into()));
546                            continue;
547                        }
548                    }
549                } else if key_has_url_suffix(&key) {
550                    if let Some(Value::String(s)) = map.get_mut(&key) {
551                        if let Some(redacted) = redact_url_in_str(s, context) {
552                            *s = redacted;
553                        }
554                        continue;
555                    }
556                }
557                if let Some(v) = map.get_mut(&key) {
558                    redact_secrets_with_context(v, context);
559                }
560            }
561        }
562        Value::Array(arr) => {
563            for v in arr {
564                redact_secrets_with_context(v, context);
565            }
566        }
567        _ => {}
568    }
569}
570
571fn redact_secrets_strict_with_context(value: &mut Value, context: &RedactionContext) {
572    match value {
573        Value::Object(map) => {
574            let keys: Vec<String> = map.keys().cloned().collect();
575            for key in keys {
576                if context.is_secret_key(&key) {
577                    map.insert(key, Value::String("***".into()));
578                } else if key_has_url_suffix(&key) {
579                    if let Some(Value::String(s)) = map.get_mut(&key) {
580                        if let Some(redacted) = redact_url_in_str(s, context) {
581                            *s = redacted;
582                        }
583                    } else if let Some(v) = map.get_mut(&key) {
584                        redact_secrets_strict_with_context(v, context);
585                    }
586                } else if let Some(v) = map.get_mut(&key) {
587                    redact_secrets_strict_with_context(v, context);
588                }
589            }
590        }
591        Value::Array(arr) => {
592            for v in arr {
593                redact_secrets_strict_with_context(v, context);
594            }
595        }
596        _ => {}
597    }
598}
599
600/// Redact secret components of a single URL string, returning `Some(redacted)`
601/// when `s` is a processable URL, or `None` when it is not (so callers can keep
602/// the original). Only secret spans change; all other bytes are preserved.
603fn redact_url_in_str(s: &str, context: &RedactionContext) -> Option<String> {
604    // Fast path + precondition: a single, whitespace-free, scheme-prefixed URL.
605    if !s.contains("://") || !is_single_url(s) || url::Url::parse(s).is_err() {
606        return None;
607    }
608    let scheme_sep = s.find("://")?;
609    let scheme = &s[..scheme_sep];
610    let rest = &s[scheme_sep + 3..];
611
612    // Authority runs from after "://" to the first '/', '?', or '#'.
613    let auth_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
614    let authority = &rest[..auth_end];
615    let remainder = &rest[auth_end..];
616
617    let new_authority = redact_userinfo_password(authority);
618
619    // Query runs from the first '?' to the first '#' (or end).
620    let new_remainder = match remainder.find('?') {
621        Some(q) => {
622            let (path, q_onwards) = remainder.split_at(q);
623            let query_body = &q_onwards[1..];
624            let (query, fragment) = match query_body.find('#') {
625                Some(h) => (&query_body[..h], &query_body[h..]),
626                None => (query_body, ""),
627            };
628            format!("{path}?{}{fragment}", redact_query(query, context))
629        }
630        None => remainder.to_string(),
631    };
632
633    Some(format!("{scheme}://{new_authority}{new_remainder}"))
634}
635
636/// Replace the userinfo password (`user:pass@`) with `***`, preserving the
637/// username. Authority without `@`, or userinfo without `:`, is unchanged.
638fn redact_userinfo_password(authority: &str) -> String {
639    let Some(at) = authority.find('@') else {
640        return authority.to_string();
641    };
642    let userinfo = &authority[..at];
643    match userinfo.find(':') {
644        Some(colon) => format!("{}:***{}", &authority[..colon], &authority[at..]),
645        None => authority.to_string(),
646    }
647}
648
649/// Redact the values of secret-named query parameters, preserving raw bytes of
650/// every other segment (keys, benign values, encoding, ordering, separators).
651fn redact_query(query: &str, context: &RedactionContext) -> String {
652    query
653        .split('&')
654        .map(|segment| {
655            let Some(eq) = segment.find('=') else {
656                return segment.to_string();
657            };
658            let raw_key = &segment[..eq];
659            // Form-decode the name (`+` → space, percent-decode) for the check.
660            let name = url::form_urlencoded::parse(segment.as_bytes())
661                .next()
662                .map(|(k, _)| k.into_owned())
663                .unwrap_or_default();
664            if context.is_secret_key(&name) {
665                format!("{raw_key}=***")
666            } else {
667                segment.to_string()
668            }
669        })
670        .collect::<Vec<_>>()
671        .join("&")
672}
673
674/// True when `s` begins with a URL scheme (`ALPHA *(ALPHA / DIGIT / "+" / "-" /
675/// ".") "://"`) and contains no ASCII whitespace — i.e. a single bare URL, not
676/// a URL embedded in prose.
677fn is_single_url(s: &str) -> bool {
678    if s.bytes().any(|b| b.is_ascii_whitespace()) {
679        return false;
680    }
681    let bytes = s.as_bytes();
682    if !bytes.first().is_some_and(|b| b.is_ascii_alphabetic()) {
683        return false;
684    }
685    let mut i = 1;
686    while i < bytes.len() {
687        let c = bytes[i];
688        if c.is_ascii_alphanumeric() || matches!(c, b'+' | b'-' | b'.') {
689            i += 1;
690        } else {
691            break;
692        }
693    }
694    s[i..].starts_with("://")
695}
696
697fn apply_redaction_policy(value: &mut Value, redaction_policy: RedactionPolicy) {
698    let context = RedactionContext::default();
699    apply_redaction_policy_with_context(value, Some(redaction_policy), &context);
700}
701
702fn apply_redaction_options(value: &mut Value, redaction_options: &RedactionOptions) {
703    let context = RedactionContext::from_options(redaction_options);
704    apply_redaction_policy_with_context(value, redaction_options.policy, &context);
705}
706
707fn apply_redaction_policy_with_context(
708    value: &mut Value,
709    redaction_policy: Option<RedactionPolicy>,
710    context: &RedactionContext,
711) {
712    match redaction_policy {
713        Some(RedactionPolicy::RedactionTraceOnly) => {
714            if let Value::Object(map) = value {
715                if let Some(trace) = map.get_mut("trace") {
716                    redact_secrets_with_context(trace, context);
717                }
718            }
719        }
720        Some(RedactionPolicy::RedactionNone) => {}
721        Some(RedactionPolicy::RedactionStrict) => {
722            redact_secrets_strict_with_context(value, context)
723        }
724        None => redact_secrets_with_context(value, context),
725    }
726}
727
728// ═══════════════════════════════════════════
729// Suffix Processing
730// ═══════════════════════════════════════════
731
732/// Strip a suffix matching exact lowercase or exact uppercase only.
733fn strip_suffix_ci(key: &str, suffix_lower: &str) -> Option<String> {
734    if let Some(s) = key.strip_suffix(suffix_lower) {
735        return Some(s.to_string());
736    }
737    let suffix_upper: String = suffix_lower
738        .chars()
739        .map(|c| c.to_ascii_uppercase())
740        .collect();
741    if let Some(s) = key.strip_suffix(&suffix_upper) {
742        return Some(s.to_string());
743    }
744    None
745}
746
747/// Extract currency code from `_{code}_cents` / `_{CODE}_CENTS` pattern.
748fn try_strip_generic_cents(key: &str) -> Option<(String, String)> {
749    let code = extract_currency_code(key)?;
750    let suffix_len = code.len() + "_cents".len() + 1; // _{code}_cents
751    let stripped = &key[..key.len() - suffix_len];
752    if stripped.is_empty() {
753        return None;
754    }
755    Some((stripped.to_string(), code.to_string()))
756}
757
758/// Try suffix-driven processing. Returns Some((stripped_key, formatted_value))
759/// when suffix matches and type is valid. None for no match or type mismatch.
760fn try_process_field(key: &str, value: &Value) -> Option<(String, String)> {
761    // Group 1: compound timestamp suffixes
762    if let Some(stripped) = strip_suffix_ci(key, "_epoch_ms") {
763        return value.as_i64().map(|ms| (stripped, format_rfc3339_ms(ms)));
764    }
765    if let Some(stripped) = strip_suffix_ci(key, "_epoch_s") {
766        return value
767            .as_i64()
768            .map(|s| (stripped, format_rfc3339_ms(s * 1000)));
769    }
770    if let Some(stripped) = strip_suffix_ci(key, "_epoch_ns") {
771        return value
772            .as_i64()
773            .map(|ns| (stripped, format_rfc3339_ms(ns.div_euclid(1_000_000))));
774    }
775
776    // Group 2: compound currency suffixes
777    if let Some(stripped) = strip_suffix_ci(key, "_usd_cents") {
778        return value
779            .as_u64()
780            .map(|n| (stripped, format!("${}.{:02}", n / 100, n % 100)));
781    }
782    if let Some(stripped) = strip_suffix_ci(key, "_eur_cents") {
783        return value
784            .as_u64()
785            .map(|n| (stripped, format!("€{}.{:02}", n / 100, n % 100)));
786    }
787    if let Some((stripped, code)) = try_strip_generic_cents(key) {
788        return value.as_u64().map(|n| {
789            (
790                stripped,
791                format!("{}.{:02} {}", n / 100, n % 100, code.to_uppercase()),
792            )
793        });
794    }
795
796    // Group 3: multi-char suffixes
797    if let Some(stripped) = strip_suffix_ci(key, "_rfc3339") {
798        return value.as_str().map(|s| (stripped, s.to_string()));
799    }
800    if let Some(stripped) = strip_suffix_ci(key, "_minutes") {
801        return value
802            .is_number()
803            .then(|| (stripped, format!("{} minutes", number_str(value))));
804    }
805    if let Some(stripped) = strip_suffix_ci(key, "_hours") {
806        return value
807            .is_number()
808            .then(|| (stripped, format!("{} hours", number_str(value))));
809    }
810    if let Some(stripped) = strip_suffix_ci(key, "_days") {
811        return value
812            .is_number()
813            .then(|| (stripped, format!("{} days", number_str(value))));
814    }
815
816    // Group 4: single-unit suffixes
817    if let Some(stripped) = strip_suffix_ci(key, "_msats") {
818        return value
819            .is_number()
820            .then(|| (stripped, format!("{}msats", number_str(value))));
821    }
822    if let Some(stripped) = strip_suffix_ci(key, "_sats") {
823        return value
824            .is_number()
825            .then(|| (stripped, format!("{}sats", number_str(value))));
826    }
827    if let Some(stripped) = strip_suffix_ci(key, "_bytes") {
828        return value.as_i64().map(|n| (stripped, format_bytes_human(n)));
829    }
830    if let Some(stripped) = strip_suffix_ci(key, "_percent") {
831        return value
832            .is_number()
833            .then(|| (stripped, format!("{}%", number_str(value))));
834    }
835    if let Some(stripped) = strip_suffix_ci(key, "_secret") {
836        return Some((stripped, "***".to_string()));
837    }
838
839    // Group 5: short suffixes (last to avoid false positives)
840    if let Some(stripped) = strip_suffix_ci(key, "_btc") {
841        return value
842            .is_number()
843            .then(|| (stripped, format!("{} BTC", number_str(value))));
844    }
845    if let Some(stripped) = strip_suffix_ci(key, "_jpy") {
846        return value
847            .as_u64()
848            .map(|n| (stripped, format!("¥{}", format_with_commas(n))));
849    }
850    if let Some(stripped) = strip_suffix_ci(key, "_ns") {
851        return value
852            .is_number()
853            .then(|| (stripped, format!("{}ns", number_str(value))));
854    }
855    if let Some(stripped) = strip_suffix_ci(key, "_us") {
856        return value
857            .is_number()
858            .then(|| (stripped, format!("{}μs", number_str(value))));
859    }
860    if let Some(stripped) = strip_suffix_ci(key, "_ms") {
861        return format_ms_value(value).map(|v| (stripped, v));
862    }
863    if let Some(stripped) = strip_suffix_ci(key, "_s") {
864        return value
865            .is_number()
866            .then(|| (stripped, format!("{}s", number_str(value))));
867    }
868
869    None
870}
871
872/// Process object fields: strip keys, format values, detect collisions.
873fn process_object_fields<'a>(
874    map: &'a serde_json::Map<String, Value>,
875) -> Vec<(String, &'a Value, Option<String>)> {
876    let mut entries: Vec<(String, &'a str, &'a Value, Option<String>)> = Vec::new();
877    for (key, value) in map {
878        match try_process_field(key, value) {
879            Some((stripped, formatted)) => {
880                entries.push((stripped, key.as_str(), value, Some(formatted)));
881            }
882            None => {
883                entries.push((key.clone(), key.as_str(), value, None));
884            }
885        }
886    }
887
888    // Detect collisions
889    let mut counts: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
890    for (stripped, _, _, _) in &entries {
891        *counts.entry(stripped.clone()).or_insert(0) += 1;
892    }
893
894    // Resolve collisions: revert both key and formatted value
895    let mut result: Vec<(String, &'a Value, Option<String>)> = entries
896        .into_iter()
897        .map(|(stripped, original, value, formatted)| {
898            if counts.get(&stripped).copied().unwrap_or(0) > 1 && original != stripped.as_str() {
899                (original.to_string(), value, None)
900            } else {
901                (stripped, value, formatted)
902            }
903        })
904        .collect();
905
906    result.sort_by(|(a, _, _), (b, _, _)| a.encode_utf16().cmp(b.encode_utf16()));
907    result
908}
909
910// ═══════════════════════════════════════════
911// Formatting Helpers
912// ═══════════════════════════════════════════
913
914fn number_str(value: &Value) -> String {
915    match value {
916        Value::Number(n) => n.to_string(),
917        _ => String::new(),
918    }
919}
920
921/// Format ms as seconds: 3 decimal places, trim trailing zeros, min 1 decimal.
922fn format_ms_as_seconds(ms: f64) -> String {
923    let formatted = format!("{:.3}", ms / 1000.0);
924    let trimmed = formatted.trim_end_matches('0');
925    if trimmed.ends_with('.') {
926        format!("{}0s", trimmed)
927    } else {
928        format!("{}s", trimmed)
929    }
930}
931
932/// Format `_ms` value: < 1000 → `{n}ms`, ≥ 1000 → seconds.
933fn format_ms_value(value: &Value) -> Option<String> {
934    let n = value.as_f64()?;
935    if n.abs() >= 1000.0 {
936        Some(format_ms_as_seconds(n))
937    } else if let Some(i) = value.as_i64() {
938        Some(format!("{}ms", i))
939    } else {
940        Some(format!("{}ms", number_str(value)))
941    }
942}
943
944/// Convert unix milliseconds (signed) to RFC 3339 with UTC timezone.
945fn format_rfc3339_ms(ms: i64) -> String {
946    use chrono::{DateTime, Utc};
947    let secs = ms.div_euclid(1000);
948    let nanos = (ms.rem_euclid(1000) * 1_000_000) as u32;
949    match DateTime::from_timestamp(secs, nanos) {
950        Some(dt) => dt
951            .with_timezone(&Utc)
952            .to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
953        None => ms.to_string(),
954    }
955}
956
957/// Format bytes as human-readable size (binary units). Handles negative values.
958fn format_bytes_human(bytes: i64) -> String {
959    const KB: f64 = 1024.0;
960    const MB: f64 = KB * 1024.0;
961    const GB: f64 = MB * 1024.0;
962    const TB: f64 = GB * 1024.0;
963
964    let sign = if bytes < 0 { "-" } else { "" };
965    let b = (bytes as f64).abs();
966    if b >= TB {
967        format!("{sign}{:.1}TB", b / TB)
968    } else if b >= GB {
969        format!("{sign}{:.1}GB", b / GB)
970    } else if b >= MB {
971        format!("{sign}{:.1}MB", b / MB)
972    } else if b >= KB {
973        format!("{sign}{:.1}KB", b / KB)
974    } else {
975        format!("{bytes}B")
976    }
977}
978
979/// Format a number with thousands separators.
980fn format_with_commas(n: u64) -> String {
981    let s = n.to_string();
982    let mut result = String::with_capacity(s.len() + s.len() / 3);
983    for (i, c) in s.chars().enumerate() {
984        if i > 0 && (s.len() - i).is_multiple_of(3) {
985            result.push(',');
986        }
987        result.push(c);
988    }
989    result
990}
991
992/// Extract currency code from a `_{code}_cents` / `_{CODE}_CENTS` suffix.
993fn extract_currency_code(key: &str) -> Option<&str> {
994    let without_cents = key
995        .strip_suffix("_cents")
996        .or_else(|| key.strip_suffix("_CENTS"))?;
997    let last_underscore = without_cents.rfind('_')?;
998    let code = &without_cents[last_underscore + 1..];
999    if code.is_empty() {
1000        return None;
1001    }
1002    Some(code)
1003}
1004
1005// ═══════════════════════════════════════════
1006// YAML Rendering
1007// ═══════════════════════════════════════════
1008
1009fn render_yaml_processed(value: &Value, indent: usize, lines: &mut Vec<String>) {
1010    let prefix = "  ".repeat(indent);
1011    match value {
1012        Value::Object(map) => {
1013            let processed = process_object_fields(map);
1014            for (display_key, v, formatted) in processed {
1015                if let Some(fv) = formatted {
1016                    lines.push(format!(
1017                        "{}{}: \"{}\"",
1018                        prefix,
1019                        display_key,
1020                        escape_yaml_str(&fv)
1021                    ));
1022                } else {
1023                    match v {
1024                        Value::Object(inner) if !inner.is_empty() => {
1025                            lines.push(format!("{}{}:", prefix, display_key));
1026                            render_yaml_processed(v, indent + 1, lines);
1027                        }
1028                        Value::Object(_) => {
1029                            lines.push(format!("{}{}: {{}}", prefix, display_key));
1030                        }
1031                        Value::Array(arr) => {
1032                            if arr.is_empty() {
1033                                lines.push(format!("{}{}: []", prefix, display_key));
1034                            } else {
1035                                lines.push(format!("{}{}:", prefix, display_key));
1036                                for item in arr {
1037                                    if item.is_object() {
1038                                        lines.push(format!("{}  -", prefix));
1039                                        render_yaml_processed(item, indent + 2, lines);
1040                                    } else {
1041                                        lines.push(format!("{}  - {}", prefix, yaml_scalar(item)));
1042                                    }
1043                                }
1044                            }
1045                        }
1046                        _ => {
1047                            lines.push(format!("{}{}: {}", prefix, display_key, yaml_scalar(v)));
1048                        }
1049                    }
1050                }
1051            }
1052        }
1053        _ => {
1054            lines.push(format!("{}{}", prefix, yaml_scalar(value)));
1055        }
1056    }
1057}
1058
1059fn render_yaml_raw(value: &Value, indent: usize, lines: &mut Vec<String>) {
1060    let prefix = "  ".repeat(indent);
1061    match value {
1062        Value::Object(map) => {
1063            for (key, v) in map {
1064                render_yaml_field_raw(&prefix, key, v, indent, lines);
1065            }
1066        }
1067        Value::Array(arr) => {
1068            render_yaml_array_raw(arr, indent, lines);
1069        }
1070        _ => {
1071            lines.push(format!("{}{}", prefix, yaml_scalar(value)));
1072        }
1073    }
1074}
1075
1076fn render_yaml_field_raw(
1077    prefix: &str,
1078    key: &str,
1079    value: &Value,
1080    indent: usize,
1081    lines: &mut Vec<String>,
1082) {
1083    match value {
1084        Value::Object(inner) if !inner.is_empty() => {
1085            lines.push(format!("{}{}:", prefix, key));
1086            render_yaml_raw(value, indent + 1, lines);
1087        }
1088        Value::Object(_) => {
1089            lines.push(format!("{}{}: {{}}", prefix, key));
1090        }
1091        Value::Array(arr) => {
1092            if arr.is_empty() {
1093                lines.push(format!("{}{}: []", prefix, key));
1094            } else {
1095                lines.push(format!("{}{}:", prefix, key));
1096                render_yaml_array_raw(arr, indent + 1, lines);
1097            }
1098        }
1099        _ => {
1100            lines.push(format!("{}{}: {}", prefix, key, yaml_scalar(value)));
1101        }
1102    }
1103}
1104
1105fn render_yaml_array_raw(arr: &[Value], indent: usize, lines: &mut Vec<String>) {
1106    let prefix = "  ".repeat(indent);
1107    for item in arr {
1108        match item {
1109            Value::Object(inner) if !inner.is_empty() => {
1110                lines.push(format!("{}-", prefix));
1111                render_yaml_raw(item, indent + 1, lines);
1112            }
1113            Value::Array(nested) if !nested.is_empty() => {
1114                lines.push(format!("{}-", prefix));
1115                render_yaml_array_raw(nested, indent + 1, lines);
1116            }
1117            Value::Object(_) => {
1118                lines.push(format!("{}- {{}}", prefix));
1119            }
1120            Value::Array(_) => {
1121                lines.push(format!("{}- []", prefix));
1122            }
1123            _ => {
1124                lines.push(format!("{}- {}", prefix, yaml_scalar(item)));
1125            }
1126        }
1127    }
1128}
1129
1130fn escape_yaml_str(s: &str) -> String {
1131    s.replace('\\', "\\\\")
1132        .replace('"', "\\\"")
1133        .replace('\n', "\\n")
1134        .replace('\r', "\\r")
1135        .replace('\t', "\\t")
1136}
1137
1138fn yaml_scalar(value: &Value) -> String {
1139    match value {
1140        Value::String(s) => {
1141            format!("\"{}\"", escape_yaml_str(s))
1142        }
1143        Value::Null => "null".to_string(),
1144        Value::Bool(b) => b.to_string(),
1145        Value::Number(n) => n.to_string(),
1146        other => format!("\"{}\"", other.to_string().replace('"', "\\\"")),
1147    }
1148}
1149
1150// ═══════════════════════════════════════════
1151// Plain Rendering (logfmt)
1152// ═══════════════════════════════════════════
1153
1154fn collect_plain_pairs(value: &Value, prefix: &str, pairs: &mut Vec<(String, String)>) {
1155    if let Value::Object(map) = value {
1156        let processed = process_object_fields(map);
1157        for (display_key, v, formatted) in processed {
1158            let full_key = if prefix.is_empty() {
1159                display_key
1160            } else {
1161                format!("{}.{}", prefix, display_key)
1162            };
1163            if let Some(fv) = formatted {
1164                pairs.push((full_key, fv));
1165            } else {
1166                match v {
1167                    Value::Object(_) => collect_plain_pairs(v, &full_key, pairs),
1168                    Value::Array(arr) => {
1169                        let joined = arr.iter().map(plain_scalar).collect::<Vec<_>>().join(",");
1170                        pairs.push((full_key, joined));
1171                    }
1172                    Value::Null => pairs.push((full_key, String::new())),
1173                    _ => pairs.push((full_key, plain_scalar(v))),
1174                }
1175            }
1176        }
1177    }
1178}
1179
1180fn collect_plain_pairs_raw(value: &Value, prefix: &str, pairs: &mut Vec<(String, String)>) {
1181    if let Value::Object(map) = value {
1182        for (key, v) in map {
1183            let full_key = if prefix.is_empty() {
1184                key.clone()
1185            } else {
1186                format!("{}.{}", prefix, key)
1187            };
1188            match v {
1189                Value::Object(_) => collect_plain_pairs_raw(v, &full_key, pairs),
1190                Value::Array(arr) => {
1191                    let joined = arr.iter().map(plain_scalar).collect::<Vec<_>>().join(",");
1192                    pairs.push((full_key, joined));
1193                }
1194                Value::Null => pairs.push((full_key, String::new())),
1195                _ => pairs.push((full_key, plain_scalar(v))),
1196            }
1197        }
1198    }
1199}
1200
1201fn plain_scalar(value: &Value) -> String {
1202    match value {
1203        Value::String(s) => s.clone(),
1204        Value::Null => "null".to_string(),
1205        Value::Bool(b) => b.to_string(),
1206        Value::Number(n) => n.to_string(),
1207        other => other.to_string(),
1208    }
1209}
1210
1211fn quote_logfmt_value(value: &str) -> String {
1212    if value.is_empty() {
1213        return String::new();
1214    }
1215    if !value
1216        .chars()
1217        .any(|c| c.is_whitespace() || matches!(c, '=' | '"' | '\\'))
1218    {
1219        return value.to_string();
1220    }
1221    let escaped = value
1222        .replace('\\', "\\\\")
1223        .replace('"', "\\\"")
1224        .replace('\n', "\\n")
1225        .replace('\r', "\\r")
1226        .replace('\t', "\\t");
1227    format!("\"{}\"", escaped)
1228}
1229
1230#[cfg(test)]
1231mod tests;