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}
195
196const TREE_OUTPUT_BUDGET: usize = 64 << 20;
202
203const TREE_MAX_DEPTH: usize = 256;
207
208fn write_indent(out: &mut String, state: &TreeState, depth: usize, is_leaf: bool) {
209 if depth < 1 {
210 return;
211 }
212 out.push_str(ANSI_DIM);
213 for idx in 0..depth {
214 if idx == depth - 1 {
215 out.push_str(if is_leaf { "└─" } else { "├─" });
216 } else if state.active.get(idx).copied().unwrap_or(false) {
217 out.push_str("│ ");
218 } else {
219 out.push_str(" ");
220 }
221 }
222 out.push_str(ANSI_RESET);
223}
224
225enum NodeIndex<'a> {
227 Key(&'a str),
228 Index(usize),
229}
230
231fn write_node(
232 out: &mut String,
233 doc: &Document,
234 id: NodeId,
235 index: &NodeIndex<'_>,
236 depth: usize,
237 is_leaf: bool,
238 state: &mut TreeState,
239) {
240 let resolved_id = doc.resolve(id);
241
242 if depth > TREE_MAX_DEPTH || state.path.contains(&resolved_id) {
243 write_indent(out, state, depth, is_leaf);
244 let _ = match index {
245 NodeIndex::Key(key) => writeln!(out, "{ANSI_BOLD}{key}{ANSI_RESET} (...)"),
246 NodeIndex::Index(idx) => writeln!(
247 out,
248 "{ANSI_DIM}[{ANSI_RESET}{ANSI_BOLD}{idx}{ANSI_RESET}{ANSI_DIM}]{ANSI_RESET} (...)"
249 ),
250 };
251 return;
252 }
253 if out.len() >= state.budget {
254 return;
255 }
256
257 let label = node_label(doc, id);
258 write_indent(out, state, depth, is_leaf);
259
260 match index {
261 NodeIndex::Key(key) => {
262 let _ = write!(out, "{ANSI_BOLD}{key}{ANSI_RESET} ({label})");
263 }
264 NodeIndex::Index(idx) => {
265 let _ = write!(
266 out,
267 "{ANSI_DIM}[{ANSI_RESET}{ANSI_BOLD}{idx}{ANSI_RESET}{ANSI_DIM}]{ANSI_RESET} ({label})"
268 );
269 }
270 }
271
272 let resolved = resolved_id;
273 let node = doc.node(resolved);
274
275 if !node.is_mapping() && !node.is_sequence() {
277 out.push_str(&scalar_preview(node.as_str().unwrap_or("")));
278 out.push('\n');
279 return;
280 }
281 out.push('\n');
282
283 if state.active.len() <= depth {
284 state.active.resize(depth + 1, false);
285 }
286 state.active[depth] = true;
287 state.path.push(resolved);
288
289 match &node.data {
290 NodeData::Mapping { entries, .. } => {
291 let entries = entries.clone();
292 let last = entries.len().saturating_sub(1);
293 for (position, entry) in entries.iter().enumerate() {
294 let leaf = position == last;
295 if leaf {
296 state.active[depth] = false;
297 }
298 let key = doc.resolved(entry.key).as_str().unwrap_or("<complex key>").to_string();
299 write_node(out, doc, entry.value, &NodeIndex::Key(&key), depth + 1, leaf, state);
300 }
301 }
302 NodeData::Sequence { items, .. } => {
303 let items = items.clone();
304 let last = items.len().saturating_sub(1);
305 for (position, item) in items.iter().enumerate() {
306 let leaf = position == last;
307 if leaf {
308 state.active[depth] = false;
309 }
310 write_node(out, doc, *item, &NodeIndex::Index(position), depth + 1, leaf, state);
311 }
312 }
313 _ => {}
314 }
315
316 state.path.pop();
317}
318
319fn write_block(out: &mut String, reader: &Reader, index: usize, verify: bool) {
321 let Ok(block) = reader.block(index) else { return };
322 let header = &block.header;
323
324 write_border(out, Border::Top);
325 write_field(out, Align::Center, &format!("Block #{index}"));
326 write_border(out, Border::Middle);
327 write_field(out, Align::Left, &format!("flags: 0x{:08x}", header.flags));
328 write_border(out, Border::Middle);
329
330 write_field(out, Align::Left, &format!("compression: \"{}\"", header.compression_name()));
334 write_border(out, Border::Middle);
335
336 write_field(out, Align::Left, &format!("allocated_size: {}", header.allocated_size));
337 write_border(out, Border::Middle);
338 write_field(out, Align::Left, &format!("used_size: {}", header.used_size));
339 write_border(out, Border::Middle);
340 write_field(out, Align::Left, &format!("data_size: {}", header.data_size));
341 write_border(out, Border::Middle);
342
343 let checksum: String = header.checksum.iter().map(|b| format!("{b:02x}")).collect();
344 let mark = if verify {
345 match reader.verify_block_checksum(index) {
346 Ok((ChecksumStatus::Valid, _)) => format!(" {COLOR_GREEN}✓{ANSI_RESET}"),
347 Ok((ChecksumStatus::Absent, _)) => String::new(),
348 _ => format!(" {COLOR_RED}✗{ANSI_RESET}"),
349 }
350 } else {
351 String::new()
352 };
353 write_field(out, Align::Left, &format!("checksum: {checksum}{mark}"));
354 write_border(out, Border::Bottom);
355}
356
357pub fn render(reader: &Reader, options: InfoOptions) -> crate::Result<String> {
359 let mut out = String::new();
360
361 if options.print_tree
362 && let Some(doc) = reader.tree()?
363 && let Some(root) = doc.root()
364 {
365 let mut state =
366 TreeState { active: vec![false; 16], path: Vec::new(), budget: TREE_OUTPUT_BUDGET };
367 write_node(&mut out, &doc, root, &NodeIndex::Key("root"), 0, true, &mut state);
368 }
369
370 if options.print_blocks {
371 for index in 0..reader.block_count() {
372 write_block(&mut out, reader, index, options.verify_checksums);
373 }
374 }
375 Ok(out)
376}
377
378#[cfg(test)]
379mod tests {
380 use super::*;
381
382 fn plain(s: &str) -> String {
384 let mut out = String::new();
385 let bytes = s.as_bytes();
386 let mut idx = 0;
387 while idx < bytes.len() {
388 if bytes[idx] == 0x1b && idx + 1 < bytes.len() && bytes[idx + 1] == b'[' {
389 idx += 2;
390 while idx < bytes.len() && bytes[idx] != b'm' {
391 idx += 1;
392 }
393 idx += 1;
394 continue;
395 }
396 let start = idx;
397 idx += 1;
398 while idx < bytes.len() && bytes[idx] & 0xc0 == 0x80 {
399 idx += 1;
400 }
401 out.push_str(core::str::from_utf8(&bytes[start..idx]).unwrap_or("?"));
402 }
403 out
404 }
405
406 fn build_file(tree: &str) -> Vec<u8> {
407 let mut buf = Vec::new();
408 buf.extend_from_slice(b"#ASDF 1.0.0\n#ASDF_STANDARD 1.6.0\n");
409 buf.extend_from_slice(b"%YAML 1.1\n%TAG ! tag:stsci.edu:asdf/\n--- !core/asdf-1.1.0\n");
410 buf.extend_from_slice(tree.as_bytes());
411 buf.extend_from_slice(b"...\n");
412 buf
413 }
414
415 #[test]
416 fn visible_len_ignores_escapes_and_counts_characters() {
417 assert_eq!(visible_len("abc"), 3);
418 assert_eq!(visible_len("\x1b[1mabc\x1b[0m"), 3);
419 assert_eq!(visible_len("✓"), 1);
421 assert_eq!(visible_len("\x1b[32m✓\x1b[0m"), 1);
422 }
423
424 #[test]
425 fn box_rows_are_all_the_same_visible_width() {
426 let mut out = String::new();
427 write_border(&mut out, Border::Top);
428 write_field(&mut out, Align::Center, "Block #0");
429 write_border(&mut out, Border::Middle);
430 write_field(&mut out, Align::Left, "flags: 0x00000000");
431 write_border(&mut out, Border::Bottom);
432
433 for line in out.lines() {
437 let line = line.strip_prefix(ANSI_RESET).unwrap_or(line);
438 if line.is_empty() {
440 continue;
441 }
442 assert_eq!(
443 visible_len(line),
444 BOX_WIDTH,
445 "row {line:?} is not {BOX_WIDTH} columns wide"
446 );
447 }
448 }
449
450 #[test]
451 fn scalar_previews_collapse_control_characters() {
452 assert_eq!(scalar_preview("hello"), ": hello");
453 assert_eq!(scalar_preview("a\nb"), ": a b");
454 assert_eq!(scalar_preview("a\n\n\tb"), ": a b");
455 assert_eq!(scalar_preview("\n\nabc"), ": abc");
457 }
458
459 #[test]
460 fn long_scalars_are_truncated() {
461 let long = "x".repeat(100);
462 let preview = scalar_preview(&long);
463 assert!(preview.ends_with("..."));
464 assert_eq!(preview.len(), 2 + SCALAR_PREVIEW_MAX + 3);
465 }
466
467 #[test]
468 fn renders_a_simple_tree() {
469 let file = build_file("a: 1\nb:\n c: two\n");
470 let reader = Reader::from_bytes(file).unwrap();
471 let out = render(&reader, InfoOptions::default()).unwrap();
472 let text = plain(&out);
473
474 assert!(text.starts_with("root (tag:stsci.edu:asdf/core/asdf-1.1.0)\n"), "{text}");
475 assert!(text.contains("├─a (scalar): 1\n"), "{text}");
476 assert!(text.contains("└─b (mapping)\n"), "{text}");
477 assert!(text.contains(" └─c (scalar): two\n"), "{text}");
480 }
481
482 #[test]
483 fn continuation_bars_track_remaining_siblings() {
484 let file = build_file("a:\n x: 1\n y: 2\nb: 3\n");
485 let reader = Reader::from_bytes(file).unwrap();
486 let text = plain(&render(&reader, InfoOptions::default()).unwrap());
487
488 assert!(text.contains("│ ├─x (scalar): 1\n"), "{text}");
490 assert!(text.contains("│ └─y (scalar): 2\n"), "{text}");
491 assert!(text.contains("└─b (scalar): 3\n"), "{text}");
492 }
493
494 #[test]
495 fn sequences_are_indexed() {
496 let file = build_file("s: [10, 20]\n");
497 let reader = Reader::from_bytes(file).unwrap();
498 let text = plain(&render(&reader, InfoOptions::default()).unwrap());
499 assert!(text.contains("├─[0] (scalar): 10\n"), "{text}");
500 assert!(text.contains("└─[1] (scalar): 20\n"), "{text}");
501 }
502
503 #[test]
504 fn tagged_nodes_show_their_tag() {
505 let file = build_file("d: !core/ndarray-1.1.0\n source: 0\n");
506 let reader = Reader::from_bytes(file).unwrap();
507 let text = plain(&render(&reader, InfoOptions::default()).unwrap());
508 assert!(text.contains("d (tag:stsci.edu:asdf/core/ndarray-1.1.0)"), "{text}");
509 }
510
511 #[test]
512 fn the_tree_can_be_suppressed() {
513 let file = build_file("a: 1\n");
514 let reader = Reader::from_bytes(file).unwrap();
515 let options = InfoOptions { print_tree: false, ..Default::default() };
516 assert!(render(&reader, options).unwrap().is_empty());
517 }
518}