From d57f549855caf8ab90b5ea0ae7cc9445f3abedfc Mon Sep 17 00:00:00 2001 From: Conrad Ludgate Date: Thu, 21 Apr 2022 10:12:56 +0100 Subject: refactor commands for better separation (#313) * refactor commands for better separation * fmt --- src/command/client.rs | 133 +++++++++ src/command/client/event.rs | 68 +++++ src/command/client/history.rs | 202 +++++++++++++ src/command/client/import.rs | 166 +++++++++++ src/command/client/init.rs | 36 +++ src/command/client/login.rs | 75 +++++ src/command/client/logout.rs | 15 + src/command/client/register.rs | 50 ++++ src/command/client/search.rs | 623 +++++++++++++++++++++++++++++++++++++++++ src/command/client/stats.rs | 101 +++++++ src/command/client/sync.rs | 19 ++ src/command/event.rs | 68 ----- src/command/history.rs | 202 ------------- src/command/import.rs | 166 ----------- src/command/init.rs | 36 --- src/command/login.rs | 75 ----- src/command/logout.rs | 12 - src/command/mod.rs | 199 +------------ src/command/register.rs | 44 --- src/command/search.rs | 558 ------------------------------------ src/command/server.rs | 8 +- src/command/stats.rs | 101 ------- src/command/sync.rs | 19 -- src/main.rs | 2 - 24 files changed, 1500 insertions(+), 1478 deletions(-) create mode 100644 src/command/client.rs create mode 100644 src/command/client/event.rs create mode 100644 src/command/client/history.rs create mode 100644 src/command/client/import.rs create mode 100644 src/command/client/init.rs create mode 100644 src/command/client/login.rs create mode 100644 src/command/client/logout.rs create mode 100644 src/command/client/register.rs create mode 100644 src/command/client/search.rs create mode 100644 src/command/client/stats.rs create mode 100644 src/command/client/sync.rs delete mode 100644 src/command/event.rs delete mode 100644 src/command/history.rs delete mode 100644 src/command/import.rs delete mode 100644 src/command/init.rs delete mode 100644 src/command/login.rs delete mode 100644 src/command/logout.rs delete mode 100644 src/command/register.rs delete mode 100644 src/command/search.rs delete mode 100644 src/command/stats.rs delete mode 100644 src/command/sync.rs diff --git a/src/command/client.rs b/src/command/client.rs new file mode 100644 index 00000000..fd65345c --- /dev/null +++ b/src/command/client.rs @@ -0,0 +1,133 @@ +use clap::CommandFactory; +use clap::Subcommand; +use clap_complete::Shell; +use clap_complete::{generate, generate_to}; +use eyre::{Result, WrapErr}; + +use atuin_client::database::Sqlite; +use atuin_client::settings::Settings; +use atuin_common::utils::uuid_v4; + +mod event; +mod history; +mod import; +mod init; +mod login; +mod logout; +mod register; +mod search; +mod stats; +mod sync; +use std::path::PathBuf; + +#[derive(Subcommand)] +#[clap(infer_subcommands = true)] +pub enum Cmd { + /// Manipulate shell history + #[clap(subcommand)] + History(history::Cmd), + + /// Import shell history from file + #[clap(subcommand)] + Import(import::Cmd), + + /// Calculate statistics for your history + #[clap(subcommand)] + Stats(stats::Cmd), + + /// Output shell setup + #[clap(subcommand)] + Init(init::Cmd), + + /// Generate a UUID + Uuid, + + /// Interactive history search + Search(search::Cmd), + + /// Sync with the configured server + Sync { + /// Force re-download everything + #[clap(long, short)] + force: bool, + }, + + /// Login to the configured server + Login(login::Cmd), + + /// Log out + Logout, + + /// Register with the configured server + Register(register::Cmd), + + /// Print the encryption key for transfer to another machine + Key, + + /// Generate shell completions + GenCompletions { + /// Set the shell for generating completions + #[clap(long, short)] + shell: Shell, + + /// Set the output directory + #[clap(long, short)] + out_dir: Option, + }, +} + +impl Cmd { + pub async fn run(self) -> Result<()> { + pretty_env_logger::init(); + + let settings = Settings::new().wrap_err("could not load client settings")?; + + let db_path = PathBuf::from(settings.db_path.as_str()); + let mut db = Sqlite::new(db_path).await?; + + match self { + Self::History(history) => history.run(&settings, &mut db).await, + Self::Import(import) => import.run(&mut db).await, + Self::Stats(stats) => stats.run(&mut db, &settings).await, + Self::Init(init) => { + init.run(); + Ok(()) + } + Self::Search(search) => search.run(&mut db, &settings).await, + Self::Sync { force } => sync::run(&settings, force, &mut db).await, + Self::Login(l) => l.run(&settings).await, + Self::Logout => logout::run(), + Self::Register(r) => r.run(&settings).await, + Self::Key => { + use atuin_client::encryption::{encode_key, load_key}; + let key = load_key(&settings).wrap_err("could not load encryption key")?; + let encode = encode_key(key).wrap_err("could not encode encryption key")?; + println!("{}", encode); + Ok(()) + } + Self::Uuid => { + println!("{}", uuid_v4()); + Ok(()) + } + Self::GenCompletions { shell, out_dir } => { + let mut cli = crate::Atuin::command(); + + match out_dir { + Some(out_dir) => { + generate_to(shell, &mut cli, env!("CARGO_PKG_NAME"), &out_dir)?; + } + None => { + generate( + shell, + &mut cli, + env!("CARGO_PKG_NAME"), + &mut std::io::stdout(), + ); + } + } + + Ok(()) + } + } + } +} diff --git a/src/command/client/event.rs b/src/command/client/event.rs new file mode 100644 index 00000000..f09752d6 --- /dev/null +++ b/src/command/client/event.rs @@ -0,0 +1,68 @@ +use std::thread; +use std::time::Duration; + +use crossbeam_channel::unbounded; +use termion::event::Key; +use termion::input::TermRead; + +pub enum Event { + Input(I), + Tick, +} + +/// A small event handler that wrap termion input and tick events. Each event +/// type is handled in its own thread and returned to a common `Receiver` +pub struct Events { + rx: crossbeam_channel::Receiver>, +} + +#[derive(Debug, Clone, Copy)] +pub struct Config { + pub exit_key: Key, + pub tick_rate: Duration, +} + +impl Default for Config { + fn default() -> Config { + Config { + exit_key: Key::Char('q'), + tick_rate: Duration::from_millis(250), + } + } +} + +impl Events { + pub fn new() -> Events { + Events::with_config(Config::default()) + } + + pub fn with_config(config: Config) -> Events { + let (tx, rx) = unbounded(); + + { + let tx = tx.clone(); + thread::spawn(move || { + let tty = termion::get_tty().expect("Could not find tty"); + for key in tty.keys().flatten() { + if let Err(err) = tx.send(Event::Input(key)) { + eprintln!("{}", err); + return; + } + } + }) + }; + + thread::spawn(move || loop { + if tx.send(Event::Tick).is_err() { + break; + } + thread::sleep(config.tick_rate); + }); + + Events { rx } + } + + pub fn next(&self) -> Result, crossbeam_channel::RecvError> { + self.rx.recv() + } +} diff --git a/src/command/client/history.rs b/src/command/client/history.rs new file mode 100644 index 00000000..6eaa6407 --- /dev/null +++ b/src/command/client/history.rs @@ -0,0 +1,202 @@ +use std::env; +use std::io::Write; +use std::time::Duration; + +use clap::Subcommand; +use eyre::Result; +use tabwriter::TabWriter; + +use atuin_client::database::Database; +use atuin_client::history::History; +use atuin_client::settings::Settings; +use atuin_client::sync; + +#[derive(Subcommand)] +#[clap(infer_subcommands = true)] +pub enum Cmd { + /// Begins a new command in the history + Start { command: Vec }, + + /// Finishes a new command in the history (adds time, exit code) + End { + id: String, + #[clap(long, short)] + exit: i64, + }, + + /// List all items in history + List { + #[clap(long, short)] + cwd: bool, + + #[clap(long, short)] + session: bool, + + #[clap(long)] + human: bool, + + /// Show only the text of the command + #[clap(long)] + cmd_only: bool, + }, + + /// Get the last command ran + Last { + #[clap(long)] + human: bool, + + /// Show only the text of the command + #[clap(long)] + cmd_only: bool, + }, +} + +#[allow(clippy::cast_sign_loss)] +pub fn print_list(h: &[History], human: bool, cmd_only: bool) { + let mut writer = TabWriter::new(std::io::stdout()).padding(2); + + let lines = h.iter().map(|h| { + if human { + let duration = humantime::format_duration(Duration::from_nanos(std::cmp::max( + h.duration, 0, + ) as u64)) + .to_string(); + let duration: Vec<&str> = duration.split(' ').collect(); + let duration = duration[0]; + + format!( + "{}\t{}\t{}\n", + h.timestamp.format("%Y-%m-%d %H:%M:%S"), + h.command.trim(), + duration, + ) + } else if cmd_only { + format!("{}\n", h.command.trim()) + } else { + format!( + "{}\t{}\t{}\n", + h.timestamp.timestamp_nanos(), + h.command.trim(), + h.duration + ) + } + }); + + for i in lines.rev() { + writer + .write_all(i.as_bytes()) + .expect("failed to write to tab writer"); + } + + writer.flush().expect("failed to flush tab writer"); +} + +impl Cmd { + pub async fn run( + &self, + settings: &Settings, + db: &mut (impl Database + Send + Sync), + ) -> Result<()> { + match self { + Self::Start { command: words } => { + let command = words.join(" "); + + if command.starts_with(' ') { + return Ok(()); + } + + // It's better for atuin to silently fail here and attempt to + // store whatever is ran, than to throw an error to the terminal + let cwd = match env::current_dir() { + Ok(dir) => dir.display().to_string(), + Err(_) => String::from(""), + }; + + let h = History::new(chrono::Utc::now(), command, cwd, -1, -1, None, None); + + // print the ID + // we use this as the key for calling end + println!("{}", h.id); + db.save(&h).await?; + Ok(()) + } + + Self::End { id, exit } => { + if id.trim() == "" { + return Ok(()); + } + + let mut h = db.load(id).await?; + + if h.duration > 0 { + debug!("cannot end history - already has duration"); + + // returning OK as this can occur if someone Ctrl-c a prompt + return Ok(()); + } + + h.exit = *exit; + h.duration = chrono::Utc::now().timestamp_nanos() - h.timestamp.timestamp_nanos(); + + db.update(&h).await?; + + if settings.should_sync()? { + debug!("running periodic background sync"); + sync::sync(settings, false, db).await?; + } else { + debug!("sync disabled! not syncing"); + } + + Ok(()) + } + + Self::List { + session, + cwd, + human, + cmd_only, + } => { + let session = if *session { + Some(env::var("ATUIN_SESSION")?) + } else { + None + }; + let cwd = if *cwd { + Some(env::current_dir()?.display().to_string()) + } else { + None + }; + + let history = match (session, cwd) { + (None, None) => db.list(None, false).await?, + (None, Some(cwd)) => { + let query = format!("select * from history where cwd = '{}';", cwd); + db.query_history(&query).await? + } + (Some(session), None) => { + let query = format!("select * from history where session = {};", session); + db.query_history(&query).await? + } + (Some(session), Some(cwd)) => { + let query = format!( + "select * from history where cwd = '{}' and session = {};", + cwd, session + ); + db.query_history(&query).await? + } + }; + + print_list(&history, *human, *cmd_only); + + Ok(()) + } + + Self::Last { human, cmd_only } => { + let last = db.last().await?; + print_list(&[last], *human, *cmd_only); + + Ok(()) + } + } + } +} diff --git a/src/command/client/import.rs b/src/command/client/import.rs new file mode 100644 index 00000000..7e2f5c5c --- /dev/null +++ b/src/command/client/import.rs @@ -0,0 +1,166 @@ +use std::{env, path::PathBuf}; + +use atuin_client::import::fish::Fish; +use clap::Parser; +use eyre::{eyre, Result}; + +use atuin_client::import::{bash::Bash, zsh::Zsh}; +use atuin_client::{database::Database, import::Importer}; +use atuin_client::{history::History, import::resh::Resh}; +use indicatif::ProgressBar; + +#[derive(Parser)] +#[clap(infer_subcommands = true)] +pub enum Cmd { + /// Import history for the current shell + Auto, + + /// Import history from the zsh history file + Zsh, + + /// Import history from the bash history file + Bash, + + /// Import history from the resh history file + Resh, + + /// Import history from the fish history file + Fish, +} + +const BATCH_SIZE: usize = 100; + +impl Cmd { + pub async fn run(&self, db: &mut (impl Database + Send + Sync)) -> Result<()> { + println!(" Atuin "); + println!("======================"); + println!(" \u{1f30d} "); + println!(" \u{1f418}\u{1f418}\u{1f418}\u{1f418} "); + println!(" \u{1f422} "); + println!("======================"); + println!("Importing history..."); + + match self { + Self::Auto => { + let shell = env::var("SHELL").unwrap_or_else(|_| String::from("NO_SHELL")); + + if shell.ends_with("/zsh") { + println!("Detected ZSH"); + import::, _>(db, BATCH_SIZE).await + } else if shell.ends_with("/fish") { + println!("Detected Fish"); + import::, _>(db, BATCH_SIZE).await + } else { + println!("cannot import {} history", shell); + Ok(()) + } + } + + Self::Zsh => import::, _>(db, BATCH_SIZE).await, + Self::Bash => import::, _>(db, BATCH_SIZE).await, + Self::Resh => import::(db, BATCH_SIZE).await, + Self::Fish => import::, _>(db, BATCH_SIZE).await, + } + } +} + +async fn import( + db: &mut DB, + buf_size: usize, +) -> Result<()> +where + I::IntoIter: Send, +{ + println!("Importing history from {}", I::NAME); + + let histpath = get_histpath::()?; + let contents = I::parse(histpath)?; + + let iter = contents.into_iter(); + let progress = if let (_, Some(upper_bound)) = iter.size_hint() { + ProgressBar::new(upper_bound as u64) + } else { + ProgressBar::new_spinner() + }; + + let mut buf = Vec::::with_capacity(buf_size); + let mut iter = progress.wrap_iter(iter); + loop { + // fill until either no more entries + // or until the buffer is full + let done = fill_buf(&mut buf, &mut iter); + + // flush + db.save_bulk(&buf).await?; + + if done { + break; + } + } + + println!("Import complete!"); + + Ok(()) +} + +fn get_histpath() -> Result { + if let Ok(p) = env::var("HISTFILE") { + is_file(PathBuf::from(p)) + } else { + is_file(I::histpath()?) + } +} + +fn is_file(p: PathBuf) -> Result { + if p.is_file() { + Ok(p) + } else { + Err(eyre!( + "Could not find history file {:?}. Try setting $HISTFILE", + p + )) + } +} + +fn fill_buf(buf: &mut Vec, iter: &mut impl Iterator>) -> bool { + buf.clear(); + loop { + match iter.next() { + Some(Ok(t)) => buf.push(t), + Some(Err(_)) => (), + None => break true, + } + + if buf.len() == buf.capacity() { + break false; + } + } +} + +#[cfg(test)] +mod tests { + use super::fill_buf; + + #[test] + fn test_fill_buf() { + let mut buf = Vec::with_capacity(4); + let mut iter = vec![ + Ok(1), + Err(2), + Ok(3), + Ok(4), + Err(5), + Ok(6), + Ok(7), + Err(8), + Ok(9), + ] + .into_iter(); + + assert!(!fill_buf(&mut buf, &mut iter)); + assert_eq!(buf, vec![1, 3, 4, 6]); + + assert!(fill_buf(&mut buf, &mut iter)); + assert_eq!(buf, vec![7, 9]); + } +} diff --git a/src/command/client/init.rs b/src/command/client/init.rs new file mode 100644 index 00000000..a2c6378c --- /dev/null +++ b/src/command/client/init.rs @@ -0,0 +1,36 @@ +use clap::Parser; + +#[derive(Parser)] +pub enum Cmd { + /// Zsh setup + Zsh, + /// Bash setup + Bash, + /// Fish setup + Fish, +} + +fn init_zsh() { + let full = include_str!("../../shell/atuin.zsh"); + println!("{}", full); +} + +fn init_bash() { + let full = include_str!("../../shell/atuin.bash"); + println!("{}", full); +} + +fn init_fish() { + let full = include_str!("../../shell/atuin.fish"); + println!("{}", full); +} + +impl Cmd { + pub fn run(&self) { + match self { + Self::Zsh => init_zsh(), + Self::Bash => init_bash(), + Self::Fish => init_fish(), + } + } +} diff --git a/src/command/client/login.rs b/src/command/client/login.rs new file mode 100644 index 00000000..efc9c590 --- /dev/null +++ b/src/command/client/login.rs @@ -0,0 +1,75 @@ +use std::io; + +use atuin_common::api::LoginRequest; +use clap::AppSettings; +use clap::Parser; +use eyre::Result; +use tokio::{fs::File, io::AsyncWriteExt}; + +use atuin_client::api_client; +use atuin_client::settings::Settings; + +#[derive(Parser)] +#[clap(setting(AppSettings::DeriveDisplayOrder))] +pub struct Cmd { + #[clap(long, short)] + pub username: Option, + + #[clap(long, short)] + pub password: Option, + + /// The encryption key for your account + #[clap(long, short)] + pub key: Option, +} + +fn get_input() -> Result { + let mut input = String::new(); + io::stdin().read_line(&mut input)?; + Ok(input.trim_end_matches(&['\r', '\n'][..]).to_string()) +} + +impl Cmd { + pub async fn run(&self, settings: &Settings) -> Result<()> { + let session_path = atuin_common::utils::data_dir().join("session"); + + if session_path.exists() { + println!( + "You are already logged in! Please run 'atuin logout' if you wish to login again" + ); + + return Ok(()); + } + + let username = or_user_input(&self.username, "username"); + let password = or_user_input(&self.password, "password"); + let key = or_user_input(&self.key, "encryption key"); + + let session = api_client::login( + settings.sync_address.as_str(), + LoginRequest { username, password }, + ) + .await?; + + let session_path = settings.session_path.as_str(); + let mut file = File::create(session_path).await?; + file.write_all(session.session.as_bytes()).await?; + + let key_path = settings.key_path.as_str(); + let mut file = File::create(key_path).await?; + file.write_all(key.as_bytes()).await?; + + println!("Logged in!"); + + Ok(()) + } +} + +pub(super) fn or_user_input(value: &'_ Option, name: &'static str) -> String { + value.clone().unwrap_or_else(|| read_user_input(name)) +} + +fn read_user_input(name: &'static str) -> String { + eprint!("Please enter {}: ", name); + get_input().expect("Failed to read from input") +} diff --git a/src/command/client/logout.rs b/src/command/client/logout.rs new file mode 100644 index 00000000..a7e9541d --- /dev/null +++ b/src/command/client/logout.rs @@ -0,0 +1,15 @@ +use eyre::{Context, Result}; +use fs_err::remove_file; + +pub fn run() -> Result<()> { + let session_path = atuin_common::utils::data_dir().join("session"); + + if session_path.exists() { + remove_file(session_path.as_path()).context("Failed to remove session file")?; + println!("You have logged out!"); + } else { + println!("You are not logged in"); + } + + Ok(()) +} diff --git a/src/command/client/register.rs b/src/command/client/register.rs new file mode 100644 index 00000000..2c60a2e9 --- /dev/null +++ b/src/command/client/register.rs @@ -0,0 +1,50 @@ +use clap::AppSettings; +use clap::Parser; +use eyre::Result; +use tokio::{fs::File, io::AsyncWriteExt}; + +use atuin_client::api_client; +use atuin_client::settings::Settings; + +#[derive(Parser)] +#[clap(setting(AppSettings::DeriveDisplayOrder))] +pub struct Cmd { + #[clap(long, short)] + pub username: Option, + + #[clap(long, short)] + pub email: Option, + + #[clap(long, short)] + pub password: Option, +} + +impl Cmd { + pub async fn run(self, settings: &Settings) -> Result<()> { + run(settings, &self.username, &self.email, &self.password).await + } +} + +pub async fn run( + settings: &Settings, + username: &Option, + email: &Option, + password: &Option, +) -> Result<()> { + use super::login::or_user_input; + let username = or_user_input(username, "username"); + let email = or_user_input(email, "email"); + let password = or_user_input(password, "password"); + + let session = + api_client::register(settings.sync_address.as_str(), &username, &email, &password).await?; + + let path = settings.session_path.as_str(); + let mut file = File::create(path).await?; + file.write_all(session.session.as_bytes()).await?; + + // Create a new key, and save it to disk + let _key = atuin_client::encryption::new_key(settings)?; + + Ok(()) +} diff --git a/src/command/client/search.rs b/src/command/client/search.rs new file mode 100644 index 00000000..a1dc5aa9 --- /dev/null +++ b/src/command/client/search.rs @@ -0,0 +1,623 @@ +use chrono::Utc; +use clap::Parser; +use eyre::Result; +use std::{io::stdout, ops::Sub, time::Duration}; +use termion::{event::Key, input::MouseTerminal, raw::IntoRawMode, screen::AlternateScreen}; +use tui::{ + backend::{Backend, TermionBackend}, + layout::{Alignment, Constraint, Corner, Direction, Layout}, + style::{Color, Modifier, Style}, + text::{Span, Spans, Text}, + widgets::{Block, Borders, List, ListItem, ListState, Paragraph}, + Frame, Terminal, +}; +use unicode_width::UnicodeWidthStr; + +use atuin_client::{ + database::Database, + history::History, + settings::{SearchMode, Settings}, +}; + +use super::event::{Event, Events}; + +const VERSION: &str = env!("CARGO_PKG_VERSION"); + +#[derive(Parser)] +pub struct Cmd { + /// Filter search result by directory + #[clap(long, short)] + cwd: Option, + + /// Exclude directory from results + #[clap(long = "exclude-cwd")] + exclude_cwd: Option, + + /// Filter search result by exit code + #[clap(long, short)] + exit: Option, + + /// Exclude results with this exit code + #[clap(long = "exclude-exit")] + exclude_exit: Option, + + /// Only include results added before this date + #[clap(long, short)] + before: Option, + + /// Only include results after this date + #[clap(long)] + after: Option, + + /// Open interactive search UI + #[clap(long, short)] + interactive: bool, + + /// Use human-readable formatting for time + #[clap(long)] + human: bool, + + query: Vec, + + /// Show only the text of the command + #[clap(long)] + cmd_only: bool, +} + +impl Cmd { + pub async fn run( + self, + db: &mut (impl Database + Send + Sync), + settings: &Settings, + ) -> Result<()> { + run( + settings, + self.cwd, + self.exit, + self.interactive, + self.human, + self.exclude_exit, + self.exclude_cwd, + self.before, + self.after, + self.cmd_only, + &self.query, + db, + ) + .await + } +} + +struct State { + input: String, + + results: Vec, + + results_state: ListState, +} + +impl State { + #[allow(clippy::cast_sign_loss)] + fn durations(&self) -> Vec<(String, String)> { + self.results + .iter() + .map(|h| { + let duration = + Duration::from_millis(std::cmp::max(h.duration, 0) as u64 / 1_000_000); + let duration = humantime::format_duration(duration).to_string(); + let duration: Vec<&str> = duration.split(' ').collect(); + + let ago = chrono::Utc::now().sub(h.timestamp); + + // Account for the chance that h.timestamp is "in the future" + // This would mean that "ago" is negative, and the unwrap here + // would fail. + // If the timestamp would otherwise be in the future, display + // the time ago as 0. + let ago = humantime::format_duration( + ago.to_std().unwrap_or_else(|_| Duration::new(0, 0)), + ) + .to_string(); + let ago: Vec<&str> = ago.split(' ').collect(); + + ( + duration[0] + .to_string() + .replace("days", "d") + .replace("day", "d") + .replace("weeks", "w") + .replace("week", "w") + .replace("months", "mo") + .replace("month", "mo") + .replace("years", "y") + .replace("year", "y"), + ago[0] + .to_string() + .replace("days", "d") + .replace("day", "d") + .replace("weeks", "w") + .replace("week", "w") + .replace("months", "mo") + .replace("month", "mo") + .replace("years", "y") + .replace("year", "y") + + " ago", + ) + }) + .collect() + } + + fn render_results( + &mut self, + f: &mut tui::Frame, + r: tui::layout::Rect, + b: tui::widgets::Block, + ) { + let durations = self.durations(); + let max_length = durations.iter().fold(0, |largest, i| { + std::cmp::max(largest, i.0.len() + i.1.len()) + }); + + let results: Vec = self + .results + .iter() + .enumerate() + .map(|(i, m)| { + let command = m.command.to_string().replace('\n', " ").replace('\t', " "); + + let mut command = Span::raw(command); + + let (duration, mut ago) = durations[i].clone(); + + while (duration.len() + ago.len()) < max_length { + ago = format!(" {}", ago); + } + + let selected_index = match self.results_state.selected() { + None => Span::raw(" "), + Some(selected) => match i.checked_sub(selected) { + None => Span::raw(" "), + Some(diff) => { + if 0 < diff && diff < 10 { + Span::raw(format!(" {} ", diff)) + } else { + Span::raw(" ") + } + } + }, + }; + + let duration = Span::styled( + duration, + Style::default().fg(if m.exit == 0 || m.duration == -1 { + Color::Green + } else { + Color::Red + }), + ); + + let ago = Span::styled(ago, Style::default().fg(Color::Blue)); + + if let Some(selected) = self.results_state.selected() { + if selected == i { + command.style = + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD); + } + } + + let spans = Spans::from(vec![ + selected_index, + duration, + Span::raw(" "), + ago, + Span::raw(" "), + command, + ]); + + ListItem::new(spans) + }) + .collect(); + + let results = List::new(results) + .block(b) + .start_corner(Corner::BottomLeft) + .highlight_symbol(">> "); + + f.render_stateful_widget(results, r, &mut self.results_state); + } +} + +async fn query_results( + app: &mut State, + search_mode: SearchMode, + 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?, + }; + + app.results = results; + + if app.results.is_empty() { + app.results_state.select(None); + } else { + app.results_state.select(Some(0)); + } + + Ok(()) +} + +async fn key_handler( + input: Key, + search_mode: SearchMode, + db: &mut (impl Database + Send + Sync), + app: &mut State, +) -> Option { + match input { + Key::Esc | Key::Ctrl('c' | 'd' | 'g') => return Some(String::from("")), + Key::Char('\n') => { + let i = app.results_state.selected().unwrap_or(0); + + return Some( + app.results + .get(i) + .map_or(app.input.clone(), |h| h.command.clone()), + ); + } + Key::Alt(c) if ('1'..='9').contains(&c) => { + let c = c.to_digit(10)? as usize; + let i = app.results_state.selected()? + c; + + return Some( + app.results + .get(i) + .map_or(app.input.clone(), |h| h.command.clone()), + ); + } + Key::Char(c) => { + app.input.push(c); + query_results(app, search_mode, db).await.unwrap(); + } + Key::Backspace => { + app.input.pop(); + query_results(app, search_mode, db).await.unwrap(); + } + // \u{7f} is escape sequence for backspace + Key::Alt('\u{7f}') => { + let words: Vec<&str> = app.input.split(' ').collect(); + if words.is_empty() { + return None; + } + if words.len() == 1 { + app.input = String::from(""); + } else { + app.input = words[0..(words.len() - 1)].join(" "); + } + query_results(app, search_mode, db).await.unwrap(); + } + Key::Ctrl('u') => { + app.input = String::from(""); + query_results(app, search_mode, db).await.unwrap(); + } + Key::Down | Key::Ctrl('n') => { + let i = match app.results_state.selected() { + Some(i) => { + if i == 0 { + 0 + } else { + i - 1 + } + } + None => 0, + }; + app.results_state.select(Some(i)); + } + Key::Up | Key::Ctrl('p') => { + let i = match app.results_state.selected() { + Some(i) => { + if i >= app.results.len() - 1 { + app.results.len() - 1 + } else { + i + 1 + } + } + None => 0, + }; + app.results_state.select(Some(i)); + } + _ => {} + }; + + None +} + +#[allow(clippy::cast_possible_truncation)] +fn draw(f: &mut Frame<'_, T>, history_count: i64, app: &mut State) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(1) + .constraints( + [ + Constraint::Length(2), + Constraint::Min(1), + Constraint::Length(3), + ] + .as_ref(), + ) + .split(f.size()); + + let top_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)].as_ref()) + .split(chunks[0]); + + let top_left_chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(1), Constraint::Length(1)].as_ref()) + .split(top_chunks[0]); + + let top_right_chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(1), Constraint::Length(1)].as_ref()) + .split(top_chunks[1]); + + let title = Paragraph::new(Text::from(Span::styled( + format!("Atuin v{}", VERSION), + Style::default().add_modifier(Modifier::BOLD), + ))); + + let help = vec![ + Span::raw("Press "), + Span::styled("Esc", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(" to exit."), + ]; + + let help = Text::from(Spans::from(help)); + let help = Paragraph::new(help); + + let input = Paragraph::new(app.input.clone()) + .block(Block::default().borders(Borders::ALL).title("Query")); + + let stats = Paragraph::new(Text::from(Span::raw(format!( + "history count: {}", + history_count, + )))) + .alignment(Alignment::Right); + + f.render_widget(title, top_left_chunks[0]); + f.render_widget(help, top_left_chunks[1]); + f.render_widget(stats, top_right_chunks[0]); + + app.render_results( + f, + chunks[1], + Block::default().borders(Borders::ALL).title("History"), + ); + f.render_widget(input, chunks[2]); + + f.set_cursor( + // Put cursor past the end of the input text + chunks[2].x + app.input.width() as u16 + 1, + // Move one line down, from the border to the input line + chunks[2].y + 1, + ); +} + +#[allow(clippy::cast_possible_truncation)] +fn draw_compact(f: &mut Frame<'_, T>, history_count: i64, app: &mut State) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(0) + .horizontal_margin(1) + .constraints( + [ + Constraint::Length(1), + Constraint::Min(1), + Constraint::Length(1), + ] + .as_ref(), + ) + .split(f.size()); + + let header_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints( + [ + Constraint::Ratio(1, 3), + Constraint::Ratio(1, 3), + Constraint::Ratio(1, 3), + ] + .as_ref(), + ) + .split(chunks[0]); + + let title = Paragraph::new(Text::from(Span::styled( + format!("Atuin v{}", VERSION), + Style::default().fg(Color::DarkGray), + ))); + + let help = Paragraph::new(Text::from(Spans::from(vec![ + Span::styled("Esc", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(" to exit"), + ]))) + .style(Style::default().fg(Color::DarkGray)) + .alignment(Alignment::Center); + + let stats = Paragraph::new(Text::from(Span::raw(format!( + "history count: {}", + history_count, + )))) + .style(Style::default().fg(Color::DarkGray)) + .alignment(Alignment::Right); + + let input = Paragraph::new(format!("] {}", app.input.clone())).block(Block::default()); + + f.render_widget(title, header_chunks[0]); + f.render_widget(help, header_chunks[1]); + f.render_widget(stats, header_chunks[2]); + + app.render_results(f, chunks[1], Block::default()); + f.render_widget(input, chunks[2]); + + f.set_cursor( + // Put cursor past the end of the input text + chunks[2].x + app.input.width() as u16 + 2, + // Move one line down, from the border to the input line + chunks[2].y + 1, + ); +} + +// this is a big blob of horrible! clean it up! +// for now, it works. But it'd be great if it were more easily readable, and +// modular. I'd like to add some more stats and stuff at some point +#[allow(clippy::cast_possible_truncation)] +async fn select_history( + query: &[String], + search_mode: SearchMode, + style: atuin_client::settings::Style, + db: &mut (impl Database + Send + Sync), +) -> Result { + let stdout = stdout().into_raw_mode()?; + let stdout = MouseTerminal::from(stdout); + let stdout = AlternateScreen::from(stdout); + let backend = TermionBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + // Setup event handlers + let events = Events::new(); + + let mut app = State { + input: query.join(" "), + results: Vec::new(), + results_state: ListState::default(), + }; + + query_results(&mut app, search_mode, db).await?; + + loop { + let history_count = db.history_count().await?; + // Handle input + if let Event::Input(input) = events.next()? { + if let Some(output) = key_handler(input, search_mode, db, &mut app).await { + return Ok(output); + } + } + + let compact = match style { + atuin_client::settings::Style::Auto => { + terminal.size().map(|size| size.height < 14).unwrap_or(true) + } + atuin_client::settings::Style::Compact => true, + atuin_client::settings::Style::Full => false, + }; + if compact { + terminal.draw(|f| draw_compact(f, history_count, &mut app))?; + } else { + terminal.draw(|f| draw(f, history_count, &mut app))?; + } + } +} + +// This is supposed to more-or-less mirror the command line version, so ofc +// it is going to have a lot of args +#[allow(clippy::too_many_arguments)] +pub async fn run( + settings: &Settings, + cwd: Option, + exit: Option, + interactive: bool, + human: bool, + exclude_exit: Option, + exclude_cwd: Option, + before: Option, + after: Option, + cmd_only: bool, + query: &[String], + db: &mut (impl Database + Send + Sync), +) -> Result<()> { + let dir = if let Some(cwd) = cwd { + if cwd == "." { + let current = std::env::current_dir()?; + let current = current.as_os_str(); + let current = current.to_str().unwrap(); + + Some(current.to_owned()) + } else { + Some(cwd) + } + } else { + None + }; + + if interactive { + let item = select_history(query, settings.search_mode, settings.style, db).await?; + eprintln!("{}", item); + } else { + let results = db + .search(None, settings.search_mode, query.join(" ").as_str()) + .await?; + + // TODO: This filtering would be better done in the SQL query, I just + // need a nice way of building queries. + let results: Vec = results + .iter() + .filter(|h| { + if let Some(exit) = exit { + if h.exit != exit { + return false; + } + } + + if let Some(exit) = exclude_exit { + if h.exit == exit { + return false; + } + } + + if let Some(cwd) = &exclude_cwd { + if h.cwd.as_str() == cwd.as_str() { + return false; + } + } + + if let Some(cwd) = &dir { + if h.cwd.as_str() != cwd.as_str() { + return false; + } + } + + if let Some(before) = &before { + let before = chrono_english::parse_date_string( + before.as_str(), + Utc::now(), + chrono_english::Dialect::Uk, + ); + + if before.is_err() || h.timestamp.gt(&before.unwrap()) { + return false; + } + } + + if let Some(after) = &after { + let after = chrono_english::parse_date_string( + after.as_str(), + Utc::now(), + chrono_english::Dialect::Uk, + ); + + if after.is_err() || h.timestamp.lt(&after.unwrap()) { + return false; + } + } + + true + }) + .map(std::borrow::ToOwned::to_owned) + .collect(); + + super::history::print_list(&results, human, cmd_only); + } + + Ok(()) +} diff --git a/src/command/client/stats.rs b/src/command/client/stats.rs new file mode 100644 index 00000000..6d342c19 --- /dev/null +++ b/src/command/client/stats.rs @@ -0,0 +1,101 @@ +use std::collections::HashMap; + +use chrono::prelude::*; +use chrono::Duration; +use chrono_english::parse_date_string; + +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::history::History; +use atuin_client::settings::Settings; + +#[derive(Parser)] +#[clap(infer_subcommands = true)] +pub enum Cmd { + /// Compute statistics for all of time + All, + + /// Compute statistics for a single day + Day { words: Vec }, +} + +fn compute_stats(history: &[History]) -> Result<()> { + let mut commands = HashMap::::new(); + + for i in history { + *commands.entry(i.command.clone()).or_default() += 1; + } + + let most_common_command = commands.iter().max_by(|a, b| a.1.cmp(b.1)); + + if most_common_command.is_none() { + bail!("No commands found"); + } + + let table = vec![ + vec![ + "Most used command".cell(), + most_common_command + .unwrap() + .0 + .cell() + .justify(Justify::Right), + ], + vec![ + "Commands ran".cell(), + history.len().to_string().cell().justify(Justify::Right), + ], + vec![ + "Unique commands ran".cell(), + commands.len().to_string().cell().justify(Justify::Right), + ], + ] + .table() + .title(vec![ + "Statistic".cell().bold(true), + "Value".cell().bold(true), + ]) + .bold(true); + + print_stdout(table)?; + + Ok(()) +} + +impl Cmd { + pub async fn run( + &self, + db: &mut (impl Database + Send + Sync), + settings: &Settings, + ) -> Result<()> { + match self { + Self::Day { words } => { + let words = if words.is_empty() { + String::from("yesterday") + } else { + words.join(" ") + }; + + let start = parse_date_string(&words, Local::now(), settings.dialect.into())?; + let end = start + Duration::days(1); + + let history = db.range(start.into(), end.into()).await?; + + compute_stats(&history)?; + + Ok(()) + } + + Self::All => { + let history = db.list(None, false).await?; + + compute_stats(&history)?; + + Ok(()) + } + } + } +} diff --git a/src/command/client/sync.rs b/src/command/client/sync.rs new file mode 100644 index 00000000..f8bfd5e2 --- /dev/null +++ b/src/command/client/sync.rs @@ -0,0 +1,19 @@ +use eyre::Result; + +use atuin_client::database::Database; +use atuin_client::settings::Settings; +use atuin_client::sync; + +pub async fn run( + settings: &Settings, + force: bool, + db: &mut (impl Database + Send + Sync), +) -> Result<()> { + sync::sync(settings, force, db).await?; + println!( + "Sync complete! {} items in database, force: {}", + db.history_count().await?, + force + ); + Ok(()) +} diff --git a/src/command/event.rs b/src/command/event.rs deleted file mode 100644 index f09752d6..00000000 --- a/src/command/event.rs +++ /dev/null @@ -1,68 +0,0 @@ -use std::thread; -use std::time::Duration; - -use crossbeam_channel::unbounded; -use termion::event::Key; -use termion::input::TermRead; - -pub enum Event { - Input(I), - Tick, -} - -/// A small event handler that wrap termion input and tick events. Each event -/// type is handled in its own thread and returned to a common `Receiver` -pub struct Events { - rx: crossbeam_channel::Receiver>, -} - -#[derive(Debug, Clone, Copy)] -pub struct Config { - pub exit_key: Key, - pub tick_rate: Duration, -} - -impl Default for Config { - fn default() -> Config { - Config { - exit_key: Key::Char('q'), - tick_rate: Duration::from_millis(250), - } - } -} - -impl Events { - pub fn new() -> Events { - Events::with_config(Config::default()) - } - - pub fn with_config(config: Config) -> Events { - let (tx, rx) = unbounded(); - - { - let tx = tx.clone(); - thread::spawn(move || { - let tty = termion::get_tty().expect("Could not find tty"); - for key in tty.keys().flatten() { - if let Err(err) = tx.send(Event::Input(key)) { - eprintln!("{}", err); - return; - } - } - }) - }; - - thread::spawn(move || loop { - if tx.send(Event::Tick).is_err() { - break; - } - thread::sleep(config.tick_rate); - }); - - Events { rx } - } - - pub fn next(&self) -> Result, crossbeam_channel::RecvError> { - self.rx.recv() - } -} diff --git a/src/command/history.rs b/src/command/history.rs deleted file mode 100644 index 6eaa6407..00000000 --- a/src/command/history.rs +++ /dev/null @@ -1,202 +0,0 @@ -use std::env; -use std::io::Write; -use std::time::Duration; - -use clap::Subcommand; -use eyre::Result; -use tabwriter::TabWriter; - -use atuin_client::database::Database; -use atuin_client::history::History; -use atuin_client::settings::Settings; -use atuin_client::sync; - -#[derive(Subcommand)] -#[clap(infer_subcommands = true)] -pub enum Cmd { - /// Begins a new command in the history - Start { command: Vec }, - - /// Finishes a new command in the history (adds time, exit code) - End { - id: String, - #[clap(long, short)] - exit: i64, - }, - - /// List all items in history - List { - #[clap(long, short)] - cwd: bool, - - #[clap(long, short)] - session: bool, - - #[clap(long)] - human: bool, - - /// Show only the text of the command - #[clap(long)] - cmd_only: bool, - }, - - /// Get the last command ran - Last { - #[clap(long)] - human: bool, - - /// Show only the text of the command - #[clap(long)] - cmd_only: bool, - }, -} - -#[allow(clippy::cast_sign_loss)] -pub fn print_list(h: &[History], human: bool, cmd_only: bool) { - let mut writer = TabWriter::new(std::io::stdout()).padding(2); - - let lines = h.iter().map(|h| { - if human { - let duration = humantime::format_duration(Duration::from_nanos(std::cmp::max( - h.duration, 0, - ) as u64)) - .to_string(); - let duration: Vec<&str> = duration.split(' ').collect(); - let duration = duration[0]; - - format!( - "{}\t{}\t{}\n", - h.timestamp.format("%Y-%m-%d %H:%M:%S"), - h.command.trim(), - duration, - ) - } else if cmd_only { - format!("{}\n", h.command.trim()) - } else { - format!( - "{}\t{}\t{}\n", - h.timestamp.timestamp_nanos(), - h.command.trim(), - h.duration - ) - } - }); - - for i in lines.rev() { - writer - .write_all(i.as_bytes()) - .expect("failed to write to tab writer"); - } - - writer.flush().expect("failed to flush tab writer"); -} - -impl Cmd { - pub async fn run( - &self, - settings: &Settings, - db: &mut (impl Database + Send + Sync), - ) -> Result<()> { - match self { - Self::Start { command: words } => { - let command = words.join(" "); - - if command.starts_with(' ') { - return Ok(()); - } - - // It's better for atuin to silently fail here and attempt to - // store whatever is ran, than to throw an error to the terminal - let cwd = match env::current_dir() { - Ok(dir) => dir.display().to_string(), - Err(_) => String::from(""), - }; - - let h = History::new(chrono::Utc::now(), command, cwd, -1, -1, None, None); - - // print the ID - // we use this as the key for calling end - println!("{}", h.id); - db.save(&h).await?; - Ok(()) - } - - Self::End { id, exit } => { - if id.trim() == "" { - return Ok(()); - } - - let mut h = db.load(id).await?; - - if h.duration > 0 { - debug!("cannot end history - already has duration"); - - // returning OK as this can occur if someone Ctrl-c a prompt - return Ok(()); - } - - h.exit = *exit; - h.duration = chrono::Utc::now().timestamp_nanos() - h.timestamp.timestamp_nanos(); - - db.update(&h).await?; - - if settings.should_sync()? { - debug!("running periodic background sync"); - sync::sync(settings, false, db).await?; - } else { - debug!("sync disabled! not syncing"); - } - - Ok(()) - } - - Self::List { - session, - cwd, - human, - cmd_only, - } => { - let session = if *session { - Some(env::var("ATUIN_SESSION")?) - } else { - None - }; - let cwd = if *cwd { - Some(env::current_dir()?.display().to_string()) - } else { - None - }; - - let history = match (session, cwd) { - (None, None) => db.list(None, false).await?, - (None, Some(cwd)) => { - let query = format!("select * from history where cwd = '{}';", cwd); - db.query_history(&query).await? - } - (Some(session), None) => { - let query = format!("select * from history where session = {};", session); - db.query_history(&query).await? - } - (Some(session), Some(cwd)) => { - let query = format!( - "select * from history where cwd = '{}' and session = {};", - cwd, session - ); - db.query_history(&query).await? - } - }; - - print_list(&history, *human, *cmd_only); - - Ok(()) - } - - Self::Last { human, cmd_only } => { - let last = db.last().await?; - print_list(&[last], *human, *cmd_only); - - Ok(()) - } - } - } -} diff --git a/src/command/import.rs b/src/command/import.rs deleted file mode 100644 index 7e2f5c5c..00000000 --- a/src/command/import.rs +++ /dev/null @@ -1,166 +0,0 @@ -use std::{env, path::PathBuf}; - -use atuin_client::import::fish::Fish; -use clap::Parser; -use eyre::{eyre, Result}; - -use atuin_client::import::{bash::Bash, zsh::Zsh}; -use atuin_client::{database::Database, import::Importer}; -use atuin_client::{history::History, import::resh::Resh}; -use indicatif::ProgressBar; - -#[derive(Parser)] -#[clap(infer_subcommands = true)] -pub enum Cmd { - /// Import history for the current shell - Auto, - - /// Import history from the zsh history file - Zsh, - - /// Import history from the bash history file - Bash, - - /// Import history from the resh history file - Resh, - - /// Import history from the fish history file - Fish, -} - -const BATCH_SIZE: usize = 100; - -impl Cmd { - pub async fn run(&self, db: &mut (impl Database + Send + Sync)) -> Result<()> { - println!(" Atuin "); - println!("======================"); - println!(" \u{1f30d} "); - println!(" \u{1f418}\u{1f418}\u{1f418}\u{1f418} "); - println!(" \u{1f422} "); - println!("======================"); - println!("Importing history..."); - - match self { - Self::Auto => { - let shell = env::var("SHELL").unwrap_or_else(|_| String::from("NO_SHELL")); - - if shell.ends_with("/zsh") { - println!("Detected ZSH"); - import::, _>(db, BATCH_SIZE).await - } else if shell.ends_with("/fish") { - println!("Detected Fish"); - import::, _>(db, BATCH_SIZE).await - } else { - println!("cannot import {} history", shell); - Ok(()) - } - } - - Self::Zsh => import::, _>(db, BATCH_SIZE).await, - Self::Bash => import::, _>(db, BATCH_SIZE).await, - Self::Resh => import::(db, BATCH_SIZE).await, - Self::Fish => import::, _>(db, BATCH_SIZE).await, - } - } -} - -async fn import( - db: &mut DB, - buf_size: usize, -) -> Result<()> -where - I::IntoIter: Send, -{ - println!("Importing history from {}", I::NAME); - - let histpath = get_histpath::()?; - let contents = I::parse(histpath)?; - - let iter = contents.into_iter(); - let progress = if let (_, Some(upper_bound)) = iter.size_hint() { - ProgressBar::new(upper_bound as u64) - } else { - ProgressBar::new_spinner() - }; - - let mut buf = Vec::::with_capacity(buf_size); - let mut iter = progress.wrap_iter(iter); - loop { - // fill until either no more entries - // or until the buffer is full - let done = fill_buf(&mut buf, &mut iter); - - // flush - db.save_bulk(&buf).await?; - - if done { - break; - } - } - - println!("Import complete!"); - - Ok(()) -} - -fn get_histpath() -> Result { - if let Ok(p) = env::var("HISTFILE") { - is_file(PathBuf::from(p)) - } else { - is_file(I::histpath()?) - } -} - -fn is_file(p: PathBuf) -> Result { - if p.is_file() { - Ok(p) - } else { - Err(eyre!( - "Could not find history file {:?}. Try setting $HISTFILE", - p - )) - } -} - -fn fill_buf(buf: &mut Vec, iter: &mut impl Iterator>) -> bool { - buf.clear(); - loop { - match iter.next() { - Some(Ok(t)) => buf.push(t), - Some(Err(_)) => (), - None => break true, - } - - if buf.len() == buf.capacity() { - break false; - } - } -} - -#[cfg(test)] -mod tests { - use super::fill_buf; - - #[test] - fn test_fill_buf() { - let mut buf = Vec::with_capacity(4); - let mut iter = vec![ - Ok(1), - Err(2), - Ok(3), - Ok(4), - Err(5), - Ok(6), - Ok(7), - Err(8), - Ok(9), - ] - .into_iter(); - - assert!(!fill_buf(&mut buf, &mut iter)); - assert_eq!(buf, vec![1, 3, 4, 6]); - - assert!(fill_buf(&mut buf, &mut iter)); - assert_eq!(buf, vec![7, 9]); - } -} diff --git a/src/command/init.rs b/src/command/init.rs deleted file mode 100644 index 37453f93..00000000 --- a/src/command/init.rs +++ /dev/null @@ -1,36 +0,0 @@ -use clap::Parser; - -#[derive(Parser)] -pub enum Cmd { - /// Zsh setup - Zsh, - /// Bash setup - Bash, - /// Fish setup - Fish, -} - -fn init_zsh() { - let full = include_str!("../shell/atuin.zsh"); - println!("{}", full); -} - -fn init_bash() { - let full = include_str!("../shell/atuin.bash"); - println!("{}", full); -} - -fn init_fish() { - let full = include_str!("../shell/atuin.fish"); - println!("{}", full); -} - -impl Cmd { - pub fn run(&self) { - match self { - Self::Zsh => init_zsh(), - Self::Bash => init_bash(), - Self::Fish => init_fish(), - } - } -} diff --git a/src/command/login.rs b/src/command/login.rs deleted file mode 100644 index efc9c590..00000000 --- a/src/command/login.rs +++ /dev/null @@ -1,75 +0,0 @@ -use std::io; - -use atuin_common::api::LoginRequest; -use clap::AppSettings; -use clap::Parser; -use eyre::Result; -use tokio::{fs::File, io::AsyncWriteExt}; - -use atuin_client::api_client; -use atuin_client::settings::Settings; - -#[derive(Parser)] -#[clap(setting(AppSettings::DeriveDisplayOrder))] -pub struct Cmd { - #[clap(long, short)] - pub username: Option, - - #[clap(long, short)] - pub password: Option, - - /// The encryption key for your account - #[clap(long, short)] - pub key: Option, -} - -fn get_input() -> Result { - let mut input = String::new(); - io::stdin().read_line(&mut input)?; - Ok(input.trim_end_matches(&['\r', '\n'][..]).to_string()) -} - -impl Cmd { - pub async fn run(&self, settings: &Settings) -> Result<()> { - let session_path = atuin_common::utils::data_dir().join("session"); - - if session_path.exists() { - println!( - "You are already logged in! Please run 'atuin logout' if you wish to login again" - ); - - return Ok(()); - } - - let username = or_user_input(&self.username, "username"); - let password = or_user_input(&self.password, "password"); - let key = or_user_input(&self.key, "encryption key"); - - let session = api_client::login( - settings.sync_address.as_str(), - LoginRequest { username, password }, - ) - .await?; - - let session_path = settings.session_path.as_str(); - let mut file = File::create(session_path).await?; - file.write_all(session.session.as_bytes()).await?; - - let key_path = settings.key_path.as_str(); - let mut file = File::create(key_path).await?; - file.write_all(key.as_bytes()).await?; - - println!("Logged in!"); - - Ok(()) - } -} - -pub(super) fn or_user_input(value: &'_ Option, name: &'static str) -> String { - value.clone().unwrap_or_else(|| read_user_input(name)) -} - -fn read_user_input(name: &'static str) -> String { - eprint!("Please enter {}: ", name); - get_input().expect("Failed to read from input") -} diff --git a/src/command/logout.rs b/src/command/logout.rs deleted file mode 100644 index 26d689cf..00000000 --- a/src/command/logout.rs +++ /dev/null @@ -1,12 +0,0 @@ -use fs_err::remove_file; - -pub fn run() { - let session_path = atuin_common::utils::data_dir().join("session"); - - if session_path.exists() { - remove_file(session_path.as_path()).expect("Failed to remove session file"); - println!("You have logged out!"); - } else { - println!("You are not logged in"); - } -} diff --git a/src/command/mod.rs b/src/command/mod.rs index 84634211..3a3ed393 100644 --- a/src/command/mod.rs +++ b/src/command/mod.rs @@ -1,212 +1,25 @@ -use std::path::PathBuf; - -use clap::CommandFactory; use clap::Subcommand; -use clap_complete::Shell; -use clap_complete::{generate, generate_to}; -use eyre::{Result, WrapErr}; - -use atuin_client::database::Sqlite; -use atuin_client::settings::Settings as ClientSettings; -use atuin_common::utils::uuid_v4; -use atuin_server::settings::Settings as ServerSettings; +use eyre::Result; -mod event; -mod history; -mod import; -mod init; -mod login; -mod logout; -mod register; -mod search; +mod client; mod server; -mod stats; -mod sync; #[derive(Subcommand)] #[clap(infer_subcommands = true)] pub enum AtuinCmd { - /// Manipulate shell history - #[clap(subcommand)] - History(history::Cmd), - - /// Import shell history from file - #[clap(subcommand)] - Import(import::Cmd), + #[clap(flatten)] + Client(client::Cmd), /// Start an atuin server #[clap(subcommand)] Server(server::Cmd), - - /// Calculate statistics for your history - #[clap(subcommand)] - Stats(stats::Cmd), - - /// Output shell setup - #[clap(subcommand)] - Init(init::Cmd), - - /// Generate a UUID - Uuid, - - /// Interactive history search - Search { - /// Filter search result by directory - #[clap(long, short)] - cwd: Option, - - /// Exclude directory from results - #[clap(long = "exclude-cwd")] - exclude_cwd: Option, - - /// Filter search result by exit code - #[clap(long, short)] - exit: Option, - - /// Exclude results with this exit code - #[clap(long = "exclude-exit")] - exclude_exit: Option, - - /// Only include results added before this date - #[clap(long, short)] - before: Option, - - /// Only include results after this date - #[clap(long)] - after: Option, - - /// Open interactive search UI - #[clap(long, short)] - interactive: bool, - - /// Use human-readable formatting for time - #[clap(long)] - human: bool, - - query: Vec, - - /// Show only the text of the command - #[clap(long)] - cmd_only: bool, - }, - - /// Sync with the configured server - Sync { - /// Force re-download everything - #[clap(long, short)] - force: bool, - }, - - /// Login to the configured server - Login(login::Cmd), - - /// Log out - Logout, - - /// Register with the configured server - Register(register::Cmd), - - /// Print the encryption key for transfer to another machine - Key, - - /// Generate shell completions - GenCompletions { - /// Set the shell for generating completions - #[clap(long, short)] - shell: Shell, - - /// Set the output directory - #[clap(long, short)] - out_dir: Option, - }, } impl AtuinCmd { pub async fn run(self) -> Result<()> { - let client_settings = ClientSettings::new().wrap_err("could not load client settings")?; - let server_settings = ServerSettings::new().wrap_err("could not load server settings")?; - - let db_path = PathBuf::from(client_settings.db_path.as_str()); - - let mut db = Sqlite::new(db_path).await?; - match self { - Self::History(history) => history.run(&client_settings, &mut db).await, - Self::Import(import) => import.run(&mut db).await, - Self::Server(server) => server.run(server_settings).await, - Self::Stats(stats) => stats.run(&mut db, &client_settings).await, - Self::Init(init) => { - init.run(); - Ok(()) - } - Self::Search { - cwd, - exit, - interactive, - human, - exclude_exit, - exclude_cwd, - before, - after, - query, - cmd_only, - } => { - search::run( - &client_settings, - cwd, - exit, - interactive, - human, - exclude_exit, - exclude_cwd, - before, - after, - cmd_only, - &query, - &mut db, - ) - .await - } - - Self::Sync { force } => sync::run(&client_settings, force, &mut db).await, - Self::Login(l) => l.run(&client_settings).await, - Self::Logout => { - logout::run(); - Ok(()) - } - Self::Register(r) => { - register::run(&client_settings, &r.username, &r.email, &r.password).await - } - Self::Key => { - use atuin_client::encryption::{encode_key, load_key}; - let key = load_key(&client_settings).wrap_err("could not load encryption key")?; - let encode = encode_key(key).wrap_err("could not encode encryption key")?; - println!("{}", encode); - Ok(()) - } - Self::Uuid => { - println!("{}", uuid_v4()); - Ok(()) - } - Self::GenCompletions { shell, out_dir } => { - let mut cli = crate::Atuin::command(); - - match out_dir { - Some(out_dir) => { - generate_to(shell, &mut cli, env!("CARGO_PKG_NAME"), &out_dir)?; - } - None => { - generate( - shell, - &mut cli, - env!("CARGO_PKG_NAME"), - &mut std::io::stdout(), - ); - } - } - - Ok(()) - } + Self::Client(client) => client.run().await, + Self::Server(server) => server.run().await, } } } diff --git a/src/command/register.rs b/src/command/register.rs deleted file mode 100644 index 46f4a65d..00000000 --- a/src/command/register.rs +++ /dev/null @@ -1,44 +0,0 @@ -use clap::AppSettings; -use clap::Parser; -use eyre::Result; -use tokio::{fs::File, io::AsyncWriteExt}; - -use atuin_client::api_client; -use atuin_client::settings::Settings; - -#[derive(Parser)] -#[clap(setting(AppSettings::DeriveDisplayOrder))] -pub struct Cmd { - #[clap(long, short)] - pub username: Option, - - #[clap(long, short)] - pub email: Option, - - #[clap(long, short)] - pub password: Option, -} - -pub async fn run( - settings: &Settings, - username: &Option, - email: &Option, - password: &Option, -) -> Result<()> { - use super::login::or_user_input; - let username = or_user_input(username, "username"); - let email = or_user_input(email, "email"); - let password = or_user_input(password, "password"); - - let session = - api_client::register(settings.sync_address.as_str(), &username, &email, &password).await?; - - let path = settings.session_path.as_str(); - let mut file = File::create(path).await?; - file.write_all(session.session.as_bytes()).await?; - - // Create a new key, and save it to disk - let _key = atuin_client::encryption::new_key(settings)?; - - Ok(()) -} diff --git a/src/command/search.rs b/src/command/search.rs deleted file mode 100644 index a28b1542..00000000 --- a/src/command/search.rs +++ /dev/null @@ -1,558 +0,0 @@ -use chrono::Utc; -use eyre::Result; -use std::{io::stdout, ops::Sub, time::Duration}; - -use termion::{event::Key, input::MouseTerminal, raw::IntoRawMode, screen::AlternateScreen}; -use tui::{ - backend::{Backend, TermionBackend}, - layout::{Alignment, Constraint, Corner, Direction, Layout}, - style::{Color, Modifier, Style}, - text::{Span, Spans, Text}, - widgets::{Block, Borders, List, ListItem, ListState, Paragraph}, - Frame, Terminal, -}; -use unicode_width::UnicodeWidthStr; - -use atuin_client::{ - database::Database, - history::History, - settings::{SearchMode, Settings}, -}; - -use crate::command::event::{Event, Events}; - -const VERSION: &str = env!("CARGO_PKG_VERSION"); - -struct State { - input: String, - - results: Vec, - - results_state: ListState, -} - -impl State { - #[allow(clippy::cast_sign_loss)] - fn durations(&self) -> Vec<(String, String)> { - self.results - .iter() - .map(|h| { - let duration = - Duration::from_millis(std::cmp::max(h.duration, 0) as u64 / 1_000_000); - let duration = humantime::format_duration(duration).to_string(); - let duration: Vec<&str> = duration.split(' ').collect(); - - let ago = chrono::Utc::now().sub(h.timestamp); - - // Account for the chance that h.timestamp is "in the future" - // This would mean that "ago" is negative, and the unwrap here - // would fail. - // If the timestamp would otherwise be in the future, display - // the time ago as 0. - let ago = humantime::format_duration( - ago.to_std().unwrap_or_else(|_| Duration::new(0, 0)), - ) - .to_string(); - let ago: Vec<&str> = ago.split(' ').collect(); - - ( - duration[0] - .to_string() - .replace("days", "d") - .replace("day", "d") - .replace("weeks", "w") - .replace("week", "w") - .replace("months", "mo") - .replace("month", "mo") - .replace("years", "y") - .replace("year", "y"), - ago[0] - .to_string() - .replace("days", "d") - .replace("day", "d") - .replace("weeks", "w") - .replace("week", "w") - .replace("months", "mo") - .replace("month", "mo") - .replace("years", "y") - .replace("year", "y") - + " ago", - ) - }) - .collect() - } - - fn render_results( - &mut self, - f: &mut tui::Frame, - r: tui::layout::Rect, - b: tui::widgets::Block, - ) { - let durations = self.durations(); - let max_length = durations.iter().fold(0, |largest, i| { - std::cmp::max(largest, i.0.len() + i.1.len()) - }); - - let results: Vec = self - .results - .iter() - .enumerate() - .map(|(i, m)| { - let command = m.command.to_string().replace('\n', " ").replace('\t', " "); - - let mut command = Span::raw(command); - - let (duration, mut ago) = durations[i].clone(); - - while (duration.len() + ago.len()) < max_length { - ago = format!(" {}", ago); - } - - let selected_index = match self.results_state.selected() { - None => Span::raw(" "), - Some(selected) => match i.checked_sub(selected) { - None => Span::raw(" "), - Some(diff) => { - if 0 < diff && diff < 10 { - Span::raw(format!(" {} ", diff)) - } else { - Span::raw(" ") - } - } - }, - }; - - let duration = Span::styled( - duration, - Style::default().fg(if m.exit == 0 || m.duration == -1 { - Color::Green - } else { - Color::Red - }), - ); - - let ago = Span::styled(ago, Style::default().fg(Color::Blue)); - - if let Some(selected) = self.results_state.selected() { - if selected == i { - command.style = - Style::default().fg(Color::Red).add_modifier(Modifier::BOLD); - } - } - - let spans = Spans::from(vec![ - selected_index, - duration, - Span::raw(" "), - ago, - Span::raw(" "), - command, - ]); - - ListItem::new(spans) - }) - .collect(); - - let results = List::new(results) - .block(b) - .start_corner(Corner::BottomLeft) - .highlight_symbol(">> "); - - f.render_stateful_widget(results, r, &mut self.results_state); - } -} - -async fn query_results( - app: &mut State, - search_mode: SearchMode, - 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?, - }; - - app.results = results; - - if app.results.is_empty() { - app.results_state.select(None); - } else { - app.results_state.select(Some(0)); - } - - Ok(()) -} - -async fn key_handler( - input: Key, - search_mode: SearchMode, - db: &mut (impl Database + Send + Sync), - app: &mut State, -) -> Option { - match input { - Key::Esc | Key::Ctrl('c' | 'd' | 'g') => return Some(String::from("")), - Key::Char('\n') => { - let i = app.results_state.selected().unwrap_or(0); - - return Some( - app.results - .get(i) - .map_or(app.input.clone(), |h| h.command.clone()), - ); - } - Key::Alt(c) if ('1'..='9').contains(&c) => { - let c = c.to_digit(10)? as usize; - let i = app.results_state.selected()? + c; - - return Some( - app.results - .get(i) - .map_or(app.input.clone(), |h| h.command.clone()), - ); - } - Key::Char(c) => { - app.input.push(c); - query_results(app, search_mode, db).await.unwrap(); - } - Key::Backspace => { - app.input.pop(); - query_results(app, search_mode, db).await.unwrap(); - } - // \u{7f} is escape sequence for backspace - Key::Alt('\u{7f}') => { - let words: Vec<&str> = app.input.split(' ').collect(); - if words.is_empty() { - return None; - } - if words.len() == 1 { - app.input = String::from(""); - } else { - app.input = words[0..(words.len() - 1)].join(" "); - } - query_results(app, search_mode, db).await.unwrap(); - } - Key::Ctrl('u') => { - app.input = String::from(""); - query_results(app, search_mode, db).await.unwrap(); - } - Key::Down | Key::Ctrl('n') => { - let i = match app.results_state.selected() { - Some(i) => { - if i == 0 { - 0 - } else { - i - 1 - } - } - None => 0, - }; - app.results_state.select(Some(i)); - } - Key::Up | Key::Ctrl('p') => { - let i = match app.results_state.selected() { - Some(i) => { - if i >= app.results.len() - 1 { - app.results.len() - 1 - } else { - i + 1 - } - } - None => 0, - }; - app.results_state.select(Some(i)); - } - _ => {} - }; - - None -} - -#[allow(clippy::cast_possible_truncation)] -fn draw(f: &mut Frame<'_, T>, history_count: i64, app: &mut State) { - let chunks = Layout::default() - .direction(Direction::Vertical) - .margin(1) - .constraints( - [ - Constraint::Length(2), - Constraint::Min(1), - Constraint::Length(3), - ] - .as_ref(), - ) - .split(f.size()); - - let top_chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(50), Constraint::Percentage(50)].as_ref()) - .split(chunks[0]); - - let top_left_chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Length(1), Constraint::Length(1)].as_ref()) - .split(top_chunks[0]); - - let top_right_chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Length(1), Constraint::Length(1)].as_ref()) - .split(top_chunks[1]); - - let title = Paragraph::new(Text::from(Span::styled( - format!("Atuin v{}", VERSION), - Style::default().add_modifier(Modifier::BOLD), - ))); - - let help = vec![ - Span::raw("Press "), - Span::styled("Esc", Style::default().add_modifier(Modifier::BOLD)), - Span::raw(" to exit."), - ]; - - let help = Text::from(Spans::from(help)); - let help = Paragraph::new(help); - - let input = Paragraph::new(app.input.clone()) - .block(Block::default().borders(Borders::ALL).title("Query")); - - let stats = Paragraph::new(Text::from(Span::raw(format!( - "history count: {}", - history_count, - )))) - .alignment(Alignment::Right); - - f.render_widget(title, top_left_chunks[0]); - f.render_widget(help, top_left_chunks[1]); - f.render_widget(stats, top_right_chunks[0]); - - app.render_results( - f, - chunks[1], - Block::default().borders(Borders::ALL).title("History"), - ); - f.render_widget(input, chunks[2]); - - f.set_cursor( - // Put cursor past the end of the input text - chunks[2].x + app.input.width() as u16 + 1, - // Move one line down, from the border to the input line - chunks[2].y + 1, - ); -} - -#[allow(clippy::cast_possible_truncation)] -fn draw_compact(f: &mut Frame<'_, T>, history_count: i64, app: &mut State) { - let chunks = Layout::default() - .direction(Direction::Vertical) - .margin(0) - .horizontal_margin(1) - .constraints( - [ - Constraint::Length(1), - Constraint::Min(1), - Constraint::Length(1), - ] - .as_ref(), - ) - .split(f.size()); - - let header_chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints( - [ - Constraint::Ratio(1, 3), - Constraint::Ratio(1, 3), - Constraint::Ratio(1, 3), - ] - .as_ref(), - ) - .split(chunks[0]); - - let title = Paragraph::new(Text::from(Span::styled( - format!("Atuin v{}", VERSION), - Style::default().fg(Color::DarkGray), - ))); - - let help = Paragraph::new(Text::from(Spans::from(vec![ - Span::styled("Esc", Style::default().add_modifier(Modifier::BOLD)), - Span::raw(" to exit"), - ]))) - .style(Style::default().fg(Color::DarkGray)) - .alignment(Alignment::Center); - - let stats = Paragraph::new(Text::from(Span::raw(format!( - "history count: {}", - history_count, - )))) - .style(Style::default().fg(Color::DarkGray)) - .alignment(Alignment::Right); - - let input = Paragraph::new(format!("] {}", app.input.clone())).block(Block::default()); - - f.render_widget(title, header_chunks[0]); - f.render_widget(help, header_chunks[1]); - f.render_widget(stats, header_chunks[2]); - - app.render_results(f, chunks[1], Block::default()); - f.render_widget(input, chunks[2]); - - f.set_cursor( - // Put cursor past the end of the input text - chunks[2].x + app.input.width() as u16 + 2, - // Move one line down, from the border to the input line - chunks[2].y + 1, - ); -} - -// this is a big blob of horrible! clean it up! -// for now, it works. But it'd be great if it were more easily readable, and -// modular. I'd like to add some more stats and stuff at some point -#[allow(clippy::cast_possible_truncation)] -async fn select_history( - query: &[String], - search_mode: SearchMode, - style: atuin_client::settings::Style, - db: &mut (impl Database + Send + Sync), -) -> Result { - let stdout = stdout().into_raw_mode()?; - let stdout = MouseTerminal::from(stdout); - let stdout = AlternateScreen::from(stdout); - let backend = TermionBackend::new(stdout); - let mut terminal = Terminal::new(backend)?; - - // Setup event handlers - let events = Events::new(); - - let mut app = State { - input: query.join(" "), - results: Vec::new(), - results_state: ListState::default(), - }; - - query_results(&mut app, search_mode, db).await?; - - loop { - let history_count = db.history_count().await?; - // Handle input - if let Event::Input(input) = events.next()? { - if let Some(output) = key_handler(input, search_mode, db, &mut app).await { - return Ok(output); - } - } - - let compact = match style { - atuin_client::settings::Style::Auto => { - terminal.size().map(|size| size.height < 14).unwrap_or(true) - } - atuin_client::settings::Style::Compact => true, - atuin_client::settings::Style::Full => false, - }; - if compact { - terminal.draw(|f| draw_compact(f, history_count, &mut app))?; - } else { - terminal.draw(|f| draw(f, history_count, &mut app))?; - } - } -} - -// This is supposed to more-or-less mirror the command line version, so ofc -// it is going to have a lot of args -#[allow(clippy::too_many_arguments)] -pub async fn run( - settings: &Settings, - cwd: Option, - exit: Option, - interactive: bool, - human: bool, - exclude_exit: Option, - exclude_cwd: Option, - before: Option, - after: Option, - cmd_only: bool, - query: &[String], - db: &mut (impl Database + Send + Sync), -) -> Result<()> { - let dir = if let Some(cwd) = cwd { - if cwd == "." { - let current = std::env::current_dir()?; - let current = current.as_os_str(); - let current = current.to_str().unwrap(); - - Some(current.to_owned()) - } else { - Some(cwd) - } - } else { - None - }; - - if interactive { - let item = select_history(query, settings.search_mode, settings.style, db).await?; - eprintln!("{}", item); - } else { - let results = db - .search(None, settings.search_mode, query.join(" ").as_str()) - .await?; - - // TODO: This filtering would be better done in the SQL query, I just - // need a nice way of building queries. - let results: Vec = results - .iter() - .filter(|h| { - if let Some(exit) = exit { - if h.exit != exit { - return false; - } - } - - if let Some(exit) = exclude_exit { - if h.exit == exit { - return false; - } - } - - if let Some(cwd) = &exclude_cwd { - if h.cwd.as_str() == cwd.as_str() { - return false; - } - } - - if let Some(cwd) = &dir { - if h.cwd.as_str() != cwd.as_str() { - return false; - } - } - - if let Some(before) = &before { - let before = chrono_english::parse_date_string( - before.as_str(), - Utc::now(), - chrono_english::Dialect::Uk, - ); - - if before.is_err() || h.timestamp.gt(&before.unwrap()) { - return false; - } - } - - if let Some(after) = &after { - let after = chrono_english::parse_date_string( - after.as_str(), - Utc::now(), - chrono_english::Dialect::Uk, - ); - - if after.is_err() || h.timestamp.lt(&after.unwrap()) { - return false; - } - } - - true - }) - .map(std::borrow::ToOwned::to_owned) - .collect(); - - super::history::print_list(&results, human, cmd_only); - } - - Ok(()) -} diff --git a/src/command/server.rs b/src/command/server.rs index 9d97e928..cd05caca 100644 --- a/src/command/server.rs +++ b/src/command/server.rs @@ -1,5 +1,5 @@ use clap::Parser; -use eyre::Result; +use eyre::{Context, Result}; use atuin_server::launch; use atuin_server::settings::Settings; @@ -20,7 +20,11 @@ pub enum Cmd { } impl Cmd { - pub async fn run(&self, settings: Settings) -> Result<()> { + pub async fn run(self) -> Result<()> { + pretty_env_logger::init(); + + let settings = Settings::new().wrap_err("could not load server settings")?; + match self { Self::Start { host, port } => { let host = host diff --git a/src/command/stats.rs b/src/command/stats.rs deleted file mode 100644 index 6d342c19..00000000 --- a/src/command/stats.rs +++ /dev/null @@ -1,101 +0,0 @@ -use std::collections::HashMap; - -use chrono::prelude::*; -use chrono::Duration; -use chrono_english::parse_date_string; - -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::history::History; -use atuin_client::settings::Settings; - -#[derive(Parser)] -#[clap(infer_subcommands = true)] -pub enum Cmd { - /// Compute statistics for all of time - All, - - /// Compute statistics for a single day - Day { words: Vec }, -} - -fn compute_stats(history: &[History]) -> Result<()> { - let mut commands = HashMap::::new(); - - for i in history { - *commands.entry(i.command.clone()).or_default() += 1; - } - - let most_common_command = commands.iter().max_by(|a, b| a.1.cmp(b.1)); - - if most_common_command.is_none() { - bail!("No commands found"); - } - - let table = vec![ - vec![ - "Most used command".cell(), - most_common_command - .unwrap() - .0 - .cell() - .justify(Justify::Right), - ], - vec![ - "Commands ran".cell(), - history.len().to_string().cell().justify(Justify::Right), - ], - vec![ - "Unique commands ran".cell(), - commands.len().to_string().cell().justify(Justify::Right), - ], - ] - .table() - .title(vec![ - "Statistic".cell().bold(true), - "Value".cell().bold(true), - ]) - .bold(true); - - print_stdout(table)?; - - Ok(()) -} - -impl Cmd { - pub async fn run( - &self, - db: &mut (impl Database + Send + Sync), - settings: &Settings, - ) -> Result<()> { - match self { - Self::Day { words } => { - let words = if words.is_empty() { - String::from("yesterday") - } else { - words.join(" ") - }; - - let start = parse_date_string(&words, Local::now(), settings.dialect.into())?; - let end = start + Duration::days(1); - - let history = db.range(start.into(), end.into()).await?; - - compute_stats(&history)?; - - Ok(()) - } - - Self::All => { - let history = db.list(None, false).await?; - - compute_stats(&history)?; - - Ok(()) - } - } - } -} diff --git a/src/command/sync.rs b/src/command/sync.rs deleted file mode 100644 index f8bfd5e2..00000000 --- a/src/command/sync.rs +++ /dev/null @@ -1,19 +0,0 @@ -use eyre::Result; - -use atuin_client::database::Database; -use atuin_client::settings::Settings; -use atuin_client::sync; - -pub async fn run( - settings: &Settings, - force: bool, - db: &mut (impl Database + Send + Sync), -) -> Result<()> { - sync::sync(settings, force, db).await?; - println!( - "Sync complete! {} items in database, force: {}", - db.history_count().await?, - force - ); - Ok(()) -} diff --git a/src/main.rs b/src/main.rs index 2fee879b..e5c81bbb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -33,7 +33,5 @@ impl Atuin { #[tokio::main] async fn main() -> Result<()> { - pretty_env_logger::init(); - Atuin::parse().run().await } -- cgit v1.3.1