use std::collections::HashMap;
use std::sync::{Arc, OnceLock, RwLock};
use crate::{
export::{GridExportPayload, build_csv_export_payload},
models::{GridColumnDef, GridOptions, GridRecord, GridRow},
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum GridExportScope {
#[default]
Visible,
All,
Selected,
}
pub struct GridRegisteredExportContext<'a> {
pub columns: &'a [GridColumnDef],
pub rows: &'a [GridRow],
pub formatted_cells: Vec<Vec<String>>,
pub options: &'a GridOptions,
pub scope: GridExportScope,
pub format: &'a str,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GridExportResult {
pub filename: String,
pub content: Vec<u8>,
pub mime_type: String,
}
impl From<GridExportPayload> for GridExportResult {
fn from(payload: GridExportPayload) -> Self {
Self {
filename: payload.filename,
content: payload.contents.into_bytes(),
mime_type: payload.mime_type,
}
}
}
pub trait GridExporter: Send + Sync {
fn export(&self, ctx: &GridRegisteredExportContext<'_>) -> GridExportResult;
}
impl<F> GridExporter for F
where
F: Fn(&GridRegisteredExportContext<'_>) -> GridExportResult + Send + Sync,
{
fn export(&self, ctx: &GridRegisteredExportContext<'_>) -> GridExportResult {
(self)(ctx)
}
}
type ExporterMap = HashMap<String, Arc<dyn GridExporter>>;
fn registry() -> &'static RwLock<ExporterMap> {
static REGISTRY: OnceLock<RwLock<ExporterMap>> = OnceLock::new();
REGISTRY.get_or_init(|| {
let mut map: ExporterMap = HashMap::new();
map.insert(
"csv".to_string(),
Arc::new(BuiltInCsvExporter) as Arc<dyn GridExporter>,
);
RwLock::new(map)
})
}
pub fn init_default_grid_exporters() {
let _ = registry();
}
pub fn register_grid_exporter(format: impl Into<String>, exporter: Arc<dyn GridExporter>) {
let mut map = registry()
.write()
.expect("ui_grid_core::exporter_registry write lock poisoned");
map.insert(format.into(), exporter);
}
pub fn unregister_grid_exporter(format: &str) -> Option<Arc<dyn GridExporter>> {
let mut map = registry()
.write()
.expect("ui_grid_core::exporter_registry write lock poisoned");
map.remove(format)
}
pub fn get_grid_exporter(format: &str) -> Option<Arc<dyn GridExporter>> {
let map = registry()
.read()
.expect("ui_grid_core::exporter_registry read lock poisoned");
map.get(format).cloned()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UnknownExportFormat {
pub format: String,
}
impl std::fmt::Display for UnknownExportFormat {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "no exporter registered for format `{}`", self.format)
}
}
impl std::error::Error for UnknownExportFormat {}
pub fn export_grid(
format: &str,
ctx: &GridRegisteredExportContext<'_>,
) -> Result<GridExportResult, UnknownExportFormat> {
let exporter = get_grid_exporter(format).ok_or_else(|| UnknownExportFormat {
format: format.to_string(),
})?;
Ok(exporter.export(ctx))
}
pub fn entities_from_export_context(ctx: &GridRegisteredExportContext<'_>) -> Vec<GridRecord> {
ctx.rows.iter().map(|row| row.entity.clone()).collect()
}
struct BuiltInCsvExporter;
impl GridExporter for BuiltInCsvExporter {
fn export(&self, ctx: &GridRegisteredExportContext<'_>) -> GridExportResult {
let core_ctx = crate::export::GridExportContext {
grid_id: &ctx.options.id,
columns: ctx.columns,
rows: ctx.rows,
};
let payload: GridExportPayload = build_csv_export_payload(&core_ctx);
GridExportResult {
filename: payload.filename,
content: payload.contents.into_bytes(),
mime_type: payload.mime_type,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn make_options() -> GridOptions {
GridOptions {
id: "registry-grid".to_string(),
..GridOptions::default()
}
}
fn make_row(id: &str, value: &str) -> GridRow {
GridRow::new(id.to_string(), json!({ "id": id, "name": value }), 0, 44)
}
fn make_columns() -> Vec<GridColumnDef> {
vec![GridColumnDef {
name: "name".to_string(),
..GridColumnDef::default()
}]
}
#[test]
fn built_in_csv_exporter_is_registered_by_default() {
init_default_grid_exporters();
assert!(get_grid_exporter("csv").is_some());
}
#[test]
fn export_grid_invokes_registered_exporter() {
init_default_grid_exporters();
let columns = make_columns();
let rows = vec![make_row("r1", "Alpha")];
let formatted = vec![vec!["Alpha".to_string()]];
let options = make_options();
let ctx = GridRegisteredExportContext {
columns: &columns,
rows: &rows,
formatted_cells: formatted,
options: &options,
scope: GridExportScope::Visible,
format: "csv",
};
let result = export_grid("csv", &ctx).expect("csv exporter must be registered");
assert_eq!(result.mime_type, "text/csv;charset=utf-8");
assert!(!result.content.is_empty());
let text = String::from_utf8(result.content).expect("csv is utf-8");
assert!(text.contains("Name"));
assert!(text.contains("Alpha"));
}
#[test]
fn export_grid_returns_unknown_format_error() {
init_default_grid_exporters();
let columns = make_columns();
let rows: Vec<GridRow> = Vec::new();
let options = make_options();
let ctx = GridRegisteredExportContext {
columns: &columns,
rows: &rows,
formatted_cells: Vec::new(),
options: &options,
scope: GridExportScope::Visible,
format: "tsv",
};
unregister_grid_exporter("tsv");
let err = export_grid("tsv", &ctx).expect_err("tsv must not be registered");
assert_eq!(err.format, "tsv");
assert!(err.to_string().contains("tsv"));
}
#[test]
fn export_grid_passes_scope_through_to_exporter() {
use std::sync::Mutex;
static CAPTURED: Mutex<Option<(GridExportScope, String, usize)>> = Mutex::new(None);
register_grid_exporter(
"scope-snapshot",
Arc::new(|ctx: &GridRegisteredExportContext<'_>| {
*CAPTURED.lock().unwrap() =
Some((ctx.scope, ctx.format.to_string(), ctx.rows.len()));
GridExportResult {
filename: "snapshot.bin".into(),
content: Vec::new(),
mime_type: "application/octet-stream".into(),
}
}),
);
let columns = make_columns();
let rows = vec![make_row("r1", "a"), make_row("r2", "b")];
let options = make_options();
let formatted = vec![vec!["a".to_string()], vec!["b".to_string()]];
for scope in [
GridExportScope::Visible,
GridExportScope::All,
GridExportScope::Selected,
] {
let ctx = GridRegisteredExportContext {
columns: &columns,
rows: &rows,
formatted_cells: formatted.clone(),
options: &options,
scope,
format: "scope-snapshot",
};
export_grid("scope-snapshot", &ctx).unwrap();
let captured = CAPTURED.lock().unwrap().clone().unwrap();
assert_eq!(captured.0, scope);
assert_eq!(captured.1, "scope-snapshot");
assert_eq!(captured.2, rows.len());
}
unregister_grid_exporter("scope-snapshot");
}
#[test]
fn register_replaces_prior_registration_and_unregister_drops_it() {
let columns = make_columns();
let rows: Vec<GridRow> = Vec::new();
let options = make_options();
register_grid_exporter(
"snapshot-test",
Arc::new(|_ctx: &GridRegisteredExportContext<'_>| GridExportResult {
filename: "first.bin".into(),
content: b"first".to_vec(),
mime_type: "application/octet-stream".into(),
}),
);
let ctx = GridRegisteredExportContext {
columns: &columns,
rows: &rows,
formatted_cells: Vec::new(),
options: &options,
scope: GridExportScope::All,
format: "snapshot-test",
};
let first = export_grid("snapshot-test", &ctx).unwrap();
assert_eq!(first.filename, "first.bin");
register_grid_exporter(
"snapshot-test",
Arc::new(|_ctx: &GridRegisteredExportContext<'_>| GridExportResult {
filename: "second.bin".into(),
content: b"second".to_vec(),
mime_type: "application/octet-stream".into(),
}),
);
let second = export_grid("snapshot-test", &ctx).unwrap();
assert_eq!(second.filename, "second.bin");
let removed = unregister_grid_exporter("snapshot-test");
assert!(removed.is_some());
assert!(get_grid_exporter("snapshot-test").is_none());
}
}