use crate::config::Config;
use crate::db;
use crate::error::AppError;
use crate::server::{self, AppState, Snapshot};
use clap::{Parser, Subcommand};
use duckdb::Connection;
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, RwLock};
use std::time::Instant;
use tracing::info;
use tracing_subscriber::EnvFilter;
#[derive(Parser, Debug)]
#[command(name = "terrana", version, about = "Zero-config spatial API server")]
pub struct Cli {
#[command(subcommand)]
pub command: Commands,
}
#[derive(Subcommand, Debug)]
pub enum Commands {
Serve {
file: PathBuf,
#[arg(long)]
lat: Option<String>,
#[arg(long)]
lon: Option<String>,
#[arg(long)]
table: Option<String>,
#[arg(long, default_value = "8080")]
port: u16,
#[arg(long, default_value = "127.0.0.1")]
bind: String,
#[arg(long)]
watch: bool,
#[arg(long)]
disk: bool,
},
}
pub fn run<I, T>(args: I) -> anyhow::Result<()>
where
I: IntoIterator<Item = T>,
T: Into<OsString> + Clone,
{
init_tracing();
let cli = Cli::parse_from(args);
match cli.command {
Commands::Serve {
file,
lat,
lon,
table,
port,
bind,
watch,
disk,
} => serve(file, lat, lon, table, port, bind, watch, disk),
}
}
fn init_tracing() {
let _ = tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("terrana=info")),
)
.try_init();
}
#[allow(clippy::too_many_arguments)]
fn serve(
file: PathBuf,
lat: Option<String>,
lon: Option<String>,
table: Option<String>,
port: u16,
bind: String,
watch: bool,
disk: bool,
) -> anyhow::Result<()> {
let config = Config {
file: file.clone(),
lat_col: lat.clone(),
lon_col: lon.clone(),
table: table.clone(),
port,
bind: bind.clone(),
watch,
disk,
};
info!("terrana v{}", env!("CARGO_PKG_VERSION"));
info!("source: {}", file.display());
if !file.exists() {
anyhow::bail!("File not found: {}", file.display());
}
let abs_path = std::fs::canonicalize(&file)?;
let source = file.display().to_string();
let _tmp_dir;
let conn = if disk {
info!("using on-disk DuckDB storage");
let (c, td) = db::create_disk_connection()?;
_tmp_dir = Some(td);
c
} else {
_tmp_dir = None;
db::create_connection()?
};
let snapshot = build_snapshot(
&conn,
&source,
&abs_path,
table.as_deref(),
lat.as_deref(),
lon.as_deref(),
)?;
let db_mutex = Arc::new(Mutex::new(conn));
let snapshot = Arc::new(RwLock::new(Arc::new(snapshot)));
if watch {
spawn_watcher(
abs_path,
source,
table.clone(),
lat.clone(),
lon.clone(),
db_mutex.clone(),
snapshot.clone(),
);
}
let state = AppState {
config: Arc::new(config),
db: db_mutex,
snapshot,
start_time: Instant::now(),
};
let addr = format!("{}:{}", bind, port);
info!("listening on {}", addr);
let rt = tokio::runtime::Runtime::new()?;
rt.block_on(async move {
server::serve(state, addr, std::future::pending::<()>()).await
})?;
Ok(())
}
fn build_snapshot(
conn: &Connection,
source: &str,
abs_path: &Path,
table: Option<&str>,
lat_override: Option<&str>,
lon_override: Option<&str>,
) -> Result<Snapshot, AppError> {
db::loader::stage_file(conn, abs_path, table)?;
let staged = db::get_table_info_relation(conn, "raw_data_stage")?;
let (lat_col, lon_col) =
match db::loader::detect_lat_lon(&staged.col_names, lat_override, lon_override) {
Ok(cols) => cols,
Err(e) => {
let _ = db::loader::discard_stage(conn);
return Err(e);
}
};
info!(lat = %lat_col, lon = %lon_col, "columns detected");
db::loader::promote_stage(conn)?;
let start_build = Instant::now();
db::loader::add_spatial_index(conn, &lat_col, &lon_col)?;
let index_build_ms = start_build.elapsed().as_millis();
info!(ms = %index_build_ms, "spatial index built");
server::build_snapshot(
conn,
source,
&lat_col,
&lon_col,
staged.row_count,
index_build_ms,
)
}
#[allow(clippy::too_many_arguments)]
fn spawn_watcher(
abs_path: std::path::PathBuf,
source: String,
table: Option<String>,
lat: Option<String>,
lon: Option<String>,
db: Arc<Mutex<Connection>>,
snapshot: Arc<RwLock<Arc<Snapshot>>>,
) {
use notify::{EventKind, RecursiveMode, Watcher};
use std::sync::mpsc;
use std::time::Duration;
std::thread::spawn(move || {
let (tx, rx) = mpsc::channel();
let mut watcher = match notify::recommended_watcher(move |res| {
let _ = tx.send(res);
}) {
Ok(w) => w,
Err(e) => {
tracing::error!("watch: failed to create watcher: {}", e);
return;
}
};
let watch_target = abs_path.parent().unwrap_or(&abs_path).to_path_buf();
if let Err(e) = watcher.watch(&watch_target, RecursiveMode::NonRecursive) {
tracing::error!("watch: failed to watch {}: {}", watch_target.display(), e);
return;
}
info!("watching {} for changes", abs_path.display());
let is_relevant = |res: ¬ify::Result<notify::Event>| -> bool {
match res {
Ok(ev) => {
matches!(ev.kind, EventKind::Modify(_) | EventKind::Create(_))
&& ev.paths.iter().any(|p| p == &abs_path)
}
Err(e) => {
tracing::warn!("watch error: {}", e);
false
}
}
};
while let Ok(first) = rx.recv() {
let mut relevant = is_relevant(&first);
while let Ok(ev) = rx.recv_timeout(Duration::from_millis(300)) {
relevant |= is_relevant(&ev);
}
if !relevant {
continue;
}
info!("source changed — reloading");
let result = match db.lock() {
Ok(conn) => build_snapshot(
&conn,
&source,
&abs_path,
table.as_deref(),
lat.as_deref(),
lon.as_deref(),
),
Err(e) => {
tracing::error!("watch: db lock poisoned: {}", e);
continue;
}
};
match result {
Ok(new_snap) => {
if let Ok(mut guard) = snapshot.write() {
*guard = Arc::new(new_snap);
}
info!("reload complete");
}
Err(e) => tracing::error!("reload failed: {}", e),
}
}
});
}