diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml
index 6e4ef80..ad81417 100644
--- a/.github/workflows/rust.yml
+++ b/.github/workflows/rust.yml
@@ -63,7 +63,7 @@ jobs:
key: ${{ runner.os }}-cargo-debug-${{ hashFiles('**/Cargo.lock') }}
- name: Run cargo test
- run: cargo test --workspace
+ run: ATUIN_SESSION=beepboopiamasession cargo test --workspace
clippy:
runs-on: ubuntu-latest
diff --git a/Cargo.lock b/Cargo.lock
index 1d03a8a..7afd0df 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -93,6 +93,7 @@ dependencies = [
"tracing-subscriber",
"tui",
"unicode-width",
+ "whoami",
]
[[package]]
diff --git a/Cargo.toml b/Cargo.toml
index be1c1b7..5eb3d2c 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -57,6 +57,7 @@ crossbeam-channel = "0.5.1"
clap = { version = "3.1.11", features = ["derive"] }
clap_complete = "3.1.2"
fs-err = "2.7"
+whoami = "1.1.2"
[dependencies.tracing-subscriber]
diff --git a/README.md b/README.md
index 2f4f421..c6e7b9f 100644
--- a/README.md
+++ b/README.md
@@ -48,6 +48,7 @@ I wanted to. And I **really** don't want to.
- calculate statistics such as "most used command"
- old history file is not replaced
- quick-jump to previous items with Alt-\
+- switch filter modes via ctrl-r; search history just from the current session, directory, or globally
## Documentation
diff --git a/atuin-client/src/database.rs b/atuin-client/src/database.rs
index 9efde2c..d1b892e 100644
--- a/atuin-client/src/database.rs
+++ b/atuin-client/src/database.rs
@@ -1,3 +1,4 @@
+use std::env;
use std::path::Path;
use std::str::FromStr;
@@ -16,7 +17,29 @@ use sqlx::{
use super::history::History;
use super::ordering;
-use super::settings::SearchMode;
+use super::settings::{FilterMode, SearchMode};
+
+pub struct Context {
+ session: String,
+ cwd: String,
+ hostname: String,
+}
+
+pub fn current_context() -> Context {
+ let session =
+ env::var("ATUIN_SESSION").expect("failed to find ATUIN_SESSION - check your shell setup");
+ let hostname = format!("{}:{}", whoami::hostname(), whoami::username());
+ let cwd = match env::current_dir() {
+ Ok(dir) => dir.display().to_string(),
+ Err(_) => String::from(""),
+ };
+
+ Context {
+ session,
+ hostname,
+ cwd,
+ }
+}
#[async_trait]
pub trait Database {
@@ -24,7 +47,13 @@ pub trait Database {
async fn save_bulk(&mut self, h: &[History]) -> Result<()>;
async fn load(&self, id: &str) -> Result;
- async fn list(&self, max: Option, unique: bool) -> Result>;
+ async fn list(
+ &self,
+ filter: FilterMode,
+ context: &Context,
+ max: Option,
+ unique: bool,
+ ) -> Result>;
async fn range(
&self,
from: chrono::DateTime,
@@ -42,6 +71,8 @@ pub trait Database {
&self,
limit: Option,
search_mode: SearchMode,
+ filter: FilterMode,
+ context: &Context,
query: &str,
) -> Result>;
@@ -179,33 +210,52 @@ impl Database for Sqlite {
}
// make a unique list, that only shows the *newest* version of things
- async fn list(&self, max: Option, unique: bool) -> Result> {
+ async fn list(
+ &self,
+ filter: FilterMode,
+ context: &Context,
+ max: Option,
+ unique: bool,
+ ) -> Result> {
debug!("listing history");
- // very likely vulnerable to SQL injection
- // however, this is client side, and only used by the client, on their
- // own data. They can just open the db file...
- // otherwise building the query is awkward
+ // gotta get that query builder in soon cuz I kinda hate this
+ let query = if unique {
+ "where timestamp = (
+ select max(timestamp) from history
+ where h.command = history.command
+ )"
+ } else {
+ ""
+ }
+ .to_string();
+
+ let mut join = if unique { "and" } else { "where" }.to_string();
+
+ let filter_query = match filter {
+ FilterMode::Global => {
+ join = "".to_string();
+ "".to_string()
+ }
+ FilterMode::Host => format!("hostname = '{}'", context.hostname).to_string(),
+ FilterMode::Session => format!("session = '{}'", context.session).to_string(),
+ FilterMode::Directory => format!("cwd = '{}'", context.cwd).to_string(),
+ };
+
+ let filter = format!("{} {}", join, filter_query);
+
+ let limit = if let Some(max) = max {
+ format!("limit {}", max)
+ } else {
+ "".to_string()
+ };
+
let query = format!(
"select * from history h
{}
order by timestamp desc
- {}",
- // inject the unique check
- if unique {
- "where timestamp = (
- select max(timestamp) from history
- where h.command = history.command
- )"
- } else {
- ""
- },
- // inject the limit
- if let Some(max) = max {
- format!("limit {}", max)
- } else {
- "".to_string()
- }
+ {} {}",
+ query, filter, limit,
);
let res = sqlx::query(query.as_str())
@@ -281,6 +331,8 @@ impl Database for Sqlite {
&self,
limit: Option,
search_mode: SearchMode,
+ filter: FilterMode,
+ context: &Context,
query: &str,
) -> Result> {
let orig_query = query;
@@ -350,6 +402,13 @@ impl Database for Sqlite {
}
};
+ let filter_sql = match filter {
+ FilterMode::Global => String::from(""),
+ FilterMode::Session => format!("and session = '{}'", context.session),
+ FilterMode::Directory => format!("and cwd = '{}'", context.cwd),
+ FilterMode::Host => format!("and hostname = '{}'", context.hostname),
+ };
+
let res = query_params
.iter()
.fold(
@@ -357,10 +416,12 @@ impl Database for Sqlite {
format!(
"select * from history h
where {}
+ {}
group by command
having max(timestamp)
order by timestamp desc {}",
query_sql.as_str(),
+ filter_sql.as_str(),
limit.clone()
)
.as_str(),
@@ -392,10 +453,18 @@ mod test {
async fn assert_search_eq<'a>(
db: &impl Database,
mode: SearchMode,
+ filter_mode: FilterMode,
query: &str,
expected: usize,
) -> Result> {
- let results = db.search(None, mode, query).await?;
+ let context = Context {
+ hostname: "test:host".to_string(),
+ session: "beepboopiamasession".to_string(),
+ cwd: "/home/ellie".to_string(),
+ };
+
+ let results = db.search(None, mode, filter_mode, &context, query).await?;
+
assert_eq!(
results.len(),
expected,
@@ -409,10 +478,11 @@ mod test {
async fn assert_search_commands(
db: &impl Database,
mode: SearchMode,
+ filter_mode: FilterMode,
query: &str,
expected_commands: Vec<&str>,
) {
- let results = assert_search_eq(db, mode, query, expected_commands.len())
+ let results = assert_search_eq(db, mode, filter_mode, query, expected_commands.len())
.await
.unwrap();
let commands: Vec<&str> = results.iter().map(|a| a.command.as_str()).collect();
@@ -437,13 +507,13 @@ mod test {
let mut db = Sqlite::new("sqlite::memory:").await.unwrap();
new_history_item(&mut db, "ls /home/ellie").await.unwrap();
- assert_search_eq(&db, SearchMode::Prefix, "ls", 1)
+ assert_search_eq(&db, SearchMode::Prefix, FilterMode::Global, "ls", 1)
.await
.unwrap();
- assert_search_eq(&db, SearchMode::Prefix, "/home", 0)
+ assert_search_eq(&db, SearchMode::Prefix, FilterMode::Global, "/home", 0)
.await
.unwrap();
- assert_search_eq(&db, SearchMode::Prefix, "ls ", 0)
+ assert_search_eq(&db, SearchMode::Prefix, FilterMode::Global, "ls ", 0)
.await
.unwrap();
}
@@ -453,13 +523,13 @@ mod test {
let mut db = Sqlite::new("sqlite::memory:").await.unwrap();
new_history_item(&mut db, "ls /home/ellie").await.unwrap();
- assert_search_eq(&db, SearchMode::FullText, "ls", 1)
+ assert_search_eq(&db, SearchMode::FullText, FilterMode::Global, "ls", 1)
.await
.unwrap();
- assert_search_eq(&db, SearchMode::FullText, "/home", 1)
+ assert_search_eq(&db, SearchMode::FullText, FilterMode::Global, "/home", 1)
.await
.unwrap();
- assert_search_eq(&db, SearchMode::FullText, "ls ", 0)
+ assert_search_eq(&db, SearchMode::FullText, FilterMode::Global, "ls ", 0)
.await
.unwrap();
}
@@ -474,70 +544,82 @@ mod test {
.await
.unwrap();
- assert_search_eq(&db, SearchMode::Fuzzy, "ls /", 3)
+ assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "ls /", 3)
.await
.unwrap();
- assert_search_eq(&db, SearchMode::Fuzzy, "ls/", 2)
+ assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "ls/", 2)
.await
.unwrap();
- assert_search_eq(&db, SearchMode::Fuzzy, "l/h/", 2)
+ assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "l/h/", 2)
.await
.unwrap();
- assert_search_eq(&db, SearchMode::Fuzzy, "/h/e", 3)
+ assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "/h/e", 3)
.await
.unwrap();
- assert_search_eq(&db, SearchMode::Fuzzy, "/hmoe/", 0)
+ assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "/hmoe/", 0)
.await
.unwrap();
- assert_search_eq(&db, SearchMode::Fuzzy, "ellie/home", 0)
+ assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "ellie/home", 0)
.await
.unwrap();
- assert_search_eq(&db, SearchMode::Fuzzy, "lsellie", 1)
+ assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "lsellie", 1)
.await
.unwrap();
- assert_search_eq(&db, SearchMode::Fuzzy, " ", 4)
+ assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, " ", 4)
.await
.unwrap();
// single term operators
- assert_search_eq(&db, SearchMode::Fuzzy, "^ls", 2)
+ assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "^ls", 2)
.await
.unwrap();
- assert_search_eq(&db, SearchMode::Fuzzy, "'ls", 2)
+ assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "'ls", 2)
.await
.unwrap();
- assert_search_eq(&db, SearchMode::Fuzzy, "ellie$", 2)
+ assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "ellie$", 2)
.await
.unwrap();
- assert_search_eq(&db, SearchMode::Fuzzy, "!^ls", 2)
+ assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "!^ls", 2)
.await
.unwrap();
- assert_search_eq(&db, SearchMode::Fuzzy, "!ellie", 1)
+ assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "!ellie", 1)
.await
.unwrap();
- assert_search_eq(&db, SearchMode::Fuzzy, "!ellie$", 2)
+ assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "!ellie$", 2)
.await
.unwrap();
// multiple terms
- assert_search_eq(&db, SearchMode::Fuzzy, "ls !ellie", 1)
+ assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "ls !ellie", 1)
.await
.unwrap();
- assert_search_eq(&db, SearchMode::Fuzzy, "^ls !e$", 1)
+ assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "^ls !e$", 1)
.await
.unwrap();
- assert_search_eq(&db, SearchMode::Fuzzy, "home !^ls", 2)
- .await
- .unwrap();
- assert_search_eq(&db, SearchMode::Fuzzy, "'frank | 'rustup", 2)
- .await
- .unwrap();
- assert_search_eq(&db, SearchMode::Fuzzy, "'frank | 'rustup 'ls", 1)
+ assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "home !^ls", 2)
.await
.unwrap();
+ assert_search_eq(
+ &db,
+ SearchMode::Fuzzy,
+ FilterMode::Global,
+ "'frank | 'rustup",
+ 2,
+ )
+ .await
+ .unwrap();
+ assert_search_eq(
+ &db,
+ SearchMode::Fuzzy,
+ FilterMode::Global,
+ "'frank | 'rustup 'ls",
+ 1,
+ )
+ .await
+ .unwrap();
// case matching
- assert_search_eq(&db, SearchMode::Fuzzy, "Ellie", 1)
+ assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "Ellie", 1)
.await
.unwrap();
}
@@ -551,18 +633,31 @@ mod test {
new_history_item(&mut db, "corburl").await.unwrap();
// if fuzzy reordering is on, it should come back in a more sensible order
- assert_search_commands(&db, SearchMode::Fuzzy, "curl", vec!["curl", "corburl"]).await;
+ assert_search_commands(
+ &db,
+ SearchMode::Fuzzy,
+ FilterMode::Global,
+ "curl",
+ vec!["curl", "corburl"],
+ )
+ .await;
- assert_search_eq(&db, SearchMode::Fuzzy, "xxxx", 0)
+ assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "xxxx", 0)
.await
.unwrap();
- assert_search_eq(&db, SearchMode::Fuzzy, "", 2)
+ assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "", 2)
.await
.unwrap();
}
#[tokio::test(flavor = "multi_thread")]
async fn test_search_bench_dupes() {
+ let context = Context {
+ hostname: "test:host".to_string(),
+ session: "beepboopiamasession".to_string(),
+ cwd: "/home/ellie".to_string(),
+ };
+
let mut db = Sqlite::new("sqlite::memory:").await.unwrap();
for _i in 1..10000 {
new_history_item(&mut db, "i am a duplicated command")
@@ -570,7 +665,10 @@ mod test {
.unwrap();
}
let start = Instant::now();
- let _results = db.search(None, SearchMode::Fuzzy, "").await.unwrap();
+ let _results = db
+ .search(None, SearchMode::Fuzzy, FilterMode::Global, &context, "")
+ .await
+ .unwrap();
let duration = start.elapsed();
assert!(duration < Duration::from_secs(15));
diff --git a/atuin-client/src/settings.rs b/atuin-client/src/settings.rs
index ee3376a..bb3424a 100644
--- a/atuin-client/src/settings.rs
+++ b/atuin-client/src/settings.rs
@@ -23,6 +23,21 @@ pub enum SearchMode {
Fuzzy,
}
+#[derive(Clone, Debug, Deserialize, Copy)]
+pub enum FilterMode {
+ #[serde(rename = "global")]
+ Global,
+
+ #[serde(rename = "host")]
+ Host,
+
+ #[serde(rename = "session")]
+ Session,
+
+ #[serde(rename = "directory")]
+ Directory,
+}
+
// FIXME: Can use upstream Dialect enum if https://github.com/stevedonovan/chrono-english/pull/16 is merged
#[derive(Clone, Debug, Deserialize, Copy)]
pub enum Dialect {
@@ -65,6 +80,7 @@ pub struct Settings {
pub key_path: String,
pub session_path: String,
pub search_mode: SearchMode,
+ pub filter_mode: FilterMode,
// This is automatically loaded when settings is created. Do not set in
// config! Keep secrets and settings apart.
pub session_token: String,
@@ -147,6 +163,7 @@ impl Settings {
.set_default("sync_frequency", "1h")?
.set_default("sync_address", "https://api.atuin.sh")?
.set_default("search_mode", "prefix")?
+ .set_default("filter_mode", "global")?
.set_default("session_token", "")?
.set_default("style", "auto")?
.add_source(
diff --git a/docs/config.md b/docs/config.md
index 405e3b5..8b5fa29 100644
--- a/docs/config.md
+++ b/docs/config.md
@@ -102,6 +102,20 @@ the search syntax [described below](#fuzzy-search-syntax).
Defaults to "prefix"
+### `filter_mode`
+
+The default filter to use when searching
+
+| Column1 | Column2 |
+|--------------- | --------------- |
+| global (default) | Search history from all hosts, all sessions, all directories |
+| host | Search history just from this host |
+| session | Search history just from the current session |
+| directory | Search history just from the current directory|
+
+Filter modes can still be toggled via ctrl-r
+
+
```
search_mode = "fulltext"
```
diff --git a/src/command/client/history.rs b/src/command/client/history.rs
index b2f68f9..994cbfd 100644
--- a/src/command/client/history.rs
+++ b/src/command/client/history.rs
@@ -6,7 +6,7 @@ use clap::Subcommand;
use eyre::Result;
use tabwriter::TabWriter;
-use atuin_client::database::Database;
+use atuin_client::database::{current_context, Database};
use atuin_client::history::History;
use atuin_client::settings::Settings;
use atuin_client::sync;
@@ -97,6 +97,8 @@ impl Cmd {
settings: &Settings,
db: &mut (impl Database + Send + Sync),
) -> Result<()> {
+ let context = current_context();
+
match self {
Self::Start { command: words } => {
let command = words.join(" ");
@@ -168,7 +170,7 @@ impl Cmd {
};
let history = match (session, cwd) {
- (None, None) => db.list(None, false).await?,
+ (None, None) => db.list(settings.filter_mode, &context, None, false).await?,
(None, Some(cwd)) => {
let query = format!("select * from history where cwd = '{}';", cwd);
db.query_history(&query).await?
diff --git a/src/command/client/search.rs b/src/command/client/search.rs
index a1dc5aa..a913e16 100644
--- a/src/command/client/search.rs
+++ b/src/command/client/search.rs
@@ -1,6 +1,7 @@
use chrono::Utc;
use clap::Parser;
use eyre::Result;
+use std::env;
use std::{io::stdout, ops::Sub, time::Duration};
use termion::{event::Key, input::MouseTerminal, raw::IntoRawMode, screen::AlternateScreen};
use tui::{
@@ -14,9 +15,11 @@ use tui::{
use unicode_width::UnicodeWidthStr;
use atuin_client::{
+ database::current_context,
+ database::Context,
database::Database,
history::History,
- settings::{SearchMode, Settings},
+ settings::{FilterMode, SearchMode, Settings},
};
use super::event::{Event, Events};
@@ -91,9 +94,13 @@ impl Cmd {
struct State {
input: String,
+ filter_mode: FilterMode,
+
results: Vec,
results_state: ListState,
+
+ context: Context,
}
impl State {
@@ -233,8 +240,14 @@ async fn query_results(
db: &mut (impl Database + Send + Sync),
) -> Result<()> {
let results = match app.input.as_str() {
- "" => db.list(Some(200), true).await?,
- i => db.search(Some(200), search_mode, i).await?,
+ "" => {
+ db.list(app.filter_mode, &app.context, Some(200), true)
+ .await?
+ }
+ i => {
+ db.search(Some(200), search_mode, app.filter_mode, &app.context, i)
+ .await?
+ }
};
app.results = results;
@@ -300,6 +313,16 @@ async fn key_handler(
app.input = String::from("");
query_results(app, search_mode, db).await.unwrap();
}
+ Key::Ctrl('r') => {
+ app.filter_mode = match app.filter_mode {
+ FilterMode::Global => FilterMode::Host,
+ FilterMode::Host => FilterMode::Session,
+ FilterMode::Session => FilterMode::Directory,
+ FilterMode::Directory => FilterMode::Global,
+ };
+
+ query_results(app, search_mode, db).await.unwrap();
+ }
Key::Down | Key::Ctrl('n') => {
let i = match app.results_state.selected() {
Some(i) => {
@@ -376,8 +399,15 @@ fn draw(f: &mut Frame<'_, T>, history_count: i64, app: &mut State) {
let help = Text::from(Spans::from(help));
let help = Paragraph::new(help);
+ let filter_mode = match app.filter_mode {
+ FilterMode::Global => "GLOBAL",
+ FilterMode::Host => "HOST",
+ FilterMode::Session => "SESSION",
+ FilterMode::Directory => "DIRECTORY",
+ };
+
let input = Paragraph::new(app.input.clone())
- .block(Block::default().borders(Borders::ALL).title("Query"));
+ .block(Block::default().borders(Borders::ALL).title(filter_mode));
let stats = Paragraph::new(Text::from(Span::raw(format!(
"history count: {}",
@@ -451,7 +481,15 @@ fn draw_compact(f: &mut Frame<'_, T>, history_count: i64, app: &mut
.style(Style::default().fg(Color::DarkGray))
.alignment(Alignment::Right);
- let input = Paragraph::new(format!("] {}", app.input.clone())).block(Block::default());
+ let filter_mode = match app.filter_mode {
+ FilterMode::Global => "GLOBAL",
+ FilterMode::Host => "HOST",
+ FilterMode::Session => "SESSION",
+ FilterMode::Directory => "DIRECTORY",
+ };
+
+ let input =
+ Paragraph::new(format!("{}] {}", filter_mode, app.input.clone())).block(Block::default());
f.render_widget(title, header_chunks[0]);
f.render_widget(help, header_chunks[1]);
@@ -460,9 +498,11 @@ fn draw_compact(f: &mut Frame<'_, T>, history_count: i64, app: &mut
app.render_results(f, chunks[1], Block::default());
f.render_widget(input, chunks[2]);
+ let extra_width = app.input.width() + filter_mode.len();
+
f.set_cursor(
// Put cursor past the end of the input text
- chunks[2].x + app.input.width() as u16 + 2,
+ chunks[2].x + extra_width as u16 + 2,
// Move one line down, from the border to the input line
chunks[2].y + 1,
);
@@ -475,6 +515,7 @@ fn draw_compact(f: &mut Frame<'_, T>, history_count: i64, app: &mut
async fn select_history(
query: &[String],
search_mode: SearchMode,
+ filter_mode: FilterMode,
style: atuin_client::settings::Style,
db: &mut (impl Database + Send + Sync),
) -> Result {
@@ -491,6 +532,8 @@ async fn select_history(
input: query.join(" "),
results: Vec::new(),
results_state: ListState::default(),
+ context: current_context(),
+ filter_mode,
};
query_results(&mut app, search_mode, db).await?;
@@ -551,11 +594,26 @@ pub async fn run(
};
if interactive {
- let item = select_history(query, settings.search_mode, settings.style, db).await?;
+ let item = select_history(
+ query,
+ settings.search_mode,
+ settings.filter_mode,
+ settings.style,
+ db,
+ )
+ .await?;
eprintln!("{}", item);
} else {
+ let context = current_context();
+
let results = db
- .search(None, settings.search_mode, query.join(" ").as_str())
+ .search(
+ None,
+ settings.search_mode,
+ settings.filter_mode,
+ &context,
+ query.join(" ").as_str(),
+ )
.await?;
// TODO: This filtering would be better done in the SQL query, I just
diff --git a/src/command/client/stats.rs b/src/command/client/stats.rs
index 6d342c1..85c58cc 100644
--- a/src/command/client/stats.rs
+++ b/src/command/client/stats.rs
@@ -8,9 +8,9 @@ use clap::Parser;
use cli_table::{format::Justify, print_stdout, Cell, Style, Table};
use eyre::{bail, Result};
-use atuin_client::database::Database;
+use atuin_client::database::{current_context, Database};
use atuin_client::history::History;
-use atuin_client::settings::Settings;
+use atuin_client::settings::{FilterMode, Settings};
#[derive(Parser)]
#[clap(infer_subcommands = true)]
@@ -71,6 +71,8 @@ impl Cmd {
db: &mut (impl Database + Send + Sync),
settings: &Settings,
) -> Result<()> {
+ let context = current_context();
+
match self {
Self::Day { words } => {
let words = if words.is_empty() {
@@ -90,7 +92,7 @@ impl Cmd {
}
Self::All => {
- let history = db.list(None, false).await?;
+ let history = db.list(FilterMode::Global, &context, None, false).await?;
compute_stats(&history)?;