use crate::data::dataframe::DataFrame;
use crate::data::doc::{Doc, Format};
use crate::data::io;
use serde_json::Value;
use std::path::{Path, PathBuf};
use std::time::SystemTime;
#[derive(Clone, PartialEq, Eq)]
pub struct Source {
pub path: PathBuf,
pub container: Option<String>,
pub delimiter: Option<u8>,
pub format: Option<String>,
}
impl Source {
pub fn from_json(value: &Value) -> Result<Self, String> {
if let Some(path) = value.as_str() {
return Ok(Self {
path: PathBuf::from(path),
container: None,
delimiter: None,
format: None,
});
}
let obj = value.as_object().ok_or_else(|| {
"'source' must be an object with a 'path', or a path string".to_string()
})?;
let path = obj
.get("path")
.and_then(Value::as_str)
.ok_or_else(|| "'source' requires a 'path'".to_string())?;
let delimiter = match obj.get("delimiter").and_then(Value::as_str) {
Some(d) => {
let mut chars = d.chars();
match (chars.next(), chars.next()) {
(Some(c), None) if c.is_ascii() => Some(c as u8),
_ => {
return Err(format!(
"'delimiter' must be a single ASCII character, got {:?}",
d
))
}
}
}
None => None,
};
Ok(Self {
path: PathBuf::from(path),
container: obj
.get("container")
.and_then(Value::as_str)
.map(str::to_string),
delimiter,
format: obj
.get("format")
.and_then(Value::as_str)
.map(|s| s.to_lowercase()),
})
}
fn extension(&self) -> String {
self.format.clone().unwrap_or_else(|| {
self.path
.extension()
.and_then(|e| e.to_str())
.unwrap_or_default()
.to_lowercase()
})
}
}
const CACHE_ENTRIES: usize = 4;
pub struct Cached {
source: Source,
stamp: Stamp,
df: DataFrame,
}
type Stamp = Vec<(Option<SystemTime>, Option<u64>)>;
fn stamp_of(path: &Path) -> Stamp {
let one = |p: &Path| match std::fs::metadata(p) {
Ok(m) => (m.modified().ok(), Some(m.len())),
Err(_) => (None, None),
};
let name = path.to_string_lossy();
let mut out = vec![one(path)];
for extra in [
format!("{}-wal", name),
format!("{}.wal", name),
format!("{}-shm", name),
] {
out.push(one(Path::new(&extra)));
}
out
}
pub fn load(server: &mut super::Server, source: &Source) -> Result<DataFrame, String> {
let stamp = stamp_of(&source.path);
let readable = stamp[0].0.is_some();
if readable {
if let Some(i) = server
.cache
.iter()
.position(|c| c.source == *source && c.stamp == stamp)
{
let hit = server.cache.remove(i);
let df = hit.df.clone();
server.cache.insert(0, hit);
return Ok(df);
}
}
let df = load_once(source)?;
server.cache.retain(|c| c.source != *source);
server.cache.insert(
0,
Cached {
source: source.clone(),
stamp,
df: df.clone(),
},
);
server.cache.truncate(CACHE_ENTRIES);
Ok(df)
}
fn is_pattern(path: &Path) -> bool {
path.to_string_lossy()
.contains(['*', '?', '[', ']'].as_slice())
}
fn matches_of(pattern: &str) -> Result<Vec<PathBuf>, String> {
let mut found: Vec<PathBuf> = glob::glob(pattern)
.map_err(|e| format!("'{}' is not a usable pattern: {}", pattern, e))?
.filter_map(std::result::Result::ok)
.filter(|p| p.is_file())
.collect();
found.sort();
Ok(found)
}
fn load_pattern(source: &Source) -> Result<DataFrame, String> {
let pattern = source.path.to_string_lossy().to_string();
let files = matches_of(&pattern)?;
let Some((first, rest)) = files.split_first() else {
return Err(format!("glob matched no files: {}", pattern));
};
let one = |p: &Path| {
load_single(&Source {
path: p.to_path_buf(),
..source.clone()
})
};
let head = one(first)?;
if rest.is_empty() {
return Ok(head);
}
let records = matches!(source.extension().as_str(), "md" | "markdown");
let names: Vec<&str> = head.columns.iter().map(|c| c.name.as_str()).collect();
let mut frames = vec![head.df.clone()];
for path in rest {
let next = one(path)?;
let next_names: Vec<&str> = next.columns.iter().map(|c| c.name.as_str()).collect();
if !records && next_names != names {
return Err(format!(
"{} has different columns from {}: [{}] against [{}]. A pattern reads \
files that hold the same table.",
path.display(),
first.display(),
next_names.join(", "),
names.join(", ")
));
}
frames.push(next.df.clone());
}
let combined = if records {
polars::functions::concat_df_diagonal(&frames).map_err(|e| {
format!(
"{} matched {} files that could not be read as one table: {}",
pattern,
files.len(),
e
)
})?
} else {
let mut stacked = frames[0].clone();
for (frame, path) in frames[1..].iter().zip(rest) {
stacked.vstack_mut(frame).map_err(|e| {
format!(
"{} could not be stacked onto {}: {}",
path.display(),
first.display(),
e
)
})?;
}
stacked
};
io::wrap_polars_df(combined)
.map_err(|e| format!("{} matched {} files: {}", pattern, files.len(), e))
}
pub fn load_once(source: &Source) -> Result<DataFrame, String> {
if is_pattern(&source.path) {
return load_pattern(source);
}
if source.path.is_dir() {
return io::load_directory(&source.path)
.map_err(|e| format!("Could not list {}: {}", source.path.display(), e));
}
load_single(source)
}
fn load_single(source: &Source) -> Result<DataFrame, String> {
if !source.path.exists() {
return Err(format!("No such file: {}", source.path.display()));
}
let ext = source.extension();
if let Some(container) = &source.container {
return load_container(&source.path, &ext, container);
}
if crate::data::io::db_write::is_db_name(&ext) {
let n = io::db_containers(&source.path)
.map(|c| c.len())
.unwrap_or(0);
return Err(format!(
"'{}' holds {} tables and views; pass 'container' to pick one. \
tuitab_inspect lists them.",
source.path.display(),
n
));
}
if let Some(declared) = source.format.as_deref() {
if Format::from_name(declared).is_none() {
return io::load_tabular(&source.path, source.delimiter, &ext).map_err(|e| {
format!("Could not read {} as {}: {}", source.path.display(), ext, e)
});
}
}
let forced = source.format.as_deref().and_then(Format::from_name);
io::load_file_as(&source.path, source.delimiter, forced)
.map(|(df, _)| df)
.map_err(|e| format!("Could not read {}: {}", source.path.display(), e))
}
pub fn load_db_table(
path: &Path,
container: &str,
) -> Result<(DataFrame, Option<io::db_write::TableSource>), String> {
match crate::data::io::db_write::kind_for_path(path) {
crate::data::io::db_write::DbKind::DuckDb => io::load_duckdb_table_full(path, container),
crate::data::io::db_write::DbKind::Sqlite => io::load_sqlite_table_full(path, container),
}
.map_err(|e| {
format!(
"Could not read '{}' from {}: {}",
container,
path.display(),
e
)
})
}
fn load_container(path: &Path, ext: &str, container: &str) -> Result<DataFrame, String> {
if crate::data::io::db_write::is_db_ext(path) {
return load_db_table(path, container).map(|(df, _)| df);
}
match ext {
"xlsx" | "xls" => io::load_excel_sheet_by_name(path, container).map_err(|e| {
format!(
"Could not read sheet '{}' from {}: {}",
container,
path.display(),
e
)
}),
other => Err(format!(
"'.{}' files hold a single table — drop 'container'",
other
)),
}
}
pub fn containers(path: &Path, ext: &str) -> Option<Vec<io::ContainerInfo>> {
if crate::data::io::db_write::is_db_ext(path) {
return io::db_containers(path).ok().filter(|c| !c.is_empty());
}
match ext {
"xlsx" | "xls" => io::excel_sheet_sizes(path).ok().map(|sheets| {
sheets
.into_iter()
.map(|(name, rows, columns)| io::ContainerInfo {
name,
view: false,
rows: Some(rows as i64),
columns,
sql: None,
})
.collect()
}),
_ => None,
}
}
pub fn extension_of(source: &Source) -> String {
source.extension()
}
pub fn load_doc(source: &Source) -> Result<Doc, String> {
let ext = source.extension();
let format = Format::from_name(&ext).ok_or_else(|| {
format!(
"jq needs a JSON, JSONL, YAML or TOML source; '{}' is not one. \
Use tuitab_query for tabular data.",
if ext.is_empty() {
"(no extension)"
} else {
&ext
}
)
})?;
Doc::load(&source.path, format)
.map_err(|e| format!("Could not read {}: {}", source.path.display(), e))
}