Skip to main content

spdr_cli/
lib.rs

1//! `spdr-cli` library · the decode-and-render core behind the `spdr` binary.
2//!
3//! Split out from `main.rs` so [`render_human`] and [`render_json`] are pure,
4//! snapshot-testable functions and the decode pipeline can be property-tested
5//! without spawning a subprocess. The binary is a thin wrapper over [`run`].
6//!
7//! The CLI presents exactly what the library decodes, with no inflated labels:
8//! the JEDEC base timings are the guaranteed fallback, and the rated DDR5 speed
9//! is shown separately in the vendor-profiles section (XMP 3.0 / EXPO), each
10//! anchored by its section CRC. The base CRC is presented as a reported status,
11//! not a verdict. A section that fails to decode is shown with its error rather
12//! than fabricated output.
13//!
14//! Alongside `decode`, the `lint` subcommand runs the semantic linter (the
15//! validation-beyond-CRC pillar) and renders its findings, with its own
16//! exit-code contract: `0` when clean or only `Info` advisories, `1` when a
17//! `Warning` or `Error` finding is present, `2` when the file could not be read.
18//! Its renderers ([`render_lint_human`], [`render_lint_json`]) are pure like the
19//! decode renderers, so they are golden-tested without spawning the process.
20
21use std::fmt::Write as _;
22use std::path::PathBuf;
23
24use clap::{Args, Parser, Subcommand};
25use serde_json::Value;
26use spdr::{
27    CasLatencies, CrcStatus, DecodeError, Expo, ExpoProfile, Finding, IdentityAndBase,
28    Manufacturing, ModuleSpecific, Picoseconds, RatedTimings, Severity, Timings, VendorProfiles,
29    Xmp, XmpProfile, decode_identity_and_base, decode_manufacturing, decode_module_specific,
30    decode_timings, decode_vendor_profiles, lint, verify_base_crc,
31};
32
33/// The `spdr` command-line interface.
34#[derive(Parser)]
35#[command(name = "spdr", version, about = "Read-only DDR5 SPD content decoder")]
36pub struct Cli {
37    #[command(subcommand)]
38    pub command: Commands,
39}
40
41/// The `spdr` subcommands. `decode` prints the typed decode; `lint` runs the
42/// semantic linter and reports its findings. Each subcommand defines its own
43/// exit-code contract, so the two do not collide.
44#[derive(Subcommand)]
45pub enum Commands {
46    /// Decode an SPD image and print it as human-readable text or JSON.
47    Decode(DecodeArgs),
48    /// Lint an SPD image for internal inconsistencies and print the findings.
49    Lint(LintArgs),
50}
51
52/// Arguments to `spdr decode`.
53#[derive(Args)]
54pub struct DecodeArgs {
55    /// Path to a raw 1024-byte SPD image (a dump file or a Linux sysfs `eeprom`).
56    pub file: PathBuf,
57    /// Emit JSON instead of human-readable text.
58    #[arg(long)]
59    pub json: bool,
60}
61
62/// Arguments to `spdr lint`. Mirrors [`DecodeArgs`]: the same file input and the
63/// same `--json` switch.
64#[derive(Args)]
65pub struct LintArgs {
66    /// Path to a raw 1024-byte SPD image (a dump file or a Linux sysfs `eeprom`).
67    pub file: PathBuf,
68    /// Emit JSON instead of human-readable text.
69    #[arg(long)]
70    pub json: bool,
71}
72
73/// The per-section decode results, each section independently `Ok` or a typed
74/// [`DecodeError`]. Borrows the input image for the manufacturing part number.
75pub struct DecodeResults<'a> {
76    /// Identity and base SDRAM configuration.
77    pub identity: Result<IdentityAndBase, DecodeError>,
78    /// Base configuration CRC status (reported, never a verdict).
79    pub crc: Result<CrcStatus, DecodeError>,
80    /// Base JEDEC timing block.
81    pub timings: Result<Timings, DecodeError>,
82    /// Module-specific block.
83    pub module: Result<ModuleSpecific, DecodeError>,
84    /// Manufacturing information block.
85    pub manufacturing: Result<Manufacturing<'a>, DecodeError>,
86    /// Vendor overclocking profiles (XMP 3.0 and EXPO). An absent region is a
87    /// successful decode, not an error.
88    pub vendor: Result<VendorProfiles<'a>, DecodeError>,
89}
90
91impl DecodeResults<'_> {
92    /// Whether every section decoded. Drives exit code 0 versus 1. A CRC mismatch
93    /// is itself a successful decode (`Ok`) and does not make this `false`;
94    /// integrity and consistency are the linter's job (the `lint` subcommand).
95    #[must_use]
96    pub fn all_decoded(&self) -> bool {
97        self.identity.is_ok()
98            && self.crc.is_ok()
99            && self.timings.is_ok()
100            && self.module.is_ok()
101            && self.manufacturing.is_ok()
102            && self.vendor.is_ok()
103    }
104}
105
106/// Run every library decoder over `bytes`, holding the per-section results. No
107/// decoder can panic on malformed input (Phase 6), so this never panics.
108#[must_use]
109pub fn decode(bytes: &[u8]) -> DecodeResults<'_> {
110    DecodeResults {
111        identity: decode_identity_and_base(bytes),
112        crc: verify_base_crc(bytes),
113        timings: decode_timings(bytes),
114        module: decode_module_specific(bytes),
115        manufacturing: decode_manufacturing(bytes),
116        vendor: decode_vendor_profiles(bytes),
117    }
118}
119
120/// Parse arguments and run the chosen subcommand, returning the process exit
121/// code. clap handles `--help`/`--version` (exit 0) and usage errors (exit 2)
122/// itself before this returns.
123#[must_use]
124pub fn run() -> i32 {
125    let cli = Cli::parse();
126    match cli.command {
127        Commands::Decode(args) => run_decode(&args),
128        Commands::Lint(args) => run_lint(&args),
129    }
130}
131
132/// The `decode` flow and its exit-code contract:
133/// - `0`: every section decoded (a CRC mismatch is reported, not an error).
134/// - `1`: ran, but at least one section returned a decode error; the report
135///   (decoded sections plus the per-section errors) is printed to stdout first.
136/// - `2`: could not run; the file was unreadable. clap already maps invalid
137///   arguments to the same code.
138fn run_decode(args: &DecodeArgs) -> i32 {
139    let bytes = match std::fs::read(&args.file) {
140        Ok(bytes) => bytes,
141        Err(error) => {
142            eprintln!("spdr: cannot read {}: {error}", args.file.display());
143            return 2;
144        }
145    };
146
147    let results = decode(&bytes);
148
149    let rendered = if args.json {
150        match render_json(&results) {
151            Ok(json) => json,
152            Err(error) => {
153                eprintln!("spdr: failed to render JSON: {error}");
154                return 2;
155            }
156        }
157    } else {
158        render_human(&results)
159    };
160    println!("{rendered}");
161
162    if results.all_decoded() { 0 } else { 1 }
163}
164
165// --- Lint surface ----------------------------------------------------------
166
167/// The collected lint findings for an image, plus whether the base configuration
168/// decoded. Mirrors [`DecodeResults`] for the lint path: produced once by
169/// [`lint_report`], rendered by the pure render functions, and used to compute the
170/// exit code.
171pub struct LintReport {
172    /// The findings, ordered deterministically (errors first, then by code).
173    pub findings: Vec<Finding>,
174    /// Whether the base configuration block decoded. When false, the checks that
175    /// depend on it (capacity and cross-field consistency) were skipped, while the
176    /// reserved-bit check still ran; a no-findings result is then not a full clean
177    /// bill, and the human output says so.
178    pub base_decode_ok: bool,
179}
180
181/// Run the core linter over `bytes`, collecting and ordering the findings. Pure
182/// and panic-free (the core lint never panics). Findings are ordered by severity
183/// (errors first) then by code, so the renders are deterministic.
184#[must_use]
185pub fn lint_report(bytes: &[u8]) -> LintReport {
186    let mut findings = Vec::new();
187    lint(bytes, &mut |finding| findings.push(finding));
188    findings.sort_by(|a, b| {
189        severity_rank(a.severity())
190            .cmp(&severity_rank(b.severity()))
191            .then_with(|| a.code().cmp(b.code()))
192    });
193    LintReport {
194        base_decode_ok: decode_identity_and_base(bytes).is_ok(),
195        findings,
196    }
197}
198
199/// The lint exit code computed from the findings: `1` if any `Error` or `Warning`
200/// is present, `0` if there are none or only `Info` advisories. The operational
201/// `2` (unreadable file or bad arguments) is handled in [`run_lint`] and by clap,
202/// never here.
203#[must_use]
204pub fn lint_exit_code(findings: &[Finding]) -> i32 {
205    let actionable = findings
206        .iter()
207        .any(|finding| matches!(finding.severity(), Severity::Error | Severity::Warning));
208    i32::from(actionable)
209}
210
211/// The `lint` flow and its exit-code contract:
212/// - `0`: lint ran, no `Warning` or `Error` finding (clean, or only `Info`).
213/// - `1`: lint ran, at least one `Warning` or `Error` finding.
214/// - `2`: could not run; the file was unreadable. clap maps invalid arguments to
215///   the same code.
216fn run_lint(args: &LintArgs) -> i32 {
217    let bytes = match std::fs::read(&args.file) {
218        Ok(bytes) => bytes,
219        Err(error) => {
220            eprintln!("spdr: cannot read {}: {error}", args.file.display());
221            return 2;
222        }
223    };
224
225    let report = lint_report(&bytes);
226
227    let rendered = if args.json {
228        match render_lint_json(&report) {
229            Ok(json) => json,
230            Err(error) => {
231                eprintln!("spdr: failed to render JSON: {error}");
232                return 2;
233            }
234        }
235    } else {
236        render_lint_human(&report)
237    };
238    println!("{rendered}");
239
240    lint_exit_code(&report.findings)
241}
242
243/// The severity sort rank: errors first, then warnings, then info.
244fn severity_rank(severity: Severity) -> u8 {
245    match severity {
246        Severity::Error => 0,
247        Severity::Warning => 1,
248        Severity::Info => 2,
249    }
250}
251
252/// The lowercase severity label used in both lint renders.
253fn severity_label(severity: Severity) -> &'static str {
254    match severity {
255        Severity::Error => "error",
256        Severity::Warning => "warning",
257        Severity::Info => "info",
258    }
259}
260
261/// Render the lint report as readable text: an optional limited-coverage note, a
262/// summary line, then one block per finding (severity and code, then the
263/// message). Pure and allocation-bounded; never panics.
264#[must_use]
265pub fn render_lint_human(report: &LintReport) -> String {
266    let mut out = String::new();
267    out.push_str("[Lint]\n");
268
269    if !report.base_decode_ok {
270        out.push_str(
271            "  Note: the base configuration did not decode, so the checks that depend on it (capacity and cross-field consistency) were skipped, while the reserved-bit check still ran; a clean result here is not a full bill of health.\n",
272        );
273    }
274
275    if report.findings.is_empty() {
276        out.push_str(
277            "  No findings. The SPD is internally consistent under the current rule set.\n",
278        );
279        return out;
280    }
281
282    let _ = writeln!(out, "  {}", lint_summary(&report.findings));
283    for finding in &report.findings {
284        let _ = writeln!(
285            out,
286            "  {} · {}",
287            severity_label(finding.severity()),
288            finding.code()
289        );
290        let _ = writeln!(out, "    {finding}");
291    }
292    out
293}
294
295/// Build the summary line, for example `"2 findings: 1 error, 1 warning."`.
296fn lint_summary(findings: &[Finding]) -> String {
297    let (errors, warnings, infos) = severity_counts(findings);
298    let total = findings.len();
299    let noun = if total == 1 { "finding" } else { "findings" };
300
301    let mut parts = Vec::new();
302    if errors > 0 {
303        parts.push(count_phrase(errors, "error"));
304    }
305    if warnings > 0 {
306        parts.push(count_phrase(warnings, "warning"));
307    }
308    if infos > 0 {
309        parts.push(count_phrase(infos, "info"));
310    }
311    format!("{total} {noun}: {}.", parts.join(", "))
312}
313
314/// `(errors, warnings, infos)` counts over the findings.
315fn severity_counts(findings: &[Finding]) -> (usize, usize, usize) {
316    let mut errors = 0;
317    let mut warnings = 0;
318    let mut infos = 0;
319    for finding in findings {
320        match finding.severity() {
321            Severity::Error => errors += 1,
322            Severity::Warning => warnings += 1,
323            Severity::Info => infos += 1,
324        }
325    }
326    (errors, warnings, infos)
327}
328
329/// `"1 error"` / `"2 errors"`; "info" is left invariant (it reads as a mass noun).
330fn count_phrase(n: usize, label: &str) -> String {
331    if n == 1 || label == "info" {
332        format!("{n} {label}")
333    } else {
334        format!("{n} {label}s")
335    }
336}
337
338/// Render the lint report as a JSON array of findings (an empty array when there
339/// are none). Each finding carries its lowercase `severity`, its stable `code`, a
340/// human `message`, and its structured `fields` (the core `Finding`'s serde
341/// representation, unwrapped to just the variant's fields, since `code` already
342/// names the rule).
343///
344/// # Errors
345/// Returns a [`serde_json::Error`] only if serializing a finding fails, which the
346/// finding types do not do in practice.
347pub fn render_lint_json(report: &LintReport) -> Result<String, serde_json::Error> {
348    let findings = report
349        .findings
350        .iter()
351        .map(finding_to_json)
352        .collect::<Result<Vec<_>, _>>()?;
353    serde_json::to_string_pretty(&Value::Array(findings))
354}
355
356/// Build one finding's JSON object.
357fn finding_to_json(finding: &Finding) -> Result<Value, serde_json::Error> {
358    // The core derives an externally-tagged enum, `{"Variant": {fields}}`; unwrap
359    // it to just the fields, since the stable `code` already identifies the rule.
360    let fields = match serde_json::to_value(finding)? {
361        Value::Object(map) if map.len() == 1 => map
362            .into_iter()
363            .next()
364            .map_or(Value::Null, |(_, value)| value),
365        other => other,
366    };
367    Ok(serde_json::json!({
368        "severity": severity_label(finding.severity()),
369        "code": finding.code(),
370        "message": finding.to_string(),
371        "fields": fields,
372    }))
373}
374
375// --- Human rendering -------------------------------------------------------
376
377/// Indent and column width for the aligned `key: value` lines.
378const LABEL_WIDTH: usize = 30;
379
380/// Render the decode as sectioned, aligned `key: value` plain text. Pure and
381/// allocation-bounded; a failed section shows its error instead of fabricated
382/// fields. Never panics (no indexing, no `unwrap` on decoded data).
383#[must_use]
384pub fn render_human(results: &DecodeResults) -> String {
385    let mut out = String::new();
386
387    render_identity(&mut out, &results.identity);
388    out.push('\n');
389    render_crc(&mut out, &results.crc);
390    out.push('\n');
391    render_timings(&mut out, &results.timings);
392    out.push('\n');
393    render_module(&mut out, &results.module);
394    out.push('\n');
395    render_manufacturing(&mut out, &results.manufacturing);
396    out.push('\n');
397    render_vendor(&mut out, &results.vendor);
398
399    out
400}
401
402/// Write one aligned `  label   value` line. `label` carries its own trailing
403/// colon. Writing to a `String` never fails, so the result is discarded.
404fn field(out: &mut String, label: &str, value: impl std::fmt::Display) {
405    let _ = writeln!(out, "  {label:<LABEL_WIDTH$} {value}");
406}
407
408/// Write the error line for a section that failed to decode.
409fn section_error(out: &mut String, error: &DecodeError) {
410    let _ = writeln!(out, "  error: {error}");
411}
412
413fn render_identity(out: &mut String, result: &Result<IdentityAndBase, DecodeError>) {
414    out.push_str("[Identity and base]\n");
415    match result {
416        Ok(id) => {
417            field(
418                out,
419                "SPD device size:",
420                format_args!("{} bytes", id.spd_bytes_total),
421            );
422            field(out, "SPD revision:", id.spd_revision);
423            field(out, "DRAM device type:", id.device_type);
424            field(out, "Module type:", id.module_type);
425            field(out, "Hybrid module:", yes_no(id.hybrid));
426            field(
427                out,
428                "Density per die:",
429                format_args!("{} Gb", id.density_per_die.gigabits()),
430            );
431            field(out, "Package:", id.package_type);
432            field(out, "Dies per package:", id.die_count);
433            field(out, "Row address bits:", id.row_address_bits);
434            field(out, "Column address bits:", id.column_address_bits);
435            field(out, "I/O width:", format_args!("x{}", id.io_width.bits()));
436            field(out, "Bank groups:", id.bank_groups.count());
437            field(
438                out,
439                "Banks per bank group:",
440                id.banks_per_bank_group.count(),
441            );
442            field(
443                out,
444                "Package ranks per channel:",
445                id.package_ranks_per_channel,
446            );
447            field(
448                out,
449                "Rank mix:",
450                if id.rank_mix_asymmetric {
451                    "asymmetric"
452                } else {
453                    "symmetric"
454                },
455            );
456            field(out, "Channels per DIMM:", id.channels_per_dimm);
457            field(
458                out,
459                "Primary bus width per channel:",
460                format_args!("{} bits", id.primary_bus_width_bits),
461            );
462        }
463        Err(error) => section_error(out, error),
464    }
465}
466
467fn render_crc(out: &mut String, result: &Result<CrcStatus, DecodeError>) {
468    out.push_str("[Base configuration CRC]\n");
469    out.push_str("  Reported status of the base CRC (bytes 0-509). Not a verdict; the vendor section CRCs are separate.\n");
470    match result {
471        Ok(crc) => {
472            field(out, "Computed:", format_args!("{:#06X}", crc.computed));
473            field(out, "Stored:", format_args!("{:#06X}", crc.stored));
474            field(out, "Match:", yes_no(crc.matches));
475        }
476        Err(error) => section_error(out, error),
477    }
478}
479
480fn render_timings(out: &mut String, result: &Result<Timings, DecodeError>) {
481    out.push_str("[JEDEC base timings]\n");
482    out.push_str(
483        "  SPD JEDEC base timings (the guaranteed fallback). The rated DDR5 speed is shown below in the vendor-profiles section.\n",
484    );
485    match result {
486        Ok(t) => {
487            let rate = t.base_data_rate_mt_s();
488            field(
489                out,
490                "Base data rate:",
491                format_args!("DDR5-{rate} ({rate} MT/s, JEDEC base)"),
492            );
493            field(
494                out,
495                "tCKAVGmin:",
496                format_args!("{} ps", t.tckavg_min.picoseconds()),
497            );
498            field(
499                out,
500                "tCKAVGmax:",
501                format_args!("{} ps", t.tckavg_max.picoseconds()),
502            );
503            field(
504                out,
505                "Supported CAS latencies:",
506                cas_list(t.supported_cas_latencies),
507            );
508            field(out, "tAA:", format_args!("{} ps", t.taa.picoseconds()));
509            field(out, "tRCD:", format_args!("{} ps", t.trcd.picoseconds()));
510            field(out, "tRP:", format_args!("{} ps", t.trp.picoseconds()));
511            field(out, "tRAS:", format_args!("{} ps", t.tras.picoseconds()));
512            field(out, "tRC:", format_args!("{} ps", t.trc.picoseconds()));
513            field(out, "tWR:", format_args!("{} ps", t.twr.picoseconds()));
514            field(out, "tRFC1:", format_args!("{} ps", t.trfc1.picoseconds()));
515            field(out, "tRFC2:", format_args!("{} ps", t.trfc2.picoseconds()));
516            field(
517                out,
518                "tRFCsb:",
519                format_args!("{} ps", t.trfcsb.picoseconds()),
520            );
521            field(out, "tRRD_L:", pair(t.trrd_l));
522            field(out, "tCCD_L:", pair(t.tccd_l));
523            field(out, "tCCD_L_WR:", pair(t.tccd_l_wr));
524            field(out, "tCCD_L_WR2:", pair(t.tccd_l_wr2));
525            field(out, "tFAW:", pair(t.tfaw));
526            field(out, "tWTR_L:", pair(t.twtr_l));
527            field(out, "tWTR_S:", pair(t.twtr_s));
528            field(out, "tRTP:", pair(t.trtp));
529        }
530        Err(error) => section_error(out, error),
531    }
532}
533
534fn render_module(out: &mut String, result: &Result<ModuleSpecific, DecodeError>) {
535    out.push_str("[Module-specific]\n");
536    match result {
537        Ok(ModuleSpecific::Unbuffered(m)) => {
538            field(out, "Module type:", "UDIMM (unbuffered)");
539            field(out, "Nominal height:", m.nominal_height);
540            field(out, "Max thickness, front:", m.max_thickness_front);
541            field(out, "Max thickness, back:", m.max_thickness_back);
542            field(out, "Reference raw card:", m.reference_raw_card);
543            field(
544                out,
545                "Rank 1 address mapping:",
546                if m.rank1_address_mirrored {
547                    "mirrored"
548                } else {
549                    "standard"
550                },
551            );
552            field(
553                out,
554                "Module attributes (raw):",
555                format_args!("{:#04X}", m.module_attributes_raw),
556            );
557        }
558        Ok(ModuleSpecific::NotYetDecoded(module_type)) => {
559            field(
560                out,
561                "Module type:",
562                format_args!("{module_type} (module-specific block not yet decoded)"),
563            );
564        }
565        Err(error) => section_error(out, error),
566    }
567}
568
569fn render_manufacturing(out: &mut String, result: &Result<Manufacturing, DecodeError>) {
570    out.push_str("[Manufacturing]\n");
571    match result {
572        Ok(m) => {
573            field(out, "Module manufacturer:", m.module_manufacturer);
574            field(out, "Manufacturing location:", m.manufacturing_location);
575            field(out, "Manufacturing date:", m.manufacturing_date);
576            field(out, "Serial number:", m.serial_number);
577            field(out, "Part number:", m.part_number);
578            field(out, "Revision code:", m.revision_code);
579            field(out, "DRAM manufacturer:", m.dram_manufacturer);
580            field(out, "DRAM stepping:", dram_stepping(m.dram_stepping));
581        }
582        Err(error) => section_error(out, error),
583    }
584}
585
586// --- Vendor-profile rendering ----------------------------------------------
587
588/// Label column width for the indented vendor-profile lines. Chosen so values
589/// align at the same column as the flat sections (33) regardless of nesting
590/// depth: at every indent, indent + (WIDTH - indent) + 1 is constant.
591const VENDOR_LABEL_WIDTH: usize = 32;
592
593fn render_vendor(out: &mut String, result: &Result<VendorProfiles, DecodeError>) {
594    out.push_str("[Vendor profiles (XMP 3.0 / EXPO)]\n");
595    out.push_str(
596        "  Rated overclock profiles. Each section is CRC-checked; the match is the region anchor.\n",
597    );
598    match result {
599        Ok(v) => {
600            render_xmp(out, &v.xmp);
601            render_expo(out, &v.expo);
602        }
603        Err(error) => section_error(out, error),
604    }
605}
606
607fn render_xmp(out: &mut String, xmp: &Xmp) {
608    match xmp {
609        Xmp::Absent => vline(out, 2, "Intel XMP 3.0:", "absent"),
610        Xmp::Present {
611            header_crc,
612            profile1,
613            profile2,
614        } => {
615            vline(out, 2, "Intel XMP 3.0:", "present");
616            vline(out, 4, "Header section CRC:", crc_summary(header_crc));
617            render_xmp_slot(out, 1, profile1);
618            render_xmp_slot(out, 2, profile2);
619        }
620    }
621}
622
623fn render_xmp_slot(out: &mut String, number: u8, slot: &Option<XmpProfile>) {
624    match slot {
625        Some(p) => {
626            let name = p.name.unwrap_or("(unnamed)");
627            vheading(out, 4, &format!("Profile {number}: {name}"));
628            render_rated(out, &p.rated);
629            vline(out, 6, "Section CRC:", crc_summary(&p.crc));
630        }
631        None => vheading(out, 4, &format!("Profile {number}: (not enabled)")),
632    }
633}
634
635fn render_expo(out: &mut String, expo: &Expo) {
636    match expo {
637        Expo::Absent => vline(out, 2, "AMD EXPO:", "absent"),
638        Expo::Present {
639            block_crc,
640            profile1,
641            profile2,
642        } => {
643            vline(out, 2, "AMD EXPO:", "present");
644            vline(out, 4, "Block section CRC:", crc_summary(block_crc));
645            render_expo_slot(out, 1, profile1);
646            render_expo_slot(out, 2, profile2);
647        }
648    }
649}
650
651fn render_expo_slot(out: &mut String, number: u8, slot: &Option<ExpoProfile>) {
652    match slot {
653        Some(p) => {
654            vheading(out, 4, &format!("Profile {number}:"));
655            render_rated(out, &p.rated);
656        }
657        None => vheading(out, 4, &format!("Profile {number}: (not populated)")),
658    }
659}
660
661/// Render the shared rated values at the per-profile indent (6 spaces).
662fn render_rated(out: &mut String, r: &RatedTimings) {
663    vline(
664        out,
665        6,
666        "Data rate:",
667        format_args!("DDR5-{0} ({0} MT/s)", r.data_rate_mt_s),
668    );
669    vline(out, 6, "CAS latency:", format_args!("CL{}", r.cas_latency));
670    vline(
671        out,
672        6,
673        "tCKAVGmin:",
674        format_args!("{} ps", r.cycle_time.picoseconds()),
675    );
676    vline(out, 6, "tRCD:", timing_clocks(r.trcd, r.cycle_time));
677    vline(out, 6, "tRP:", timing_clocks(r.trp, r.cycle_time));
678    vline(out, 6, "tRAS:", timing_clocks(r.tras, r.cycle_time));
679    vline(
680        out,
681        6,
682        "VDD / VDDQ / VPP:",
683        format_args!("{} / {} / {}", r.vdd, r.vddq, r.vpp),
684    );
685}
686
687/// Write one indented `label   value` line; values align at a fixed column.
688fn vline(out: &mut String, indent: usize, label: &str, value: impl std::fmt::Display) {
689    let pad = VENDOR_LABEL_WIDTH.saturating_sub(indent);
690    let _ = writeln!(out, "{:i$}{label:<pad$} {value}", "", i = indent);
691}
692
693/// Write one indented heading line (no value column).
694fn vheading(out: &mut String, indent: usize, text: &str) {
695    let _ = writeln!(out, "{:i$}{text}", "", i = indent);
696}
697
698/// Format a timing in picoseconds with its whole-cycle count, guarding a zero
699/// cycle time so a malformed-but-decoded profile cannot divide by zero.
700fn timing_clocks(t: Picoseconds, cycle_time: Picoseconds) -> String {
701    let ps = t.picoseconds();
702    let tck = cycle_time.picoseconds();
703    match (ps + tck / 2).checked_div(tck) {
704        Some(clocks) => format!("{ps} ps ({clocks} clocks)"),
705        None => format!("{ps} ps"),
706    }
707}
708
709/// One-line CRC summary: the computed and stored values and whether they match.
710fn crc_summary(crc: &CrcStatus) -> String {
711    format!(
712        "computed {:#06X}, stored {:#06X} ({})",
713        crc.computed,
714        crc.stored,
715        if crc.matches { "match" } else { "MISMATCH" }
716    )
717}
718
719fn yes_no(value: bool) -> &'static str {
720    if value { "yes" } else { "no" }
721}
722
723/// Format a [`spdr::TimingPair`] as `"<ps> ps / <nCK> nCK"`.
724fn pair(value: spdr::TimingPair) -> String {
725    format!(
726        "{} ps / {} nCK",
727        value.time.picoseconds(),
728        value.clocks.cycles()
729    )
730}
731
732/// Format the supported CAS latency set as an ascending comma-separated list.
733fn cas_list(value: CasLatencies) -> String {
734    let mut out = String::new();
735    for (i, cl) in value.iter().enumerate() {
736        if i > 0 {
737            out.push_str(", ");
738        }
739        let _ = write!(out, "{cl}");
740    }
741    if out.is_empty() {
742        out.push_str("(none)");
743    }
744    out
745}
746
747/// Format DRAM stepping, naming the conventional `0xff` "not specified".
748fn dram_stepping(value: u8) -> String {
749    if value == 0xFF {
750        "255 (not specified)".to_string()
751    } else {
752        format!("{value}")
753    }
754}
755
756// --- JSON rendering --------------------------------------------------------
757
758/// Build the per-section JSON value: the serialized library type, or, for a
759/// failed section, an `{ "error": <message> }` object so the JSON stays complete
760/// and valid. A macro, not a generic function, so the section types stay
761/// concrete without the CLI depending on `serde` directly (only `serde_json`).
762macro_rules! section_value {
763    ($result:expr) => {
764        match $result {
765            Ok(value) => serde_json::to_value(value)?,
766            Err(error) => serde_json::json!({ "error": error.to_string() }),
767        }
768    };
769}
770
771/// Render the decode as a JSON object keyed by section. Each value is the
772/// serde-serialized library type; a failed section carries an error indicator,
773/// so the object always has all five keys and stays valid JSON.
774///
775/// # Errors
776/// Returns a [`serde_json::Error`] only if serializing a decoded value fails,
777/// which the decoded types do not do in practice.
778pub fn render_json(results: &DecodeResults) -> Result<String, serde_json::Error> {
779    let mut object = serde_json::Map::new();
780    object.insert(
781        "identity_and_base".to_string(),
782        section_value!(&results.identity),
783    );
784    object.insert("base_crc".to_string(), section_value!(&results.crc));
785    object.insert(
786        "jedec_base_timings".to_string(),
787        section_value!(&results.timings),
788    );
789    object.insert(
790        "module_specific".to_string(),
791        section_value!(&results.module),
792    );
793    object.insert(
794        "manufacturing".to_string(),
795        section_value!(&results.manufacturing),
796    );
797    object.insert(
798        "vendor_profiles".to_string(),
799        section_value!(&results.vendor),
800    );
801    serde_json::to_string_pretty(&Value::Object(object))
802}