1use core::fmt::Write as _;
9
10use asdf_yaml::{Document, NodeData, NodeId};
11
12use crate::reader::{ChecksumStatus, Reader};
13
14const ANSI_RESET: &str = "\x1b[0m";
15const ANSI_BOLD: &str = "\x1b[1m";
16const ANSI_DIM: &str = "\x1b[2m";
17const COLOR_GREEN: &str = "\x1b[32m";
18const COLOR_RED: &str = "\x1b[31m";
19
20const SCALAR_PREVIEW_MAX: usize = 64;
25
26const BOX_WIDTH: usize = 50;
28
29#[derive(Clone, Copy, Debug)]
31pub struct InfoOptions {
32 pub print_tree: bool,
34 pub print_blocks: bool,
36 pub verify_checksums: bool,
38}
39
40impl Default for InfoOptions {
41 fn default() -> Self {
42 Self { print_tree: true, print_blocks: false, verify_checksums: false }
43 }
44}
45
46#[derive(Clone, Copy)]
48enum Border {
49 Top,
50 Middle,
51 Bottom,
52}
53
54#[derive(Clone, Copy)]
56enum Align {
57 Left,
58 Center,
59}
60
61fn visible_len(s: &str) -> usize {
64 let bytes = s.as_bytes();
65 let mut len = 0;
66 let mut idx = 0;
67 while idx < bytes.len() {
68 if bytes[idx] == 0x1b && idx + 1 < bytes.len() && bytes[idx + 1] == b'[' {
70 idx += 2;
71 while idx < bytes.len() && bytes[idx] != b'm' {
72 idx += 1;
73 }
74 if idx < bytes.len() {
75 idx += 1;
76 }
77 continue;
78 }
79 if bytes[idx] & 0xc0 != 0x80 {
81 len += 1;
82 }
83 idx += 1;
84 }
85 len
86}
87
88fn write_border(out: &mut String, border: Border) {
89 out.push_str(ANSI_DIM);
90 let (left, right) = match border {
91 Border::Top => ("┌", "┐"),
92 Border::Middle => ("├", "┤"),
93 Border::Bottom => ("└", "┘"),
94 };
95 out.push_str(left);
96 for _ in 1..BOX_WIDTH - 1 {
97 out.push('─');
98 }
99 out.push_str(right);
100 out.push('\n');
101 out.push_str(ANSI_RESET);
102}
103
104fn write_field(out: &mut String, align: Align, text: &str) {
105 let len = visible_len(text);
106 let _ = write!(out, "{ANSI_DIM}│{ANSI_RESET}");
107 match align {
108 Align::Left => {
109 let pad = BOX_WIDTH.saturating_sub(len + 3);
111 let _ = write!(out, " {text}{:pad$}", "", pad = pad);
112 }
113 Align::Center => {
114 let left = (BOX_WIDTH.saturating_sub(len)) / 2 - 1;
115 let right = BOX_WIDTH.saturating_sub(len + left + 2);
116 let _ = write!(out, "{:left$}{text}{:right$}", "", "", left = left, right = right);
117 }
118 }
119 let _ = writeln!(out, "{ANSI_DIM}│{ANSI_RESET}");
120}
121
122fn scalar_preview(value: &str) -> String {
127 let mut out = String::from(": ");
128 let mut cols = 0usize;
129 let mut pending_space = false;
130 let mut any = false;
131
132 for ch in value.chars() {
133 if (ch as u32) < 0x20 || ch as u32 == 0x7f {
134 if any {
136 pending_space = true;
137 }
138 continue;
139 }
140 if cols >= SCALAR_PREVIEW_MAX {
141 out.push_str("...");
142 return out;
143 }
144 if pending_space {
145 out.push(' ');
146 pending_space = false;
147 cols += 1;
148 if cols >= SCALAR_PREVIEW_MAX {
149 out.push_str("...");
151 return out;
152 }
153 }
154 out.push(ch);
155 cols += 1;
156 any = true;
157 }
158 out
159}
160
161fn node_label(doc: &Document, id: NodeId) -> String {
163 if let Some(tag) = doc.tag_of(id) {
164 return tag.full();
165 }
166 match &doc.resolved(id).data {
167 NodeData::Mapping { .. } => "mapping".into(),
168 NodeData::Sequence { .. } => "sequence".into(),
169 _ => "scalar".into(),
170 }
171}
172
173struct TreeState {
175 active: Vec<bool>,
178 path: Vec<NodeId>,
187 budget: usize,
194 visits: u64,
202}
203
204const TREE_OUTPUT_BUDGET: usize = 8 << 20;
209
210const TREE_VISITS_PER_NODE: u64 = 64;
218
219const TREE_MAX_DEPTH: usize = 256;
223
224fn write_indent(out: &mut String, state: &TreeState, depth: usize, is_leaf: bool) {
225 if depth < 1 {
226 return;
227 }
228 out.push_str(ANSI_DIM);
229 for idx in 0..depth {
230 if idx == depth - 1 {
231 out.push_str(if is_leaf { "└─" } else { "├─" });
232 } else if state.active.get(idx).copied().unwrap_or(false) {
233 out.push_str("│ ");
234 } else {
235 out.push_str(" ");
236 }
237 }
238 out.push_str(ANSI_RESET);
239}
240
241enum NodeIndex<'a> {
243 Key(&'a str),
244 Index(usize),
245}
246
247fn write_node(
248 out: &mut String,
249 doc: &Document,
250 id: NodeId,
251 index: &NodeIndex<'_>,
252 depth: usize,
253 is_leaf: bool,
254 state: &mut TreeState,
255) {
256 let resolved_id = doc.resolve(id);
257
258 if depth > TREE_MAX_DEPTH || state.path.contains(&resolved_id) {
259 write_indent(out, state, depth, is_leaf);
260 let _ = match index {
261 NodeIndex::Key(key) => writeln!(out, "{ANSI_BOLD}{key}{ANSI_RESET} (...)"),
262 NodeIndex::Index(idx) => writeln!(
263 out,
264 "{ANSI_DIM}[{ANSI_RESET}{ANSI_BOLD}{idx}{ANSI_RESET}{ANSI_DIM}]{ANSI_RESET} (...)"
265 ),
266 };
267 return;
268 }
269 if out.len() >= state.budget || state.visits == 0 {
270 return;
271 }
272 state.visits -= 1;
273
274 let label = node_label(doc, id);
275 write_indent(out, state, depth, is_leaf);
276
277 match index {
278 NodeIndex::Key(key) => {
279 let _ = write!(out, "{ANSI_BOLD}{key}{ANSI_RESET} ({label})");
280 }
281 NodeIndex::Index(idx) => {
282 let _ = write!(
283 out,
284 "{ANSI_DIM}[{ANSI_RESET}{ANSI_BOLD}{idx}{ANSI_RESET}{ANSI_DIM}]{ANSI_RESET} ({label})"
285 );
286 }
287 }
288
289 let resolved = resolved_id;
290 let node = doc.node(resolved);
291
292 if !node.is_mapping() && !node.is_sequence() {
294 out.push_str(&scalar_preview(node.as_str().unwrap_or("")));
295 out.push('\n');
296 return;
297 }
298 out.push('\n');
299
300 if state.active.len() <= depth {
301 state.active.resize(depth + 1, false);
302 }
303 state.active[depth] = true;
304 state.path.push(resolved);
305
306 match &node.data {
307 NodeData::Mapping { entries, .. } => {
308 let entries = entries.clone();
309 let last = entries.len().saturating_sub(1);
310 for (position, entry) in entries.iter().enumerate() {
311 let leaf = position == last;
312 if leaf {
313 state.active[depth] = false;
314 }
315 let key = doc.resolved(entry.key).as_str().unwrap_or("<complex key>").to_string();
316 write_node(out, doc, entry.value, &NodeIndex::Key(&key), depth + 1, leaf, state);
317 }
318 }
319 NodeData::Sequence { items, .. } => {
320 let items = items.clone();
321 let last = items.len().saturating_sub(1);
322 for (position, item) in items.iter().enumerate() {
323 let leaf = position == last;
324 if leaf {
325 state.active[depth] = false;
326 }
327 write_node(out, doc, *item, &NodeIndex::Index(position), depth + 1, leaf, state);
328 }
329 }
330 _ => {}
331 }
332
333 state.path.pop();
334}
335
336fn write_block(out: &mut String, reader: &Reader, index: usize, verify: bool) {
338 let Ok(block) = reader.block(index) else { return };
339 let header = &block.header;
340
341 write_border(out, Border::Top);
342 write_field(out, Align::Center, &format!("Block #{index}"));
343 write_border(out, Border::Middle);
344 write_field(out, Align::Left, &format!("flags: 0x{:08x}", header.flags));
345 write_border(out, Border::Middle);
346
347 write_field(out, Align::Left, &format!("compression: \"{}\"", header.compression_name()));
351 write_border(out, Border::Middle);
352
353 write_field(out, Align::Left, &format!("allocated_size: {}", header.allocated_size));
354 write_border(out, Border::Middle);
355 write_field(out, Align::Left, &format!("used_size: {}", header.used_size));
356 write_border(out, Border::Middle);
357 write_field(out, Align::Left, &format!("data_size: {}", header.data_size));
358 write_border(out, Border::Middle);
359
360 let checksum: String = header.checksum.iter().map(|b| format!("{b:02x}")).collect();
361 let mark = if verify {
362 match reader.verify_block_checksum(index) {
363 Ok((ChecksumStatus::Valid, _)) => format!(" {COLOR_GREEN}✓{ANSI_RESET}"),
364 Ok((ChecksumStatus::Absent, _)) => String::new(),
365 _ => format!(" {COLOR_RED}✗{ANSI_RESET}"),
366 }
367 } else {
368 String::new()
369 };
370 write_field(out, Align::Left, &format!("checksum: {checksum}{mark}"));
371 write_border(out, Border::Bottom);
372}
373
374pub fn render(reader: &Reader, options: InfoOptions) -> crate::Result<String> {
376 let mut out = String::new();
377
378 if options.print_tree
379 && let Some(doc) = reader.tree()?
380 && let Some(root) = doc.root()
381 {
382 let mut state = TreeState {
383 active: vec![false; 16],
384 path: Vec::new(),
385 budget: TREE_OUTPUT_BUDGET,
386 visits: (doc.node_count() as u64).saturating_mul(TREE_VISITS_PER_NODE).max(4096),
387 };
388 write_node(&mut out, &doc, root, &NodeIndex::Key("root"), 0, true, &mut state);
389 }
390
391 if options.print_blocks {
392 for index in 0..reader.block_count() {
393 write_block(&mut out, reader, index, options.verify_checksums);
394 }
395 }
396 Ok(out)
397}
398
399#[cfg(test)]
400mod tests {
401 use super::*;
402
403 fn plain(s: &str) -> String {
405 let mut out = String::new();
406 let bytes = s.as_bytes();
407 let mut idx = 0;
408 while idx < bytes.len() {
409 if bytes[idx] == 0x1b && idx + 1 < bytes.len() && bytes[idx + 1] == b'[' {
410 idx += 2;
411 while idx < bytes.len() && bytes[idx] != b'm' {
412 idx += 1;
413 }
414 idx += 1;
415 continue;
416 }
417 let start = idx;
418 idx += 1;
419 while idx < bytes.len() && bytes[idx] & 0xc0 == 0x80 {
420 idx += 1;
421 }
422 out.push_str(core::str::from_utf8(&bytes[start..idx]).unwrap_or("?"));
423 }
424 out
425 }
426
427 fn build_file(tree: &str) -> Vec<u8> {
428 let mut buf = Vec::new();
429 buf.extend_from_slice(b"#ASDF 1.0.0\n#ASDF_STANDARD 1.6.0\n");
430 buf.extend_from_slice(b"%YAML 1.1\n%TAG ! tag:stsci.edu:asdf/\n--- !core/asdf-1.1.0\n");
431 buf.extend_from_slice(tree.as_bytes());
432 buf.extend_from_slice(b"...\n");
433 buf
434 }
435
436 #[test]
437 fn visible_len_ignores_escapes_and_counts_characters() {
438 assert_eq!(visible_len("abc"), 3);
439 assert_eq!(visible_len("\x1b[1mabc\x1b[0m"), 3);
440 assert_eq!(visible_len("✓"), 1);
442 assert_eq!(visible_len("\x1b[32m✓\x1b[0m"), 1);
443 }
444
445 #[test]
446 fn box_rows_are_all_the_same_visible_width() {
447 let mut out = String::new();
448 write_border(&mut out, Border::Top);
449 write_field(&mut out, Align::Center, "Block #0");
450 write_border(&mut out, Border::Middle);
451 write_field(&mut out, Align::Left, "flags: 0x00000000");
452 write_border(&mut out, Border::Bottom);
453
454 for line in out.lines() {
458 let line = line.strip_prefix(ANSI_RESET).unwrap_or(line);
459 if line.is_empty() {
461 continue;
462 }
463 assert_eq!(
464 visible_len(line),
465 BOX_WIDTH,
466 "row {line:?} is not {BOX_WIDTH} columns wide"
467 );
468 }
469 }
470
471 #[test]
472 fn scalar_previews_collapse_control_characters() {
473 assert_eq!(scalar_preview("hello"), ": hello");
474 assert_eq!(scalar_preview("a\nb"), ": a b");
475 assert_eq!(scalar_preview("a\n\n\tb"), ": a b");
476 assert_eq!(scalar_preview("\n\nabc"), ": abc");
478 }
479
480 #[test]
481 fn long_scalars_are_truncated() {
482 let long = "x".repeat(100);
483 let preview = scalar_preview(&long);
484 assert!(preview.ends_with("..."));
485 assert_eq!(preview.len(), 2 + SCALAR_PREVIEW_MAX + 3);
486 }
487
488 #[test]
489 fn renders_a_simple_tree() {
490 let file = build_file("a: 1\nb:\n c: two\n");
491 let reader = Reader::from_bytes(file).unwrap();
492 let out = render(&reader, InfoOptions::default()).unwrap();
493 let text = plain(&out);
494
495 assert!(text.starts_with("root (tag:stsci.edu:asdf/core/asdf-1.1.0)\n"), "{text}");
496 assert!(text.contains("├─a (scalar): 1\n"), "{text}");
497 assert!(text.contains("└─b (mapping)\n"), "{text}");
498 assert!(text.contains(" └─c (scalar): two\n"), "{text}");
501 }
502
503 #[test]
504 fn continuation_bars_track_remaining_siblings() {
505 let file = build_file("a:\n x: 1\n y: 2\nb: 3\n");
506 let reader = Reader::from_bytes(file).unwrap();
507 let text = plain(&render(&reader, InfoOptions::default()).unwrap());
508
509 assert!(text.contains("│ ├─x (scalar): 1\n"), "{text}");
511 assert!(text.contains("│ └─y (scalar): 2\n"), "{text}");
512 assert!(text.contains("└─b (scalar): 3\n"), "{text}");
513 }
514
515 #[test]
516 fn sequences_are_indexed() {
517 let file = build_file("s: [10, 20]\n");
518 let reader = Reader::from_bytes(file).unwrap();
519 let text = plain(&render(&reader, InfoOptions::default()).unwrap());
520 assert!(text.contains("├─[0] (scalar): 10\n"), "{text}");
521 assert!(text.contains("└─[1] (scalar): 20\n"), "{text}");
522 }
523
524 #[test]
525 fn tagged_nodes_show_their_tag() {
526 let file = build_file("d: !core/ndarray-1.1.0\n source: 0\n");
527 let reader = Reader::from_bytes(file).unwrap();
528 let text = plain(&render(&reader, InfoOptions::default()).unwrap());
529 assert!(text.contains("d (tag:stsci.edu:asdf/core/ndarray-1.1.0)"), "{text}");
530 }
531
532 #[test]
533 fn the_tree_can_be_suppressed() {
534 let file = build_file("a: 1\n");
535 let reader = Reader::from_bytes(file).unwrap();
536 let options = InfoOptions { print_tree: false, ..Default::default() };
537 assert!(render(&reader, options).unwrap().is_empty());
538 }
539}