Skip to content

Commit

Permalink
Merge pull request #374 from TD-Sky/dump-history
Browse files Browse the repository at this point in the history
feat(dump history): You can specify the time range, regex and format to dump
  • Loading branch information
cantino authored Dec 3, 2023
2 parents 4fe49ce + aeae7a0 commit e7f241e
Show file tree
Hide file tree
Showing 12 changed files with 577 additions and 337 deletions.
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

0 comments on commit e7f241e

Please sign in to comment.