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