Skip to main content

pcf_debug/render/
text.rs

1//! The CLI/ASCII renderer: a layout map, a partition table, a block-chain tree,
2//! decoded field trees, and a diagnostics footer — all driven by the shared
3//! [`Report`].
4
5use pcf::TYPE_RAW;
6
7use super::color::Palette;
8use super::{label_or, uid_hex, Report};
9use crate::model::algo_name;
10use crate::model::diag::Severity;
11use crate::model::{LayoutMap, RegionKind};
12use crate::plugin::{Decoded, FieldNode, FieldValue};
13
14/// Width, in cells, of the proportional ASCII byte-map strip.
15const STRIP_WIDTH: usize = 64;
16
17/// Format a partition type, naming the two reserved values.
18fn type_str(t: u32) -> String {
19    match t {
20        TYPE_RAW => format!("{t:#010x} (RAW)"),
21        0 => format!("{t:#010x} (RESERVED)"),
22        _ => format!("{t:#010x}"),
23    }
24}
25
26fn verify_cell(ok: Option<bool>, pal: Palette) -> String {
27    match ok {
28        Some(true) => pal.green("OK"),
29        Some(false) => pal.red("FAIL"),
30        None => pal.dim("—"),
31    }
32}
33
34/// The compact proportional strip: one glyph per `STRIP_WIDTH` slice of the file.
35pub fn strip(layout: &LayoutMap, pal: Palette) -> String {
36    if layout.file_len == 0 {
37        return String::new();
38    }
39    let mut cells = vec!['.'; STRIP_WIDTH];
40    for r in &layout.regions {
41        if r.len == 0 {
42            continue;
43        }
44        let start_cell = (r.start * STRIP_WIDTH as u64 / layout.file_len) as usize;
45        let end_cell =
46            ((r.end().saturating_sub(1)) * STRIP_WIDTH as u64 / layout.file_len) as usize;
47        for cell in cells
48            .iter_mut()
49            .take(end_cell.min(STRIP_WIDTH - 1) + 1)
50            .skip(start_cell)
51        {
52            *cell = r.kind.glyph();
53        }
54    }
55    let bar: String = cells.into_iter().collect();
56    format!(
57        "{}\n[{}]\nlegend: H=header T=table-hdr E=entries D=data _=slack .=gap",
58        pal.bold("byte map"),
59        bar
60    )
61}
62
63/// The region-by-region physical layout map.
64pub fn layout(layout: &LayoutMap, pal: Palette) -> String {
65    let mut out = String::new();
66    out.push_str(&pal.bold("layout\n"));
67    out.push_str(&format!("  file length: {} byte(s)\n", layout.file_len));
68    for r in &layout.regions {
69        let kind = match &r.kind {
70            RegionKind::Gap => pal.yellow(r.kind.short()),
71            RegionKind::Slack { .. } => pal.dim(r.kind.short()),
72            _ => pal.cyan(r.kind.short()),
73        };
74        out.push_str(&format!(
75            "  {:#010x}..{:#010x}  {:>8}  {:>11}  {}\n",
76            r.start,
77            r.end(),
78            r.len,
79            kind,
80            r.label
81        ));
82    }
83    out
84}
85
86/// The partition table.
87pub fn table(layout: &LayoutMap, pal: Palette) -> String {
88    let mut out = String::new();
89    out.push_str(&pal.bold("partitions\n"));
90    out.push_str(&pal.dim(
91        "  type            uid        label             start       used/max (free)      algo     data\n",
92    ));
93    let mut any = false;
94    for b in &layout.blocks {
95        for ev in &b.entries {
96            any = true;
97            let e = &ev.entry;
98            out.push_str(&format!(
99                "  {:<14}  {:.8}…  {:<16}  {:>9}  {:>6}/{:<6} ({:>5})  {:<7}  {}\n",
100                type_str(e.partition_type),
101                uid_hex(&e.uid),
102                label_or(e),
103                e.start_offset,
104                e.used_bytes,
105                e.max_length,
106                e.free_bytes(),
107                algo_name(e.data_hash_algo),
108                verify_cell(ev.data_hash_ok, pal),
109            ));
110        }
111    }
112    if !any {
113        out.push_str(&pal.dim("  (no partitions)\n"));
114    }
115    out
116}
117
118/// The table-block chain as an indented tree.
119pub fn chain(layout: &LayoutMap, pal: Palette) -> String {
120    let mut out = String::new();
121    out.push_str(&pal.bold("block chain\n"));
122    if let Some(h) = layout.header {
123        out.push_str(&format!(
124            "  header: v{}.{}  first block @ {:#x}\n",
125            h.version_major, h.version_minor, h.partition_table_offset
126        ));
127    }
128    for b in &layout.blocks {
129        let next = if b.next_offset == 0 {
130            "end".to_string()
131        } else {
132            format!("{:#x}", b.next_offset)
133        };
134        let hash = match b.table_hash_ok {
135            Some(true) => pal.green("hash OK"),
136            Some(false) => pal.red("hash FAIL"),
137            None => pal.dim("hash —"),
138        };
139        out.push_str(&format!(
140            "  block {} @ {:#x}  count={}  next={}  {}  [{}]\n",
141            b.index,
142            b.offset,
143            b.header.partition_count,
144            next,
145            hash,
146            algo_name(b.header.table_hash_algo),
147        ));
148        for (i, ev) in b.entries.iter().enumerate() {
149            let last = i + 1 == b.entries.len();
150            let branch = if last { "└─" } else { "├─" };
151            let valid = match &ev.validate_ok {
152                Ok(()) => String::new(),
153                Err(reason) => format!("  {}", pal.red(&format!("invalid: {reason}"))),
154            };
155            out.push_str(&format!(
156                "    {branch} [{}] {} @ {:#x}{}\n",
157                ev.slot,
158                label_or(&ev.entry),
159                ev.entry.start_offset,
160                valid,
161            ));
162        }
163    }
164    out
165}
166
167/// Format a single field value as a one-line string.
168fn value_str(v: &FieldValue) -> String {
169    match v {
170        FieldValue::None => String::new(),
171        FieldValue::U64(n) => n.to_string(),
172        FieldValue::Bytes(b) => {
173            if b.is_empty() {
174                "(empty)".into()
175            } else {
176                b.iter()
177                    .map(|x| format!("{x:02x}"))
178                    .collect::<Vec<_>>()
179                    .join("")
180            }
181        }
182        FieldValue::Text(s) => format!("\"{s}\""),
183        FieldValue::Uid(u) => uid_hex(u),
184        FieldValue::Enum { raw, name } => format!("{raw} ({name})"),
185        FieldValue::Flags { raw, set } => {
186            if set.is_empty() {
187                format!("{raw:#x} (none)")
188            } else {
189                format!("{raw:#x} ({})", set.join("|"))
190            }
191        }
192    }
193}
194
195fn field_tree(node: &FieldNode, prefix: &str, last: bool, out: &mut String, pal: Palette) {
196    let branch = if last { "└─" } else { "├─" };
197    let val = value_str(&node.value);
198    let val_part = if val.is_empty() {
199        String::new()
200    } else {
201        format!(" = {val}")
202    };
203    let range_part = match node.range {
204        Some((a, b)) => pal.dim(&format!("  [{a}..{b}]")),
205        None => String::new(),
206    };
207    let note_part = match &node.note {
208        Some(n) => pal.dim(&format!("  // {n}")),
209        None => String::new(),
210    };
211    out.push_str(&format!(
212        "{prefix}{branch} {}{val_part}{range_part}{note_part}\n",
213        pal.bold(&node.name)
214    ));
215    let child_prefix = format!("{prefix}{}", if last { "   " } else { "│  " });
216    for (i, c) in node.children.iter().enumerate() {
217        field_tree(c, &child_prefix, i + 1 == node.children.len(), out, pal);
218    }
219}
220
221/// The decoded field trees for every partition (or a filtered subset).
222pub fn decode(report: &Report, pal: Palette) -> String {
223    let mut out = String::new();
224    out.push_str(&pal.bold("decoded partitions\n"));
225    if report.decoded.is_empty() {
226        out.push_str(&pal.dim("  (nothing to decode)\n"));
227        return out;
228    }
229    for (uid, dec) in &report.decoded {
230        out.push_str(&format!(
231            "  {} [{}]\n",
232            pal.magenta(&format!("uid {}", &uid_hex(uid)[..16])),
233            dec.format_name
234        ));
235        render_decoded_body(dec, "    ", &mut out, pal);
236    }
237    out
238}
239
240fn render_decoded_body(dec: &Decoded, prefix: &str, out: &mut String, pal: Palette) {
241    for (i, f) in dec.fields.iter().enumerate() {
242        field_tree(f, prefix, i + 1 == dec.fields.len(), out, pal);
243    }
244    for w in &dec.warnings {
245        out.push_str(&format!("{prefix}{}\n", pal.yellow(&format!("⚠ {w}"))));
246    }
247}
248
249/// The diagnostics footer.
250pub fn diagnostics(layout: &LayoutMap, pal: Palette) -> String {
251    let mut out = String::new();
252    out.push_str(&pal.bold("diagnostics\n"));
253    if layout.diagnostics.is_empty() {
254        out.push_str(&pal.green("  no anomalies\n"));
255        return out;
256    }
257    for d in &layout.diagnostics {
258        let tag = match d.severity {
259            Severity::Info => pal.dim(d.severity.tag()),
260            Severity::Warning => pal.yellow(d.severity.tag()),
261            Severity::Error => pal.red(d.severity.tag()),
262        };
263        out.push_str(&format!("  [{tag}] {}\n", d.message));
264    }
265    out
266}
267
268/// The default `inspect` view: strip, layout, table, chain, diagnostics.
269pub fn inspect(report: &Report, pal: Palette) -> String {
270    let l = &report.layout;
271    let mut out = String::new();
272    out.push_str(&strip(l, pal));
273    out.push_str("\n\n");
274    out.push_str(&layout(l, pal));
275    out.push('\n');
276    out.push_str(&table(l, pal));
277    out.push('\n');
278    out.push_str(&chain(l, pal));
279    out.push('\n');
280    out.push_str(&diagnostics(l, pal));
281    out
282}