use clap::Parser;
use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::Arc;
use tableski::{
ACCEPT_STREAMABLE, AppState, FileSource, HeaderMode, IngestOptions, QueryLimits, TableSource,
app_router, register_sources,
};
#[derive(Parser, Debug)]
#[command(name = "tableski")]
#[command(
about = "Every spreadsheet is a table — SQL, schema, and column stats over CSV/Excel via MCP"
)]
struct Args {
#[arg(long, default_value = "0.0.0.0:8080")]
bind: String,
#[arg(long)]
csv: Option<PathBuf>,
#[arg(long)]
xlsx: Option<PathBuf>,
#[arg(long, default_value = "data")]
table: String,
#[arg(long, value_enum, default_value_t = HeaderMode::Auto)]
headers: HeaderMode,
#[arg(long, default_value_t = 1_000_000)]
max_rows: usize,
#[arg(long = "file")]
files: Vec<PathBuf>,
#[arg(long)]
export_dir: Option<PathBuf>,
#[arg(long)]
untrusted_sql: bool,
#[arg(long, default_value_t = 2048)]
max_memory_mb: u64,
#[arg(long, default_value_t = 60)]
query_timeout_secs: u64,
#[arg(long, default_value_t = 10_000)]
max_result_rows: usize,
#[arg(long, default_value_t = 16)]
max_result_mb: usize,
}
fn limits_from(args: &Args) -> QueryLimits {
let nonzero = |v: usize| (v > 0).then_some(v);
QueryLimits {
memory_bytes: nonzero(args.max_memory_mb as usize).map(|mb| mb * 1024 * 1024),
timeout: (args.query_timeout_secs > 0)
.then(|| std::time::Duration::from_secs(args.query_timeout_secs)),
max_rows: nonzero(args.max_result_rows),
max_bytes: nonzero(args.max_result_mb).map(|mb| mb * 1024 * 1024),
}
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let args = Args::parse();
if args.csv.is_none() && args.xlsx.is_none() && args.files.is_empty() {
return Err("nothing to serve: pass --file <data-file> (or --csv/--xlsx)".into());
}
let limits = limits_from(&args);
let ctx = limits.session_context()?;
let opts = IngestOptions {
headers: args.headers,
max_rows: args.max_rows,
};
let mut files = FileSource::new();
if let Some(csv) = &args.csv {
files = files.csv_named(csv, &args.table);
}
if let Some(xlsx) = &args.xlsx {
files = files.workbook(xlsx);
}
for file in &args.files {
files = files.path(file);
}
let sources: Vec<Box<dyn TableSource>> = vec![Box::new(files)];
let tables = register_sources(&ctx, &sources, &opts).await?;
for t in &tables {
match &t.sheet {
Some(sheet) => eprintln!(
"tableski: table `{}` <- sheet `{}` of `{}` ({} rows)",
t.name,
sheet,
t.source,
t.rows.unwrap_or(0)
),
None => eprintln!("tableski: table `{}` <- `{}`", t.name, t.source),
}
}
let mut state = AppState::new(Arc::new(ctx), tables).with_limits(limits);
eprintln!(
"tableski: limits: memory {} MiB, timeout {} s, result {} rows / {} MiB (0 = unlimited)",
args.max_memory_mb, args.query_timeout_secs, args.max_result_rows, args.max_result_mb
);
if let Some(dir) = &args.export_dir {
std::fs::create_dir_all(dir)?;
eprintln!("tableski: export_result enabled -> {}", dir.display());
state = state.with_export_dir(dir);
}
if args.untrusted_sql {
eprintln!("tableski: --untrusted-sql: read-only queries over registered tables only");
state = state.untrusted();
}
let app = app_router(state);
let addr: SocketAddr = args.bind.parse()?;
eprintln!("tableski: stateless Streamable HTTP on http://{addr}");
eprintln!("Accept header for clients: `{ACCEPT_STREAMABLE}`");
let listener = tokio::net::TcpListener::bind(addr).await?;
axum::serve(listener, app).await?;
Ok(())
}