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