use std::path::Path;
use acta::{BlockMetadata, FileMetadata, LogicalType, Reader, Schema, TimeUnit, TimeZone};
const ABSENT: &str = "-";
const NOTHING: &str = "none";
const NO_BLOCKS: &str = "(no data blocks)";
const COLUMN_GAP: usize = 2;
const ROW_IDS_FEATURE: u64 = 1;
pub fn inspect_path(path: &Path) -> acta::Result<String> {
Ok(format_inspection(&Reader::open(path)?))
}
pub fn format_inspection(reader: &Reader) -> String {
let (major, minor) = reader.file_metadata().format_version();
let sections = [
format!("Acta v{major}.{minor}"),
section("File", &file_section(reader)),
section("Schema", &schema_section(reader.schema())),
section("Blocks", &block_section(reader.blocks())),
];
format!("{}\n", sections.join("\n\n"))
}
fn section(title: &str, body: &str) -> String {
format!("{title}\n{}\n{body}", "-".repeat(title.len()))
}
fn file_section(reader: &Reader) -> String {
let metadata = reader.file_metadata();
field_list(&[
("file id", hexadecimal(metadata.file_id())),
("features", features(metadata.feature_flags())),
("schema id", metadata.schema_id().to_string()),
("size", format!("{} bytes", metadata.file_size())),
("blocks", metadata.block_count().to_string()),
("rows", metadata.total_rows().to_string()),
("primary", primary_column(reader.schema())),
("tail", tail(metadata)),
])
}
fn field_list(fields: &[(&str, String)]) -> String {
let width = fields
.iter()
.map(|(label, _)| label.chars().count())
.max()
.unwrap_or_default();
fields
.iter()
.map(|(label, value)| {
let label = format!("{label}:");
format!("{label:<0$} {value}", width + 1)
})
.collect::<Vec<_>>()
.join("\n")
}
fn hexadecimal(bytes: &[u8]) -> String {
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
}
fn features(feature_flags: u64) -> String {
if feature_flags & ROW_IDS_FEATURE != 0 {
return "ROW_IDS".to_owned();
}
NOTHING.to_owned()
}
fn primary_column(schema: &Schema) -> String {
match schema.primary_column() {
Some(column) => format!("{} (column {})", column.name(), column.id()),
None => NOTHING.to_owned(),
}
}
fn tail(metadata: &FileMetadata) -> String {
if metadata.incomplete_tail() {
return format!(
"incomplete, resume at offset {}",
metadata.last_good_offset()
);
}
"complete".to_owned()
}
fn schema_section(schema: &Schema) -> String {
let mut table = Table::new(&["ID", "Name", "Type", "Nullable", "Primary"]);
for column in schema.columns() {
table.push(vec![
column.id().to_string(),
column.name().to_owned(),
logical_type(column.logical_type()),
yes_or_no(column.is_nullable()),
yes_or_no(schema.primary_column_id() == Some(column.id())),
]);
}
table.render()
}
fn logical_type(logical_type: &LogicalType) -> String {
match logical_type {
LogicalType::Bool => "bool".to_owned(),
LogicalType::Int8 => "int8".to_owned(),
LogicalType::Int16 => "int16".to_owned(),
LogicalType::Int32 => "int32".to_owned(),
LogicalType::Int64 => "int64".to_owned(),
LogicalType::UInt8 => "uint8".to_owned(),
LogicalType::UInt16 => "uint16".to_owned(),
LogicalType::UInt32 => "uint32".to_owned(),
LogicalType::UInt64 => "uint64".to_owned(),
LogicalType::Float32 => "float32".to_owned(),
LogicalType::Float64 => "float64".to_owned(),
LogicalType::Utf8 => "utf8".to_owned(),
LogicalType::Binary => "binary".to_owned(),
LogicalType::Date32 => "date32".to_owned(),
LogicalType::Decimal { precision, scale } => {
format!("decimal64(precision={precision}, scale={scale})")
}
LogicalType::Timestamp { unit, timezone } => {
format!("timestamp64({}, {})", time_unit(unit), time_zone(timezone))
}
LogicalType::Categorical { ordered } => format!("categorical(ordered={ordered})"),
LogicalType::FixedBinary { byte_width } => format!("fixed_binary({byte_width})"),
}
}
fn time_unit(unit: &TimeUnit) -> &'static str {
match unit {
TimeUnit::Second => "s",
TimeUnit::Millisecond => "ms",
TimeUnit::Microsecond => "us",
TimeUnit::Nanosecond => "ns",
}
}
fn time_zone(timezone: &TimeZone) -> &str {
match timezone {
TimeZone::Naive => "naive",
TimeZone::Utc => "UTC",
TimeZone::Iana(name) => name,
}
}
fn block_section(blocks: &[BlockMetadata]) -> String {
if blocks.is_empty() {
return NO_BLOCKS.to_owned();
}
let mut table = Table::new(&[
"Seq",
"Offset",
"Bytes",
"Rows",
"Base Row",
"Primary Min",
"Primary Max",
"Sorted",
]);
for block in blocks {
let (minimum, maximum) = primary_bounds(block);
table.push(vec![
block.sequence().to_string(),
block.file_offset().to_string(),
block.total_length().to_string(),
block.row_count().to_string(),
number_or_absent(block.base_row_id()),
minimum,
maximum,
yes_or_no(block.ts_sorted()),
]);
}
table.render()
}
fn primary_bounds(block: &BlockMetadata) -> (String, String) {
match block.primary_bounds() {
Some(bounds) => (bounds.min().to_string(), bounds.max().to_string()),
None => (ABSENT.to_owned(), ABSENT.to_owned()),
}
}
fn number_or_absent(value: Option<u64>) -> String {
match value {
Some(value) => value.to_string(),
None => ABSENT.to_owned(),
}
}
fn yes_or_no(value: bool) -> String {
if value {
return "yes".to_owned();
}
"no".to_owned()
}
struct Table {
rows: Vec<Vec<String>>,
}
impl Table {
fn new(headings: &[&str]) -> Self {
Self {
rows: vec![
headings
.iter()
.map(|heading| (*heading).to_owned())
.collect(),
],
}
}
fn push(&mut self, cells: Vec<String>) {
self.rows.push(cells);
}
fn render(&self) -> String {
let widths = self.column_widths();
self.rows
.iter()
.map(|row| render_row(row, &widths))
.collect::<Vec<_>>()
.join("\n")
}
fn column_widths(&self) -> Vec<usize> {
let mut widths = vec![0; self.rows.first().map_or(0, Vec::len)];
for row in &self.rows {
for (width, cell) in widths.iter_mut().zip(row) {
*width = (*width).max(cell.chars().count());
}
}
widths
}
}
fn render_row(cells: &[String], widths: &[usize]) -> String {
cells
.iter()
.zip(widths)
.map(|(cell, width)| format!("{cell:<width$}"))
.collect::<Vec<_>>()
.join(&" ".repeat(COLUMN_GAP))
.trim_end()
.to_owned()
}