//! Column configuration for table mode. Defines which fields //! to display as columns and how to label them. use crate::item::resolve_field_path; use serde_json::Value; /// A single column definition: which field to extract and /// what to call it in the header. #[derive(Debug, Clone)] pub struct ColumnDef { /// Dotted path into the item's JSON value (e.g. "meta.res"). pub field: String, /// Display name for the column header. Defaults to the /// field path if no alias is given. pub display_name: String, } /// Column layout for table mode. Controls which fields are /// shown and in what order. #[derive(Debug, Clone)] pub struct ColumnConfig { pub columns: Vec, } impl ColumnConfig { /// Parse a column spec string like "label,meta.res:Resolution,meta.size". /// Columns are comma-separated. Each column can have an alias /// after the first colon: "field:Alias". pub fn parse(spec: &str) -> Self { let columns = spec .split(',') .filter(|s| !s.is_empty()) .map(|part| { let part = part.trim(); if let Some((field, alias)) = part.split_once(':') { ColumnDef { field: field.trim().to_string(), display_name: alias.trim().to_string(), } } else { ColumnDef { field: part.to_string(), display_name: part.to_string(), } } }) .collect(); Self { columns } } /// Build a ColumnConfig from raw header names. Used when /// auto-generating columns from CSV headers. pub fn from_headers(headers: &[String]) -> Self { let columns = headers .iter() .map(|h| ColumnDef { field: h.clone(), display_name: h.clone(), }) .collect(); Self { columns } } /// Resolve each column's value from an item's JSON value. /// Missing fields produce an empty string. pub fn resolve_values(&self, value: &Value) -> Vec { self.columns .iter() .map(|col| { resolve_field_path(value, &col.field) .map(value_to_cell) .unwrap_or_default() }) .collect() } } /// Convert a JSON value to a cell string for display. fn value_to_cell(v: &Value) -> String { match v { Value::String(s) => s.clone(), Value::Number(n) => n.to_string(), Value::Bool(b) => b.to_string(), Value::Null => String::new(), other => other.to_string(), } } #[cfg(test)] mod tests { use super::*; use serde_json::json; #[test] fn parse_simple_columns() { let config = ColumnConfig::parse("label,age,city"); assert_eq!(config.columns.len(), 3); assert_eq!(config.columns[0].field, "label"); assert_eq!(config.columns[0].display_name, "label"); assert_eq!(config.columns[2].field, "city"); } #[test] fn parse_with_alias() { let config = ColumnConfig::parse("label,meta.res:Resolution"); assert_eq!(config.columns.len(), 2); assert_eq!(config.columns[1].field, "meta.res"); assert_eq!(config.columns[1].display_name, "Resolution"); } #[test] fn parse_empty_string() { let config = ColumnConfig::parse(""); assert!(config.columns.is_empty()); } #[test] fn parse_trailing_comma() { let config = ColumnConfig::parse("label,age,"); assert_eq!(config.columns.len(), 2); } #[test] fn resolve_flat_values() { let config = ColumnConfig::parse("label,age"); let value = json!({"label": "alice", "age": 30}); let cells = config.resolve_values(&value); assert_eq!(cells, vec!["alice", "30"]); } #[test] fn resolve_nested_values() { let config = ColumnConfig::parse("label,meta.res"); let value = json!({"label": "test", "meta": {"res": "1080p"}}); let cells = config.resolve_values(&value); assert_eq!(cells, vec!["test", "1080p"]); } #[test] fn resolve_missing_field() { let config = ColumnConfig::parse("label,nope"); let value = json!({"label": "test"}); let cells = config.resolve_values(&value); assert_eq!(cells, vec!["test", ""]); } #[test] fn from_headers_builds_identity() { let headers = vec!["name".to_string(), "age".to_string()]; let config = ColumnConfig::from_headers(&headers); assert_eq!(config.columns.len(), 2); assert_eq!(config.columns[0].field, "name"); assert_eq!(config.columns[0].display_name, "name"); } }