Skip to main content

libmagic_rs/output/format/
mod.rs

1// Copyright (c) 2025-2026 the libmagic-rs contributors
2// SPDX-License-Identifier: Apache-2.0
3
4//! Printf-style format specifier substitution for magic rule messages.
5//!
6//! Magic file messages frequently contain C-style format specifiers such as
7//! `%lld`, `%02x`, or `%s` that reference the rule's read value. GNU `file`
8//! renders the message with the value substituted at the specifier's
9//! position; without this pass libmagic-rs would emit the literal
10//! specifier tokens (e.g., `at_offset %lld`) and diverge visibly from
11//! `file(1)` output.
12//!
13//! The substitution is intentionally narrow: it supports the subset of
14//! C's `printf` syntax that appears in shipping magic corpora (notably
15//! `third_party/tests/searchbug.magic` and the GNU `file` `Magdir`
16//! collection). Unrecognized specifiers pass through literally with a
17//! `debug!` log rather than erroring -- matching the evaluator's
18//! graceful-skip discipline.
19//!
20//! Width masking for hex specifiers uses [`crate::parser::ast::TypeKind::bit_width`]
21//! so that e.g. a signed byte rendered with `%02x` produces the unsigned
22//! 8-bit interpretation (`0xff`, not `0xffffffffffffffff`).
23//!
24//! See the project plan at
25//! `docs/plans/2026-04-22-001-feat-meta-type-offset-and-format-substitution-plan.md`
26//! for scope, and GOTCHAS.md S14.2 for historical context.
27
28use log::debug;
29
30use crate::parser::ast::{TypeKind, Value};
31
32/// Substitute printf-style format specifiers in a magic rule message.
33///
34/// Walks `template` left to right. Plain text is copied verbatim; on
35/// each `%`, the full specifier (`%[flags][width][.precision][length]<conv>`)
36/// is parsed and substituted from `value`. `%%` emits a single `%`.
37/// Unrecognized or malformed specifiers are passed through literally
38/// with a `debug!` log.
39///
40/// `type_kind` is consulted only for hex specifiers, which need the
41/// natural bit width of the underlying read to mask sign-extended
42/// values correctly. For non-hex specifiers `type_kind` is ignored.
43///
44/// For a `%s` substitution against a `string`/`pstring`/`string16` value,
45/// the rendered field is bounded to at most `MAX_DESCRIPTION_FIELD_LEN`
46/// bytes (cut on a UTF-8 character boundary); `regex`/`search` values
47/// render through a separate, unbounded path and are exempt. This entry
48/// point never stops at a newline (R2's gate) -- use
49/// [`format_magic_message_with_gate`] to opt into that.
50///
51/// # Examples
52///
53/// ```
54/// use libmagic_rs::output::format::format_magic_message;
55/// use libmagic_rs::parser::ast::{TypeKind, Value};
56///
57/// let out = format_magic_message(
58///     "at_offset %lld",
59///     &Value::Uint(11),
60///     &TypeKind::Byte { signed: false },
61/// );
62/// assert_eq!(out, "at_offset 11");
63///
64/// let out = format_magic_message(
65///     "followed_by 0x%02x",
66///     &Value::Uint(0x31),
67///     &TypeKind::Byte { signed: false },
68/// );
69/// assert_eq!(out, "followed_by 0x31");
70///
71/// // Unknown specifier falls through literally.
72/// let out = format_magic_message("%q", &Value::Uint(0), &TypeKind::Byte { signed: false });
73/// assert_eq!(out, "%q");
74///
75/// // `%%` is an escaped literal percent.
76/// let out = format_magic_message("100%% sure", &Value::Uint(0), &TypeKind::Byte { signed: false });
77/// assert_eq!(out, "100% sure");
78/// ```
79#[must_use]
80pub fn format_magic_message(template: &str, value: &Value, type_kind: &TypeKind) -> String {
81    format_magic_message_with_gate(template, value, type_kind, false)
82}
83
84/// Substitute printf-style format specifiers in a magic rule message,
85/// additionally carrying R2's newline-stop gate signal.
86///
87/// Identical to [`format_magic_message`] except for `stop_at_newline`: when
88/// set, a `%s` substitution against a `string`/`pstring`/`string16` value
89/// additionally stops at the first `\r` or `\n` byte within the
90/// `MAX_DESCRIPTION_FIELD_LEN`-byte bound. The caller computes
91/// `stop_at_newline` from the rule's operator and pattern (see
92/// `evaluator::engine::value_eval::newline_stop_gate` in the crate
93/// source) -- neither reaches this formatter otherwise (R14).
94/// `regex`/`search` values ignore this flag entirely (R5).
95#[must_use]
96// Indexing is invariant-safe: every `bytes[i]` is guarded by an
97// `i < bytes.len()` loop condition.
98#[allow(clippy::indexing_slicing)]
99pub fn format_magic_message_with_gate(
100    template: &str,
101    value: &Value,
102    type_kind: &TypeKind,
103    stop_at_newline: bool,
104) -> String {
105    let mut out = String::with_capacity(template.len());
106    let bytes = template.as_bytes();
107    let mut i = 0;
108    // Start of the most recent run of non-`%` bytes. We copy the run
109    // as a string slice rather than byte-by-byte so non-ASCII UTF-8
110    // code points survive intact. Scanning still happens at the byte
111    // level (safe because `%` is ASCII 0x25 and cannot appear as a
112    // UTF-8 continuation byte, which is always >= 0x80).
113    let mut plain_start = 0;
114
115    while i < bytes.len() {
116        if bytes[i] != b'%' {
117            i += 1;
118            continue;
119        }
120
121        // Flush any pending plain-text run as a single UTF-8 slice.
122        if plain_start < i {
123            out.push_str(&template[plain_start..i]);
124        }
125
126        // Start of a format specifier at position i.
127        let spec_start = i;
128        let Some(parsed_spec) = parse_spec(bytes, i + 1) else {
129            // Malformed specifier (e.g., trailing `%` with nothing after,
130            // or a sequence that doesn't end in a valid conversion char).
131            // Pass through the remaining literal and stop scanning.
132            debug!(
133                "format_magic_message: malformed specifier at byte {i} in template {template:?}; passing through remainder literally",
134            );
135            out.push_str(&template[i..]);
136            // Skip the trailing flush -- we have already emitted the
137            // remainder above.
138            plain_start = bytes.len();
139            break;
140        };
141        let next_i = parsed_spec.end;
142        if let Some(rendered) = render(&parsed_spec, value, type_kind, stop_at_newline) {
143            out.push_str(&rendered);
144        } else {
145            // Type mismatch or unsupported conversion; pass through the
146            // literal specifier and log.
147            let literal = &template[spec_start..next_i];
148            debug!(
149                "format_magic_message: unsupported specifier {literal:?} for value {value:?}; passing through literally",
150            );
151            out.push_str(literal);
152        }
153        i = next_i;
154        plain_start = i;
155    }
156
157    // Flush any trailing plain-text run.
158    if plain_start < bytes.len() {
159        out.push_str(&template[plain_start..]);
160    }
161
162    out
163}
164
165/// Kinds of conversion characters we recognize.
166#[derive(Debug, Clone, Copy, PartialEq, Eq)]
167enum Conv {
168    /// `%d`, `%i`, `%ld`, `%lld` -- signed decimal.
169    Signed,
170    /// `%u`, `%lu`, `%llu` -- unsigned decimal.
171    Unsigned,
172    /// `%x` -- lowercase hex.
173    HexLower,
174    /// `%X` -- uppercase hex.
175    HexUpper,
176    /// `%o` -- octal.
177    Octal,
178    /// `%s` -- string.
179    Str,
180    /// `%c` -- single character (full 0x00-0xff byte range via Latin-1 code points).
181    Char,
182    /// `%%` -- literal percent.
183    Percent,
184}
185
186/// Parsed format specifier.
187#[derive(Debug, Clone)]
188struct Spec {
189    zero_pad: bool,
190    left_align: bool,
191    alt_form: bool,
192    width: usize,
193    /// `.<digits>` precision. Currently honored only for `%s` (`Conv::Str`),
194    /// where it truncates the rendered string to at most `precision`
195    /// characters -- e.g. sgml's `%.3s` renders the XML version `1.0` from a
196    /// longer field. `None` means no precision was given (no truncation).
197    precision: Option<usize>,
198    conv: Conv,
199    /// Byte index of the character *after* this specifier in the template.
200    end: usize,
201}
202
203/// Maximum width value accepted from a format specifier.
204///
205/// Caps the field width to prevent crafted magic rules with enormous widths
206/// (e.g., `%999999999d`) from driving unbounded `repeat_n` allocations in the
207/// padding helpers. 4096 is generous for any real magic-corpus usage.
208const MAX_FORMAT_WIDTH: usize = 4096;
209
210/// Bound on how much file-derived text a `%s` substitution renders, taken
211/// from GNU `file`'s `MAXstring - 1` (`src/file.h`). `MAXstring` was 96
212/// through `file` 5.38 and became 128 at 5.39, so this value (127) holds
213/// for `file` 5.39 and later only.
214///
215/// Applies only to the value-buffer types `string`, `pstring`, and
216/// `string16` ([`is_bounded_string_family`]); `regex` and `search` are
217/// bounded elsewhere by their own scan-window limits and are exempt from
218/// this bound and from the newline stop (R5).
219///
220/// `pub(crate)` so the any-value and ordering-display *readers*
221/// (`evaluator::types::any_value_string_bound`,
222/// `evaluator::engine::value_eval::string_ordering_display_value`) can
223/// reuse the same constant rather than redefining it (R12, KTD2) --
224/// keeping the read/anchor bound and the render bound numerically
225/// identical by construction.
226pub(crate) const MAX_DESCRIPTION_FIELD_LEN: usize = 127;
227
228/// Shared first step of both description bounds: clamp to
229/// `MAX_DESCRIPTION_FIELD_LEN` and, when gated, stop at the first `\r` or
230/// `\n` inside that window.
231///
232/// Callers then walk the result back to their own boundary kind -- a `str`
233/// char boundary for [`bound_description_field`], a UTF-8 continuation-byte
234/// boundary for [`bound_bytes`] -- which is why only this prefix is shared.
235// Slicing is invariant-safe: `cut` is clamped to `bytes.len()` on the line
236// above before `bytes[..cut]` is taken.
237#[allow(clippy::indexing_slicing)]
238fn bounded_newline_cut(bytes: &[u8], stop_at_newline: bool) -> usize {
239    let mut cut = bytes.len().min(MAX_DESCRIPTION_FIELD_LEN);
240    if stop_at_newline
241        && let Some(newline_pos) = bytes[..cut].iter().position(|&b| b == b'\r' || b == b'\n')
242    {
243        cut = newline_pos;
244    }
245    cut
246}
247
248/// Parse a format specifier starting at `start` (the first byte after the
249/// leading `%`). Returns `None` if the sequence does not end in a
250/// recognized conversion character.
251// Indexing is invariant-safe: every `bytes[i]` is guarded by an
252// `i < bytes.len()` loop or branch condition.
253#[allow(clippy::indexing_slicing)]
254fn parse_spec(bytes: &[u8], start: usize) -> Option<Spec> {
255    let mut i = start;
256    let mut zero_pad = false;
257    let mut left_align = false;
258    let mut alt_form = false;
259
260    // Flags (subset: 0, -, #). Other flags (+, space) are parsed but ignored.
261    while i < bytes.len() {
262        match bytes[i] {
263            b'0' => {
264                zero_pad = true;
265                i += 1;
266            }
267            b'-' => {
268                left_align = true;
269                i += 1;
270            }
271            b'#' => {
272                alt_form = true;
273                i += 1;
274            }
275            b'+' | b' ' => {
276                // Accepted for syntactic completeness, no rendering effect
277                // in the current subset.
278                i += 1;
279            }
280            _ => break,
281        }
282    }
283
284    // Width (decimal digits). Capped at MAX_FORMAT_WIDTH to prevent
285    // unbounded allocations from crafted format strings.
286    let mut width: usize = 0;
287    while i < bytes.len() && bytes[i].is_ascii_digit() {
288        let digit = (bytes[i] - b'0') as usize;
289        width = width
290            .saturating_mul(10)
291            .saturating_add(digit)
292            .min(MAX_FORMAT_WIDTH);
293        i += 1;
294    }
295
296    // Precision (`.<digits>`). Captured for `%s` truncation (see `render`);
297    // numeric rendering is still whole-value and ignores it. A bare `.` with
298    // no digits means precision 0 (C semantics). The digit run is capped at
299    // MAX_FORMAT_WIDTH for the same crafted-input protection as `width`.
300    let mut precision: Option<usize> = None;
301    if i < bytes.len() && bytes[i] == b'.' {
302        i += 1;
303        let mut prec: usize = 0;
304        while i < bytes.len() && bytes[i].is_ascii_digit() {
305            let digit = (bytes[i] - b'0') as usize;
306            prec = prec
307                .saturating_mul(10)
308                .saturating_add(digit)
309                .min(MAX_FORMAT_WIDTH);
310            i += 1;
311        }
312        precision = Some(prec);
313    }
314
315    // Length modifier (`h`, `hh`, `l`, `ll`, `j`, `z`, `t`). We consume
316    // these for syntactic completeness but never rely on them -- all
317    // numeric rendering uses full u64/i64 width.
318    while i < bytes.len() {
319        match bytes[i] {
320            b'l' | b'h' | b'j' | b'z' | b't' => i += 1,
321            _ => break,
322        }
323    }
324
325    if i >= bytes.len() {
326        return None;
327    }
328
329    let conv = match bytes[i] {
330        b'd' | b'i' => Conv::Signed,
331        b'u' => Conv::Unsigned,
332        b'x' => Conv::HexLower,
333        b'X' => Conv::HexUpper,
334        b'o' => Conv::Octal,
335        b's' => Conv::Str,
336        b'c' => Conv::Char,
337        b'%' => Conv::Percent,
338        _ => return None,
339    };
340    i += 1;
341
342    Some(Spec {
343        zero_pad,
344        left_align,
345        alt_form,
346        width,
347        precision,
348        conv,
349        end: i,
350    })
351}
352
353/// Render the specifier against `value`, or return `None` if the value
354/// is type-incompatible with the conversion.
355///
356/// `stop_at_newline` is R2's gate signal (see [`format_magic_message`]);
357/// it is consulted only by the `%s` arm.
358fn render(
359    spec: &Spec,
360    value: &Value,
361    type_kind: &TypeKind,
362    stop_at_newline: bool,
363) -> Option<String> {
364    match spec.conv {
365        Conv::Percent => Some("%".to_string()),
366        Conv::Str => Some(render_str_spec(spec, value, type_kind, stop_at_newline)),
367        Conv::Signed => {
368            let n = coerce_to_i64(value)?;
369            Some(pad_numeric(&n.to_string(), spec))
370        }
371        Conv::Unsigned => {
372            let n = coerce_to_u64(value)?;
373            Some(pad_numeric(&n.to_string(), spec))
374        }
375        Conv::HexLower => {
376            let n = coerce_to_u64_masked(value, type_kind)?;
377            // C printf suppresses the `0x`/`0X` alt-form prefix when the
378            // value is zero: `printf("%#x", 0)` emits `"0"`, not `"0x0"`.
379            let prefix = if spec.alt_form && n != 0 { "0x" } else { "" };
380            Some(render_prefixed_int(&format!("{n:x}"), prefix, spec))
381        }
382        Conv::HexUpper => {
383            let n = coerce_to_u64_masked(value, type_kind)?;
384            let prefix = if spec.alt_form && n != 0 { "0X" } else { "" };
385            Some(render_prefixed_int(&format!("{n:X}"), prefix, spec))
386        }
387        Conv::Octal => {
388            let n = coerce_to_u64_masked(value, type_kind)?;
389            // C printf uses a single "0" prefix for %#o (not Rust's "0o"),
390            // and suppresses the prefix when the value itself is zero --
391            // the resulting digit `0` already satisfies the "starts with
392            // 0" invariant that the alt-form is meant to guarantee.
393            let prefix = if spec.alt_form && n != 0 { "0" } else { "" };
394            Some(render_prefixed_int(&format!("{n:o}"), prefix, spec))
395        }
396        Conv::Char => {
397            let n = coerce_to_u64(value)?;
398            let byte = u8::try_from(n).ok()?;
399            // GNU `file` / C printf `%c` converts the int argument to
400            // unsigned char and emits it directly for all byte values
401            // 0x00-0xff. Rust's `String` must be valid UTF-8, so we
402            // embed bytes >= 0x80 as their Latin-1 code points (U+0080
403            // through U+00FF) via `char::from(u8)` which is infallible
404            // and lossless. Consumers with UTF-8 terminals see the
405            // 2-byte UTF-8 encoding of that code point; consumers
406            // iterating the returned bytes directly can recover the
407            // original byte by re-encoding the code point as Latin-1.
408            //
409            // POSIX: the `0` flag is ignored for `%c` -- zero-padding only
410            // applies to numeric/float conversions. Always use space-padding
411            // for `%c`, matching C printf behavior.
412            Some(pad_non_numeric(&char::from(byte).to_string(), spec))
413        }
414    }
415}
416
417/// Whether `type_kind` is one of the value-buffer types R1/R2 bound:
418/// `string`, `pstring`, `string16`. `regex` and `search` render through a
419/// separate, unbounded path (R5); other types render `%s` via decimal or
420/// lossy-UTF-8 conversion and are far under the bound regardless.
421fn is_bounded_string_family(type_kind: &TypeKind) -> bool {
422    matches!(
423        type_kind,
424        TypeKind::String { .. } | TypeKind::String16 { .. } | TypeKind::PString { .. }
425    )
426}
427
428/// Bound `base` to at most `MAX_DESCRIPTION_FIELD_LEN` bytes, cutting at
429/// a UTF-8 character boundary at or before the limit (R3): a multi-byte
430/// character straddling the limit is dropped whole rather than split,
431/// which renders a few bytes shorter than `file`'s raw-byte cut -- an
432/// accepted parity tolerance.
433///
434/// When `stop_at_newline` is set (R2's gate -- an any-value rule, or an
435/// ordering-compared rule whose pattern's first byte is a null byte; see
436/// `evaluator::engine::value_eval::newline_stop_gate`), the bound
437/// additionally stops at the first `\r` or `\n` byte within the limit.
438// Indexing/slicing is invariant-safe: `cut` is clamped to `bytes.len()`
439// above and only ever walked downward to a valid char boundary before
440// either slice is taken.
441#[allow(clippy::indexing_slicing)]
442fn bound_description_field(base: &str, stop_at_newline: bool) -> String {
443    let mut cut = bounded_newline_cut(base.as_bytes(), stop_at_newline);
444    while cut > 0 && !base.is_char_boundary(cut) {
445        cut -= 1;
446    }
447    base[..cut].to_string()
448}
449
450/// Raw-byte counterpart of [`bound_description_field`] for `Value::Bytes`.
451///
452/// Operates on the file-derived byte slice directly, before any lossy
453/// UTF-8 decode, so the `MAX_DESCRIPTION_FIELD_LEN` budget is measured on
454/// file bytes rather than on decode-inflated `U+FFFD` bytes (see
455/// [`render_string_bounded`] for why that ordering matters).
456///
457/// After cutting to the byte/newline limit, walks back over any trailing
458/// UTF-8 continuation bytes (`0b10xxxxxx`) so a valid multi-byte character
459/// straddling the cut is dropped whole (R3) rather than left to decode as
460/// a stray replacement character. This is a lightweight structural check,
461/// not a full UTF-8 validity scan: arbitrary (possibly non-UTF-8) bytes
462/// are always safe to slice at any offset, and the later lossy decode
463/// handles whatever is left over.
464// Indexing/slicing is invariant-safe: `cut` is clamped to `bytes.len()`
465// above and only ever walked downward before either slice is taken;
466// `bytes.get(cut)` guards the boundary-walk read.
467#[allow(clippy::indexing_slicing)]
468fn bound_bytes(bytes: &[u8], stop_at_newline: bool) -> Vec<u8> {
469    let mut cut = bounded_newline_cut(bytes, stop_at_newline);
470    while cut > 0
471        && bytes
472            .get(cut)
473            .is_some_and(|&b| b & 0b1100_0000 == 0b1000_0000)
474    {
475        cut -= 1;
476    }
477    bytes[..cut].to_vec()
478}
479
480/// Render a [`Value`] for `%s`, applying the R1/R2 description bound when
481/// `apply_bound` is set ([`is_bounded_string_family`] gates this at the
482/// call site).
483///
484/// The R1 127-byte budget is measured on FILE-DERIVED bytes, before any
485/// lossy UTF-8 decode: for `Value::Bytes` the bound ([`bound_bytes`]) is
486/// applied to the raw byte slice first, and only the already-bounded
487/// result is decoded via lossy UTF-8. Measuring after decode would let a
488/// single invalid byte inflate the count -- `String::from_utf8_lossy`
489/// expands each invalid byte into the 3-byte `U+FFFD` replacement
490/// character, so bounding the decoded `String`'s byte length would burn
491/// the budget up to 3x too fast and cut file-derived content short. This
492/// is the high-byte UTF-8 corruption bug class documented in GOTCHAS.md.
493///
494/// `Value::String` is already valid UTF-8 carrying the file's byte count
495/// 1:1 (it comes from `read_string`/`read_string_exact`), so it is bounded
496/// directly via [`bound_description_field`]. Numeric values render far
497/// under the bound regardless and are never bound.
498fn render_string_bounded(value: &Value, apply_bound: bool, stop_at_newline: bool) -> String {
499    match value {
500        Value::String(s) if apply_bound => bound_description_field(s, stop_at_newline),
501        Value::String(s) => s.clone(),
502        Value::Bytes(b) if apply_bound => {
503            String::from_utf8_lossy(&bound_bytes(b, stop_at_newline)).into_owned()
504        }
505        Value::Bytes(b) => String::from_utf8_lossy(b).into_owned(),
506        Value::Uint(n) => n.to_string(),
507        Value::Int(n) => n.to_string(),
508        Value::Float(f) => f.to_string(),
509    }
510}
511
512/// Render a `%s` specifier: base string (bounded per R1/R2 for
513/// string-family types), then optional `.<precision>` truncation, then
514/// width padding.
515///
516/// The description bound and `.<precision>` truncation compose as a
517/// minimum: the bound is applied first, so a precision wider than the
518/// bound is a no-op past it, and a precision narrower than the bound still
519/// truncates further.
520///
521/// Precision truncates to at most `precision` characters. Truncation is
522/// **char-wise**, not byte-wise: C's `%.Ns` truncates by bytes, but our value
523/// is a Rust `String` and a byte-wise cut could split a multi-byte UTF-8
524/// sequence. For the ASCII version/name fields that use precision in the magic
525/// corpus (`%.3s`, `%-.4s`, `%.10s`) the two are identical; char-wise is the
526/// safe choice for the rare non-ASCII case.
527///
528/// Width padding (via [`pad_non_numeric`]) is applied after truncation so
529/// `%4.4s` and `%-.4s` render correctly -- previously `%s` dropped width
530/// entirely.
531fn render_str_spec(
532    spec: &Spec,
533    value: &Value,
534    type_kind: &TypeKind,
535    stop_at_newline: bool,
536) -> String {
537    let apply_bound = is_bounded_string_family(type_kind);
538    let bounded = render_string_bounded(value, apply_bound, stop_at_newline);
539    // Truncate to `p` chars in a single pass bounded by `p` (no preceding full
540    // `chars().count()` pass, and no byte slicing -- the repo forbids `&s[n..]`
541    // for UTF-8 safety). `take(p)` stops after at most `p` chars regardless of
542    // string length; when `p >= len` this collects an identical copy, which is
543    // a cheap, correct no-op for the rare precision case.
544    let truncated = match spec.precision {
545        Some(p) => bounded.chars().take(p).collect::<String>(),
546        None => bounded,
547    };
548    pad_non_numeric(&truncated, spec)
549}
550
551/// Coerce a numeric-ish [`Value`] to `i64`. Float values are truncated
552/// toward zero (documented intent -- matches C's `(long long)float`
553/// semantics that libmagic's `printf` path relies on). String/Bytes
554/// values have no sensible mapping and return `None`.
555#[allow(
556    clippy::cast_possible_truncation,
557    clippy::cast_sign_loss,
558    clippy::cast_possible_wrap
559)]
560fn coerce_to_i64(value: &Value) -> Option<i64> {
561    match value {
562        Value::Int(n) => Some(*n),
563        // u64 -> i64 bit-pattern reinterpret: matches C's implicit
564        // cast in `printf("%lld", (unsigned long long)...)`.
565        Value::Uint(n) => Some(*n as i64),
566        // f64 -> i64 truncation toward zero, matching C behavior for
567        // `printf("%d", (double)...)`.
568        Value::Float(f) => Some(*f as i64),
569        Value::String(_) | Value::Bytes(_) => None,
570    }
571}
572
573/// Coerce a numeric-ish [`Value`] to `u64`. Mirrors [`coerce_to_i64`]
574/// but preserves the unsigned bit pattern when the source is signed.
575#[allow(
576    clippy::cast_possible_truncation,
577    clippy::cast_sign_loss,
578    clippy::cast_precision_loss
579)]
580fn coerce_to_u64(value: &Value) -> Option<u64> {
581    match value {
582        Value::Uint(n) => Some(*n),
583        // i64 -> u64 bit-pattern reinterpret for rendering; parallels
584        // the `coerce_to_i64` case.
585        Value::Int(n) => Some(*n as u64),
586        Value::Float(f) => Some(*f as u64),
587        Value::String(_) | Value::Bytes(_) => None,
588    }
589}
590
591/// Coerce a numeric-ish [`Value`] to `u64`, masked to the natural bit
592/// width of `type_kind`. Used by hex/octal specifiers to avoid
593/// surprising sign-extended renderings like `byte = -1` rendering as
594/// `ffffffffffffffff` when the user expected `ff`.
595fn coerce_to_u64_masked(value: &Value, type_kind: &TypeKind) -> Option<u64> {
596    let raw = coerce_to_u64(value)?;
597    let mask = match type_kind.bit_width() {
598        Some(8) => 0xff_u64,
599        Some(16) => 0xffff_u64,
600        Some(32) => 0xffff_ffff_u64,
601        // 64-bit, unknown width, or any other case: no mask needed.
602        _ => return Some(raw),
603    };
604    Some(raw & mask)
605}
606
607/// Render a numeric body with an alt-form prefix (`0x` / `0o` / empty),
608/// applying width and padding correctly.
609///
610/// For zero-padded widths (`%#0Nx`), C printf inserts zeros *between*
611/// the prefix and the digits: `%#06x` + `0xab` -> `0x00ab`, not
612/// `  0xab`. For space-padded widths (`%#Nx`), the spaces go *before*
613/// the prefix: `%#6x` + `0xab` -> `  0xab`. For left-aligned widths
614/// (`%-#6x`), trailing spaces follow the digits: `0xab  `.
615fn render_prefixed_int(digits: &str, prefix: &str, spec: &Spec) -> String {
616    // The effective body length for width comparison is prefix + digits.
617    let body_len = prefix.len() + digits.len();
618    if body_len >= spec.width {
619        return format!("{prefix}{digits}");
620    }
621    let pad = spec.width - body_len;
622    if spec.zero_pad && !spec.left_align {
623        // Zeros insert between the prefix and the digits.
624        let zeros: String = std::iter::repeat_n('0', pad).collect();
625        format!("{prefix}{zeros}{digits}")
626    } else if spec.left_align {
627        let spaces: String = std::iter::repeat_n(' ', pad).collect();
628        format!("{prefix}{digits}{spaces}")
629    } else {
630        let spaces: String = std::iter::repeat_n(' ', pad).collect();
631        format!("{spaces}{prefix}{digits}")
632    }
633}
634
635/// Apply width and alignment to a non-numeric rendered body using space-only padding.
636///
637/// Used for `%c` (and any other non-numeric conversion where the POSIX `0` flag
638/// must be ignored). Zero-padding is not applied regardless of `spec.zero_pad`.
639fn pad_non_numeric(body: &str, spec: &Spec) -> String {
640    if body.len() >= spec.width {
641        return body.to_string();
642    }
643    let pad = spec.width - body.len();
644    let padding: String = std::iter::repeat_n(' ', pad).collect();
645    if spec.left_align {
646        format!("{body}{padding}")
647    } else {
648        format!("{padding}{body}")
649    }
650}
651
652/// Apply width and padding to an already-rendered numeric body.
653///
654/// For zero-padded right-aligned formatting, a leading `-` sign is kept at
655/// the front while zeros are inserted between the sign and the magnitude
656/// digits -- matching C printf semantics (e.g., `%05d` with `-7` → `-0007`,
657/// not `000-7`).
658fn pad_numeric(body: &str, spec: &Spec) -> String {
659    if body.len() >= spec.width {
660        return body.to_string();
661    }
662    // C printf sign-aware zero-padding: sign goes before the zeros.
663    if spec.zero_pad
664        && !spec.left_align
665        && let Some(digits) = body.strip_prefix('-')
666    {
667        let needed = spec.width.saturating_sub(1 + digits.len());
668        if needed == 0 {
669            return body.to_string();
670        }
671        let zeros: String = std::iter::repeat_n('0', needed).collect();
672        return format!("-{zeros}{digits}");
673    }
674    let pad = spec.width - body.len();
675    let pad_char = if spec.zero_pad && !spec.left_align {
676        '0'
677    } else {
678        ' '
679    };
680    let padding: String = std::iter::repeat_n(pad_char, pad).collect();
681    if spec.left_align {
682        format!("{body}{padding}")
683    } else {
684        format!("{padding}{body}")
685    }
686}
687
688#[cfg(test)]
689mod tests;