Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(dump history): You can specify the time range, regex and format to dump #374

Merged
merged 5 commits into from
Dec 3, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
533 changes: 237 additions & 296 deletions Cargo.lock

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@ debug = true

[dependencies]
chrono = "0.4"
chrono-systemd-time = "0.3"
csv = "1"
serde_json = "1"
serde = { version = "1", features = ["derive"] }
humantime = "2.1"
directories-next = "2.0"
itertools = "0.10"
Expand All @@ -36,7 +39,6 @@ features = ["functions", "unlock_notify"]
version = "0.26"
features = ["use-dev-tty"]


[dependencies.clap]
version = "4"
features = ["derive"]
Expand Down
53 changes: 53 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,59 @@ To avoid McFly's UI messing up your scrollback history in iTerm2, make sure this

<img src="/docs/iterm2.jpeg" alt="iterm2 UI instructions">

## Dump history

McFly can dump the command history into *stdout*.

For example:
```bash
mcfly dump --since '2023-01-01' --before '2023-09-12 09:15:30'
```
will dump the command run between *2023-01-01 00:00:00.0* to *2023-09-12 09:15:30*(**exclusive**) as **json**.
You can specify **csv** as dump format via `--format csv` as well.

Each item in dumped commands has the following fields:
* `cmd`: The run command.
* `when_run`: The time when the command ran in your local timezone.

You can dump all the commands history without any arguments:
```bash
mcfly dump
```

### Timestamp format
McFly use [chrono-systemd-time-ng] parsing timestamp.

**chrono-systemd-time-ng** is a non-strict implementation of [systemd.time](https://www.freedesktop.org/software/systemd/man/systemd.time.html), with the following exceptions:
* time units **must** accompany all time span values.
* time zone suffixes are **not** supported.
* weekday prefixes are **not** supported.

Users of McFly simply need to understand **specifying timezone in timestamp isn't allowed**.
McFly will always use your **local timezone**.

For more details, please refer to [the document of chrono-systemd-time-ng][chrono-systemd-time-ng].

[chrono-systemd-time-ng]: https://docs.rs/chrono-systemd-time-ng/latest/chrono_systemd_time/

### Regex
*Dump* supports filtering commands with regex.
The regex syntax follows [crate regex](https://docs.rs/regex/latest/regex/#syntax).

For example:
```bash
mcfly dump -r '^cargo run'
```
will dump all command prefixes with `cargo run`.

You can use `-r/--regex` and time options at the same time.

For example:
```bash
mcfly dump -r '^cargo run' --since '2023-09-12 09:15:30'
```
will dump all command prefixes with `cargo run` ran since *2023-09-12 09:15:30*.

## Settings
A number of settings can be set via environment variables. To set a setting you should add the following snippets to your `~/.bashrc` / `~/.zshrc` / `~/.config/fish/config.fish`.

Expand Down
58 changes: 58 additions & 0 deletions src/cli.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use clap::{Parser, Subcommand, ValueEnum};
use regex::Regex;
use std::path::PathBuf;

/// Fly through your shell history
Expand Down Expand Up @@ -108,6 +109,36 @@ pub enum SubCommand {
#[arg(value_enum)]
shell: InitMode,
},

/// Dump history into stdout; the results are sorted by timestamp
Dump {
/// Select all commands ran since the point
#[arg(long)]
since: Option<String>,

/// Select all commands ran before the point
#[arg(long)]
before: Option<String>,

/// Sort order [case ignored]
#[arg(
long,
short,
value_name = "ORDER",
value_enum,
default_value_t,
ignore_case = true
)]
sort: SortOrder,

/// Require commands to match the pattern
#[arg(long, short)]
regex: Option<Regex>,

/// The format to dump in
#[arg(long, short, value_enum, default_value_t)]
format: DumpFormat,
},
}

#[derive(Clone, Copy, ValueEnum, Default)]
Expand All @@ -126,8 +157,35 @@ pub enum InitMode {
Fish,
}

#[derive(Debug, Clone, Copy, ValueEnum, Default)]
#[value(rename_all = "UPPER")]
pub enum SortOrder {
#[default]
#[value(alias = "asc")]
Asc,
#[value(alias = "desc")]
Desc,
}

#[derive(Debug, Clone, Copy, ValueEnum, Default)]
pub enum DumpFormat {
#[default]
Json,
Csv,
}

impl Cli {
pub fn is_init(&self) -> bool {
matches!(self.command, SubCommand::Init { .. })
}
}

impl SortOrder {
#[inline]
pub fn to_str(&self) -> &'static str {
match self {
Self::Asc => "ASC",
Self::Desc => "DESC",
}
}
}
56 changes: 56 additions & 0 deletions src/dumper.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
use std::io::{self, BufWriter, Write};

use crate::cli::DumpFormat;
use crate::history::{DumpCommand, History};
use crate::settings::Settings;
use crate::time::to_datetime;

#[derive(Debug)]
pub struct Dumper<'a> {
settings: &'a Settings,
history: &'a History,
}

impl<'a> Dumper<'a> {
#[inline]
pub fn new(settings: &'a Settings, history: &'a History) -> Self {
Self { settings, history }
}

pub fn dump(&self) {
let mut commands = self
.history
.dump(&self.settings.time_range, &self.settings.sort_order);
if commands.is_empty() {
println!("McFly: No history");
return;
}

if let Some(pat) = &self.settings.pattern {
commands.retain(|dc| pat.is_match(&dc.cmd));
}

match self.settings.dump_format {
DumpFormat::Json => Self::dump2json(&commands),
DumpFormat::Csv => Self::dump2csv(&commands),
}
.unwrap_or_else(|err| panic!("McFly error: Failed while output history ({err})"));
}
}

impl<'a> Dumper<'a> {
fn dump2json(commands: &[DumpCommand]) -> io::Result<()> {
let mut stdout = BufWriter::new(io::stdout().lock());
serde_json::to_writer_pretty(&mut stdout, commands).map_err(io::Error::from)?;
stdout.flush()
}

fn dump2csv(commands: &[DumpCommand]) -> io::Result<()> {
let mut wtr = csv::Writer::from_writer(io::stdout().lock());
wtr.write_record(["cmd", "when_run"])?;
for dc in commands {
wtr.write_record([dc.cmd.as_str(), to_datetime(dc.when_run).as_str()])?;
}
wtr.flush()
}
}
119 changes: 89 additions & 30 deletions src/history/history.rs
Original file line number Diff line number Diff line change
@@ -1,20 +1,22 @@
#![allow(clippy::module_inception)]
use crate::shell_history;
use rusqlite::named_params;
use rusqlite::{Connection, MappedRows, Row};
use std::cmp::Ordering;
use std::io::Write;
use std::path::PathBuf;
use std::{fmt, fs, io};
//use std::time::Instant;
use crate::cli::SortOrder;
use crate::history::{db_extensions, schema};
use crate::network::Network;
use crate::path_update_helpers;
use crate::settings::{HistoryFormat, ResultFilter, ResultSort, Settings};
use crate::settings::{HistoryFormat, ResultFilter, ResultSort, Settings, TimeRange};
use crate::shell_history;
use crate::simplified_command::SimplifiedCommand;
use crate::time::to_datetime;
use itertools::Itertools;
use rusqlite::named_params;
use rusqlite::types::ToSql;
use rusqlite::{Connection, MappedRows, Row};
use serde::{Serialize, Serializer};
use std::cmp::Ordering;
use std::io::Write;
use std::path::PathBuf;
use std::time::{Instant, SystemTime, UNIX_EPOCH};
use std::{fmt, fs, io};

#[derive(Debug, Clone, Default)]
pub struct Features {
Expand Down Expand Up @@ -46,6 +48,13 @@ pub struct Command {
pub match_bounds: Vec<(usize, usize)>,
}

#[derive(Debug, Clone, Serialize)]
pub struct DumpCommand {
pub cmd: String,
#[serde(serialize_with = "ser_to_datetime")]
pub when_run: i64,
}

impl fmt::Display for Command {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
self.cmd.fmt(f)
Expand All @@ -58,6 +67,14 @@ impl From<Command> for String {
}
}

#[inline]
fn ser_to_datetime<S>(when_run: &i64, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&to_datetime(*when_run))
}

#[derive(Debug)]
pub struct History {
pub connection: Connection,
Expand Down Expand Up @@ -631,8 +648,22 @@ impl History {
format!("SELECT id, cmd, cmd_tpl, session_id, when_run, exit_code, selected, dir FROM commands WHERE session_id = :session_id ORDER BY {} DESC LIMIT :limit OFFSET :offset", order)
};

let closure: fn(&Row) -> rusqlite::Result<Command> = |row| {
Ok(Command {
id: row.get(0)?,
cmd: row.get(1)?,
cmd_tpl: row.get(2)?,
session_id: row.get(3)?,
when_run: row.get(4)?,
exit_code: row.get(5)?,
selected: row.get(6)?,
dir: row.get(7)?,
..Command::default()
})
};

if session_id.is_none() {
self.run_query(&query, &[(":limit", &num), (":offset", &offset)])
self.run_query(&query, &[(":limit", &num), (":offset", &offset)], closure)
} else {
self.run_query(
&query,
Expand All @@ -641,34 +672,24 @@ impl History {
(":limit", &num),
(":offset", &offset),
],
closure,
)
}
}

fn run_query(&self, query: &str, params: &[(&str, &dyn ToSql)]) -> Vec<Command> {
fn run_query<T, F>(&self, query: &str, params: &[(&str, &dyn ToSql)], f: F) -> Vec<T>
where
F: FnMut(&Row<'_>) -> rusqlite::Result<T>,
{
let mut statement = self.connection.prepare(query).unwrap();

let closure: fn(&Row) -> Result<Command, _> = |row| {
Ok(Command {
id: row.get(0)?,
cmd: row.get(1)?,
cmd_tpl: row.get(2)?,
session_id: row.get(3)?,
when_run: row.get(4)?,
exit_code: row.get(5)?,
selected: row.get(6)?,
dir: row.get(7)?,
..Command::default()
})
};

let command_iter: MappedRows<_> = statement
.query_map(params, closure)
let rows: MappedRows<_> = statement
.query_map(params, f)
.unwrap_or_else(|err| panic!("McFly error: Query Map to work ({})", err));

let mut vec = Vec::new();
for command in command_iter.flatten() {
vec.push(command);
let mut vec: Vec<T> = Vec::new();
for row in rows.flatten() {
vec.push(row);
}

vec
Expand Down Expand Up @@ -755,6 +776,44 @@ impl History {
}
}

pub fn dump(&self, time_range: &TimeRange, order: &SortOrder) -> Vec<DumpCommand> {
let mut where_clause = String::new();
// Were there condtions in where clause?
let mut has_conds = false;
let mut params: Vec<(&str, &dyn ToSql)> = Vec::with_capacity(2);

if !time_range.is_full() {
where_clause.push_str("WHERE");

if let Some(since) = &time_range.since {
where_clause.push_str(" :since <= when_run");
has_conds = true;
params.push((":since", since));
}

if let Some(before) = &time_range.before {
if has_conds {
where_clause.push_str(" AND");
}

where_clause.push_str(" when_run < :before");
params.push((":before", before));
}
}

let query = format!(
"SELECT cmd, when_run FROM commands {} ORDER BY when_run {}",
where_clause,
order.to_str()
);
self.run_query(&query, params.as_slice(), |row| {
Ok(DumpCommand {
cmd: row.get(0)?,
when_run: row.get(1)?,
})
})
}

fn from_shell_history(history_format: HistoryFormat) -> History {
print!(
"McFly: Importing shell history for the first time. This may take a minute or two..."
Expand Down
2 changes: 1 addition & 1 deletion src/history/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
pub use self::history::{Command, Features, History};
pub use self::history::{Command, DumpCommand, Features, History};

mod db_extensions;
mod history;
Expand Down
Loading
Loading