use std::cmp::Ordering;
use std::collections::HashMap;
use std::fs::File;
use std::io::{BufWriter, Write};
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use crate::config::{self, Profile};
use crate::data::Dataset;
use crate::filter;
pub const RENDER_CAP: usize = 2000;
pub const DEFAULT_COLUMN_COUNT: usize = 8;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Focus {
FieldTree,
ActiveColumns,
Facets,
Table,
Search,
Filter,
GroupBy,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PromptKind {
SaveProfile,
LoadProfile,
Export,
}
#[derive(Debug, Default, Clone, Copy)]
pub struct Area {
pub x: u16,
pub y: u16,
pub w: u16,
pub h: u16,
}
impl Area {
pub fn contains(&self, x: u16, y: u16) -> bool {
x >= self.x && x < self.x + self.w && y >= self.y && y < self.y + self.h
}
}
#[derive(Debug, Default, Clone)]
pub struct UiLayout {
pub search: Area,
pub filter: Area,
pub group: Area,
pub tree: Area,
pub tree_offset: usize,
pub columns: Area,
pub columns_offset: usize,
pub facets: Area,
pub facets_offset: usize,
pub table: Area,
pub table_offset: usize,
pub col_spans: Vec<(u16, u16)>,
pub detail: Area,
}
pub struct Prompt {
pub kind: PromptKind,
pub label: String,
pub input: String,
}
pub struct App {
pub dataset: Dataset,
pub file_path: PathBuf,
pub field_paths: Vec<String>,
pub shape_hash: String,
pub active_columns: Vec<String>,
pub sort_field: Option<String>,
pub sort_desc: bool,
pub group_field: Option<String>,
pub group_facet: Option<String>,
pub search_input: String,
pub filter_input: String,
pub group_input: String,
pub filter_error: Option<String>,
pub search_error: Option<String>,
pub visible_indices: Vec<usize>,
pub facets: Vec<(String, usize)>,
pub focus: Focus,
pub tree_selected: usize,
pub columns_selected: usize,
pub table_selected: usize,
pub table_col_selected: usize,
pub facet_selected: usize,
pub tree_view: usize,
pub columns_view: usize,
pub facets_view: usize,
pub table_view: usize,
pub detail: Option<usize>,
pub detail_text: Option<String>,
pub detail_scroll: u16,
pub prompt: Option<Prompt>,
pub status: Option<String>,
pub should_quit: bool,
pub ui: UiLayout,
}
impl App {
pub fn new(dataset: Dataset, file_path: PathBuf) -> Self {
let field_paths: Vec<String> = dataset.schema.keys().cloned().collect();
let shape_hash = crate::data::shape_hash(dataset.schema.keys());
let active_columns = default_columns(&dataset);
let mut app = App {
dataset,
file_path,
field_paths,
shape_hash,
active_columns,
sort_field: None,
sort_desc: false,
group_field: None,
group_facet: None,
search_input: String::new(),
filter_input: String::new(),
group_input: String::new(),
filter_error: None,
search_error: None,
visible_indices: Vec::new(),
facets: Vec::new(),
focus: Focus::Table,
tree_selected: 0,
columns_selected: 0,
table_selected: 0,
table_col_selected: 0,
facet_selected: 0,
tree_view: 0,
columns_view: 0,
facets_view: 0,
table_view: 0,
detail: None,
detail_text: None,
detail_scroll: 0,
prompt: None,
status: None,
should_quit: false,
ui: UiLayout::default(),
};
app.recompute();
app
}
pub fn apply_profile(&mut self, p: &Profile) {
let cols: Vec<String> = p
.columns
.iter()
.filter(|c| self.dataset.schema.contains_key(*c))
.cloned()
.collect();
if !cols.is_empty() {
self.active_columns = cols;
}
self.sort_field = p
.sort_field
.clone()
.filter(|f| self.dataset.schema.contains_key(f));
self.sort_desc = p.sort_desc;
self.group_field = p
.group_field
.clone()
.filter(|f| self.dataset.schema.contains_key(f));
self.group_facet = None;
self.group_input = self.group_field.clone().unwrap_or_default();
if let Some(s) = &p.search {
self.search_input = s.clone();
}
if let Some(f) = &p.filter {
self.filter_input = f.clone();
}
self.recompute();
}
pub fn current_profile(&self, name: &str) -> Profile {
Profile {
name: name.to_string(),
columns: self.active_columns.clone(),
sort_field: self.sort_field.clone(),
sort_desc: self.sort_desc,
group_field: self.group_field.clone(),
search: Some(self.search_input.clone()).filter(|s| !s.trim().is_empty()),
filter: Some(self.filter_input.clone()).filter(|s| !s.trim().is_empty()),
}
}
pub fn recompute(&mut self) {
self.filter_error = None;
let clauses = if self.filter_input.trim().is_empty() {
Vec::new()
} else {
match filter::parse_filter(&self.filter_input) {
Ok(c) => c,
Err(e) => {
self.filter_error = Some(e.to_string());
Vec::new()
}
}
};
self.search_error = None;
let search = match filter::build_search(&self.search_input) {
Ok(s) => s,
Err(e) => {
self.search_error = Some(e.to_string());
None
}
};
let search_columns: Vec<u32> = if search.is_some() {
if self.active_columns.is_empty() {
(0..self.dataset.interner.len() as u32).collect()
} else {
self.active_columns
.iter()
.filter_map(|c| self.dataset.path_id(c))
.collect()
}
} else {
Vec::new()
};
let clause_ids: Vec<Option<u32>> = clauses
.iter()
.map(|c| self.dataset.path_id(&c.field))
.collect();
let mut base: Vec<usize> = Vec::new();
for (i, rec) in self.dataset.records.iter().enumerate() {
if !filter::matches_all(&clauses, &clause_ids, &rec.flat) {
continue;
}
if let Some(mode) = &search {
if !filter::record_matches_search(&rec.flat, &search_columns, mode) {
continue;
}
}
base.push(i);
}
self.facets.clear();
if let Some(gf) = self.group_field.clone() {
let gf_id = self.dataset.path_id(&gf);
let mut counts: HashMap<String, usize> = HashMap::new();
for &i in &base {
if let Some(v) = gf_id.and_then(|id| self.dataset.records[i].flat.get(id)) {
*counts.entry(v.display()).or_default() += 1;
}
}
let mut facets: Vec<(String, usize)> = counts.into_iter().collect();
facets.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
self.facets = facets;
if let Some(sel) = self.group_facet.clone() {
if self.facets.iter().any(|(v, _)| *v == sel) {
base.retain(|&i| {
gf_id
.and_then(|id| self.dataset.records[i].flat.get(id))
.map(|v| v.display() == sel)
.unwrap_or(false)
});
} else {
self.group_facet = None;
}
}
} else {
self.group_facet = None;
}
if let Some(field) = self.sort_field.clone() {
let desc = self.sort_desc;
let fid = self.dataset.path_id(&field);
let mut keyed: Vec<(usize, Option<filter::SortKey>)> = base
.iter()
.map(|&i| {
(
i,
fid.and_then(|id| self.dataset.records[i].flat.get(id))
.and_then(filter::sort_key),
)
})
.collect();
keyed.sort_by(|a, b| match (&a.1, &b.1) {
(Some(x), Some(y)) => x.compare(y, desc),
(Some(_), None) => Ordering::Less,
(None, Some(_)) => Ordering::Greater,
(None, None) => Ordering::Equal,
});
base = keyed.into_iter().map(|(i, _)| i).collect();
}
self.visible_indices = base;
self.clamp_selections();
}
pub fn clamp_selections(&mut self) {
let shown = self.visible_indices.len().min(RENDER_CAP);
self.table_selected = self.table_selected.min(shown.saturating_sub(1));
self.table_col_selected = self
.table_col_selected
.min(self.active_columns.len().saturating_sub(1));
self.columns_selected = self
.columns_selected
.min(self.active_columns.len().saturating_sub(1));
self.tree_selected = self.tree_selected.min(self.field_paths.len().saturating_sub(1));
self.facet_selected = self.facet_selected.min(self.facets.len().saturating_sub(1));
self.table_view = self.table_view.min(shown.saturating_sub(1));
self.tree_view = self.tree_view.min(self.field_paths.len().saturating_sub(1));
self.columns_view = self
.columns_view
.min(self.active_columns.len().saturating_sub(1));
self.facets_view = self.facets_view.min(self.facets.len().saturating_sub(1));
}
pub fn shown_rows(&self) -> usize {
self.visible_indices.len().min(RENDER_CAP)
}
pub fn toggle_field_at(&mut self, idx: usize) {
let Some(path) = self.field_paths.get(idx).cloned() else {
return;
};
if let Some(pos) = self.active_columns.iter().position(|c| *c == path) {
self.active_columns.remove(pos);
} else {
self.active_columns.push(path);
}
self.recompute();
}
pub fn remove_selected_column(&mut self) {
if self.columns_selected < self.active_columns.len() {
self.active_columns.remove(self.columns_selected);
self.recompute();
}
}
pub fn move_column(&mut self, delta: isize) {
let len = self.active_columns.len();
if len < 2 {
return;
}
let i = self.columns_selected.min(len - 1);
let j = i as isize + delta;
if j < 0 || j as usize >= len {
return;
}
self.active_columns.swap(i, j as usize);
self.columns_selected = j as usize;
}
pub fn sort_by(&mut self, field: &str) {
if self.sort_field.as_deref() == Some(field) {
self.sort_desc = !self.sort_desc;
} else {
self.sort_field = Some(field.to_string());
self.sort_desc = false;
}
let dir = if self.sort_desc { "desc" } else { "asc" };
self.status = Some(format!("Sorted by {field} ({dir})"));
self.recompute();
}
pub fn sort_by_selected_table_column(&mut self) {
if let Some(col) = self.active_columns.get(self.table_col_selected).cloned() {
self.sort_by(&col);
}
}
pub fn sort_by_selected_active_column(&mut self) {
if let Some(col) = self.active_columns.get(self.columns_selected).cloned() {
self.sort_by(&col);
}
}
pub fn sync_group_input(&mut self) {
self.group_input = self.group_field.clone().unwrap_or_default();
}
pub fn commit_group_input(&mut self) {
let g = self.group_input.trim().to_string();
if g.is_empty() {
self.group_field = None;
self.group_facet = None;
} else if self.dataset.schema.contains_key(&g) {
if self.group_field.as_deref() != Some(g.as_str()) {
self.group_facet = None;
self.facet_selected = 0;
}
self.group_field = Some(g);
} else {
self.status = Some(format!("Unknown field '{g}' for group-by"));
return;
}
self.recompute();
}
pub fn toggle_facet_at(&mut self, idx: usize) {
let Some((value, _)) = self.facets.get(idx).cloned() else {
return;
};
if self.group_facet.as_deref() == Some(value.as_str()) {
self.group_facet = None;
} else {
self.group_facet = Some(value);
}
self.recompute();
}
pub fn reset_view(&mut self) {
self.search_input.clear();
self.filter_input.clear();
self.group_input.clear();
self.sort_field = None;
self.sort_desc = false;
self.group_field = None;
self.group_facet = None;
self.status = Some("Search/filter/group/sort reset".to_string());
self.recompute();
}
pub fn cycle_focus(&mut self, backwards: bool) {
let mut order = vec![Focus::FieldTree, Focus::ActiveColumns];
if self.group_field.is_some() {
order.push(Focus::Facets);
}
order.extend([Focus::Table, Focus::Search, Focus::Filter, Focus::GroupBy]);
let cur = order.iter().position(|f| *f == self.focus).unwrap_or(0);
let next = if backwards {
(cur + order.len() - 1) % order.len()
} else {
(cur + 1) % order.len()
};
self.focus = order[next];
if self.focus == Focus::GroupBy {
self.sync_group_input();
}
}
pub fn open_prompt(&mut self, kind: PromptKind) {
let (label, input) = match kind {
PromptKind::SaveProfile => (
"Save profile as (Enter to confirm, Esc to cancel)".to_string(),
self.default_profile_name(),
),
PromptKind::LoadProfile => {
let names = config::list_profiles();
let label = if names.is_empty() {
"Load profile (none saved yet)".to_string()
} else {
format!("Load profile — saved: {}", names.join(", "))
};
(label, String::new())
}
PromptKind::Export => (
"Export visible records to (Enter to confirm, Esc to cancel)".to_string(),
self.default_export_path(),
),
};
self.prompt = Some(Prompt { kind, label, input });
}
pub fn confirm_prompt(&mut self) {
let Some(p) = self.prompt.take() else {
return;
};
let input = p.input.trim().to_string();
match p.kind {
PromptKind::SaveProfile => {
let name = if input.is_empty() {
self.default_profile_name()
} else {
input
};
let profile = self.current_profile(&name);
match config::save_profile(&profile) {
Ok(path) => {
let _ = config::save_shape_profile(&self.shape_hash, &profile);
self.status =
Some(format!("Saved profile '{}' to {}", name, path.display()));
}
Err(e) => self.status = Some(format!("Save failed: {e:#}")),
}
}
PromptKind::LoadProfile => {
if input.is_empty() {
self.status = Some("Load cancelled (no profile name)".to_string());
return;
}
match config::load_profile(&input) {
Ok(profile) => {
self.apply_profile(&profile);
self.status = Some(format!("Loaded profile '{input}'"));
}
Err(e) => self.status = Some(format!("Load failed: {e:#}")),
}
}
PromptKind::Export => {
if input.is_empty() {
self.status = Some("Export cancelled (no path)".to_string());
return;
}
match self.export_to(Path::new(&input)) {
Ok(n) => {
self.status = Some(format!(
"Wrote {} records to {input}",
group_digits(n)
));
}
Err(e) => self.status = Some(format!("Export failed: {e:#}")),
}
}
}
}
pub fn default_profile_name(&self) -> String {
self.file_path
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "default".to_string())
}
pub fn default_export_path(&self) -> String {
let stem = self
.file_path
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "export".to_string());
format!("{stem}.filtered.jsonl")
}
pub fn export_to(&self, path: &Path) -> Result<usize> {
let file = File::create(path)
.with_context(|| format!("cannot create '{}'", path.display()))?;
let mut w = BufWriter::new(file);
let mut fetcher = self.dataset.fetcher();
for &i in &self.visible_indices {
let line = fetcher
.line(&self.dataset.records[i])
.context("failed reading record from source")?;
w.write_all(line)
.and_then(|()| w.write_all(b"\n"))
.with_context(|| format!("failed writing to '{}'", path.display()))?;
}
w.flush()
.with_context(|| format!("failed flushing '{}'", path.display()))?;
Ok(self.visible_indices.len())
}
pub fn open_detail(&mut self) {
if let Some(&rec_idx) = self.visible_indices.get(self.table_selected) {
self.detail = Some(rec_idx);
self.detail_scroll = 0;
let mut fetcher = self.dataset.fetcher();
let text = fetcher
.value(&self.dataset.records[rec_idx])
.and_then(|v| serde_json::to_string_pretty(&v).map_err(anyhow::Error::from));
self.detail_text = Some(match text {
Ok(s) => s,
Err(e) => format!("<failed to load record: {e:#}>"),
});
}
}
pub fn close_detail(&mut self) {
self.detail = None;
self.detail_text = None;
self.detail_scroll = 0;
}
}
fn default_columns(dataset: &Dataset) -> Vec<String> {
let mut fields: Vec<(&String, usize)> = dataset
.schema
.iter()
.map(|(path, info)| (path, info.count))
.collect();
fields.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(b.0)));
fields
.into_iter()
.take(DEFAULT_COLUMN_COUNT)
.map(|(p, _)| p.clone())
.collect()
}
pub fn group_digits(n: usize) -> String {
let s = n.to_string();
let mut out = String::with_capacity(s.len() + s.len() / 3);
for (i, c) in s.chars().enumerate() {
if i > 0 && (s.len() - i).is_multiple_of(3) {
out.push(',');
}
out.push(c);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::data::load_from_reader;
use std::io::Cursor;
fn sample_app() -> App {
let input = "\
{\"type\":\"a\",\"score\":10,\"user\":{\"id\":1,\"name\":\"ada\"}}\n\
{\"type\":\"b\",\"score\":3,\"user\":{\"id\":2,\"name\":\"bob\"}}\n\
{\"type\":\"a\",\"score\":7,\"user\":{\"id\":3,\"name\":\"eve\"}}\n\
{\"type\":\"b\",\"user\":{\"id\":4,\"name\":\"mal\"}}\n";
let ds = load_from_reader(Cursor::new(input), None).unwrap();
App::new(ds, PathBuf::from("sample.jsonl"))
}
#[test]
fn default_columns_by_presence() {
let app = sample_app();
assert!(app.active_columns.contains(&"type".to_string()));
assert!(app.active_columns.contains(&"score".to_string()));
assert_eq!(app.active_columns.last().unwrap(), "score");
}
#[test]
fn filter_search_group_pipeline() {
let mut app = sample_app();
app.filter_input = "score>3".to_string();
app.recompute();
assert_eq!(app.visible_indices, vec![0, 2]);
app.search_input = "eve".to_string();
app.recompute();
assert_eq!(app.visible_indices, vec![2]);
app.search_input.clear();
app.filter_input.clear();
app.group_field = Some("type".to_string());
app.recompute();
assert_eq!(app.facets, vec![("a".to_string(), 2), ("b".to_string(), 2)]);
app.group_facet = Some("b".to_string());
app.recompute();
assert_eq!(app.visible_indices, vec![1, 3]);
}
#[test]
fn sort_numeric_missing_last() {
let mut app = sample_app();
app.sort_field = Some("score".to_string());
app.recompute();
assert_eq!(app.visible_indices, vec![1, 2, 0, 3]); app.sort_desc = true;
app.recompute();
assert_eq!(app.visible_indices, vec![0, 2, 1, 3]); }
#[test]
fn sort_noisy_currency_strings_and_nulls() {
let input = "\
{\"price\":\"1 000 $\"}\n\
{\"price\":\"500 $\"}\n\
{\"price\":null}\n\
{}\n\
{\"price\":\"239129 EURO\"}\n\
{\"price\":\"N/A\"}\n";
let ds = crate::data::load_from_reader(std::io::Cursor::new(input), None).unwrap();
let mut app = App::new(ds, PathBuf::from("prices.jsonl"));
app.sort_field = Some("price".to_string());
app.recompute();
assert_eq!(app.visible_indices, vec![1, 0, 4, 5, 2, 3]);
app.sort_desc = true;
app.recompute();
assert_eq!(app.visible_indices, vec![4, 0, 1, 5, 2, 3]);
}
#[test]
fn mixed_types_sort_numbers_first_in_both_directions() {
let input = "\
{\"price\":\"1 000 $\"}\n\
{\"price\":\"500 $\"}\n\
{\"price\":null}\n\
{}\n\
{\"price\":\"2 000 000 EUROS\"}\n\
{\"price\":\"N/A\"}\n\
{\"price\":\"hello\"}\n";
let ds = crate::data::load_from_reader(std::io::Cursor::new(input), None).unwrap();
let mut app = App::new(ds, PathBuf::from("repro.jsonl"));
app.sort_field = Some("price".to_string());
app.sort_desc = false;
app.recompute();
assert_eq!(app.visible_indices, vec![1, 0, 4, 5, 6, 2, 3]);
app.sort_desc = true;
app.recompute();
assert_eq!(app.visible_indices, vec![4, 0, 1, 6, 5, 2, 3]);
}
#[test]
fn move_column_reorders() {
let mut app = sample_app();
app.active_columns = vec!["a".into(), "b".into(), "c".into()];
app.columns_selected = 2;
app.move_column(-1);
assert_eq!(app.active_columns, vec!["a", "c", "b"]);
assert_eq!(app.columns_selected, 1);
app.move_column(-1);
app.move_column(-1); assert_eq!(app.active_columns, vec!["c", "a", "b"]);
assert_eq!(app.columns_selected, 0);
app.move_column(1);
assert_eq!(app.active_columns, vec!["a", "c", "b"]);
}
#[test]
fn export_writes_filtered_original_records() {
let mut app = sample_app();
app.filter_input = "type=a".to_string();
app.recompute();
assert_eq!(app.visible_indices.len(), 2);
let path = std::env::temp_dir().join(format!(
"jsonl-tui-export-test-{}.jsonl",
std::process::id()
));
let n = app.export_to(&path).unwrap();
assert_eq!(n, 2);
let text = std::fs::read_to_string(&path).unwrap();
let ds2 = load_from_reader(Cursor::new(text), None).unwrap();
assert_eq!(ds2.records.len(), 2);
assert_eq!(ds2.parse_errors, 0);
let mut src = app.dataset.fetcher();
let mut out = ds2.fetcher();
assert_eq!(
out.value(&ds2.records[0]).unwrap(),
src.value(&app.dataset.records[0]).unwrap()
);
assert_eq!(
out.value(&ds2.records[1]).unwrap(),
src.value(&app.dataset.records[2]).unwrap()
);
std::fs::remove_file(&path).unwrap();
}
#[test]
fn export_error_is_not_a_panic() {
let app = sample_app();
let err = app
.export_to(Path::new("/nonexistent-dir-hopefully/out.jsonl"))
.unwrap_err();
assert!(err.to_string().contains("cannot create"));
}
#[test]
fn profile_apply_filters_unknown_fields() {
let mut app = sample_app();
let p = Profile {
name: "t".into(),
columns: vec!["score".into(), "ghost".into(), "type".into()],
sort_field: Some("ghost".into()),
sort_desc: true,
group_field: Some("type".into()),
search: None,
filter: None,
};
app.apply_profile(&p);
assert_eq!(app.active_columns, vec!["score", "type"]);
assert_eq!(app.sort_field, None); assert_eq!(app.group_field, Some("type".to_string()));
}
#[test]
fn group_digits_formats() {
assert_eq!(group_digits(0), "0");
assert_eq!(group_digits(999), "999");
assert_eq!(group_digits(1532), "1,532");
assert_eq!(group_digits(1234567), "1,234,567");
}
}