ublx 0.1.1

TUI to index once, enrich with metadata, and browse a flat snapshot in a 3-pane layout with multiple modes.
Documentation
//! Key/value and value display formatting for metadata and contents tables.

use ratatui::style::Style;

use crate::themes::DEFAULT_COLORS;
use crate::utils::Epsilon;
use crate::utils::format_bytes;

/// Words that are displayed in full caps (e.g. "svo" -> "SVO", "pdf" -> "PDF").
const ALL_CAPS: &[&str] = &[
    "svo", "pdf", "tiff", "jpeg", "png", "rgb", "yuv", "aac", "mp3", "h264", "cbr", "lf", "und",
    "eng",
];

const FLOAT_PRECISION: usize = 4;

#[inline]
#[must_use]
pub fn is_byte_key(key: &str) -> bool {
    key.to_lowercase().contains("size")
        || key.to_lowercase().contains("compressed")
        || key.to_lowercase().contains("uncompressed")
        || key.to_lowercase().contains("byte")
}

#[must_use]
pub fn is_bool(value: &serde_json::Value) -> bool {
    matches!(value, serde_json::Value::Bool(_))
}

/// Optional style for a value cell (e.g. TRUE = green, FALSE = red). Returns `None` for non-bool values.
#[must_use]
pub fn value_cell_style(formatted_value: &str) -> Option<Style> {
    match formatted_value {
        "TRUE" => Some(Style::default().fg(DEFAULT_COLORS.green)),
        "FALSE" => Some(Style::default().fg(DEFAULT_COLORS.red)),
        _ => None,
    }
}

/// Join parts with middle dot: 2 parts → "a · b", 3 parts → "a · b · c".
pub fn join_dot(parts: impl IntoIterator<Item = impl AsRef<str>>) -> String {
    parts
        .into_iter()
        .map(|p| p.as_ref().to_string())
        .collect::<Vec<_>>()
        .join(" · ")
}

/// Schema tree: prefix + label (e.g. tree branch + node name).
#[must_use]
pub fn prefixed_label(prefix: &str, label: &str) -> String {
    format!("{prefix}{label}")
}

/// Schema tree: prefix + label + ": " + value string (leaf line).
#[must_use]
pub fn prefixed_label_with_value(prefix: &str, label: &str, value_str: &str) -> String {
    format!("{prefix}{label}: {value_str}")
}

#[must_use]
pub fn format_key(key: &str) -> String {
    let words: Vec<String> = key
        .split('_')
        .map(|w| {
            let lower = w.to_lowercase();
            if ALL_CAPS.iter().any(|&c| c == lower) {
                w.to_uppercase()
            } else {
                let mut c = w.chars();
                match c.next() {
                    None => String::new(),
                    Some(first) => first.to_uppercase().chain(c).collect(),
                }
            }
        })
        .collect();
    words.join(" ")
}

pub fn value_to_string(v: &serde_json::Value) -> String {
    match v {
        serde_json::Value::Null => "".to_string(),
        serde_json::Value::Bool(b) => b.to_string().to_uppercase(),
        serde_json::Value::Number(n) => n
            .as_f64()
            .filter(|f| f.fract().abs() >= Epsilon::FORMAT)
            .map_or_else(|| n.to_string(), |f| format!("{f:.FLOAT_PRECISION$}")),
        serde_json::Value::String(s) => s.clone(),
        serde_json::Value::Array(arr) => {
            if arr.is_empty() {
                "[]".to_string()
            } else if arr.len() <= 3 && arr.iter().all(|x| x.is_string() || x.is_number()) {
                arr.iter()
                    .map(value_to_string)
                    .collect::<Vec<_>>()
                    .join(", ")
            } else {
                format!("[{} items]", arr.len())
            }
        }
        serde_json::Value::Object(_) => "{…}".to_string(),
    }
}

/// Non-negative JSON floats for byte-like keys: clamp to `u64`, then truncate toward zero.
fn json_f64_to_u64_for_bytes(f: f64) -> u64 {
    if f <= 0.0 || !f.is_finite() {
        return 0;
    }
    if f >= u64::MAX as f64 {
        return u64::MAX;
    }
    f as u64
}

/// Format value for display: byte format when key contains "size", "compressed", or "uncompressed" (and value is numeric); "%" when key contains "percent" (case-insensitive).
#[must_use]
pub fn format_value(v: &serde_json::Value, key: &str) -> String {
    let key_lower = key.to_lowercase();
    if is_byte_key(&key_lower) {
        if let Some(n) = v.as_u64() {
            return format_bytes(n);
        }
        if let Some(n) = v.as_i64().filter(|&x| x >= 0) {
            // Non-negative `i64` → `u64` without `as` sign-loss ambiguity.
            return format_bytes(n.cast_unsigned());
        }
        if let Some(f) = v.as_f64().filter(|&x| x >= 0.0 && x.is_finite()) {
            return format_bytes(json_f64_to_u64_for_bytes(f));
        }
    }
    let s = value_to_string(v);
    if key_lower.contains("percent") {
        format!("{s}%")
    } else {
        s
    }
}