summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorVlad Stepanov <8uk.8ak@gmail.com>2023-06-15 14:29:40 +0400
committerGitHub <noreply@github.com>2023-06-15 10:29:40 +0000
commit4077c33adfdacaf0ed68657a1955a7b69a78d373 (patch)
tree432d5c23992388a6c1bd4b11d41785ea00d56905
parent0c75cfbfda1f991c618fcd00921ac6be99fd03f7 (diff)
Builder interface for History objects (#933)
* [feature] store env variables in History records WIP: remove `HistoryWithoutDelete`, add some docstrings, tests * Create History objects through builders. Assure in compile-time that all required fields are set for the given construction scenario * (from #882) split Cmd::run into subfns * Update `History` doc * remove rmp-serde from history * update warning --------- Co-authored-by: Conrad Ludgate <conrad.ludgate@truelayer.com>
-rw-r--r--Cargo.lock2
-rw-r--r--atuin-client/Cargo.toml2
-rw-r--r--atuin-client/src/database.rs47
-rw-r--r--atuin-client/src/encryption.rs259
-rw-r--r--atuin-client/src/history.rs150
-rw-r--r--atuin-client/src/history/builder.rs100
-rw-r--r--atuin-client/src/import/bash.rs14
-rw-r--r--atuin-client/src/import/fish.rs28
-rw-r--r--atuin-client/src/import/nu.rs14
-rw-r--r--atuin-client/src/import/nu_histdb.rs23
-rw-r--r--atuin-client/src/import/resh.rs23
-rw-r--r--atuin-client/src/import/zsh.rs35
-rw-r--r--atuin-client/src/import/zsh_histdb.rs36
-rw-r--r--atuin-client/src/message.rs5
-rw-r--r--atuin-server-database/src/models.rs6
-rw-r--r--atuin/src/command/client/history.rs199
16 files changed, 659 insertions, 284 deletions
diff --git a/Cargo.lock b/Cargo.lock
index a8ee3179..cd2556e7 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -154,6 +154,7 @@ dependencies = [
"rand",
"regex",
"reqwest",
+ "rmp",
"rmp-serde",
"semver",
"serde",
@@ -164,6 +165,7 @@ dependencies = [
"sql-builder",
"sqlx",
"tokio",
+ "typed-builder",
"urlencoding",
"uuid",
"whoami",
diff --git a/atuin-client/Cargo.toml b/atuin-client/Cargo.toml
index 7b85bf76..e00dc910 100644
--- a/atuin-client/Cargo.toml
+++ b/atuin-client/Cargo.toml
@@ -50,7 +50,9 @@ fs-err = { workspace = true }
sql-builder = "3"
lazy_static = "1"
memchr = "2.5"
+rmp = { version = "0.8.11" }
rmp-serde = { version = "1.1.1" }
+typed-builder = "0.14.0"
# sync
urlencoding = { version = "2.1.0", optional = true }
diff --git a/atuin-client/src/database.rs b/atuin-client/src/database.rs
index a2d8c533..b7b44409 100644
--- a/atuin-client/src/database.rs
+++ b/atuin-client/src/database.rs
@@ -168,17 +168,18 @@ impl Sqlite {
fn query_history(row: SqliteRow) -> History {
let deleted_at: Option<i64> = row.get("deleted_at");
- History {
- id: row.get("id"),
- timestamp: Utc.timestamp_nanos(row.get("timestamp")),
- duration: row.get("duration"),
- exit: row.get("exit"),
- command: row.get("command"),
- cwd: row.get("cwd"),
- session: row.get("session"),
- hostname: row.get("hostname"),
- deleted_at: deleted_at.map(|t| Utc.timestamp_nanos(t)),
- }
+ History::from_db()
+ .id(row.get("id"))
+ .timestamp(Utc.timestamp_nanos(row.get("timestamp")))
+ .duration(row.get("duration"))
+ .exit(row.get("exit"))
+ .command(row.get("command"))
+ .cwd(row.get("cwd"))
+ .session(row.get("session"))
+ .hostname(row.get("hostname"))
+ .deleted_at(deleted_at.map(|t| Utc.timestamp_nanos(t)))
+ .build()
+ .into()
}
}
@@ -594,17 +595,19 @@ mod test {
}
async fn new_history_item(db: &mut impl Database, cmd: &str) -> Result<()> {
- let history = History::new(
- chrono::Utc::now(),
- cmd.to_string(),
- "/home/ellie".to_string(),
- 0,
- 1,
- Some("beep boop".to_string()),
- Some("booop".to_string()),
- None,
- );
- db.save(&history).await
+ let mut captured: History = History::capture()
+ .timestamp(chrono::Utc::now())
+ .command(cmd)
+ .cwd("/home/ellie")
+ .build()
+ .into();
+
+ captured.exit = 0;
+ captured.duration = 1;
+ captured.session = "beep boop".to_string();
+ captured.hostname = "booop".to_string();
+
+ db.save(&captured).await
}
#[tokio::test(flavor = "multi_thread")]
diff --git a/atuin-client/src/encryption.rs b/atuin-client/src/encryption.rs
index 7ed640a5..a7aec0e7 100644
--- a/atuin-client/src/encryption.rs
+++ b/atuin-client/src/encryption.rs
@@ -11,8 +11,10 @@
use std::{io::prelude::*, path::PathBuf};
use base64::prelude::{Engine, BASE64_STANDARD};
-use eyre::{eyre, Context, Result};
+use chrono::{DateTime, Utc};
+use eyre::{bail, eyre, Context, Result};
use fs_err as fs;
+use rmp::{decode::Bytes, Marker};
use serde::{Deserialize, Serialize};
pub use xsalsa20poly1305::Key;
use xsalsa20poly1305::{
@@ -20,10 +22,7 @@ use xsalsa20poly1305::{
AeadInPlace, KeyInit, XSalsa20Poly1305,
};
-use crate::{
- history::{History, HistoryWithoutDelete},
- settings::Settings,
-};
+use crate::{history::History, settings::Settings};
#[derive(Debug, Serialize, Deserialize)]
pub struct EncryptedHistory {
@@ -75,7 +74,9 @@ pub fn load_encoded_key(settings: &Settings) -> Result<String> {
}
pub fn encode_key(key: &Key) -> Result<String> {
- let buf = rmp_serde::to_vec(key.as_slice()).wrap_err("could not encode key to message pack")?;
+ let mut buf = vec![];
+ rmp::encode::write_bin(&mut buf, key.as_slice())
+ .wrap_err("could not encode key to message pack")?;
let buf = BASE64_STANDARD.encode(buf);
Ok(buf)
@@ -86,23 +87,23 @@ pub fn decode_key(key: String) -> Result<Key> {
.decode(key.trim_end())
.wrap_err("encryption key is not a valid base64 encoding")?;
- let mbuf: Result<[u8; 32]> =
- rmp_serde::from_slice(&buf).wrap_err("encryption key is not a valid message pack encoding");
-
- match mbuf {
- Ok(b) => Ok(*Key::from_slice(&b)),
- Err(_) => {
- let buf: &[u8] = rmp_serde::from_slice(&buf)
- .wrap_err("encryption key is not a valid message pack encoding")?;
-
- Ok(*Key::from_slice(buf))
+ // old code wrote the key as a fixed length array of 32 bytes
+ // new code writes the key with a length prefix
+ if buf.len() == 32 {
+ Ok(*Key::from_slice(&buf))
+ } else {
+ let mut bytes = Bytes::new(&buf);
+ let key_len = rmp::decode::read_bin_len(&mut bytes).map_err(error_report)?;
+ if key_len != 32 || bytes.remaining_slice().len() != key_len as usize {
+ bail!("encryption key is not the correct size")
}
+ Ok(*Key::from_slice(bytes.remaining_slice()))
}
}
pub fn encrypt(history: &History, key: &Key) -> Result<EncryptedHistory> {
// serialize with msgpack
- let mut buf = rmp_serde::to_vec(history)?;
+ let mut buf = encode(history)?;
let nonce = XSalsa20Poly1305::generate_nonce(&mut OsRng);
XSalsa20Poly1305::new(key)
@@ -125,25 +126,111 @@ pub fn decrypt(mut encrypted_history: EncryptedHistory, key: &Key) -> Result<His
.map_err(|_| eyre!("could not encrypt"))?;
let plaintext = encrypted_history.ciphertext;
- let history = rmp_serde::from_slice(&plaintext);
+ let history = decode(&plaintext)?;
- let Ok(history) = history else {
- let history: HistoryWithoutDelete = rmp_serde::from_slice(&plaintext)?;
+ Ok(history)
+}
- return Ok(History {
- id: history.id,
- cwd: history.cwd,
- exit: history.exit,
- command: history.command,
- session: history.session,
- duration: history.duration,
- hostname: history.hostname,
- timestamp: history.timestamp,
- deleted_at: None,
- });
- };
+fn encode(h: &History) -> Result<Vec<u8>> {
+ use rmp::encode;
+
+ let mut output = vec![];
+ // INFO: ensure this is updated when adding new fields
+ encode::write_array_len(&mut output, 9)?;
+
+ encode::write_str(&mut output, &h.id)?;
+ encode::write_str(
+ &mut output,
+ &dbg!(h
+ .timestamp
+ .to_rfc3339_opts(chrono::SecondsFormat::AutoSi, true)),
+ )?;
+ encode::write_sint(&mut output, h.duration)?;
+ encode::write_sint(&mut output, h.exit)?;
+ encode::write_str(&mut output, &h.command)?;
+ encode::write_str(&mut output, &h.cwd)?;
+ encode::write_str(&mut output, &h.session)?;
+ encode::write_str(&mut output, &h.hostname)?;
+ match h.deleted_at {
+ Some(d) => encode::write_str(
+ &mut output,
+ &d.to_rfc3339_opts(chrono::SecondsFormat::AutoSi, true),
+ )?,
+ None => encode::write_nil(&mut output)?,
+ }
- Ok(history)
+ Ok(output)
+}
+
+fn decode(bytes: &[u8]) -> Result<History> {
+ use rmp::decode::{self, DecodeStringError};
+
+ let mut bytes = Bytes::new(bytes);
+
+ let nfields = decode::read_array_len(&mut bytes).map_err(error_report)?;
+ if nfields < 8 {
+ bail!("malformed decrypted history")
+ }
+ if nfields > 9 {
+ bail!("cannot decrypt history from a newer version of atuin");
+ }
+
+ let bytes = bytes.remaining_slice();
+ let (id, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?;
+ let (timestamp, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?;
+
+ let mut bytes = Bytes::new(bytes);
+ let duration = decode::read_int(&mut bytes).map_err(error_report)?;
+ let exit = decode::read_int(&mut bytes).map_err(error_report)?;
+
+ let bytes = bytes.remaining_slice();
+ let (command, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?;
+ let (cwd, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?;
+ let (session, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?;
+ let (hostname, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?;
+
+ // if we have more fields, try and get the deleted_at
+ let mut deleted_at = None;
+ let mut bytes = bytes;
+ if nfields > 8 {
+ bytes = match decode::read_str_from_slice(bytes) {
+ Ok((d, b)) => {
+ deleted_at = Some(d);
+ b
+ }
+ // we accept null here
+ Err(DecodeStringError::TypeMismatch(Marker::Null)) => {
+ // consume the null marker
+ let mut c = Bytes::new(bytes);
+ decode::read_nil(&mut c).map_err(error_report)?;
+ c.remaining_slice()
+ }
+ Err(err) => return Err(error_report(err)),
+ };
+ }
+
+ if !bytes.is_empty() {
+ bail!("trailing bytes in encoded history. malformed")
+ }
+
+ Ok(History {
+ id: id.to_owned(),
+ timestamp: DateTime::parse_from_rfc3339(timestamp)?.with_timezone(&Utc),
+ duration,
+ exit,
+ command: command.to_owned(),
+ cwd: cwd.to_owned(),
+ session: session.to_owned(),
+ hostname: hostname.to_owned(),
+ deleted_at: deleted_at
+ .map(DateTime::parse_from_rfc3339)
+ .transpose()?
+ .map(|dt| dt.with_timezone(&Utc)),
+ })
+}
+
+fn error_report<E: std::fmt::Debug>(err: E) -> eyre::Report {
+ eyre!("{err:?}")
}
#[cfg(test)]
@@ -152,23 +239,25 @@ mod test {
use crate::history::History;
- use super::{decrypt, encrypt};
+ use super::{decode, decrypt, encode, encrypt};
#[test]
fn test_encrypt_decrypt() {
let key1 = XSalsa20Poly1305::generate_key(&mut OsRng);
let key2 = XSalsa20Poly1305::generate_key(&mut OsRng);
- let history = History::new(
- chrono::Utc::now(),
- "ls".to_string(),
- "/home/ellie".to_string(),
- 0,
- 1,
- Some("beep boop".to_string()),
- Some("booop".to_string()),
- None,
- );
+ let history = History::from_db()
+ .id("1".into())
+ .timestamp(chrono::Utc::now())
+ .command("ls".into())
+ .cwd("/home/ellie".into())
+ .exit(0)
+ .duration(1)
+ .session("beep boop".into())
+ .hostname("booop".into())
+ .deleted_at(None)
+ .build()
+ .into();
let e1 = encrypt(&history, &key1).unwrap();
let e2 = encrypt(&history, &key2).unwrap();
@@ -186,4 +275,86 @@ mod test {
// this should err
let _ = decrypt(e2, &key1).expect_err("expected an error decrypting with invalid key");
}
+
+ #[test]
+ fn test_decode() {
+ let bytes = [
+ 0x99, 0xD9, 32, 54, 54, 100, 49, 54, 99, 98, 101, 101, 55, 99, 100, 52, 55, 53, 51, 56,
+ 101, 53, 99, 53, 98, 56, 98, 52, 52, 101, 57, 48, 48, 54, 101, 187, 50, 48, 50, 51, 45,
+ 48, 53, 45, 50, 56, 84, 49, 56, 58, 51, 53, 58, 52, 48, 46, 54, 51, 51, 56, 55, 50, 90,
+ 206, 2, 238, 210, 240, 0, 170, 103, 105, 116, 32, 115, 116, 97, 116, 117, 115, 217, 42,
+ 47, 85, 115, 101, 114, 115, 47, 99, 111, 110, 114, 97, 100, 46, 108, 117, 100, 103, 97,
+ 116, 101, 47, 68, 111, 99, 117, 109, 101, 110, 116, 115, 47, 99, 111, 100, 101, 47, 97,
+ 116, 117, 105, 110, 217, 32, 98, 57, 55, 100, 57, 97, 51, 48, 54, 102, 50, 55, 52, 52,
+ 55, 51, 97, 50, 48, 51, 100, 50, 101, 98, 97, 52, 49, 102, 57, 52, 53, 55, 187, 102,
+ 118, 102, 103, 57, 51, 54, 99, 48, 107, 112, 102, 58, 99, 111, 110, 114, 97, 100, 46,
+ 108, 117, 100, 103, 97, 116, 101, 192,
+ ];
+ let history = History {
+ id: "66d16cbee7cd47538e5c5b8b44e9006e".to_owned(),
+ timestamp: "2023-05-28T18:35:40.633872Z".parse().unwrap(),
+ duration: 49206000,
+ exit: 0,
+ command: "git status".to_owned(),
+ cwd: "/Users/conrad.ludgate/Documents/code/atuin".to_owned(),
+ session: "b97d9a306f274473a203d2eba41f9457".to_owned(),
+ hostname: "fvfg936c0kpf:conrad.ludgate".to_owned(),
+ deleted_at: None,
+ };
+
+ let h = decode(&bytes).unwrap();
+ assert_eq!(history, h);
+
+ let b = encode(&h).unwrap();
+ assert_eq!(&bytes, &*b);
+ }
+
+ #[test]
+ fn test_decode_deleted() {
+ let history = History {
+ id: "66d16cbee7cd47538e5c5b8b44e9006e".to_owned(),
+ timestamp: "2023-05-28T18:35:40.633872Z".parse().unwrap(),
+ duration: 49206000,
+ exit: 0,
+ command: "git status".to_owned(),
+ cwd: "/Users/conrad.ludgate/Documents/code/atuin".to_owned(),
+ session: "b97d9a306f274473a203d2eba41f9457".to_owned(),
+ hostname: "fvfg936c0kpf:conrad.ludgate".to_owned(),
+ deleted_at: Some("2023-05-28T18:35:40.633872Z".parse().unwrap()),
+ };
+
+ let b = encode(&history).unwrap();
+ let h = decode(&b).unwrap();
+ assert_eq!(history, h);
+ }
+
+ #[test]
+ fn test_decode_old() {
+ let bytes = [
+ 0x98, 0xD9, 32, 54, 54, 100, 49, 54, 99, 98, 101, 101, 55, 99, 100, 52, 55, 53, 51, 56,
+ 101, 53, 99, 53, 98, 56, 98, 52, 52, 101, 57, 48, 48, 54, 101, 187, 50, 48, 50, 51, 45,
+ 48, 53, 45, 50, 56, 84, 49, 56, 58, 51, 53, 58, 52, 48, 46, 54, 51, 51, 56, 55, 50, 90,
+ 206, 2, 238, 210, 240, 0, 170, 103, 105, 116, 32, 115, 116, 97, 116, 117, 115, 217, 42,
+ 47, 85, 115, 101, 114, 115, 47, 99, 111, 110, 114, 97, 100, 46, 108, 117, 100, 103, 97,
+ 116, 101, 47, 68, 111, 99, 117, 109, 101, 110, 116, 115, 47, 99, 111, 100, 101, 47, 97,
+ 116, 117, 105, 110, 217, 32, 98, 57, 55, 100, 57, 97, 51, 48, 54, 102, 50, 55, 52, 52,
+ 55, 51, 97, 50, 48, 51, 100, 50, 101, 98, 97, 52, 49, 102, 57, 52, 53, 55, 187, 102,
+ 118, 102, 103, 57, 51, 54, 99, 48, 107, 112, 102, 58, 99, 111, 110, 114, 97, 100, 46,
+ 108, 117, 100, 103, 97, 116, 101,
+ ];
+ let history = History {
+ id: "66d16cbee7cd47538e5c5b8b44e9006e".to_owned(),
+ timestamp: "2023-05-28T18:35:40.633872Z".parse().unwrap(),
+ duration: 49206000,
+ exit: 0,
+ command: "git status".to_owned(),
+ cwd: "/Users/conrad.ludgate/Documents/code/atuin".to_owned(),
+ session: "b97d9a306f274473a203d2eba41f9457".to_owned(),
+ hostname: "fvfg936c0kpf:conrad.ludgate".to_owned(),
+ deleted_at: None,
+ };
+
+ let h = decode(&bytes).unwrap();
+ assert_eq!(history, h);
+ }
}
diff --git a/atuin-client/src/history.rs b/atuin-client/src/history.rs
index 05bfbf7f..441960c8 100644
--- a/atuin-client/src/history.rs
+++ b/atuin-client/src/history.rs
@@ -1,42 +1,51 @@
use std::env;
use chrono::Utc;
-use serde::{Deserialize, Serialize};
use atuin_common::utils::uuid_v7;
-// Any new fields MUST be Optional<>!
-#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, sqlx::FromRow)]
+mod builder;
+
+/// Client-side history entry.
+///
+/// Client stores data unencrypted, and only encrypts it before sending to the server.
+///
+/// To create a new history entry, use one of the builders:
+/// - [`History::import()`] to import an entry from the shell history file
+/// - [`History::capture()`] to capture an entry via hook
+/// - [`History::from_db()`] to create an instance from the database entry
+//
+// ## Implementation Notes
+//
+// New fields must should be added to `encryption::{encode, decode}` in a backwards
+// compatible way. (eg sensible defaults and updating the nfields parameter)
+#[derive(Debug, Clone, PartialEq, Eq, sqlx::FromRow)]
pub struct History {
+ /// A client-generated ID, used to identify the entry when syncing.
+ ///
+ /// Stored as `client_id` in the database.
pub id: String,
+ /// When the command was run.
pub timestamp: chrono::DateTime<Utc>,
+ /// How long the command took to run.
pub duration: i64,
+ /// The exit code of the command.
pub exit: i64,
+ /// The command that was run.
pub command: String,
+ /// The current working directory when the command was run.
pub cwd: String,
+ /// The session ID, associated with a terminal session.
pub session: String,
+ /// The hostname of the machine the command was run on.
pub hostname: String,
+ /// Timestamp, which is set when the entry is deleted, allowing a soft delete.
pub deleted_at: Option<chrono::DateTime<Utc>>,
}
-// Forgive me, for I have sinned
-// I need to replace rmp with something that is more backwards-compatible.
-// Protobuf, or maybe just json
-#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, sqlx::FromRow)]
-pub struct HistoryWithoutDelete {
- pub id: String,
- pub timestamp: chrono::DateTime<Utc>,
- pub duration: i64,
- pub exit: i64,
- pub command: String,
- pub cwd: String,
- pub session: String,
- pub hostname: String,
-}
-
impl History {
#[allow(clippy::too_many_arguments)]
- pub fn new(
+ fn new(
timestamp: chrono::DateTime<Utc>,
command: String,
cwd: String,
@@ -70,6 +79,109 @@ impl History {
}
}
+ /// Builder for a history entry that is imported from shell history.
+ ///
+ /// The only two required fields are `timestamp` and `command`.
+ ///
+ /// ## Examples
+ /// ```
+ /// use atuin_client::history::History;
+ ///
+ /// let history: History = History::import()
+ /// .timestamp(chrono::Utc::now())
+ /// .command("ls -la")
+ /// .build()
+ /// .into();
+ /// ```
+ ///
+ /// If shell history contains more information, it can be added to the builder:
+ /// ```
+ /// use atuin_client::history::History;
+ ///
+ /// let history: History = History::import()
+ /// .timestamp(chrono::Utc::now())
+ /// .command("ls -la")
+ /// .cwd("/home/user")
+ /// .exit(0)
+ /// .duration(100)
+ /// .build()
+ /// .into();
+ /// ```
+ ///
+ /// Unknown command or command without timestamp cannot be imported, which
+ /// is forced at compile time:
+ ///
+ /// ```compile_fail
+ /// use atuin_client::history::History;
+ ///
+ /// // this will not compile because timestamp is missing
+ /// let history: History = History::import()
+ /// .command("ls -la")
+ /// .build()
+ /// .into();
+ /// ```
+ pub fn import() -> builder::HistoryImportedBuilder {
+ builder::HistoryImported::builder()
+ }
+
+ /// Builder for a history entry that is captured via hook.
+ ///
+ /// This builder is used only at the `start` step of the hook,
+ /// so it doesn't have any fields which are known only after
+ /// the command is finished, such as `exit` or `duration`.
+ ///
+ /// ## Examples
+ /// ```rust
+ /// use atuin_client::history::History;
+ ///
+ /// let history: History = History::capture()
+ /// .timestamp(chrono::Utc::now())
+ /// .command("ls -la")
+ /// .cwd("/home/user")
+ /// .build()
+ /// .into();
+ /// ```
+ ///
+ /// Command without any required info cannot be captured, which is forced at compile time:
+ ///
+ /// ```compile_fail
+ /// use atuin_client::history::History;
+ ///
+ /// // this will not compile because `cwd` is missing
+ /// let history: History = History::capture()
+ /// .timestamp(chrono::Utc::now())
+ /// .command("ls -la")
+ /// .build()
+ /// .into();
+ /// ```
+ pub fn capture() -> builder::HistoryCapturedBuilder {
+ builder::HistoryCaptured::builder()
+ }
+
+ /// Builder for a history entry that is imported from the database.
+ ///
+ /// All fields are required, as they are all present in the database.
+ ///
+ /// ```compile_fail
+ /// use atuin_client::history::History;
+ ///
+ /// // this will not compile because `id` field is missing
+ /// let history: History = History::from_db()
+ /// .timestamp(chrono::Utc::now())
+ /// .command("ls -la".to_string())
+ /// .cwd("/home/user".to_string())
+ /// .exit(0)
+ /// .duration(100)
+ /// .session("somesession".to_string())
+ /// .hostname("localhost".to_string())
+ /// .deleted_at(None)
+ /// .build()
+ /// .into();
+ /// ```
+ pub fn from_db() -> builder::HistoryFromDbBuilder {
+ builder::HistoryFromDb::builder()
+ }
+
pub fn success(&self) -> bool {
self.exit == 0 || self.duration == -1
}
diff --git a/atuin-client/src/history/builder.rs b/atuin-client/src/history/builder.rs
new file mode 100644
index 00000000..dc22b60e
--- /dev/null
+++ b/atuin-client/src/history/builder.rs
@@ -0,0 +1,100 @@
+use chrono::Utc;
+use typed_builder::TypedBuilder;
+
+use super::History;
+
+/// Builder for a history entry that is imported from shell history.
+///
+/// The only two required fields are `timestamp` and `command`.
+#[derive(Debug, Clone, TypedBuilder)]
+pub struct HistoryImported {
+ timestamp: chrono::DateTime<Utc>,
+ #[builder(setter(into))]
+ command: String,
+ #[builder(default = "unknown".into(), setter(into))]
+ cwd: String,
+ #[builder(default = -1)]
+ exit: i64,
+ #[builder(default = -1)]
+ duration: i64,
+ #[builder(default, setter(strip_option, into))]
+ session: Option<String>,
+ #[builder(default, setter(strip_option, into))]
+ hostname: Option<String>,
+}
+
+impl From<HistoryImported> for History {
+ fn from(imported: HistoryImported) -> Self {
+ History::new(
+ imported.timestamp,
+ imported.command,
+ imported.cwd,
+ imported.exit,
+ imported.duration,
+ imported.session,
+ imported.hostname,
+ None,
+ )
+ }
+}
+
+/// Builder for a history entry that is captured via hook.
+///
+/// This builder is used only at the `start` step of the hook,
+/// so it doesn't have any fields which are known only after
+/// the command is finished, such as `exit` or `duration`.
+#[derive(Debug, Clone, TypedBuilder)]
+pub struct HistoryCaptured {
+ timestamp: chrono::DateTime<Utc>,
+ #[builder(setter(into))]
+ command: String,
+ #[builder(setter(into))]
+ cwd: String,
+}
+
+impl From<HistoryCaptured> for History {
+ fn from(captured: HistoryCaptured) -> Self {
+ History::new(
+ captured.timestamp,
+ captured.command,
+ captured.cwd,
+ -1,
+ -1,
+ None,
+ None,
+ None,
+ )
+ }
+}
+
+/// Builder for a history entry that is loaded from the database.
+///
+/// All fields are required, as they are all present in the database.
+#[derive(Debug, Clone, TypedBuilder)]
+pub struct HistoryFromDb {
+ id: String,
+ timestamp: chrono::DateTime<Utc>,
+ command: String,
+ cwd: String,
+ exit: i64,
+ duration: i64,
+ session: String,
+ hostname: String,
+ deleted_at: Option<chrono::DateTime<Utc>>,
+}
+
+impl From<HistoryFromDb> for History {
+ fn from(from_db: HistoryFromDb) -> Self {
+ History {
+ id: from_db.id,
+ timestamp: from_db.timestamp,
+ exit: from_db.exit,
+ command: from_db.command,
+ cwd: from_db.cwd,
+ duration: from_db.duration,
+ session: from_db.session,
+ hostname: from_db.hostname,
+ deleted_at: from_db.deleted_at,
+ }
+ }
+}
diff --git a/atuin-client/src/import/bash.rs b/atuin-client/src/import/bash.rs
index 9901c1f3..25ede053 100644
--- a/atuin-client/src/import/bash.rs
+++ b/atuin-client/src/import/bash.rs
@@ -80,17 +80,9 @@ impl Importer for Bash {
next_timestamp = t;
}
LineType::Command(c) => {
- let entry = History::new(
- next_timestamp,
- c.into(),
- "unknown".into(),
- -1,
- -1,
- None,
- None,
- None,
- );
- h.push(entry).await?;
+ let imported = History::import().timestamp(next_timestamp).command(c);
+
+ h.push(imported.build().into()).await?;
next_timestamp += timestamp_increment;
}
}
diff --git a/atuin-client/src/import/fish.rs b/atuin-client/src/import/fish.rs
index e54ca730..90ecabc3 100644
--- a/atuin-client/src/import/fish.rs
+++ b/atuin-client/src/import/fish.rs
@@ -73,19 +73,9 @@ impl Importer for Fish {
// first, we must deal with the prev cmd
if let Some(cmd) = cmd.take() {
let time = time.unwrap_or(now);
+ let entry = History::import().timestamp(time).command(cmd);
- loader
- .push(History::new(
- time,
- cmd,
- "unknown".into(),
- -1,
- -1,
- None,
- None,
- None,
- ))
- .await?;
+ loader.push(entry.build().into()).await?;
}
// using raw strings to avoid needing escaping.
@@ -109,19 +99,9 @@ impl Importer for Fish {
// we might have a trailing cmd
if let Some(cmd) = cmd.take() {
let time = time.unwrap_or(now);
+ let entry = History::import().timestamp(time).command(cmd);
- loader
- .push(History::new(
- time,
- cmd,
- "unknown".into(),
- -1,
- -1,
- None,
- None,
- None,
- ))
- .await?;
+ loader.push(entry.build().into()).await?;
}
Ok(())
diff --git a/atuin-client/src/import/nu.rs b/atuin-client/src/import/nu.rs
index 0f107604..46600325 100644
--- a/atuin-client/src/import/nu.rs
+++ b/atuin-client/src/import/nu.rs
@@ -58,17 +58,9 @@ impl Importer for Nu {
let offset = chrono::Duration::nanoseconds(counter);
counter += 1;
- h.push(History::new(
- now - offset, // preserve ordering
- cmd,
- String::from("unknown"),
- -1,
- -1,
- None,
- None,
- None,
- ))
- .await?;
+ let entry = History::import().timestamp(now - offset).command(cmd);
+
+ h.push(entry.build().into()).await?;
}
Ok(())
diff --git a/atuin-client/src/import/nu_histdb.rs b/atuin-client/src/import/nu_histdb.rs
index 0fb5192e..34568d80 100644
--- a/atuin-client/src/import/nu_histdb.rs
+++ b/atuin-client/src/import/nu_histdb.rs
@@ -30,16 +30,19 @@ impl From<HistDbEntry> for History {
fn from(histdb_item: HistDbEntry) -> Self {
let ts_secs = histdb_item.start_timestamp / 1000;