tuible 0.0.2-alpha.1

A keyboard-driven database client for your terminal, built for both humans and AI agents.
#[derive(Debug, Clone, PartialEq)]
pub enum Value {
    Null,
    Int(i64),
    Float(f64),
    Decimal(String),
    Text(String),
    Bool(bool),
    Bytes(Vec<u8>),
    Json(serde_json::Value),
}

#[derive(Debug, Clone)]
pub struct Column {
    pub name: String,
}

#[derive(Debug, Clone)]
pub struct Row {
    pub values: Vec<Value>,
}

#[derive(Debug, Clone)]
pub struct TablePage {
    pub columns: Vec<Column>,
    pub rows: Vec<Row>,
    pub rowids: Option<Vec<i64>>,
    pub offset: usize,
    pub has_more: bool,
}

#[derive(Debug, Clone, PartialEq)]
pub struct SchemaColumn {
    pub name: String,
    pub col_type: String,
    pub notnull: bool,
    pub pk: bool,
}

#[derive(Debug, Clone)]
pub enum QueryOutcome {
    Rows {
        columns: Vec<Column>,
        rows: Vec<Row>,
        truncated: bool,
        next_token: Option<String>,
        read_only: bool,
    },
    Affected(u64),
    Executed,
}

impl std::fmt::Display for Value {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Value::Null => write!(f, ""),
            Value::Int(i) => write!(f, "{i}"),
            Value::Float(x) => write!(f, "{x}"),
            Value::Decimal(value) => write!(f, "{value}"),
            Value::Text(s) => write!(f, "{s}"),
            Value::Bool(value) => write!(f, "{value}"),
            Value::Bytes(b) => write!(f, "<{} bytes>", b.len()),
            Value::Json(value) => write!(f, "{value}"),
        }
    }
}

impl Value {
    pub fn type_name(&self) -> &'static str {
        match self {
            Value::Null => "null",
            Value::Int(_) => "integer",
            Value::Float(_) => "real",
            Value::Decimal(_) => "number",
            Value::Text(_) => "text",
            Value::Bool(_) => "boolean",
            Value::Bytes(_) => "binary",
            Value::Json(_) => "json",
        }
    }

    pub fn detail_text(&self) -> String {
        match self {
            Value::Null => "NULL".to_string(),
            Value::Bytes(value) => {
                let hex = value
                    .iter()
                    .take(32)
                    .map(|byte| format!("{byte:02x}"))
                    .collect::<String>();
                if value.len() > 32 {
                    format!("0x{hex}... ({} bytes)", value.len())
                } else {
                    format!("0x{hex} ({} bytes)", value.len())
                }
            }
            Value::Json(value) => {
                serde_json::to_string_pretty(value).unwrap_or_else(|_| value.to_string())
            }
            value => value.to_string(),
        }
    }

    pub fn edited(&self, text: &str) -> Result<Self, String> {
        match self {
            Value::Null if text.is_empty() => Ok(Value::Null),
            Value::Null | Value::Text(_) => Ok(Value::Text(text.to_string())),
            Value::Int(_) => text
                .parse()
                .map(Value::Int)
                .map_err(|_| "expected an integer".to_string()),
            Value::Float(_) => text
                .parse()
                .map(Value::Float)
                .map_err(|_| "expected a number".to_string()),
            Value::Decimal(_) => text
                .parse::<serde_json::Number>()
                .map(|_| Value::Decimal(text.to_string()))
                .map_err(|_| "expected a number".to_string()),
            Value::Bool(_) => text
                .parse()
                .map(Value::Bool)
                .map_err(|_| "expected true or false".to_string()),
            Value::Bytes(_) => Err("binary values cannot be edited in the TUI".to_string()),
            Value::Json(_) => serde_json::from_str(text)
                .map(Value::Json)
                .map_err(|error| format!("invalid JSON: {error}")),
        }
    }

    pub fn to_json(&self) -> serde_json::Value {
        match self {
            Value::Null => serde_json::Value::Null,
            Value::Int(value) => (*value).into(),
            Value::Float(value) => serde_json::Number::from_f64(*value)
                .map(serde_json::Value::Number)
                .unwrap_or_else(|| serde_json::Value::String(value.to_string())),
            Value::Decimal(value) => value
                .parse::<serde_json::Number>()
                .map(serde_json::Value::Number)
                .unwrap_or_else(|_| serde_json::Value::String(value.clone())),
            Value::Text(value) => value.clone().into(),
            Value::Bool(value) => (*value).into(),
            Value::Bytes(value) => serde_json::json!({
                "type": "bytes",
                "hex": value.iter().map(|byte| format!("{byte:02x}")).collect::<String>(),
            }),
            Value::Json(value) => value.clone(),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn displays_int_as_plain_number() {
        assert_eq!(Value::Int(42).to_string(), "42");
    }

    #[test]
    fn displays_null_as_empty_string() {
        assert_eq!(Value::Null.to_string(), "");
    }

    #[test]
    fn displays_text_unquoted() {
        assert_eq!(Value::Text("hello".to_string()).to_string(), "hello");
    }

    #[test]
    fn displays_bytes_with_length() {
        assert_eq!(Value::Bytes(vec![1, 2, 3]).to_string(), "<3 bytes>");
    }

    #[test]
    fn edits_preserve_numeric_and_null_types() {
        assert_eq!(Value::Int(1).edited("42"), Ok(Value::Int(42)));
        assert_eq!(Value::Null.edited(""), Ok(Value::Null));
        assert_eq!(Value::Null.edited("hello"), Ok(Value::Text("hello".into())));
        assert!(Value::Float(1.0).edited("hello").is_err());
    }
}