opsis 0.1.0

Config-driven framework for blazingly fast visualizations.
Documentation
use std::collections::BTreeMap;
use std::path::Path;

use serde::{Deserialize, Serialize};

use crate::error::{OpsisError, Result};

/// A single value in a record. Kept narrow on purpose — opsis is a plotting
/// framework, not a dataframe library.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum Value {
    Number(f64),
    Text(String),
    Bool(bool),
    Null,
}

impl Value {
    pub fn as_f64(&self) -> Option<f64> {
        match self {
            Value::Number(n) => Some(*n),
            Value::Bool(b) => Some(if *b { 1.0 } else { 0.0 }),
            Value::Text(s) => s.parse().ok(),
            Value::Null => None,
        }
    }

    pub fn as_str(&self) -> Option<String> {
        match self {
            Value::Text(s) => Some(s.clone()),
            Value::Number(n) => Some(n.to_string()),
            Value::Bool(b) => Some(b.to_string()),
            Value::Null => None,
        }
    }
}

impl From<f64> for Value {
    fn from(v: f64) -> Self { Value::Number(v) }
}
impl From<i64> for Value {
    fn from(v: i64) -> Self { Value::Number(v as f64) }
}
impl From<&str> for Value {
    fn from(v: &str) -> Self { Value::Text(v.to_string()) }
}
impl From<String> for Value {
    fn from(v: String) -> Self { Value::Text(v) }
}

/// One row of data. Field order is preserved by BTreeMap on key, which is
/// fine because we look up by name everywhere.
pub type Record = BTreeMap<String, Value>;

/// A dataset is a homogeneous-ish collection of records. We don't enforce
/// schema — encodings pick which fields to use.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Dataset {
    pub records: Vec<Record>,
}

impl Dataset {
    pub fn new(records: Vec<Record>) -> Self { Self { records } }

    pub fn is_empty(&self) -> bool { self.records.is_empty() }
    pub fn len(&self) -> usize { self.records.len() }

    /// Names of fields seen across all records.
    pub fn fields(&self) -> Vec<String> {
        let mut seen: Vec<String> = Vec::new();
        for r in &self.records {
            for k in r.keys() {
                if !seen.contains(k) { seen.push(k.clone()); }
            }
        }
        seen
    }

    pub fn column_f64(&self, field: &str) -> Result<Vec<f64>> {
        if !self.fields().iter().any(|f| f == field) {
            return Err(OpsisError::UnknownField {
                field: field.into(),
                available: self.fields(),
            });
        }
        self.records
            .iter()
            .map(|r| {
                r.get(field)
                    .and_then(|v| v.as_f64())
                    .ok_or(OpsisError::WrongFieldType {
                        field: field.into(),
                        wanted: "number",
                    })
            })
            .collect()
    }

    pub fn column_str(&self, field: &str) -> Result<Vec<String>> {
        if !self.fields().iter().any(|f| f == field) {
            return Err(OpsisError::UnknownField {
                field: field.into(),
                available: self.fields(),
            });
        }
        Ok(self
            .records
            .iter()
            .map(|r| r.get(field).and_then(|v| v.as_str()).unwrap_or_default())
            .collect())
    }

    /// Read a dataset from a CSV file. Numeric columns are auto-detected per
    /// cell — strings that parse as f64 become numbers.
    pub fn from_csv_path(path: impl AsRef<Path>) -> Result<Self> {
        let mut rdr = csv::Reader::from_path(path)?;
        let headers = rdr.headers()?.clone();
        let mut records = Vec::new();
        for row in rdr.records() {
            let row = row?;
            let mut rec = Record::new();
            for (i, cell) in row.iter().enumerate() {
                let key = headers.get(i).unwrap_or("").to_string();
                let val = if let Ok(n) = cell.parse::<f64>() {
                    Value::Number(n)
                } else if cell.is_empty() {
                    Value::Null
                } else {
                    Value::Text(cell.to_string())
                };
                rec.insert(key, val);
            }
            records.push(rec);
        }
        Ok(Self { records })
    }

    pub fn from_json_str(s: &str) -> Result<Self> {
        let records: Vec<Record> = serde_json::from_str(s)?;
        Ok(Self { records })
    }
}