use std::fmt::Write as _;
use std::fs::{self, OpenOptions};
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::query::engine::spill_policy::platform_tetration_cache_dir;
use crate::query::types::{AxisSlice, Operation, QueryDocument};
use super::text::{contains_ascii_case_insensitive, truncate_field};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct HistorySettings {
pub cli_query_max: usize,
pub history_max_cap: usize,
pub history_file_name: String,
}
impl Default for HistorySettings {
fn default() -> Self {
Self {
cli_query_max: 50,
history_max_cap: 10_000,
history_file_name: "query_history.jsonl".to_owned(),
}
}
}
impl HistorySettings {
#[must_use]
pub fn from_env() -> Self {
let mut settings = Self::default();
if let Ok(raw) = std::env::var("TET_QUERY_HISTORY_MAX")
&& let Ok(n) = raw.trim().parse::<usize>()
&& (1..=settings.history_max_cap).contains(&n)
{
settings.cli_query_max = n;
}
settings
}
#[must_use]
pub fn path(&self) -> Option<PathBuf> {
if let Ok(path) = std::env::var("TET_QUERY_HISTORY_FILE") {
return Some(PathBuf::from(path));
}
platform_tetration_cache_dir().map(|dir| dir.join(&self.history_file_name))
}
pub fn append(
&self,
query: &QueryDocument,
tet: Option<&str>,
execute: bool,
) -> io::Result<()> {
if !cli_query_history_enabled() {
return Ok(());
}
let path = self.path().ok_or_else(|| {
io::Error::new(
io::ErrorKind::NotFound,
"no platform cache directory for query history",
)
})?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let entry = CliQueryHistoryEntry {
at: unix_timestamp_secs(),
tet: tet.map(str::to_owned),
execute,
query: query.clone(),
};
let mut entries = read_entries(&path)?;
if let Some(last) = entries.last_mut()
&& entries_equivalent(last, &entry)
{
last.at = entry.at;
return write_entries(&path, &entries);
}
entries.push(entry);
if entries.len() > self.cli_query_max {
let drop = entries.len() - self.cli_query_max;
entries.drain(0..drop);
}
write_entries(&path, &entries)
}
pub fn list(
&self,
limit: usize,
all: bool,
filter: Option<&HistoryListFilter>,
) -> io::Result<Vec<CliQueryHistoryEntry>> {
let mut entries = self.read_newest_first()?;
if let Some(f) = filter {
entries.retain(|e| f.matches(e));
}
if !all {
entries.truncate(limit);
}
Ok(entries)
}
pub fn get(
&self,
index: usize,
filter: Option<&HistoryListFilter>,
) -> io::Result<CliQueryHistoryEntry> {
if index == 0 {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"history index must be >= 1 (1 = newest)",
));
}
let entries = self.list(usize::MAX, true, filter)?;
let have = entries.len();
let pos = index - 1;
entries.into_iter().nth(pos).ok_or_else(|| {
io::Error::new(
io::ErrorKind::NotFound,
format!("history entry {index} not found (have {have} matching entr(y/ies))"),
)
})
}
pub fn clear(&self) -> io::Result<()> {
let Some(path) = self.path() else {
return Ok(());
};
match fs::remove_file(&path) {
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
other => other,
}
}
fn read_newest_first(&self) -> io::Result<Vec<CliQueryHistoryEntry>> {
let Some(path) = self.path() else {
return Ok(Vec::new());
};
if !path.is_file() {
return Ok(Vec::new());
}
let mut entries = read_entries(&path)?;
entries.reverse();
Ok(entries)
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct HistoryListFilter {
pub dataset: Option<String>,
pub tet: Option<String>,
pub mode: Option<HistoryExecuteFilter>,
pub grep: Option<String>,
}
impl HistoryListFilter {
#[must_use]
pub fn matches(&self, entry: &CliQueryHistoryEntry) -> bool {
if let Some(needle) = self.dataset.as_deref()
&& !contains_ascii_case_insensitive(&entry.query.dataset, needle)
{
return false;
}
if let Some(needle) = self.tet.as_deref() {
let hay = entry.tet.as_deref().unwrap_or("");
if !contains_ascii_case_insensitive(hay, needle) {
return false;
}
}
if let Some(mode) = self.mode {
let is_execute = entry.execute;
match mode {
HistoryExecuteFilter::Execute if !is_execute => return false,
HistoryExecuteFilter::Plan if is_execute => return false,
_ => {}
}
}
if let Some(needle) = self.grep.as_deref() {
let op = operation_label(entry.query.operation.as_ref());
let hay = format!(
"{} {} {}",
entry.query.dataset,
entry.tet.as_deref().unwrap_or(""),
op
);
if !contains_ascii_case_insensitive(&hay, needle) {
return false;
}
}
true
}
#[must_use]
pub fn summary(&self) -> String {
let mut parts = Vec::new();
if let Some(d) = &self.dataset {
parts.push(format!("dataset~{d}"));
}
if let Some(t) = &self.tet {
parts.push(format!("tet~{t}"));
}
if let Some(m) = self.mode {
let execute = matches!(m, HistoryExecuteFilter::Execute);
parts.push(format!("mode={}", history_entry_mode(execute)));
}
if let Some(g) = &self.grep {
parts.push(format!("grep~{g}"));
}
parts.join(" ")
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.dataset.is_none() && self.tet.is_none() && self.mode.is_none() && self.grep.is_none()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HistoryExecuteFilter {
Execute,
Plan,
}
pub fn parse_history_execute_filter(s: &str) -> Result<HistoryExecuteFilter, String> {
match s.trim().to_ascii_lowercase().as_str() {
"x" | "execute" => Ok(HistoryExecuteFilter::Execute),
"p" | "plan" => Ok(HistoryExecuteFilter::Plan),
other => Err(format!(
"unknown history mode {other:?}; expected x, execute, p, or plan"
)),
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CliQueryHistoryEntry {
pub at: u64,
pub tet: Option<String>,
pub execute: bool,
pub query: QueryDocument,
}
#[must_use]
pub fn history_entry_mode(execute: bool) -> &'static str {
if execute { "x" } else { "p" }
}
const HISTORY_MODE_LEGEND: &str = "mode: x = had -x (execute), p = plan only (no -x)";
#[derive(Serialize)]
struct HistoryListRow<'a> {
at: u64,
mode: &'static str,
tet: Option<&'a str>,
query: &'a QueryDocument,
}
impl<'a> From<&'a CliQueryHistoryEntry> for HistoryListRow<'a> {
fn from(e: &'a CliQueryHistoryEntry) -> Self {
Self {
at: e.at,
mode: history_entry_mode(e.execute),
tet: e.tet.as_deref(),
query: &e.query,
}
}
}
#[must_use]
pub fn cli_query_history_enabled() -> bool {
!matches!(
std::env::var("TET_NO_QUERY_HISTORY").ok().as_deref(),
Some("1" | "true" | "TRUE" | "yes" | "YES")
)
}
#[must_use]
pub fn cli_query_history_max() -> usize {
HistorySettings::from_env().cli_query_max
}
#[must_use]
pub fn cli_query_history_path() -> Option<PathBuf> {
HistorySettings::from_env().path()
}
pub fn append_cli_query_history(
query: &QueryDocument,
tet: Option<&str>,
execute: bool,
) -> io::Result<()> {
HistorySettings::from_env().append(query, tet, execute)
}
pub fn list_cli_query_history(
limit: usize,
all: bool,
filter: Option<&HistoryListFilter>,
) -> io::Result<Vec<CliQueryHistoryEntry>> {
HistorySettings::from_env().list(limit, all, filter)
}
pub fn get_cli_query_history_entry(
index: usize,
filter: Option<&HistoryListFilter>,
) -> io::Result<CliQueryHistoryEntry> {
HistorySettings::from_env().get(index, filter)
}
pub fn clear_cli_query_history() -> io::Result<()> {
HistorySettings::from_env().clear()
}
#[must_use]
pub fn format_history_list_text(
entries: &[CliQueryHistoryEntry],
path: Option<&Path>,
settings: &HistorySettings,
filter: Option<&HistoryListFilter>,
) -> String {
let mut out = String::new();
if let Some(path) = path {
let _ = writeln!(out, "file: {}", path.display());
}
let _ = writeln!(
out,
"shown: {} keep: {} on disk",
entries.len(),
settings.cli_query_max
);
if let Some(f) = filter.filter(|f| !f.is_empty()) {
let _ = writeln!(out, "filter: {}", f.summary());
}
if entries.is_empty() {
if filter.is_some_and(|f| !f.is_empty()) {
out.push_str("(no rows match filter)\n");
} else {
out.push_str("(empty — run `tet query … -t file.tet -x` to record)\n");
}
return out;
}
out.push('\n');
let _ = writeln!(
out,
"{:>3} {:^4} {:<18} {:<10} {:<8} tet",
"#", "mode", "dataset", "op", "select"
);
for (i, e) in entries.iter().enumerate() {
let mode = history_entry_mode(e.execute);
let op = operation_label(e.query.operation.as_ref());
let sel = selection_label(e.query.selection.as_ref());
let tet = e.tet.as_deref().map_or("-", |p| {
Path::new(p)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(p)
});
let _ = writeln!(
out,
"{:>3} {:^4} {:<18} {:<10} {:<8} {}",
i + 1,
mode,
truncate_field(&e.query.dataset, 18),
op,
sel,
tet
);
}
out.push('\n');
let _ = writeln!(out, "{HISTORY_MODE_LEGEND}");
out.push_str("replay: tet qhist run <#> (1 = newest)\n");
out
}
pub fn format_history_list_json(
entries: &[CliQueryHistoryEntry],
path: Option<&Path>,
settings: &HistorySettings,
filter: Option<&HistoryListFilter>,
) -> Result<String, String> {
let rows: Vec<HistoryListRow<'_>> = entries.iter().map(HistoryListRow::from).collect();
let filter_summary = filter
.filter(|f| !f.is_empty())
.map(HistoryListFilter::summary);
let out = serde_json::json!({
"path": path.map(|p| p.display().to_string()),
"settings": settings,
"shown": entries.len(),
"filter": filter_summary,
"mode_key": {
"x": "execute (-x was set)",
"p": "plan only (no -x)",
},
"entries": rows,
});
serde_json::to_string_pretty(&out).map_err(|e| e.to_string())
}
fn entries_equivalent(a: &CliQueryHistoryEntry, b: &CliQueryHistoryEntry) -> bool {
a.tet == b.tet && a.execute == b.execute && history_queries_equal(&a.query, &b.query)
}
fn history_queries_equal(a: &QueryDocument, b: &QueryDocument) -> bool {
match (serde_json::to_string(a), serde_json::to_string(b)) {
(Ok(left), Ok(right)) => left == right,
_ => false,
}
}
fn operation_label(op: Option<&Operation>) -> &'static str {
match op {
None => "-",
Some(op) => op.wire_key(),
}
}
fn selection_label(sel: Option<&Vec<AxisSlice>>) -> &'static str {
match sel {
None => "full",
Some(v) if v.is_empty() => "full",
Some(v) if v.iter().any(|s| s.step.is_some_and(|st| st > 1)) => "strided",
Some(_) => "subset",
}
}
fn read_entries(path: &Path) -> io::Result<Vec<CliQueryHistoryEntry>> {
if !path.is_file() {
return Ok(Vec::new());
}
let raw = fs::read_to_string(path)?;
let mut out = Vec::new();
for line in raw.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let entry: CliQueryHistoryEntry = serde_json::from_str(line).map_err(|e| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("query history parse: {e}"),
)
})?;
out.push(entry);
}
Ok(out)
}
fn write_entries(path: &Path, entries: &[CliQueryHistoryEntry]) -> io::Result<()> {
let mut f = OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(path)?;
for entry in entries {
let line = serde_json::to_string(entry)
.map_err(|e| io::Error::other(format!("query history encode: {e}")))?;
f.write_all(line.as_bytes())?;
f.write_all(b"\n")?;
}
f.sync_all()?;
Ok(())
}
fn unix_timestamp_secs() -> u64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_secs())
}