diff options
| author | Ellie Huxtable <ellie@atuin.sh> | 2026-02-25 19:10:58 -0800 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-02-25 19:10:58 -0800 |
| commit | 9fe7d10fcf73570767ba7b4eabaa95f65958821b (patch) | |
| tree | ed9705225db8e5bbe2f3c689ba5d1c9908b75eb1 /crates/atuin-client/src | |
| parent | feat: Generate commands or ask questions with `atuin ai` (#3199) (diff) | |
| download | atuin-9fe7d10fcf73570767ba7b4eabaa95f65958821b.zip | |
feat: Add history author/intent metadata and v1 record version (#3205)
<!-- Thank you for making a PR! Bug fixes are always welcome, but if
you're adding a new feature or changing an existing one, we'd really
appreciate if you open an issue, post on the forum, or drop in on
Discord -->
## Checks
- [x] I am happy for maintainers to push small adjustments to this PR,
to speed up the review cycle
- [x] I have checked that there are no existing pull requests for the
same thing
Adds `author` and `intent` to client history records and DB persistence,
including migration/backfill and CLI/daemon propagation.
Introduces V2 record-store history version `v1` while retaining read
compatibility for legacy `v0` records.
Adds `--author` and `--intent` flags to `atuin history start`, plus
`{author}` and `{intent}` format keys for listing/history output.
Updates shell-integration docs for `ATUIN_HISTORY_AUTHOR` and
`ATUIN_HISTORY_INTENT`, and updates related tests/fixtures.
Validated with `cargo test -p atuin-client --lib`, `cargo test -p
atuin-daemon --tests`, `cargo test -p atuin search::inspector`, and
`cargo fmt --check`.
Diffstat (limited to 'crates/atuin-client/src')
| -rw-r--r-- | crates/atuin-client/src/database.rs | 23 | ||||
| -rw-r--r-- | crates/atuin-client/src/encryption.rs | 10 | ||||
| -rw-r--r-- | crates/atuin-client/src/history.rs | 237 | ||||
| -rw-r--r-- | crates/atuin-client/src/history/builder.rs | 22 | ||||
| -rw-r--r-- | crates/atuin-client/src/history/store.rs | 21 |
5 files changed, 263 insertions, 50 deletions
diff --git a/crates/atuin-client/src/database.rs b/crates/atuin-client/src/database.rs index 7aa095f7..5f292bec 100644 --- a/crates/atuin-client/src/database.rs +++ b/crates/atuin-client/src/database.rs @@ -200,8 +200,8 @@ impl Sqlite { async fn save_raw(tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, h: &History) -> Result<()> { sqlx::query( - "insert or ignore into history(id, timestamp, duration, exit, command, cwd, session, hostname, deleted_at) - values(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + "insert or ignore into history(id, timestamp, duration, exit, command, cwd, session, hostname, author, intent, deleted_at) + values(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", ) .bind(h.id.0.as_str()) .bind(h.timestamp.unix_timestamp_nanos() as i64) @@ -211,6 +211,8 @@ impl Sqlite { .bind(h.cwd.as_str()) .bind(h.session.as_str()) .bind(h.hostname.as_str()) + .bind(h.author.as_str()) + .bind(h.intent.as_deref()) .bind(h.deleted_at.map(|t|t.unix_timestamp_nanos() as i64)) .execute(&mut **tx) .await?; @@ -232,6 +234,13 @@ impl Sqlite { fn query_history(row: SqliteRow) -> History { let deleted_at: Option<i64> = row.get("deleted_at"); + let hostname: String = row.get("hostname"); + let author: Option<String> = row.try_get("author").ok().flatten(); + let author = author + .filter(|author| !author.trim().is_empty()) + .unwrap_or_else(|| History::author_from_hostname(hostname.as_str())); + let intent: Option<String> = row.try_get("intent").ok().flatten(); + let intent = intent.filter(|intent| !intent.trim().is_empty()); History::from_db() .id(row.get("id")) @@ -244,7 +253,9 @@ impl Sqlite { .command(row.get("command")) .cwd(row.get("cwd")) .session(row.get("session")) - .hostname(row.get("hostname")) + .hostname(hostname) + .author(author) + .intent(intent) .deleted_at( deleted_at.and_then(|t| OffsetDateTime::from_unix_timestamp_nanos(t as i128).ok()), ) @@ -295,7 +306,7 @@ impl Database for Sqlite { sqlx::query( "update history - set timestamp = ?2, duration = ?3, exit = ?4, command = ?5, cwd = ?6, session = ?7, hostname = ?8, deleted_at = ?9 + set timestamp = ?2, duration = ?3, exit = ?4, command = ?5, cwd = ?6, session = ?7, hostname = ?8, author = ?9, intent = ?10, deleted_at = ?11 where id = ?1", ) .bind(h.id.0.as_str()) @@ -306,6 +317,8 @@ impl Database for Sqlite { .bind(h.cwd.as_str()) .bind(h.session.as_str()) .bind(h.hostname.as_str()) + .bind(h.author.as_str()) + .bind(h.intent.as_deref()) .bind(h.deleted_at.map(|t|t.unix_timestamp_nanos() as i64)) .execute(&self.pool) .await?; @@ -612,6 +625,8 @@ impl Database for Sqlite { "exit", "command", "deleted_at", + "null as author", + "null as intent", "group_concat(cwd, ':') as cwd", "group_concat(session) as session", "group_concat(hostname, ',') as hostname", diff --git a/crates/atuin-client/src/encryption.rs b/crates/atuin-client/src/encryption.rs index a56dbd09..f2032482 100644 --- a/crates/atuin-client/src/encryption.rs +++ b/crates/atuin-client/src/encryption.rs @@ -253,6 +253,8 @@ fn decode(bytes: &[u8]) -> Result<History> { cwd: cwd.to_owned(), session: session.to_owned(), hostname: hostname.to_owned(), + author: History::author_from_hostname(hostname), + intent: None, deleted_at: deleted_at .map(|t| OffsetDateTime::parse(t, &Rfc3339)) .transpose()?, @@ -287,6 +289,8 @@ mod test { .duration(1) .session("beep boop".into()) .hostname("booop".into()) + .author("booop".into()) + .intent(None) .deleted_at(None) .build() .into(); @@ -331,6 +335,8 @@ mod test { cwd: "/Users/conrad.ludgate/Documents/code/atuin".to_owned(), session: "b97d9a306f274473a203d2eba41f9457".to_owned(), hostname: "fvfg936c0kpf:conrad.ludgate".to_owned(), + author: "conrad.ludgate".to_owned(), + intent: None, deleted_at: None, }; @@ -352,6 +358,8 @@ mod test { cwd: "/Users/conrad.ludgate/Documents/code/atuin".to_owned(), session: "b97d9a306f274473a203d2eba41f9457".to_owned(), hostname: "fvfg936c0kpf:conrad.ludgate".to_owned(), + author: "conrad.ludgate".to_owned(), + intent: None, deleted_at: Some(datetime!(2023-05-28 18:35:40.633872 +00:00)), }; @@ -383,6 +391,8 @@ mod test { cwd: "/Users/conrad.ludgate/Documents/code/atuin".to_owned(), session: "b97d9a306f274473a203d2eba41f9457".to_owned(), hostname: "fvfg936c0kpf:conrad.ludgate".to_owned(), + author: "conrad.ludgate".to_owned(), + intent: None, deleted_at: None, }; diff --git a/crates/atuin-client/src/history.rs b/crates/atuin-client/src/history.rs index d9d5a203..a5adc233 100644 --- a/crates/atuin-client/src/history.rs +++ b/crates/atuin-client/src/history.rs @@ -1,4 +1,5 @@ use core::fmt::Formatter; +use rmp::decode::DecodeStringError; use rmp::decode::ValueReadError; use rmp::{Marker, decode::Bytes}; use std::env; @@ -17,8 +18,14 @@ use time::OffsetDateTime; mod builder; pub mod store; -const HISTORY_VERSION: &str = "v0"; +pub(crate) const HISTORY_VERSION_V0: &str = "v0"; +pub(crate) const HISTORY_VERSION_V1: &str = "v1"; +const HISTORY_RECORD_VERSION_V0: u16 = 0; +const HISTORY_RECORD_VERSION_V1: u16 = 1; +pub(crate) const HISTORY_VERSION: &str = HISTORY_VERSION_V1; pub const HISTORY_TAG: &str = "history"; +const HISTORY_AUTHOR_ENV: &str = "ATUIN_HISTORY_AUTHOR"; +const HISTORY_INTENT_ENV: &str = "ATUIN_HISTORY_INTENT"; #[derive(Clone, Debug, Eq, PartialEq, Hash)] pub struct HistoryId(pub String); @@ -46,8 +53,8 @@ impl From<String> for HistoryId { // // ## 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) +// New fields must be added to `History::{serialize,deserialize}` in a backwards +// compatible way (sensible defaults and careful `nfields` handling). #[derive(Debug, Clone, PartialEq, Eq, sqlx::FromRow)] pub struct History { /// A client-generated ID, used to identify the entry when syncing. @@ -68,6 +75,10 @@ pub struct History { pub session: String, /// The hostname of the machine the command was run on. pub hostname: String, + /// Who wrote this command (human user or automation/agent identity). + pub author: String, + /// Optional rationale for why the command was executed. + pub intent: Option<String>, /// Timestamp, which is set when the entry is deleted, allowing a soft delete. pub deleted_at: Option<OffsetDateTime>, } @@ -93,6 +104,23 @@ pub struct HistoryStats { } impl History { + pub(crate) fn author_from_hostname(hostname: &str) -> String { + hostname + .split_once(':') + .map_or_else(|| hostname.to_owned(), |(_, user)| user.to_owned()) + } + + fn normalize_optional_field(field: Option<String>) -> Option<String> { + field.and_then(|value| { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_owned()) + } + }) + } + #[allow(clippy::too_many_arguments)] fn new( timestamp: OffsetDateTime, @@ -102,12 +130,19 @@ impl History { duration: i64, session: Option<String>, hostname: Option<String>, + author: Option<String>, + intent: Option<String>, deleted_at: Option<OffsetDateTime>, ) -> Self { let session = session .or_else(|| env::var("ATUIN_SESSION").ok()) .unwrap_or_else(|| uuid_v7().as_simple().to_string()); let hostname = hostname.unwrap_or_else(get_host_user); + let author = Self::normalize_optional_field(author) + .or_else(|| Self::normalize_optional_field(env::var(HISTORY_AUTHOR_ENV).ok())) + .unwrap_or_else(|| Self::author_from_hostname(hostname.as_str())); + let intent = Self::normalize_optional_field(intent) + .or_else(|| Self::normalize_optional_field(env::var(HISTORY_INTENT_ENV).ok())); Self { id: uuid_v7().as_simple().to_string().into(), @@ -118,6 +153,8 @@ impl History { duration, session, hostname, + author, + intent, deleted_at, } } @@ -131,9 +168,9 @@ impl History { let mut output = vec![]; // write the version - encode::write_u16(&mut output, 0)?; - // INFO: ensure this is updated when adding new fields - encode::write_array_len(&mut output, 9)?; + encode::write_u16(&mut output, HISTORY_RECORD_VERSION_V1)?; + let include_intent = self.intent.is_some(); + encode::write_array_len(&mut output, 10 + u32::from(include_intent))?; encode::write_str(&mut output, &self.id.0)?; encode::write_u64(&mut output, self.timestamp.unix_timestamp_nanos() as u64)?; @@ -149,9 +186,33 @@ impl History { None => encode::write_nil(&mut output)?, } + encode::write_str(&mut output, self.author.as_str())?; + if let Some(intent) = &self.intent { + encode::write_str(&mut output, intent.as_str())?; + } + Ok(DecryptedData(output)) } + fn read_optional_string(bytes: &[u8]) -> Result<(Option<String>, &[u8])> { + use rmp::decode; + + fn error_report<E: std::fmt::Debug>(err: E) -> eyre::Report { + eyre!("{err:?}") + } + + match decode::read_str_from_slice(bytes) { + Ok((value, bytes)) => Ok((Some(value.to_owned()), bytes)), + Err(DecodeStringError::TypeMismatch(Marker::Null)) => { + let mut cursor = Bytes::new(bytes); + decode::read_nil(&mut cursor).map_err(error_report)?; + + Ok((None, cursor.remaining_slice())) + } + Err(err) => Err(error_report(err)), + } + } + fn deserialize_v0(bytes: &[u8]) -> Result<History> { use rmp::decode; @@ -163,7 +224,7 @@ impl History { let version = decode::read_u16(&mut bytes).map_err(error_report)?; - if version != 0 { + if version != HISTORY_RECORD_VERSION_V0 { bail!("expected decoding v0 record, found v{version}"); } @@ -187,7 +248,6 @@ impl History { 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 bytes = Bytes::new(bytes); let (deleted_at, bytes) = match decode::read_u64(&mut bytes) { @@ -196,6 +256,76 @@ impl History { Err(ValueReadError::TypeMismatch(Marker::Null)) => (None, bytes.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().into(), + timestamp: OffsetDateTime::from_unix_timestamp_nanos(timestamp as i128)?, + duration, + exit, + command: command.to_owned(), + cwd: cwd.to_owned(), + session: session.to_owned(), + hostname: hostname.to_owned(), + author: Self::author_from_hostname(hostname), + intent: None, + deleted_at: deleted_at + .map(|t| OffsetDateTime::from_unix_timestamp_nanos(t as i128)) + .transpose()?, + }) + } + + fn deserialize_v1(bytes: &[u8]) -> Result<History> { + use rmp::decode; + + fn error_report<E: std::fmt::Debug>(err: E) -> eyre::Report { + eyre!("{err:?}") + } + + let mut bytes = Bytes::new(bytes); + + let version = decode::read_u16(&mut bytes).map_err(error_report)?; + + if version != HISTORY_RECORD_VERSION_V1 { + bail!("expected decoding v1 record, found v{version}"); + } + + let nfields = decode::read_array_len(&mut bytes).map_err(error_report)?; + + if !(10..=11).contains(&nfields) { + bail!("cannot decrypt history from a different version of Atuin"); + } + + let bytes = bytes.remaining_slice(); + let (id, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?; + + let mut bytes = Bytes::new(bytes); + let timestamp = decode::read_u64(&mut bytes).map_err(error_report)?; + 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)?; + + let mut bytes = Bytes::new(bytes); + + let (deleted_at, bytes) = match decode::read_u64(&mut bytes) { + Ok(unix) => (Some(unix), bytes.remaining_slice()), + // we accept null here + Err(ValueReadError::TypeMismatch(Marker::Null)) => (None, bytes.remaining_slice()), + Err(err) => return Err(error_report(err)), + }; + let (author, bytes) = Self::read_optional_string(bytes)?; + let (intent, bytes) = if nfields > 10 { + Self::read_optional_string(bytes)? + } else { + (None, bytes) + }; if !bytes.is_empty() { bail!("trailing bytes in encoded history. malformed") @@ -210,6 +340,8 @@ impl History { cwd: cwd.to_owned(), session: session.to_owned(), hostname: hostname.to_owned(), + author: author.unwrap_or_else(|| Self::author_from_hostname(hostname)), + intent, deleted_at: deleted_at .map(|t| OffsetDateTime::from_unix_timestamp_nanos(t as i128)) .transpose()?, @@ -218,7 +350,8 @@ impl History { pub fn deserialize(bytes: &[u8], version: &str) -> Result<History> { match version { - HISTORY_VERSION => Self::deserialize_v0(bytes), + HISTORY_VERSION_V0 => Self::deserialize_v0(bytes), + HISTORY_VERSION_V1 => Self::deserialize_v1(bytes), _ => bail!("unknown version {version:?}"), } @@ -361,6 +494,8 @@ impl History { /// .duration(100) /// .session("somesession".to_string()) /// .hostname("localhost".to_string()) + /// .author("user".to_string()) + /// .intent(None) /// .deleted_at(None) /// .build() /// .into(); @@ -469,18 +604,6 @@ mod tests { #[test] fn test_serialize_deserialize() { - let bytes = [ - 205, 0, 0, 153, 217, 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, 207, 23, 99, - 98, 117, 24, 210, 246, 128, 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().into(), timestamp: datetime!(2023-05-28 18:35:40.633872 +00:00), @@ -490,20 +613,21 @@ mod tests { cwd: "/Users/conrad.ludgate/Documents/code/atuin".to_owned(), session: "b97d9a306f274473a203d2eba41f9457".to_owned(), hostname: "fvfg936c0kpf:conrad.ludgate".to_owned(), + author: "conrad.ludgate".to_owned(), + intent: None, deleted_at: None, }; let serialized = history.serialize().expect("failed to serialize history"); - assert_eq!(serialized.0, bytes); + assert_eq!( + &serialized.0[0..3], + [205, 0, 1], + "should encode as history v1" + ); let deserialized = History::deserialize(&serialized.0, HISTORY_VERSION) .expect("failed to deserialize history"); assert_eq!(history, deserialized); - - // test the snapshot too - let deserialized = - History::deserialize(&bytes, HISTORY_VERSION).expect("failed to deserialize history"); - assert_eq!(history, deserialized); } #[test] @@ -517,6 +641,8 @@ mod tests { cwd: "/Users/conrad.ludgate/Documents/code/atuin".to_owned(), session: "b97d9a306f274473a203d2eba41f9457".to_owned(), hostname: "fvfg936c0kpf:conrad.ludgate".to_owned(), + author: "conrad.ludgate".to_owned(), + intent: None, deleted_at: Some(datetime!(2023-11-19 20:18 +00:00)), }; @@ -529,6 +655,29 @@ mod tests { } #[test] + fn test_serialize_deserialize_with_author_and_intent() { + let history = History { + id: "66d16cbee7cd47538e5c5b8b44e9006e".to_owned().into(), + timestamp: datetime!(2023-05-28 18:35:40.633872 +00:00), + 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(), + author: "claude".to_owned(), + intent: Some("check repository status".to_owned()), + deleted_at: None, + }; + + let serialized = history.serialize().expect("failed to serialize history"); + let deserialized = History::deserialize(&serialized.0, HISTORY_VERSION) + .expect("failed to deserialize history"); + + assert_eq!(history, deserialized); + } + + #[test] fn test_serialize_deserialize_version() { // v0 let bytes_v0 = [ @@ -543,23 +692,31 @@ mod tests { 99, 111, 110, 114, 97, 100, 46, 108, 117, 100, 103, 97, 116, 101, 192, ]; - // some other version - let bytes_v1 = [ - 205, 1, 0, 153, 217, 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, 207, 23, 99, - 98, 117, 24, 210, 246, 128, 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 deserialized = History::deserialize(&bytes_v0, "v0"); + assert!(deserialized.is_ok()); let deserialized = History::deserialize(&bytes_v0, HISTORY_VERSION); + assert!(deserialized.is_err()); + + let current = History { + id: "66d16cbee7cd47538e5c5b8b44e9006e".to_owned().into(), + timestamp: datetime!(2023-05-28 18:35:40.633872 +00:00), + 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(), + author: "conrad.ludgate".to_owned(), + intent: None, + deleted_at: None, + }; + + let bytes_v1 = current.serialize().expect("failed to serialize history"); + let deserialized = History::deserialize(&bytes_v1.0, HISTORY_VERSION); assert!(deserialized.is_ok()); - let deserialized = History::deserialize(&bytes_v1, HISTORY_VERSION); + let deserialized = History::deserialize(&bytes_v1.0, "v0"); assert!(deserialized.is_err()); } } diff --git a/crates/atuin-client/src/history/builder.rs b/crates/atuin-client/src/history/builder.rs index 2b28339f..72a505fd 100644 --- a/crates/atuin-client/src/history/builder.rs +++ b/crates/atuin-client/src/history/builder.rs @@ -20,6 +20,10 @@ pub struct HistoryImported { session: Option<String>, #[builder(default, setter(strip_option, into))] hostname: Option<String>, + #[builder(default, setter(strip_option, into))] + author: Option<String>, + #[builder(default, setter(strip_option, into))] + intent: Option<String>, } impl From<HistoryImported> for History { @@ -32,6 +36,8 @@ impl From<HistoryImported> for History { imported.duration, imported.session, imported.hostname, + imported.author, + imported.intent, None, ) } @@ -49,6 +55,10 @@ pub struct HistoryCaptured { command: String, #[builder(setter(into))] cwd: String, + #[builder(default, setter(strip_option, into))] + author: Option<String>, + #[builder(default, setter(strip_option, into))] + intent: Option<String>, } impl From<HistoryCaptured> for History { @@ -61,6 +71,8 @@ impl From<HistoryCaptured> for History { -1, None, None, + captured.author, + captured.intent, None, ) } @@ -79,6 +91,8 @@ pub struct HistoryFromDb { duration: i64, session: String, hostname: String, + author: String, + intent: Option<String>, deleted_at: Option<time::OffsetDateTime>, } @@ -93,6 +107,8 @@ impl From<HistoryFromDb> for History { duration: from_db.duration, session: from_db.session, hostname: from_db.hostname, + author: from_db.author, + intent: from_db.intent, deleted_at: from_db.deleted_at, } } @@ -114,6 +130,10 @@ pub struct HistoryDaemonCapture { session: String, #[builder(setter(into))] hostname: String, + #[builder(default, setter(strip_option, into))] + author: Option<String>, + #[builder(default, setter(strip_option, into))] + intent: Option<String>, } impl From<HistoryDaemonCapture> for History { @@ -126,6 +146,8 @@ impl From<HistoryDaemonCapture> for History { -1, Some(captured.session), Some(captured.hostname), + captured.author, + captured.intent, None, ) } diff --git a/crates/atuin-client/src/history/store.rs b/crates/atuin-client/src/history/store.rs index 041d90ce..d166564f 100644 --- a/crates/atuin-client/src/history/store.rs +++ b/crates/atuin-client/src/history/store.rs @@ -10,7 +10,7 @@ use crate::{ }; use atuin_common::record::{DecryptedData, Host, HostId, Record, RecordId, RecordIdx}; -use super::{HISTORY_TAG, HISTORY_VERSION, History, HistoryId}; +use super::{HISTORY_TAG, HISTORY_VERSION, HISTORY_VERSION_V0, History, HistoryId}; #[derive(Debug, Clone)] pub struct HistoryStore { @@ -196,10 +196,11 @@ impl HistoryStore { for record in records.into_iter() { let hist = match record.version.as_str() { - HISTORY_VERSION => { + HISTORY_VERSION_V0 | HISTORY_VERSION => { + let version = record.version.clone(); let decrypted = record.decrypt::<PASETO_V4>(&self.encryption_key)?; - HistoryRecord::deserialize(&decrypted.data, HISTORY_VERSION) + HistoryRecord::deserialize(&decrypted.data, version.as_str()) } version => bail!("unknown history version {version:?}"), }?; @@ -257,8 +258,14 @@ impl HistoryStore { continue; } + let version = record.version.clone(); let decrypted = record.decrypt::<PASETO_V4>(&self.encryption_key)?; - let record = HistoryRecord::deserialize(&decrypted.data, HISTORY_VERSION)?; + let record = match version.as_str() { + HISTORY_VERSION_V0 | HISTORY_VERSION => { + HistoryRecord::deserialize(&decrypted.data, version.as_str())? + } + version => bail!("unknown history version {version:?}"), + }; match record { HistoryRecord::Create(h) => { @@ -350,14 +357,14 @@ mod tests { #[test] fn test_serialize_deserialize_create() { let bytes = [ - 204, 0, 196, 141, 205, 0, 0, 153, 217, 32, 48, 49, 56, 99, 100, 52, 102, 101, 56, 49, + 204, 0, 196, 147, 205, 0, 1, 154, 217, 32, 48, 49, 56, 99, 100, 52, 102, 101, 56, 49, 55, 53, 55, 99, 100, 50, 97, 101, 101, 54, 53, 99, 100, 55, 56, 54, 49, 102, 57, 99, 56, 49, 207, 23, 166, 251, 212, 181, 82, 0, 0, 100, 0, 162, 108, 115, 217, 41, 47, 85, 115, 101, 114, 115, 47, 101, 108, 108, 105, 101, 47, 115, 114, 99, 47, 103, 105, 116, 104, 117, 98, 46, 99, 111, 109, 47, 97, 116, 117, 105, 110, 115, 104, 47, 97, 116, 117, 105, 110, 217, 32, 48, 49, 56, 99, 100, 52, 102, 101, 97, 100, 56, 57, 55, 53, 57, 55, 56, 53, 50, 53, 50, 55, 97, 51, 49, 99, 57, 57, 56, 48, 53, 57, 170, 98, 111, 111, 112, - 58, 101, 108, 108, 105, 101, 192, + 58, 101, 108, 108, 105, 101, 192, 165, 101, 108, 108, 105, 101, ]; let history = History { @@ -369,6 +376,8 @@ mod tests { cwd: "/Users/ellie/src/github.com/atuinsh/atuin".to_owned(), session: "018cd4fead897597852527a31c998059".to_owned(), hostname: "boop:ellie".to_owned(), + author: "ellie".to_owned(), + intent: None, deleted_at: None, }; |
