aboutsummaryrefslogtreecommitdiffstats
path: root/crates
diff options
context:
space:
mode:
authorBenedikt Peetz <benedikt.peetz@b-peetz.de>2026-07-20 19:30:40 +0200
committerBenedikt Peetz <benedikt.peetz@b-peetz.de>2026-07-20 19:30:40 +0200
commit966a80c4199a49898cc7d8641012d520ce6b2efa (patch)
tree51029ff75842090fd1eecbea97b6f7c447e3dea9 /crates
parentchore(server): Remove warnings (diff)
downloadatuin-966a80c4199a49898cc7d8641012d520ce6b2efa.zip
chore: Commit
Diffstat (limited to '')
-rw-r--r--crates/client/Cargo.toml1
-rw-r--r--crates/client/src/command/client/history/start.rs5
-rw-r--r--crates/daemon/Cargo.toml1
-rw-r--r--crates/daemon/src/aclient/database/mod.rs19
-rw-r--r--crates/daemon/src/aclient/history/builder.rs154
-rw-r--r--crates/daemon/src/aclient/history/mod.rs329
-rw-r--r--crates/daemon/src/aclient/history/store.rs4
-rw-r--r--crates/daemon/src/aclient/mod.rs11
-rw-r--r--crates/daemon/src/aclient/ordering.rs3
-rw-r--r--crates/daemon/src/aclient/utils.rs15
-rw-r--r--crates/daemon/src/api/control.rs (renamed from crates/daemon/src/api/server/control.rs)16
-rw-r--r--crates/daemon/src/api/history.rs (renamed from crates/daemon/src/api/server/history.rs)13
-rw-r--r--crates/daemon/src/api/mod.rs8
-rw-r--r--crates/daemon/src/api/server/mod.rs2
-rw-r--r--crates/daemon/src/events.rs2
-rw-r--r--crates/daemon/src/lib.rs161
-rw-r--r--crates/daemon/src/main.rs167
-rw-r--r--crates/daemon/src/server.rs17
-rw-r--r--crates/turtle/Cargo.toml1
-rw-r--r--crates/turtle/build.rs (renamed from crates/daemon/build.rs)0
-rw-r--r--crates/turtle/proto/control.proto (renamed from crates/daemon/proto/control.proto)0
-rw-r--r--crates/turtle/proto/history.proto (renamed from crates/daemon/proto/history.proto)0
-rw-r--r--crates/turtle/src/client/mod.rs (renamed from crates/daemon/src/api/client/mod.rs)33
-rw-r--r--crates/turtle/src/generated.rs (renamed from crates/daemon/src/api/generated.rs)11
-rw-r--r--crates/turtle/src/history/builder.rs78
-rw-r--r--crates/turtle/src/history/mod.rs300
-rw-r--r--crates/turtle/src/history/secrets.rs (renamed from crates/daemon/src/aclient/secrets.rs)0
-rw-r--r--crates/turtle/src/lib.rs5
28 files changed, 626 insertions, 730 deletions
diff --git a/crates/client/Cargo.toml b/crates/client/Cargo.toml
index fd5ea57a..058f2f3a 100644
--- a/crates/client/Cargo.toml
+++ b/crates/client/Cargo.toml
@@ -14,6 +14,7 @@ repository = { workspace = true }
[dependencies]
turtle-daemon = {workspace = true}
turtle-common = {workspace = true}
+turtle = { workspace = true }
clap = { version = "4.5.7", features = ["derive"] }
clap_complete = "4.5.8"
clap_complete_nushell = "4.5.4"
diff --git a/crates/client/src/command/client/history/start.rs b/crates/client/src/command/client/history/start.rs
index 81aaa904..defd46a3 100644
--- a/crates/client/src/command/client/history/start.rs
+++ b/crates/client/src/command/client/history/start.rs
@@ -6,9 +6,10 @@ use crate::{
use eyre::{Result, eyre};
use time::OffsetDateTime;
use tracing::debug;
+use turtle::History;
use turtle_common::utils;
use turtle_daemon::{
- aclient::history::{History, SettingsFilter},
+ aclient::history::SettingsFilter,
api::client::{HistoryClient, daemon_matches_expected, daemon_mismatch_message},
};
@@ -23,7 +24,7 @@ pub(super) async fn handle(
let cwd = utils::get_current_dir();
let command = normalize_command_for_storage(command, settings);
- let mut h: History = History::capture()
+ let mut h: History = History::daemon()
.timestamp(OffsetDateTime::now_utc())
.command(command)
.cwd(cwd)
diff --git a/crates/daemon/Cargo.toml b/crates/daemon/Cargo.toml
index 07fd84d7..830dbd12 100644
--- a/crates/daemon/Cargo.toml
+++ b/crates/daemon/Cargo.toml
@@ -13,6 +13,7 @@ repository = { workspace = true }
[dependencies]
turtle-common = { workspace = true }
+turtle = { workspace = true }
async-trait = "0.1.58"
axum = "0.8"
base64 = "0.22"
diff --git a/crates/daemon/src/aclient/database/mod.rs b/crates/daemon/src/aclient/database/mod.rs
index 5287807c..cdf71065 100644
--- a/crates/daemon/src/aclient/database/mod.rs
+++ b/crates/daemon/src/aclient/database/mod.rs
@@ -3,7 +3,6 @@ use std::{
str::FromStr,
};
-use crate::aclient::utils::setup_db;
use fs_err::{self as fs};
use itertools::Itertools;
use sql_builder::{SqlBuilder, SqlName, bind::Bind, esc, quote};
@@ -13,19 +12,16 @@ use sqlx::{
};
use time::OffsetDateTime;
use tracing::debug;
+use turtle::history::{History, HistoryId, get_host_user};
use turtle_common::utils;
use uuid::Uuid;
use crate::aclient::{
- history::{HistoryId, HistoryStats},
- utils::get_host_user,
-};
-
-use super::{
- history::History,
+ history::HistoryStats,
ordering,
- settings::{FilterMode, SearchMode, Settings},
+ settings::{FilterMode, SearchMode},
};
+use crate::aclient::{settings::Settings, utils::setup_db};
#[derive(Clone)]
pub(crate) struct Context {
@@ -858,10 +854,15 @@ mod test {
}
async fn new_history_item(db: &mut ClientSqlite, cmd: &str) -> Result<()> {
- let mut captured: History = History::capture()
+ const SESSION: &str = "test";
+ const HOSTNAME: &str = "test.host";
+
+ let mut captured: History = History::daemon()
.timestamp(OffsetDateTime::now_utc())
.command(cmd)
.cwd("/home/ellie")
+ .session(SESSION)
+ .hostname(HOSTNAME)
.build()
.into();
diff --git a/crates/daemon/src/aclient/history/builder.rs b/crates/daemon/src/aclient/history/builder.rs
deleted file mode 100644
index ef52637b..00000000
--- a/crates/daemon/src/aclient/history/builder.rs
+++ /dev/null
@@ -1,154 +0,0 @@
-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(crate) struct HistoryImported {
- timestamp: time::OffsetDateTime,
- #[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>,
- #[builder(default, setter(strip_option, into))]
- author: Option<String>,
- #[builder(default, setter(strip_option, into))]
- intent: Option<String>,
-}
-
-impl From<HistoryImported> for History {
- fn from(imported: HistoryImported) -> Self {
- Self::new(
- imported.timestamp,
- imported.command,
- imported.cwd,
- imported.exit,
- imported.duration,
- imported.session,
- imported.hostname,
- imported.author,
- imported.intent,
- 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: time::OffsetDateTime,
- #[builder(setter(into))]
- 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 {
- fn from(captured: HistoryCaptured) -> Self {
- Self::new(
- captured.timestamp,
- captured.command,
- captured.cwd,
- -1,
- -1,
- None,
- None,
- captured.author,
- captured.intent,
- 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(crate) struct HistoryFromDb {
- id: String,
- timestamp: time::OffsetDateTime,
- command: String,
- cwd: String,
- exit: i64,
- duration: i64,
- session: String,
- hostname: String,
- author: String,
- intent: Option<String>,
- deleted_at: Option<time::OffsetDateTime>,
-}
-
-impl From<HistoryFromDb> for History {
- fn from(from_db: HistoryFromDb) -> Self {
- Self {
- id: from_db.id.into(),
- 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,
- author: from_db.author,
- intent: from_db.intent,
- deleted_at: from_db.deleted_at,
- }
- }
-}
-
-/// Builder for a history entry that is captured via hook and sent to the daemon
-///
-/// This builder is similar to Capture, but we just require more information up front.
-/// For the old setup, we could just rely on `History::new` to read some of the missing
-/// data. This is no longer the case.
-#[derive(Debug, Clone, TypedBuilder)]
-pub(crate) struct HistoryDaemonCapture {
- timestamp: time::OffsetDateTime,
- #[builder(setter(into))]
- command: String,
- #[builder(setter(into))]
- cwd: String,
- #[builder(setter(into))]
- 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 {
- fn from(captured: HistoryDaemonCapture) -> Self {
- Self::new(
- captured.timestamp,
- captured.command,
- captured.cwd,
- -1,
- -1,
- Some(captured.session),
- Some(captured.hostname),
- captured.author,
- captured.intent,
- None,
- )
- }
-}
diff --git a/crates/daemon/src/aclient/history/mod.rs b/crates/daemon/src/aclient/history/mod.rs
index 527d1bb6..ea71dcf4 100644
--- a/crates/daemon/src/aclient/history/mod.rs
+++ b/crates/daemon/src/aclient/history/mod.rs
@@ -5,17 +5,15 @@ use rmp::decode::ValueReadError;
use rmp::{Marker, decode::Bytes};
use std::env;
use std::fmt::Display;
+use turtle::history::History;
use turtle_common::record::DecryptedData;
use turtle_common::utils::uuid_v7;
use eyre::{Result, bail, eyre};
-use crate::aclient::secrets::SECRET_PATTERNS_RE;
-use crate::aclient::utils::get_host_user;
use time::OffsetDateTime;
-mod builder;
pub(crate) mod store;
pub(crate) const HISTORY_VERSION_V0: &str = "v0";
@@ -27,72 +25,6 @@ pub(crate) 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);
-
-impl Display for HistoryId {
- fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
- write!(f, "{}", self.0)
- }
-}
-
-impl From<String> for HistoryId {
- fn from(s: String) -> Self {
- Self(s)
- }
-}
-
-/// 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 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.
- ///
- /// Stored as `client_id` in the database.
- pub id: HistoryId,
-
- /// When the command was run.
- pub timestamp: OffsetDateTime,
-
- /// 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,
-
- /// 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>,
-}
-
#[derive(Debug, Clone, PartialEq, Eq, sqlx::FromRow)]
pub(crate) struct HistoryStats {
/// The command that was ran after this one in the session
@@ -113,63 +45,17 @@ pub(crate) struct HistoryStats {
pub(crate) duration_over_time: Vec<(String, i64)>,
}
-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())
- }
- })
- }
-
- #[expect(clippy::too_many_arguments)]
- fn new(
- timestamp: OffsetDateTime,
- command: String,
- cwd: String,
- exit: i64,
- 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(),
- timestamp,
- command,
- cwd,
- exit,
- duration,
- session,
- hostname,
- author,
- intent,
- deleted_at,
- }
- }
+pub(crate) trait HistoryExt: Sized {
+ fn serialize(&self) -> Result<DecryptedData>;
+ fn read_optional_string(bytes: &[u8]) -> Result<(Option<String>, &[u8])>;
+ fn deserialize_v0(bytes: &[u8]) -> Result<Self>;
+ fn deserialize_v1(bytes: &[u8]) -> Result<Self>;
+ fn deserialize(bytes: &[u8], version: &str) -> Result<Self>;
+ fn success(&self) -> bool;
+}
- pub(crate) fn serialize(&self) -> Result<DecryptedData> {
+impl HistoryExt for History {
+ fn serialize(&self) -> Result<DecryptedData> {
// This is pretty much the same as what we used for the old history, with one difference -
// it uses integers for timestamps rather than a string format.
@@ -358,7 +244,7 @@ impl History {
})
}
- pub(crate) fn deserialize(bytes: &[u8], version: &str) -> Result<Self> {
+ fn deserialize(bytes: &[u8], version: &str) -> Result<Self> {
match version {
HISTORY_VERSION_V0 => Self::deserialize_v0(bytes),
HISTORY_VERSION_V1 => Self::deserialize_v1(bytes),
@@ -367,118 +253,10 @@ impl History {
}
}
- /// 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 crate::aclient::history::History;
- ///
- /// let history: History = History::capture()
- /// .timestamp(time::OffsetDateTime::now_utc())
- /// .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 crate::aclient::history::History;
- ///
- /// // this will not compile because `cwd` is missing
- /// let history: History = History::capture()
- /// .timestamp(time::OffsetDateTime::now_utc())
- /// .command("ls -la")
- /// .build()
- /// .into();
- /// ```
- pub fn capture() -> builder::HistoryCapturedBuilder {
- builder::HistoryCaptured::builder()
- }
-
- /// Builder for a history entry that is captured via hook, and sent to the daemon.
- ///
- /// 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`.
- ///
- /// It does, however, include information that can usually be inferred.
- ///
- /// This is because the daemon we are sending a request to lacks the context of the command
- ///
- /// ## Examples
- /// ```rust
- /// use crate::aclient::history::History;
- ///
- /// let history: History = History::daemon()
- /// .timestamp(time::OffsetDateTime::now_utc())
- /// .command("ls -la")
- /// .cwd("/home/user")
- /// .session("018deb6e8287781f9973ef40e0fde76b")
- /// .hostname("computer:ellie")
- /// .build()
- /// .into();
- /// ```
- ///
- /// Command without any required info cannot be captured, which is forced at compile time:
- ///
- /// ```compile_fail
- /// use crate::aclient::history::History;
- ///
- /// // this will not compile because `hostname` is missing
- /// let history: History = History::daemon()
- /// .timestamp(time::OffsetDateTime::now_utc())
- /// .command("ls -la")
- /// .cwd("/home/user")
- /// .session("018deb6e8287781f9973ef40e0fde76b")
- /// .build()
- /// .into();
- /// ```
- pub(crate) fn daemon() -> builder::HistoryDaemonCaptureBuilder {
- builder::HistoryDaemonCapture::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 crate::aclient::history::History;
- ///
- /// // this will not compile because `id` field is missing
- /// let history: History = History::from_db()
- /// .timestamp(time::OffsetDateTime::now_utc())
- /// .command("ls -la".to_string())
- /// .cwd("/home/user".to_string())
- /// .exit(0)
- /// .duration(100)
- /// .session("somesession".to_string())
- /// .hostname("localhost".to_string())
- /// .author("user".to_string())
- /// .intent(None)
- /// .deleted_at(None)
- /// .build()
- /// .into();
- /// ```
- pub(crate) fn from_db() -> builder::HistoryFromDbBuilder {
- builder::HistoryFromDb::builder()
- }
-
- pub(crate) fn success(&self) -> bool {
+ #[expect(unused)]
+ fn success(&self) -> bool {
self.exit == 0 || self.duration == -1
}
-
- pub fn should_save(&self, filter: SettingsFilter<'_>) -> bool {
- !(self.command.is_empty()
- || filter.history.is_match(&self.command)
- || filter.cwd.is_match(&self.cwd)
- || (filter.secrets && SECRET_PATTERNS_RE.is_match(&self.command)))
- }
}
#[derive(Debug, Copy, Clone)]
@@ -490,89 +268,12 @@ pub struct SettingsFilter<'a> {
#[cfg(test)]
mod tests {
- use regex::RegexSet;
use time::macros::datetime;
- use crate::aclient::{history::HISTORY_VERSION, settings::Settings};
+ use crate::aclient::history::{HISTORY_VERSION, HistoryExt};
use super::History;
- // Test that we don't save history where necessary
- #[test]
- fn privacy_test() {
- let settings = Settings {
- cwd_filter: RegexSet::new(["^/supasecret"]).unwrap(),
- history_filter: RegexSet::new(["^psql"]).unwrap(),
- ..Settings::default()
- };
-
- let normal_command: History = History::capture()
- .timestamp(time::OffsetDateTime::now_utc())
- .command("echo foo")
- .cwd("/")
- .build()
- .into();
-
- let with_space: History = History::capture()
- .timestamp(time::OffsetDateTime::now_utc())
- .command(" echo bar")
- .cwd("/")
- .build()
- .into();
-
- let empty: History = History::capture()
- .timestamp(time::OffsetDateTime::now_utc())
- .command("")
- .cwd("/")
- .build()
- .into();
-
- let stripe_key: History = History::capture()
- .timestamp(time::OffsetDateTime::now_utc())
- .command("curl foo.com/bar?key=sk_test_1234567890abcdefghijklmnop")
- .cwd("/")
- .build()
- .into();
-
- let secret_dir: History = History::capture()
- .timestamp(time::OffsetDateTime::now_utc())
- .command("echo ohno")
- .cwd("/supasecret")
- .build()
- .into();
-
- let with_psql: History = History::capture()
- .timestamp(time::OffsetDateTime::now_utc())
- .command("psql")
- .cwd("/supasecret")
- .build()
- .into();
-
- assert!(normal_command.should_save(&settings));
- assert!(!with_space.should_save(&settings));
- assert!(!empty.should_save(&settings));
- assert!(!stripe_key.should_save(&settings));
- assert!(!secret_dir.should_save(&settings));
- assert!(!with_psql.should_save(&settings));
- }
-
- #[test]
- fn disable_secrets() {
- let settings = Settings {
- secrets_filter: false,
- ..Settings::new().unwrap()
- };
-
- let stripe_key: History = History::capture()
- .timestamp(time::OffsetDateTime::now_utc())
- .command("curl foo.com/bar?key=sk_test_1234567890abcdefghijklmnop")
- .cwd("/")
- .build()
- .into();
-
- assert!(stripe_key.should_save(&settings));
- }
-
#[test]
fn test_serialize_deserialize() {
let history = History {
diff --git a/crates/daemon/src/aclient/history/store.rs b/crates/daemon/src/aclient/history/store.rs
index 216f85d6..c62f9068 100644
--- a/crates/daemon/src/aclient/history/store.rs
+++ b/crates/daemon/src/aclient/history/store.rs
@@ -2,14 +2,16 @@ use std::collections::HashSet;
use eyre::{Result, bail, eyre};
use rmp::decode::Bytes;
+use turtle::history::{History, HistoryId};
use crate::aclient::{
database::ClientSqlite,
+ history::HistoryExt,
record::{encryption::PASETO_V4, sqlite_store::SqliteStore},
};
use turtle_common::record::{DecryptedData, Host, HostId, Record, RecordId, RecordIdx};
-use super::{HISTORY_TAG, HISTORY_VERSION, HISTORY_VERSION_V0, History, HistoryId};
+use super::{HISTORY_TAG, HISTORY_VERSION, HISTORY_VERSION_V0};
#[derive(Debug, Clone)]
pub(crate) struct HistoryStore {
diff --git a/crates/daemon/src/aclient/mod.rs b/crates/daemon/src/aclient/mod.rs
index 3f3709a9..f2d14e01 100644
--- a/crates/daemon/src/aclient/mod.rs
+++ b/crates/daemon/src/aclient/mod.rs
@@ -1,11 +1,10 @@
-pub mod database;
+pub(crate) mod database;
pub mod history;
-pub mod record;
-pub mod settings;
+pub(crate) mod record;
+pub(crate) mod settings;
+pub(crate) mod api_client;
pub(crate) mod encryption;
pub(crate) mod meta;
-pub(crate) mod utils;
pub(crate) mod ordering;
-pub(crate) mod secrets;
-pub(crate) mod api_client;
+pub(crate) mod utils;
diff --git a/crates/daemon/src/aclient/ordering.rs b/crates/daemon/src/aclient/ordering.rs
index 84001f52..8fa6498e 100644
--- a/crates/daemon/src/aclient/ordering.rs
+++ b/crates/daemon/src/aclient/ordering.rs
@@ -1,6 +1,7 @@
use minspan::minspan;
+use turtle::history::History;
-use super::{history::History, settings::SearchMode};
+use super::settings::SearchMode;
pub(crate) fn reorder_fuzzy(mode: SearchMode, query: &str, res: Vec<History>) -> Vec<History> {
match mode {
diff --git a/crates/daemon/src/aclient/utils.rs b/crates/daemon/src/aclient/utils.rs
index cf515183..18e732a0 100644
--- a/crates/daemon/src/aclient/utils.rs
+++ b/crates/daemon/src/aclient/utils.rs
@@ -1,18 +1,3 @@
-pub(crate) fn get_hostname() -> String {
- std::env::var("ATUIN_HOST_NAME")
- .unwrap_or_else(|_| whoami::hostname().unwrap_or_else(|_| "unknown-host".to_string()))
-}
-
-pub(crate) fn get_username() -> String {
- std::env::var("ATUIN_HOST_USER")
- .unwrap_or_else(|_| whoami::username().unwrap_or_else(|_| "unknown-user".to_string()))
-}
-
-/// Returns a pair of the hostname and username, separated by a colon.
-pub(crate) fn get_host_user() -> String {
- format!("{}:{}", get_hostname(), get_username())
-}
-
/// Setup a [`SQLite`] database.
///
/// This takes care of correct locking, so that we avoid a race when setting up the database.
diff --git a/crates/daemon/src/api/server/control.rs b/crates/daemon/src/api/control.rs
index a5e26355..a9d9cff3 100644
--- a/crates/daemon/src/api/server/control.rs
+++ b/crates/daemon/src/api/control.rs
@@ -6,15 +6,17 @@ use tokio::time::{self, MissedTickBehavior};
use tonic::{Request, Response, Status};
use tracing::{Level, instrument};
+use turtle::generated::{
+ DAEMON_PROTOCOL_VERSION,
+ control::{
+ ForceSyncReply, ForceSyncRequest, PathsReply, PathsRequest, StatusReply, StatusRequest,
+ control_server::{Control, ControlServer},
+ },
+};
+
use crate::{
+ DAEMON_VERSION,
aclient::{history::store::HistoryStore, record::sync, settings::Settings},
- api::{
- DAEMON_PROTOCOL_VERSION, DAEMON_VERSION,
- generated::control::{
- ForceSyncReply, ForceSyncRequest, PathsReply, PathsRequest, StatusReply, StatusRequest,
- control_server::{Control, ControlServer},
- },
- },
daemon::DaemonHandle,
events::DaemonEvent,
};
diff --git a/crates/daemon/src/api/server/history.rs b/crates/daemon/src/api/history.rs
index 0edf3b94..bcd2ee5a 100644
--- a/crates/daemon/src/api/server/history.rs
+++ b/crates/daemon/src/api/history.rs
@@ -10,20 +10,23 @@ use tracing::{Level, instrument};
use crate::{
aclient::{
database::{ClientSqlite, current_context},
- history::{History, HistoryId, store::HistoryStore},
+ history::store::HistoryStore,
settings::Settings,
},
- api::{
+ daemon::DaemonHandle,
+ events::DaemonEvent,
+};
+use turtle::{
+ generated::{
DAEMON_PROTOCOL_VERSION,
- generated::history::{
+ history::{
EndHistoryReply, EndHistoryRequest, HistoryEntry, HistoryEventKind, HistoryReply,
HistoryRequest, StartHistoryReply, StartHistoryRequest, TailHistoryReply,
TailHistoryRequest,
history_server::{History as HistorySvc, HistoryServer},
},
},
- daemon::DaemonHandle,
- events::DaemonEvent,
+ history::{History, HistoryId},
};
/// The gRPC service implementation.
diff --git a/crates/daemon/src/api/mod.rs b/crates/daemon/src/api/mod.rs
index b7f82a7b..8d475fe9 100644
--- a/crates/daemon/src/api/mod.rs
+++ b/crates/daemon/src/api/mod.rs
@@ -1,6 +1,2 @@
-pub mod client;
-pub(crate) mod generated;
-pub(crate) mod server;
-
-pub(crate) const DAEMON_VERSION: &str = env!("CARGO_PKG_VERSION");
-const DAEMON_PROTOCOL_VERSION: u32 = 1;
+pub(crate) mod control;
+pub(crate) mod history;
diff --git a/crates/daemon/src/api/server/mod.rs b/crates/daemon/src/api/server/mod.rs
deleted file mode 100644
index 8d475fe9..00000000
--- a/crates/daemon/src/api/server/mod.rs
+++ /dev/null
@@ -1,2 +0,0 @@
-pub(crate) mod control;
-pub(crate) mod history;
diff --git a/crates/daemon/src/events.rs b/crates/daemon/src/events.rs
index e3f6ebc9..1864d224 100644
--- a/crates/daemon/src/events.rs
+++ b/crates/daemon/src/events.rs
@@ -7,7 +7,7 @@
//! External processes (like CLI commands) can also inject events via the
//! Control gRPC service.
-use crate::aclient::history::{History, HistoryId};
+use turtle::history::{History, HistoryId};
use turtle_common::record::RecordId;
/// Events that flow through the daemon's event bus.
diff --git a/crates/daemon/src/lib.rs b/crates/daemon/src/lib.rs
deleted file mode 100644
index c6877096..00000000
--- a/crates/daemon/src/lib.rs
+++ /dev/null
@@ -1,161 +0,0 @@
-#![expect(unused_crate_dependencies, reason = "Didn't remove them yet")]
-
-use std::{
- fs::{self, File, OpenOptions},
- io::Write,
- path::{Path, PathBuf},
- time::{Duration, Instant},
-};
-
-use eyre::{Context, Result, bail, eyre};
-use fs4::fs_std::FileExt;
-use tokio::time::sleep;
-
-use crate::{
- aclient::{database::ClientSqlite, record::sqlite_store::SqliteStore, settings::Settings},
- api::{
- DAEMON_VERSION,
- server::{control::ControlService, history::HistoryService},
- },
- daemon::Daemon,
-};
-
-pub mod aclient;
-
-pub mod api;
-pub(crate) mod daemon;
-pub(crate) mod events;
-pub(crate) mod server;
-
-/// Boot the daemon.
-///
-/// This creates a daemon,
-/// starts the gRPC server with services, and runs the event loop.
-pub async fn boot(settings: Settings, store: SqliteStore, history_db: ClientSqlite) -> Result<()> {
- let pidfile_path = PathBuf::from(&settings.daemon.pidfile_path);
- let _pidfile_guard = PidfileGuard::acquire(&pidfile_path)?;
-
- let mut daemon = Daemon::builder(settings.clone())
- .store(store)
- .history_db(history_db.clone())
- .build()?;
-
- let handle = {
- let handle = daemon.handle();
-
- // Spawn signal handler to emit ShutdownRequested on Ctrl+C/SIGTERM
- let signal_handle = handle.clone();
- tokio::spawn(async move {
- shutdown_signal().await;
- tracing::info!("received shutdown signal");
- signal_handle.shutdown();
- });
-
- handle
- };
-
- let history_service = HistoryService::new(handle.clone(), history_db).await?;
- let control_service = ControlService::new(handle.clone());
-
- server::run_grpc_server(
- &settings,
- history_service.into_server(),
- control_service.into_server(),
- handle,
- )?;
-
- daemon.run_event_loop().await?;
-
- tracing::info!("daemon shut down complete");
- Ok(())
-}
-
-/// Wait for a shutdown signal (Ctrl+C or SIGTERM).
-#[cfg(unix)]
-async fn shutdown_signal() {
- let mut term = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
- .expect("failed to register sigterm handler");
- let mut int = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())
- .expect("failed to register sigint handler");
-
- tokio::select! {
- _ = term.recv() => {},
- _ = int.recv() => {},
- }
-}
-
-struct PidfileGuard {
- file: File,
-}
-
-impl PidfileGuard {
- fn acquire(path: &Path) -> Result<Self> {
- let mut file = open_lock_file(path)?;
-
- if !file.try_lock_exclusive()? {
- bail!(
- "daemon already running (pidfile lock busy at {})",
- path.display()
- );
- }
-
- file.set_len(0)
- .wrap_err_with(|| format!("could not truncate daemon pidfile {}", path.display()))?;
- writeln!(file, "{}", std::process::id())
- .and_then(|()| writeln!(file, "{DAEMON_VERSION}"))
- .wrap_err_with(|| format!("could not write daemon pidfile {}", path.display()))?;
-
- Ok(Self { file })
- }
-}
-
-impl Drop for PidfileGuard {
- fn drop(&mut self) {
- drop(self.file.unlock());
- }
-}
-
-fn open_lock_file(path: &Path) -> Result<File> {
- if let Some(parent) = path.parent() {
- fs::create_dir_all(parent)
- .wrap_err_with(|| format!("could not create lock directory {}", parent.display()))?;
- }
-
- OpenOptions::new()
- .read(true)
- .write(true)
- .create(true)
- .truncate(false)
- .open(path)
- .wrap_err_with(|| format!("could not open lock file {}", path.display()))
-}
-
-async fn wait_for_lock(path: &Path, timeout: Duration) -> Result<File> {
- const LOCK_POLL: Duration = Duration::from_millis(20);
-
- let file = open_lock_file(path)?;
- let start = Instant::now();
-
- loop {
- match file.try_lock_exclusive() {
- Ok(true) => return Ok(file),
- Ok(false) => {
- if start.elapsed() >= timeout {
- bail!("timed out waiting for lock at {}", path.display());
- }
-
- sleep(LOCK_POLL).await;
- }
- Err(err) => {
- return Err(eyre!("could not lock {}: {err}", path.display()));
- }
- }
- }
-}
-
-async fn wait_for_pidfile_available(path: &Path, timeout: Duration) -> Result<()> {
- let file = wait_for_lock(path, timeout).await?;
- file.unlock()
- .wrap_err_with(|| format!("failed to unlock {}", path.display()))?;
- Ok(())
-}
diff --git a/crates/daemon/src/main.rs b/crates/daemon/src/main.rs
index 46d9ad4d..59d4c7ff 100644
--- a/crates/daemon/src/main.rs
+++ b/crates/daemon/src/main.rs
@@ -1,15 +1,35 @@
-#![expect(unused_crate_dependencies, reason = "Didn't remove them yet")]
+#![expect(unused_crate_dependencies)]
+
+use std::{
+ fs::{self, File, OpenOptions},
+ io::Write,
+ path::{Path, PathBuf},
+ time::{Duration, Instant},
+};
use clap::Parser;
-use eyre::{Result, WrapErr};
-use std::path::PathBuf;
-use turtle_daemon::aclient::{
- database::ClientSqlite, record::sqlite_store::SqliteStore, settings::Settings,
+use eyre::WrapErr;
+use eyre::{Context, Result, bail, eyre};
+use fs4::fs_std::FileExt;
+use tokio::time::sleep;
+
+use crate::{
+ aclient::{database::ClientSqlite, record::sqlite_store::SqliteStore, settings::Settings},
+ api::{control::ControlService, history::HistoryService},
+ daemon::Daemon,
};
+pub(crate) mod aclient;
+pub(crate) mod api;
+pub(crate) mod daemon;
+pub(crate) mod events;
+pub(crate) mod server;
+
+const DAEMON_VERSION: &str = env!("CARGO_PKG_VERSION");
+
#[derive(Parser, Debug)]
#[command(infer_subcommands = true)]
-pub(crate) enum Cmd {
+enum Cmd {
/// Start the daemon server
Start {
/// Also write daemon logs to the console (useful for debugging)
@@ -28,8 +48,139 @@ async fn main() -> Result<()> {
let sqlite_store = SqliteStore::new(record_store_path, settings.local_timeout).await?;
match Cmd::parse() {
- Cmd::Start { show_logs, .. } => {
- turtle_daemon::boot(settings, sqlite_store, history_db).await
+ Cmd::Start { show_logs, .. } => boot(settings, sqlite_store, history_db).await,
+ }
+}
+
+/// Boot the daemon.
+///
+/// This creates a daemon,
+/// starts the gRPC server with services, and runs the event loop.
+async fn boot(settings: Settings, store: SqliteStore, history_db: ClientSqlite) -> Result<()> {
+ let pidfile_path = PathBuf::from(&settings.daemon.pidfile_path);
+ let _pidfile_guard = PidfileGuard::acquire(&pidfile_path)?;
+
+ let mut daemon = Daemon::builder(settings.clone())
+ .store(store)
+ .history_db(history_db.clone())
+ .build()?;
+
+ let handle = {
+ let handle = daemon.handle();
+
+ // Spawn signal handler to emit ShutdownRequested on Ctrl+C/SIGTERM
+ let signal_handle = handle.clone();
+ tokio::spawn(async move {
+ shutdown_signal().await;
+ tracing::info!("received shutdown signal");
+ signal_handle.shutdown();
+ });
+
+ handle
+ };
+
+ let history_service = HistoryService::new(handle.clone(), history_db).await?;
+ let control_service = ControlService::new(handle.clone());
+
+ server::run_grpc_server(
+ &settings,
+ history_service.into_server(),
+ control_service.into_server(),
+ handle,
+ )?;
+
+ daemon.run_event_loop().await?;
+
+ tracing::info!("daemon shut down complete");
+ Ok(())
+}
+
+/// Wait for a shutdown signal (Ctrl+C or SIGTERM).
+#[cfg(unix)]
+async fn shutdown_signal() {
+ let mut term = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
+ .expect("failed to register sigterm handler");
+ let mut int = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())
+ .expect("failed to register sigint handler");
+
+ tokio::select! {
+ _ = term.recv() => {},
+ _ = int.recv() => {},
+ }
+}
+
+struct PidfileGuard {
+ file: File,
+}
+
+impl PidfileGuard {
+ fn acquire(path: &Path) -> Result<Self> {
+ let mut file = open_lock_file(path)?;
+
+ if !file.try_lock_exclusive()? {
+ bail!(
+ "daemon already running (pidfile lock busy at {})",
+ path.display()
+ );
}
+
+ file.set_len(0)
+ .wrap_err_with(|| format!("could not truncate daemon pidfile {}", path.display()))?;
+ writeln!(file, "{}", std::process::id())
+ .and_then(|()| writeln!(file, "{DAEMON_VERSION}"))
+ .wrap_err_with(|| format!("could not write daemon pidfile {}", path.display()))?;
+
+ Ok(Self { file })
+ }
+}
+
+impl Drop for PidfileGuard {
+ fn drop(&mut self) {
+ drop(self.file.unlock());
+ }
+}
+
+fn open_lock_file(path: &Path) -> Result<File> {
+ if let Some(parent) = path.parent() {
+ fs::create_dir_all(parent)
+ .wrap_err_with(|| format!("could not create lock directory {}", parent.display()))?;
}
+
+ OpenOptions::new()
+ .read(true)
+ .write(true)
+ .create(true)
+ .truncate(false)
+ .open(path)
+ .wrap_err_with(|| format!("could not open lock file {}", path.display()))
+}
+
+async fn wait_for_lock(path: &Path, timeout: Duration) -> Result<File> {
+ const LOCK_POLL: Duration = Duration::from_millis(20);
+
+ let file = open_lock_file(path)?;
+ let start = Instant::now();
+
+ loop {
+ match file.try_lock_exclusive() {
+ Ok(true) => return Ok(file),
+ Ok(false) => {
+ if start.elapsed() >= timeout {
+ bail!("timed out waiting for lock at {}", path.display());
+ }
+
+ sleep(LOCK_POLL).await;
+ }
+ Err(err) => {
+ return Err(eyre!("could not lock {}: {err}", path.display()));
+ }
+ }
+ }
+}
+
+async fn wait_for_pidfile_available(path: &Path, timeout: Duration) -> Result<()> {
+ let file = wait_for_lock(path, timeout).await?;
+ file.unlock()
+ .wrap_err_with(|| format!("failed to unlock {}", path.display()))?;
+ Ok(())
}
diff --git a/crates/daemon/src/server.rs b/crates/daemon/src/server.rs
index 3400ad62..6747f276 100644
--- a/crates/daemon/src/server.rs
+++ b/crates/daemon/src/server.rs
@@ -1,19 +1,14 @@
-use std::os::unix::net::SocketAddr;
-use std::path::PathBuf;
+use std::{os::unix::net::SocketAddr, path::PathBuf};
use eyre::Result;
use eyre::{OptionExt, WrapErr};
-
-#[cfg(unix)]
-use crate::api::server::{control::ControlService, history::HistoryService};
-use crate::{
- aclient::settings::Settings,
- api::generated::{
- control::control_server::ControlServer, history::history_server::HistoryServer,
- },
- daemon::DaemonHandle,
+use turtle::generated::{
+ control::control_server::ControlServer, history::history_server::HistoryServer,
};
+use crate::api::{control::ControlService, history::HistoryService};
+use crate::{aclient::settings::Settings, daemon::DaemonHandle};
+
/// Run the gRPC server with the given services.
///
/// This starts the gRPC server in the background and returns immediately.
diff --git a/crates/turtle/Cargo.toml b/crates/turtle/Cargo.toml
index 3595008d..102c5e9a 100644
--- a/crates/turtle/Cargo.toml
+++ b/crates/turtle/Cargo.toml
@@ -12,6 +12,7 @@ homepage = { workspace = true }
repository = { workspace = true }
[dependencies]
+turtle-common = {workspace = true}
async-trait = "0.1.58"
axum = "0.8"
base64 = "0.22"
diff --git a/crates/daemon/build.rs b/crates/turtle/build.rs
index 62612968..62612968 100644
--- a/crates/daemon/build.rs
+++ b/crates/turtle/build.rs
diff --git a/crates/daemon/proto/control.proto b/crates/turtle/proto/control.proto
index a8026cb8..a8026cb8 100644
--- a/crates/daemon/proto/control.proto
+++ b/crates/turtle/proto/control.proto
diff --git a/crates/daemon/proto/history.proto b/crates/turtle/proto/history.proto
index 850b16b9..850b16b9 100644
--- a/crates/daemon/proto/history.proto
+++ b/crates/turtle/proto/history.proto
diff --git a/crates/daemon/src/api/client/mod.rs b/crates/turtle/src/client/mod.rs
index c588fb09..07f01e6c 100644
--- a/crates/daemon/src/api/client/mod.rs
+++ b/crates/turtle/src/client/mod.rs
@@ -9,26 +9,21 @@ use hyper_util::rt::TokioIo;
#[cfg(unix)]
use tokio::net::UnixStream;
-use crate::api::generated;
-use crate::api::generated::control::{ForceSyncReply, ForceSyncRequest, PathsReply, PathsRequest};
-use crate::api::generated::history::{HistoryEntry, HistoryRequest};
-use crate::{
- aclient::history::History,
- api::{
- DAEMON_PROTOCOL_VERSION, DAEMON_VERSION,
- generated::{
- control::{
- StatusReply, StatusRequest, control_client::ControlClient as ControlServiceClient,
- },
- history::{
- EndHistoryReply, EndHistoryRequest, StartHistoryReply, StartHistoryRequest,
- TailHistoryRequest, history_client::HistoryClient as HistoryServiceClient,
- },
- },
+use crate::generated::{
+ self, DAEMON_PROTOCOL_VERSION,
+ control::{
+ ForceSyncReply, ForceSyncRequest, PathsReply, PathsRequest, StatusReply, StatusRequest,
+ control_client::ControlClient as ControlServiceClient,
+ },
+ history::{
+ EndHistoryReply, EndHistoryRequest, HistoryEntry, HistoryRequest, StartHistoryReply,
+ StartHistoryRequest, TailHistoryRequest,
+ history_client::HistoryClient as HistoryServiceClient,
},
};
-pub use crate::api::generated::history::{HistoryEventKind, TailHistoryReply};
+pub use crate::generated::history::{HistoryEventKind, TailHistoryReply};
+use crate::history::History;
fn normalize_optional_field(value: &str) -> Option<String> {
let trimmed = value.trim();
@@ -60,13 +55,13 @@ pub fn history_entry_to_history(entry: HistoryEntry) -> History {
#[must_use]
pub fn daemon_matches_expected(version: &str, protocol: u32) -> bool {
- version == DAEMON_VERSION && protocol == DAEMON_PROTOCOL_VERSION
+ protocol == DAEMON_PROTOCOL_VERSION
}
#[must_use]
pub fn daemon_mismatch_message(version: &str, protocol: u32) -> String {
if protocol == DAEMON_PROTOCOL_VERSION {
- format!("daemon is out of date: expected {DAEMON_VERSION}, got {version}")
+ unreachable!()
} else {
format!("daemon protocol mismatch: expected {DAEMON_PROTOCOL_VERSION}, got {protocol}")
}
diff --git a/crates/daemon/src/api/generated.rs b/crates/turtle/src/generated.rs
index 304edcd9..e5e28ac7 100644
--- a/crates/daemon/src/api/generated.rs
+++ b/crates/turtle/src/generated.rs
@@ -1,28 +1,23 @@
#![expect(
- unreachable_pub,
unused_qualifications,
clippy::doc_markdown,
clippy::default_trait_access,
clippy::too_many_lines,
- clippy::trivially_copy_pass_by_ref,
clippy::allow_attributes,
clippy::derive_partial_eq_without_eq,
reason = "All of these lints are triggered by the generated code"
)]
-/// Semantic command capture gRPC service types.
-pub(crate) mod semantic {
- tonic::include_proto!("semantic");
-}
+pub const DAEMON_PROTOCOL_VERSION: u32 = 1;
/// History module for the daemon gRPC history service.
///
/// This module contains the proto-generated types for the history gRPC service.
-pub(crate) mod history {
+pub mod history {
tonic::include_proto!("history");
}
/// Control module for external control.
-pub(crate) mod control {
+pub mod control {
tonic::include_proto!("control");
}
diff --git a/crates/turtle/src/history/builder.rs b/crates/turtle/src/history/builder.rs
new file mode 100644
index 00000000..7eca0491
--- /dev/null
+++ b/crates/turtle/src/history/builder.rs
@@ -0,0 +1,78 @@
+use typed_builder::TypedBuilder;
+
+use super::History;
+
+/// 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: time::OffsetDateTime,
+ command: String,
+ cwd: String,
+ exit: i64,
+ duration: i64,
+ session: String,
+ hostname: String,
+ author: String,
+ intent: Option<String>,
+ deleted_at: Option<time::OffsetDateTime>,
+}
+
+impl From<HistoryFromDb> for History {
+ fn from(from_db: HistoryFromDb) -> Self {
+ Self {
+ id: from_db.id.into(),
+ 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,
+ author: from_db.author,
+ intent: from_db.intent,
+ deleted_at: from_db.deleted_at,
+ }
+ }
+}
+
+/// Builder for a history entry that is captured via hook and sent to the daemon
+///
+/// This builder is similar to Capture, but we just require more information up front.
+/// For the old setup, we could just rely on `History::new` to read some of the missing
+/// data. This is no longer the case.
+#[derive(Debug, Clone, TypedBuilder)]
+pub struct HistoryDaemonCapture {
+ timestamp: time::OffsetDateTime,
+ #[builder(setter(into))]
+ command: String,
+ #[builder(setter(into))]
+ cwd: String,
+ #[builder(setter(into))]
+ 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 {
+ fn from(captured: HistoryDaemonCapture) -> Self {
+ Self::new(
+ captured.timestamp,
+ captured.command,
+ captured.cwd,
+ -1,
+ -1,
+ Some(captured.session),
+ Some(captured.hostname),
+ captured.author,
+ captured.intent,
+ None,
+ )
+ }
+}
diff --git a/crates/turtle/src/history/mod.rs b/crates/turtle/src/history/mod.rs
new file mode 100644
index 00000000..10e74d8e
--- /dev/null
+++ b/crates/turtle/src/history/mod.rs
@@ -0,0 +1,300 @@
+use core::fmt::Formatter;
+use regex::RegexSet;
+use std::env;
+use std::fmt::Display;
+
+use turtle_common::utils::uuid_v7;
+
+use time::OffsetDateTime;
+
+use crate::history::secrets::SECRET_PATTERNS_RE;
+
+pub mod builder;
+mod secrets;
+
+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);
+
+impl Display for HistoryId {
+ fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
+ write!(f, "{}", self.0)
+ }
+}
+
+impl From<String> for HistoryId {
+ fn from(s: String) -> Self {
+ Self(s)
+ }
+}
+
+pub(crate) fn get_hostname() -> String {
+ env::var("ATUIN_HOST_NAME")
+ .unwrap_or_else(|_| whoami::hostname().unwrap_or_else(|_| "unknown-host".to_string()))
+}
+
+pub(crate) fn get_username() -> String {
+ env::var("ATUIN_HOST_USER")
+ .unwrap_or_else(|_| whoami::username().unwrap_or_else(|_| "unknown-user".to_string()))
+}
+
+/// Returns a pair of the hostname and username, separated by a colon.
+#[must_use]
+pub fn get_host_user() -> String {
+ format!("{}:{}", get_hostname(), get_username())
+}
+
+/// 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 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.
+ ///
+ /// Stored as `client_id` in the database.
+ pub id: HistoryId,
+
+ /// When the command was run.
+ pub timestamp: OffsetDateTime,
+
+ /// 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,
+
+ /// 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>,
+}
+
+impl History {
+ #[must_use]
+ pub 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())
+ }
+ })
+ }
+
+ #[expect(clippy::too_many_arguments)]
+ fn new(
+ timestamp: OffsetDateTime,
+ command: String,
+ cwd: String,
+ exit: i64,
+ 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(),
+ timestamp,
+ command,
+ cwd,
+ exit,
+ duration,
+ session,
+ hostname,
+ author,
+ intent,
+ deleted_at,
+ }
+ }
+
+ /// Builder for a history entry that is captured via hook, and sent to the daemon.
+ ///
+ /// 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`.
+ ///
+ /// It does, however, include information that can usually be inferred.
+ ///
+ /// This is because the daemon we are sending a request to lacks the context of the command
+ ///
+ /// ## Examples
+ /// ```rust
+ /// use crate::aclient::history::History;
+ ///
+ /// let history: History = History::daemon()
+ /// .timestamp(time::OffsetDateTime::now_utc())
+ /// .command("ls -la")
+ /// .cwd("/home/user")
+ /// .session("018deb6e8287781f9973ef40e0fde76b")
+ /// .hostname("computer:ellie")
+ /// .build()
+ /// .into();
+ /// ```
+ ///
+ /// Command without any required info cannot be captured, which is forced at compile time:
+ ///
+ /// ```compile_fail
+ /// use crate::aclient::history::History;
+ ///
+ /// // this will not compile because `hostname` is missing
+ /// let history: History = History::daemon()
+ /// .timestamp(time::OffsetDateTime::now_utc())
+ /// .command("ls -la")
+ /// .cwd("/home/user")
+ /// .session("018deb6e8287781f9973ef40e0fde76b")
+ /// .build()
+ /// .into();
+ /// ```
+ pub fn daemon() -> builder::HistoryDaemonCaptureBuilder {
+ builder::HistoryDaemonCapture::builder()
+ }
+
+ #[doc(hidden)]
+ pub fn from_db() -> builder::HistoryFromDbBuilder {
+ builder::HistoryFromDb::builder()
+ }
+
+ pub fn should_save(&self, filter: SettingsFilter<'_>) -> bool {
+ !(self.command.is_empty()
+ || filter.history.is_match(&self.command)
+ || filter.cwd.is_match(&self.cwd)
+ || (filter.secrets && SECRET_PATTERNS_RE.is_match(&self.command)))
+ }
+}
+
+#[derive(Debug, Copy, Clone)]
+pub struct SettingsFilter<'a> {
+ pub history: &'a RegexSet,
+ pub cwd: &'a RegexSet,
+ pub secrets: bool,
+}
+
+#[cfg(test)]
+mod tests {
+ // use regex::RegexSet;
+ //
+ // use crate::history::History;
+
+ // // Test that we don't save history where necessary
+ // #[test]
+ // fn privacy_test() {
+ // let settings = Settings {
+ // cwd_filter: RegexSet::new(["^/supasecret"]).unwrap(),
+ // history_filter: RegexSet::new(["^psql"]).unwrap(),
+ // ..Settings::default()
+ // };
+ //
+ // let normal_command: History = History::daemon()
+ // .timestamp(time::OffsetDateTime::now_utc())
+ // .command("echo foo")
+ // .cwd("/")
+ // .build()
+ // .into();
+ //
+ // let with_space: History = History::daemon()
+ // .timestamp(time::OffsetDateTime::now_utc())
+ // .command(" echo bar")
+ // .cwd("/")
+ // .build()
+ // .into();
+ //
+ // let empty: History = History::daemon()
+ // .timestamp(time::OffsetDateTime::now_utc())
+ // .command("")
+ // .cwd("/")
+ // .build()
+ // .into();
+ //
+ // let stripe_key: History = History::daemon()
+ // .timestamp(time::OffsetDateTime::now_utc())
+ // .command("curl foo.com/bar?key=sk_test_1234567890abcdefghijklmnop")
+ // .cwd("/")
+ // .build()
+ // .into();
+ //
+ // let secret_dir: History = History::daemon()
+ // .timestamp(time::OffsetDateTime::now_utc())
+ // .command("echo ohno")
+ // .cwd("/supasecret")
+ // .build()
+ // .into();
+ //
+ // let with_psql: History = History::daemon()
+ // .timestamp(time::OffsetDateTime::now_utc())
+ // .command("psql")
+ // .cwd("/supasecret")
+ // .build()
+ // .into();
+ //
+ // assert!(normal_command.should_save(&settings));
+ // assert!(!with_space.should_save(&settings));
+ // assert!(!empty.should_save(&settings));
+ // assert!(!stripe_key.should_save(&settings));
+ // assert!(!secret_dir.should_save(&settings));
+ // assert!(!with_psql.should_save(&settings));
+ // }
+ //
+ // #[test]
+ // fn disable_secrets() {
+ // let settings = Settings {
+ // secrets_filter: false,
+ // ..Settings::new().unwrap()
+ // };
+ //
+ // let stripe_key: History = History::capture()
+ // .timestamp(time::OffsetDateTime::now_utc())
+ // .command("curl foo.com/bar?key=sk_test_1234567890abcdefghijklmnop")
+ // .cwd("/")
+ // .build()
+ // .into();
+ //
+ // assert!(stripe_key.should_save(&settings));
+ // }
+}
diff --git a/crates/daemon/src/aclient/secrets.rs b/crates/turtle/src/history/secrets.rs
index 08d24339..08d24339 100644
--- a/crates/daemon/src/aclient/secrets.rs
+++ b/crates/turtle/src/history/secrets.rs
diff --git a/crates/turtle/src/lib.rs b/crates/turtle/src/lib.rs
index e69de29b..c78b0475 100644
--- a/crates/turtle/src/lib.rs
+++ b/crates/turtle/src/lib.rs
@@ -0,0 +1,5 @@
+#![expect(unused_crate_dependencies)]
+
+pub mod client;
+pub mod generated;
+pub mod history;