aboutsummaryrefslogtreecommitdiffstats
path: root/crates
diff options
context:
space:
mode:
authorBenedikt Peetz <benedikt.peetz@b-peetz.de>2026-07-09 22:00:59 +0200
committerBenedikt Peetz <benedikt.peetz@b-peetz.de>2026-07-09 22:00:59 +0200
commit620cf8fa33835c1ce1e56b1028ff6e2a6fbe0f39 (patch)
treee97e54ff112c8e3fa9f88335bfd1275420a4d69f /crates
parentchore: Separate daemon, client, server, and lib into crates (diff)
downloadatuin-620cf8fa33835c1ce1e56b1028ff6e2a6fbe0f39.zip
chore: Add more things
Diffstat (limited to 'crates')
-rw-r--r--crates/common/Cargo.toml103
-rw-r--r--crates/common/src/api.rs22
-rw-r--r--crates/common/src/lib.rs58
-rw-r--r--crates/common/src/record.rs284
-rw-r--r--crates/common/src/shell.rs52
-rw-r--r--crates/common/src/tls.rs15
-rw-r--r--crates/common/src/utils.rs257
-rw-r--r--crates/daemon/Cargo.toml1
-rw-r--r--crates/daemon/build.rs58
-rw-r--r--crates/daemon/src/client.rs42
-rw-r--r--crates/daemon/src/components/history.rs10
-rw-r--r--crates/daemon/src/components/mod.rs16
-rw-r--r--crates/daemon/src/components/search.rs12
-rw-r--r--crates/daemon/src/components/semantic.rs12
-rw-r--r--crates/daemon/src/components/sync.rs6
-rw-r--r--crates/daemon/src/control/mod.rs6
-rw-r--r--crates/daemon/src/daemon.rs50
-rw-r--r--crates/daemon/src/events.rs4
-rw-r--r--crates/daemon/src/generated.rs10
-rw-r--r--crates/daemon/src/lib.rs26
-rw-r--r--crates/daemon/src/search/mod.rs54
-rw-r--r--crates/daemon/src/server.rs4
-rw-r--r--crates/server/Cargo.toml1
-rw-r--r--crates/server/src/database/db/mod.rs18
-rw-r--r--crates/server/src/database/db/wrappers.rs4
-rw-r--r--crates/server/src/database/mod.rs18
-rw-r--r--crates/server/src/database/models.rs4
-rw-r--r--crates/server/src/handlers/mod.rs14
-rw-r--r--crates/server/src/handlers/v0/mod.rs2
-rw-r--r--crates/server/src/handlers/v0/record.rs10
-rw-r--r--crates/server/src/lib.rs12
-rw-r--r--crates/server/src/main.rs61
-rw-r--r--crates/server/src/metrics.rs4
-rw-r--r--crates/server/src/router.rs17
-rw-r--r--crates/server/src/settings.rs30
35 files changed, 1067 insertions, 230 deletions
diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml
new file mode 100644
index 00000000..0fd7f065
--- /dev/null
+++ b/crates/common/Cargo.toml
@@ -0,0 +1,103 @@
+[package]
+name = "turtle-common"
+edition = "2024"
+description = "common definitions and types needed by all the turtle-* crates"
+readme = "./README.md"
+
+rust-version = { workspace = true }
+version = { workspace = true }
+authors = { workspace = true }
+license = { workspace = true }
+homepage = { workspace = true }
+repository = { workspace = true }
+
+[dependencies]
+async-trait = "0.1.58"
+atuin-nucleo-matcher = { workspace = true }
+atuin-nucleo = { workspace = true }
+axum = "0.8"
+base64 = "0.22"
+clap = { version = "4.5.7", features = ["derive"] }
+clap_complete = "4.5.8"
+clap_complete_nushell = "4.5.4"
+colored = "2.0.4"
+config = { version = "0.15.8", default-features = false, features = ["toml"] }
+crossterm = {version = "0.29.0", features = ["use-dev-tty", "serde"] }
+crypto_secretbox = "0.1.1"
+dashmap = "6.1.0"
+directories = "6.0.0"
+eyre = "0.6"
+fs-err = "3.1"
+fs4 = "0.13.1"
+fuzzy-matcher = "0.3.7"
+hyper-util = "0.1"
+indicatif = "0.18.0"
+interim = { version = "0.2.0", features = ["time_0_3"] }
+itertools = "0.14.0"
+lasso = { version = "0.7", features = ["multi-threaded"] }
+log = "0.4"
+metrics = "0.24"
+metrics-exporter-prometheus = { version = "0.18", default-features = false }
+minspan = "0.1.5"
+norm = { version = "0.1.1", features = ["fzf-v2"] }
+notify = "7"
+prost = "0.14"
+rand = { version = "0.8.5", features = ["std"] }
+ratatui = "0.30.0"
+regex = "1.10.5"
+reqwest = { version = "0.13", features = ["json", "rustls-no-provider", "stream"], default-features = false }
+rmp = { version = "0.8.14" }
+runtime-format = "0.1.3"
+rustix = { version = "1.1.4", features = ["process", "fs"] }
+rustls = { version = "0.23", default-features = false, features = [ "ring", "std", "tls12", ] }
+rusty_paserk = { version = "0.5.0", default-features = false, features = [ "v4", "serde", ] }
+rusty_paseto = { version = "0.8.0", default-features = false }
+semver = "1.0.20"
+serde = { version = "1.0.202", features = ["derive"] }
+serde_json = "1.0.119"
+serde_regex = "1.1.0"
+serde_with = "3.8.1"
+shellexpand = "3"
+sql-builder = "3"
+sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "time", "postgres", "uuid", "sqlite", "regexp"] }
+thiserror = "2"
+time = { version = "0.3.47", features = [ "serde-human-readable", "macros", "local-offset", "macros", "formatting", "parsing"] }
+tokio = { version = "1", features = ["full"] }
+tokio-stream = { version = "0.1.14", features = ["net"] }
+toml_edit = "0.25.4"
+tonic = "0.14"
+tonic-prost = "0.14"
+tower = "0.5"
+tower-http = { version = "0.6", features = ["trace"] }
+tracing = "0.1"
+tracing-appender = "0.2"
+tracing-subscriber = { version = "0.3", features = ["ansi", "fmt", "registry", "env-filter", "json"] }
+typed-builder = "0.18.2"
+unicode-segmentation = "1.11.0"
+unicode-width = "0.2"
+url = "2.5.2"
+uuid = { version = "1.9", features = ["v4", "v7", "serde"] }
+vt100 = "0.16"
+whoami = "2.1.0"
+
+[target.'cfg(target_os = "linux")'.dependencies]
+arboard = { version = "3.4", default-features = false, features = [ "wayland-data-control", ] }
+listenfd = "1.0.1"
+
+[target.'cfg(unix)'.dependencies]
+daemonize = "0.5.0"
+portable-pty = "0.9"
+signal-hook = "0.3"
+
+[dev-dependencies]
+tokio = { version = "1", features = ["full"] }
+
+[build-dependencies]
+protox = "0.9"
+tonic-prost-build = "0.14"
+
+[package.metadata.docs.rs]
+all-features = true
+
+[lints]
+workspace = true
diff --git a/crates/common/src/api.rs b/crates/common/src/api.rs
new file mode 100644
index 00000000..0e34171d
--- /dev/null
+++ b/crates/common/src/api.rs
@@ -0,0 +1,22 @@
+use semver::Version;
+use serde::{Deserialize, Serialize};
+use std::borrow::Cow;
+use std::sync::LazyLock;
+
+// the usage of X- has been deprecated for quite along time, it turns out
+pub static ATUIN_HEADER_VERSION: &str = "Atuin-Version";
+pub static ATUIN_CARGO_VERSION: &str = env!("CARGO_PKG_VERSION");
+
+pub static ATUIN_VERSION: LazyLock<Version> =
+ LazyLock::new(|| Version::parse(ATUIN_CARGO_VERSION).expect("failed to parse self semver"));
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct ErrorResponse<'a> {
+ pub reason: Cow<'a, str>,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct IndexResponse {
+ pub homage: String,
+ pub version: String,
+}
diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs
new file mode 100644
index 00000000..d886520d
--- /dev/null
+++ b/crates/common/src/lib.rs
@@ -0,0 +1,58 @@
+/// Defines a new UUID type wrapper
+macro_rules! new_uuid {
+ ($name:ident) => {
+ #[derive(
+ Debug,
+ Copy,
+ Clone,
+ PartialEq,
+ Eq,
+ Hash,
+ PartialOrd,
+ Ord,
+ serde::Serialize,
+ serde::Deserialize,
+ )]
+ #[serde(transparent)]
+ pub struct $name(pub Uuid);
+
+ impl<DB: sqlx::Database> sqlx::Type<DB> for $name
+ where
+ Uuid: sqlx::Type<DB>,
+ {
+ fn type_info() -> <DB as sqlx::Database>::TypeInfo {
+ Uuid::type_info()
+ }
+ }
+
+ impl<'r, DB: sqlx::Database> sqlx::Decode<'r, DB> for $name
+ where
+ Uuid: sqlx::Decode<'r, DB>,
+ {
+ fn decode(
+ value: DB::ValueRef<'r>,
+ ) -> std::result::Result<Self, sqlx::error::BoxDynError> {
+ Uuid::decode(value).map(Self)
+ }
+ }
+
+ impl<'q, DB: sqlx::Database> sqlx::Encode<'q, DB> for $name
+ where
+ Uuid: sqlx::Encode<'q, DB>,
+ {
+ fn encode_by_ref(
+ &self,
+ buf: &mut DB::ArgumentBuffer<'q>,
+ ) -> Result<sqlx::encode::IsNull, Box<dyn std::error::Error + Send + Sync + 'static>>
+ {
+ self.0.encode_by_ref(buf)
+ }
+ }
+ };
+}
+
+pub mod api;
+pub mod record;
+pub mod shell;
+pub mod tls;
+pub mod utils;
diff --git a/crates/common/src/record.rs b/crates/common/src/record.rs
new file mode 100644
index 00000000..c5985cea
--- /dev/null
+++ b/crates/common/src/record.rs
@@ -0,0 +1,284 @@
+use std::collections::HashMap;
+
+use eyre::Result;
+use serde::{Deserialize, Serialize};
+use typed_builder::TypedBuilder;
+use uuid::Uuid;
+
+#[derive(Clone, Debug, PartialEq)]
+pub struct DecryptedData(pub Vec<u8>);
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct EncryptedData {
+ pub data: String,
+ pub content_encryption_key: String,
+}
+
+#[derive(Debug, PartialEq, PartialOrd, Ord, Eq)]
+pub struct Diff {
+ pub host: HostId,
+ pub tag: String,
+ pub local: Option<RecordIdx>,
+ pub remote: Option<RecordIdx>,
+}
+
+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
+pub struct Host {
+ pub id: HostId,
+ pub name: String,
+}
+
+impl Host {
+ pub fn new(id: HostId) -> Self {
+ Self {
+ id,
+ name: String::new(),
+ }
+ }
+}
+
+new_uuid!(RecordId);
+new_uuid!(HostId);
+
+pub type RecordIdx = u64;
+
+/// A single record stored inside of our local database
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TypedBuilder)]
+pub struct Record<Data> {
+ /// a unique ID
+ #[builder(default = RecordId(crate::utils::uuid_v7()))]
+ pub id: RecordId,
+
+ /// The integer record ID. This is only unique per (host, tag).
+ pub idx: RecordIdx,
+
+ /// The unique ID of the host.
+ // TODO(ellie): Optimize the storage here. We use a bunch of IDs, and currently store
+ // as strings. I would rather avoid normalization, so store as UUID binary instead of
+ // encoding to a string and wasting much more storage.
+ pub host: Host,
+
+ /// The creation time in nanoseconds since unix epoch
+ #[builder(default = time::OffsetDateTime::now_utc().unix_timestamp_nanos() as u64)]
+ pub timestamp: u64,
+
+ /// The version the data in the entry conforms to
+ // However we want to track versions for this tag, eg v2
+ pub version: String,
+
+ /// The type of data we are storing here. Eg, "history"
+ pub tag: String,
+
+ /// Some data. This can be anything you wish to store. Use the tag field to know how to handle it.
+ pub data: Data,
+}
+
+/// Extra data from the record that should be encoded in the data
+#[derive(Debug, Copy, Clone)]
+pub struct AdditionalData<'a> {
+ pub id: &'a RecordId,
+ pub idx: &'a u64,
+ pub version: &'a str,
+ pub tag: &'a str,
+ pub host: &'a HostId,
+}
+
+/// An index representing the current state of the record stores
+/// This can be both remote, or local, and compared in either direction
+#[derive(Debug, Serialize, Deserialize)]
+pub struct RecordStatus {
+ // A map of host -> tag -> max(idx)
+ pub hosts: HashMap<HostId, HashMap<String, RecordIdx>>,
+}
+
+impl Default for RecordStatus {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+impl Extend<(HostId, String, RecordIdx)> for RecordStatus {
+ fn extend<T: IntoIterator<Item = (HostId, String, RecordIdx)>>(&mut self, iter: T) {
+ for (host, tag, tail_idx) in iter {
+ self.set_raw(host, tag, tail_idx);
+ }
+ }
+}
+
+impl RecordStatus {
+ pub fn new() -> Self {
+ Self {
+ hosts: HashMap::new(),
+ }
+ }
+
+ /// Insert a new tail record into the store
+ pub fn set_raw(&mut self, host: HostId, tag: String, tail_id: RecordIdx) {
+ self.hosts.entry(host).or_default().insert(tag, tail_id);
+ }
+
+ pub fn get(&self, host: HostId, tag: &str) -> Option<RecordIdx> {
+ self.hosts.get(&host).and_then(|v| v.get(tag)).copied()
+ }
+
+ /// Diff this index with another, likely remote index.
+ /// The two diffs can then be reconciled, and the optimal change set calculated
+ /// Returns a tuple, with (host, tag, Option(OTHER))
+ /// OTHER is set to the value of the idx on the other machine. If it is greater than our index,
+ /// then we need to do some downloading. If it is smaller, then we need to do some uploading
+ /// Note that we cannot upload if we are not the owner of the record store - hosts can only
+ /// write to their own store.
+ pub fn diff(&self, other: &Self) -> Vec<Diff> {
+ let mut ret = Vec::new();
+
+ // First, we check if other has everything that self has
+ for (host, tag_map) in &self.hosts {
+ for (tag, idx) in tag_map {
+ match other.get(*host, tag) {
+ // The other store is all up to date! No diff.
+ Some(t) if t.eq(idx) => (),
+
+ // The other store does exist, and it is either ahead or behind us. A diff regardless
+ Some(t) => ret.push(Diff {
+ host: *host,
+ tag: tag.clone(),
+ local: Some(*idx),
+ remote: Some(t),
+ }),
+
+ // The other store does not exist :O
+ None => ret.push(Diff {
+ host: *host,
+ tag: tag.clone(),
+ local: Some(*idx),
+ remote: None,
+ }),
+ }
+ }
+ }
+
+ // At this point, there is a single case we have not yet considered.
+ // If the other store knows of a tag that we are not yet aware of, then the diff will be missed
+
+ // account for that!
+ for (host, tag_map) in &other.hosts {
+ for (tag, idx) in tag_map {
+ match self.get(*host, tag) {
+ // If we have this host/tag combo, the comparison and diff will have already happened above
+ Some(_) => (),
+
+ None => ret.push(Diff {
+ host: *host,
+ tag: tag.clone(),
+ remote: Some(*idx),
+ local: None,
+ }),
+ }
+ }
+ }
+
+ // Stability is a nice property to have
+ ret.sort();
+ ret
+ }
+}
+
+pub trait Encryption {
+ fn re_encrypt(
+ data: EncryptedData,
+ ad: AdditionalData<'_>,
+ old_key: &[u8; 32],
+ new_key: &[u8; 32],
+ ) -> Result<EncryptedData> {
+ let data = Self::decrypt(data, ad, old_key)?;
+ Ok(Self::encrypt(data, ad, new_key))
+ }
+ fn encrypt(data: DecryptedData, ad: AdditionalData<'_>, key: &[u8; 32]) -> EncryptedData;
+ fn decrypt(
+ data: EncryptedData,
+ ad: AdditionalData<'_>,
+ key: &[u8; 32],
+ ) -> Result<DecryptedData>;
+}
+
+impl Record<DecryptedData> {
+ pub fn encrypt<E: Encryption>(self, key: &[u8; 32]) -> Record<EncryptedData> {
+ let ad = AdditionalData {
+ id: &self.id,
+ version: &self.version,
+ tag: &self.tag,
+ host: &self.host.id,
+ idx: &self.idx,
+ };
+ Record {
+ data: E::encrypt(self.data, ad, key),
+ id: self.id,
+ host: self.host,
+ idx: self.idx,
+ timestamp: self.timestamp,
+ version: self.version,
+ tag: self.tag,
+ }
+ }
+}
+
+impl Record<EncryptedData> {
+ pub fn decrypt<E: Encryption>(self, key: &[u8; 32]) -> Result<Record<DecryptedData>> {
+ let ad = AdditionalData {
+ id: &self.id,
+ version: &self.version,
+ tag: &self.tag,
+ host: &self.host.id,
+ idx: &self.idx,
+ };
+ Ok(Record {
+ data: E::decrypt(self.data, ad, key)?,
+ id: self.id,
+ host: self.host,
+ idx: self.idx,
+ timestamp: self.timestamp,
+ version: self.version,
+ tag: self.tag,
+ })
+ }
+
+ pub fn re_encrypt<E: Encryption>(
+ self,
+ old_key: &[u8; 32],
+ new_key: &[u8; 32],
+ ) -> Result<Self> {
+ let ad = AdditionalData {
+ id: &self.id,
+ version: &self.version,
+ tag: &self.tag,
+ host: &self.host.id,
+ idx: &self.idx,
+ };
+ Ok(Self {
+ data: E::re_encrypt(self.data, ad, old_key, new_key)?,
+ id: self.id,
+ host: self.host,
+ idx: self.idx,
+ timestamp: self.timestamp,
+ version: self.version,
+ tag: self.tag,
+ })
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use crate::record::{Host, HostId};
+
+ use super::{DecryptedData, Record};
+
+ fn test_record() -> Record<DecryptedData> {
+ Record::builder()
+ .host(Host::new(HostId(crate::utils::uuid_v7())))
+ .version("v1".into())
+ .tag(crate::utils::uuid_v7().simple().to_string())
+ .data(DecryptedData(vec![0, 1, 2, 3]))
+ .idx(0)
+ .build()
+ }
+}
diff --git a/crates/common/src/shell.rs b/crates/common/src/shell.rs
new file mode 100644
index 00000000..8bab806a
--- /dev/null
+++ b/crates/common/src/shell.rs
@@ -0,0 +1,52 @@
+#[derive(PartialEq)]
+pub enum Shell {
+ Sh,
+ Bash,
+ Fish,
+ Zsh,
+ Xonsh,
+ Nu,
+ Powershell,
+
+ Unknown,
+}
+
+impl std::fmt::Display for Shell {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ let shell = match self {
+ Self::Bash => "bash",
+ Self::Fish => "fish",
+ Self::Zsh => "zsh",
+ Self::Nu => "nu",
+ Self::Xonsh => "xonsh",
+ Self::Sh => "sh",
+ Self::Powershell => "powershell",
+
+ Self::Unknown => "unknown",
+ };
+
+ write!(f, "{shell}")
+ }
+}
+
+impl Shell {
+ pub fn from_env() -> Self {
+ std::env::var("ATUIN_SHELL").map_or(Self::Unknown, |shell| {
+ Self::from_string(shell.trim().to_lowercase().as_str())
+ })
+ }
+
+ pub fn from_string(name: &str) -> Self {
+ match name {
+ "bash" => Self::Bash,
+ "fish" => Self::Fish,
+ "zsh" => Self::Zsh,
+ "xonsh" => Self::Xonsh,
+ "nu" => Self::Nu,
+ "sh" => Self::Sh,
+ "powershell" => Self::Powershell,
+
+ _ => Self::Unknown,
+ }
+ }
+}
diff --git a/crates/common/src/tls.rs b/crates/common/src/tls.rs
new file mode 100644
index 00000000..e8c840e0
--- /dev/null
+++ b/crates/common/src/tls.rs
@@ -0,0 +1,15 @@
+use std::sync::Once;
+
+static INIT: Once = Once::new();
+
+/// Ensure the rustls crypto provider (ring) is installed.
+///
+/// Must be called before creating any reqwest clients. Safe to call
+/// multiple times — only the first call installs the provider.
+pub fn ensure_crypto_provider() {
+ INIT.call_once(|| {
+ rustls::crypto::ring::default_provider()
+ .install_default()
+ .expect("Failed to install rustls crypto provider");
+ });
+}
diff --git a/crates/common/src/utils.rs b/crates/common/src/utils.rs
new file mode 100644
index 00000000..d6077ee7
--- /dev/null
+++ b/crates/common/src/utils.rs
@@ -0,0 +1,257 @@
+use std::borrow::Cow;
+use std::env;
+use std::path::{Path, PathBuf};
+
+use uuid::Uuid;
+
+pub fn uuid_v7() -> Uuid {
+ Uuid::now_v7()
+}
+
+pub fn has_git_dir(path: &str) -> bool {
+ let mut gitdir = PathBuf::from(path);
+ gitdir.push(".git");
+
+ gitdir.exists()
+}
+
+// in a git worktree, .git is a file containing "gitdir: <path>" pointing
+// to the main repo's .git/worktrees/<name> directory. follow the pointer
+// back to the main repo root so all worktrees share a workspace.
+fn resolve_git_worktree(path: &Path) -> Option<PathBuf> {
+ let git_path = path.join(".git");
+
+ if !git_path.is_file() {
+ return None;
+ }
+
+ let contents = std::fs::read_to_string(&git_path).ok()?;
+ let gitdir_str = contents.strip_prefix("gitdir: ")?.trim();
+
+ let gitdir = PathBuf::from(gitdir_str);
+ let gitdir = if gitdir.is_absolute() {
+ gitdir
+ } else {
+ path.join(gitdir_str)
+ };
+
+ // walk up from e.g. /repo/.git/worktrees/feature to find /repo
+ let mut candidate = gitdir.as_path();
+ while let Some(parent) = candidate.parent() {
+ if parent.join(".git").is_dir() {
+ return Some(parent.to_path_buf());
+ }
+ candidate = parent;
+ }
+
+ None
+}
+
+// detect if any parent dir has a git repo in it
+// I really don't want to bring in libgit for something simple like this
+// If we start to do anything more advanced, then perhaps
+pub fn in_git_repo(path: &str) -> Option<PathBuf> {
+ let mut gitdir = PathBuf::from(path);
+
+ while gitdir.parent().is_some() && !has_git_dir(gitdir.to_str().unwrap()) {
+ gitdir.pop();
+ }
+
+ // No parent? then we hit root, finding no git
+ if gitdir.parent().is_some() {
+ // if .git is a file (worktree), resolve to the main repo root
+ if let Some(main_repo) = resolve_git_worktree(&gitdir) {
+ return Some(main_repo);
+ }
+ return Some(gitdir);
+ }
+
+ None
+}
+
+// TODO: more reliable, more tested
+// I don't want to use ProjectDirs, it puts config in awkward places on
+// mac. Data too. Seems to be more intended for GUI apps.
+
+pub fn home_dir() -> PathBuf {
+ directories::BaseDirs::new()
+ .map(|d| d.home_dir().to_path_buf())
+ .expect("could not determine home directory")
+}
+
+pub fn config_dir() -> PathBuf {
+ let config_dir =
+ env::var("XDG_CONFIG_HOME").map_or_else(|_| home_dir().join(".config"), PathBuf::from);
+ config_dir.join("atuin")
+}
+
+pub fn data_dir() -> PathBuf {
+ let data_dir = env::var("XDG_DATA_HOME")
+ .map_or_else(|_| home_dir().join(".local").join("share"), PathBuf::from);
+
+ data_dir.join("atuin")
+}
+
+pub fn runtime_dir() -> PathBuf {
+ env::var("XDG_RUNTIME_DIR").map_or_else(|_| data_dir(), PathBuf::from)
+}
+
+pub fn logs_dir() -> PathBuf {
+ home_dir().join(".atuin").join("logs")
+}
+
+pub fn get_current_dir() -> String {
+ // Prefer PWD environment variable over cwd if available to better support symbolic links
+ env::var("PWD").unwrap_or_else(|_| {
+ env::current_dir().map_or_else(|_| String::new(), |dir| dir.display().to_string())
+ })
+}
+
+pub fn broken_symlink<P: Into<PathBuf>>(path: P) -> bool {
+ let path = path.into();
+ path.is_symlink() && !path.exists()
+}
+
+/// Extension trait for anything that can behave like a string to make it easy to escape control
+/// characters.
+///
+/// Intended to help prevent control characters being printed and interpreted by the terminal when
+/// printing history as well as to ensure the commands that appear in the interactive search
+/// reflect the actual command run rather than just the printable characters.
+pub trait Escapable: AsRef<str> {
+ fn escape_control(&self) -> Cow<'_, str> {
+ if self.as_ref().contains(|c: char| c.is_ascii_control()) {
+ let mut remaining = self.as_ref();
+ // Not a perfect way to reserve space but should reduce the allocations
+ let mut buf = String::with_capacity(remaining.len());
+ while let Some(i) = remaining.find(|c: char| c.is_ascii_control()) {
+ // safe to index with `..i`, `i` and `i+1..` as part[i] is a single byte ascii char
+ buf.push_str(&remaining[..i]);
+ buf.push('^');
+ buf.push(match remaining.as_bytes()[i] {
+ 0x7F => '?',
+ code => char::from_u32(u32::from(code) + 64).unwrap(),
+ });
+ remaining = &remaining[i + 1..];
+ }
+ buf.push_str(remaining);
+ buf.into()
+ } else {
+ self.as_ref().into()
+ }
+ }
+}
+
+impl<T: AsRef<str>> Escapable for T {}
+
+#[cfg(test)]
+mod tests {
+ use super::{Cow, Escapable, Uuid, env, in_git_repo, uuid_v7};
+
+ use std::collections::HashSet;
+
+ #[test]
+ fn uuid_is_unique() {
+ let how_many: usize = 1_000_000;
+
+ // for peace of mind
+ let mut uuids: HashSet<Uuid> = HashSet::with_capacity(how_many);
+
+ // there will be many in the same millisecond
+ for _ in 0..how_many {
+ let uuid = uuid_v7();
+ uuids.insert(uuid);
+ }
+
+ assert_eq!(uuids.len(), how_many);
+ }
+
+ #[test]
+ fn escape_control_characters() {
+ use super::Escapable;
+ // CSI colour sequence
+ assert_eq!("\x1b[31mfoo".escape_control(), "^[[31mfoo");
+
+ // Tabs count as control chars
+ assert_eq!("foo\tbar".escape_control(), "foo^Ibar");
+
+ // space is in control char range but should be excluded
+ assert_eq!("two words".escape_control(), "two words");
+
+ // unicode multi-byte characters
+ let s = "🐢\x1b[32m🦀";
+ assert_eq!(s.escape_control(), s.replace("\x1b", "^["));
+ }
+
+ #[test]
+ fn escape_no_control_characters() {
+ use super::Escapable as _;
+ assert!(matches!(
+ "no control characters".escape_control(),
+ Cow::Borrowed(_)
+ ));
+ assert!(matches!(
+ "with \x1b[31mcontrol\x1b[0m characters".escape_control(),
+ Cow::Owned(_)
+ ));
+ }
+
+ #[cfg(not(windows))]
+ #[test]
+ fn in_git_repo_regular() {
+ // regular git repo should resolve to the directory containing .git
+ let tmp = env::temp_dir().join("atuin-test-regular-git");
+ drop(std::fs::remove_dir_all(&tmp));
+ let subdir = tmp.join("src").join("deep");
+ std::fs::create_dir_all(&subdir).unwrap();
+ std::fs::create_dir_all(tmp.join(".git")).unwrap();
+
+ let result = in_git_repo(subdir.to_str().unwrap());
+ assert_eq!(result, Some(tmp.clone()));
+
+ std::fs::remove_dir_all(&tmp).unwrap();
+ }
+
+ #[cfg(not(windows))]
+ #[test]
+ fn in_git_repo_worktree_resolves_to_main_repo() {
+ // worktree .git is a file pointing back to the main repo —
+ // in_git_repo should follow it so all worktrees share a workspace
+ let tmp = env::temp_dir().join("atuin-test-worktree-git");
+ drop(std::fs::remove_dir_all(&tmp));
+
+ // main repo at tmp/main with a real .git directory
+ let main_repo = tmp.join("main");
+ let worktree_git_dir = main_repo.join(".git").join("worktrees").join("feature");
+ std::fs::create_dir_all(&worktree_git_dir).unwrap();
+
+ // worktree at tmp/worktree with a .git file
+ let worktree = tmp.join("worktree");
+ let worktree_subdir = worktree.join("src");
+ std::fs::create_dir_all(&worktree_subdir).unwrap();
+ std::fs::write(
+ worktree.join(".git"),
+ format!("gitdir: {}", worktree_git_dir.to_str().unwrap()),
+ )
+ .unwrap();
+
+ // should resolve to the main repo root, not the worktree root
+ let result = in_git_repo(worktree_subdir.to_str().unwrap());
+ assert_eq!(result, Some(main_repo.clone()));
+
+ std::fs::remove_dir_all(&tmp).unwrap();
+ }
+
+ #[test]
+ fn dumb_random_test() {
+ // Obviously not a test of randomness, but make sure we haven't made some
+ // catastrophic error
+
+ assert_ne!(crypto_random_string::<1>(), crypto_random_string::<1>());
+ assert_ne!(crypto_random_string::<2>(), crypto_random_string::<2>());
+ assert_ne!(crypto_random_string::<4>(), crypto_random_string::<4>());
+ assert_ne!(crypto_random_string::<8>(), crypto_random_string::<8>());
+ assert_ne!(crypto_random_string::<16>(), crypto_random_string::<16>());
+ assert_ne!(crypto_random_string::<32>(), crypto_random_string::<32>());
+ }
+}
diff --git a/crates/daemon/Cargo.toml b/crates/daemon/Cargo.toml
index 509595b0..6d5de681 100644
--- a/crates/daemon/Cargo.toml
+++ b/crates/daemon/Cargo.toml
@@ -12,6 +12,7 @@ homepage = { workspace = true }
repository = { workspace = true }
[dependencies]
+turtle-common = { workspace = true }
async-trait = "0.1.58"
atuin-nucleo-matcher = { workspace = true }
atuin-nucleo = { workspace = true }
diff --git a/crates/daemon/build.rs b/crates/daemon/build.rs
index ad4bc3c8..646b8588 100644
--- a/crates/daemon/build.rs
+++ b/crates/daemon/build.rs
@@ -1,48 +1,34 @@
-use std::process::Command;
use std::{env, fs, path::PathBuf};
use protox::Compiler;
use protox::prost::Message;
fn main() -> Result<(), std::io::Error> {
- {
- let output = Command::new("git").args(["rev-parse", "HEAD"]).output();
+ let proto_paths = [
+ "proto/history.proto",
+ "proto/search.proto",
+ "proto/control.proto",
+ "proto/semantic.proto",
+ ];
+ let proto_include_dirs = ["proto"];
- let sha = match output {
- Ok(sha) => String::from_utf8(sha.stdout).unwrap(),
- Err(_) => String::from("NO_GIT"),
- };
+ let file_descriptor_set = Compiler::new(proto_include_dirs)
+ .map_err(std::io::Error::other)?
+ .include_source_info(true)
+ .include_imports(true)
+ .open_files(proto_paths)
+ .map_err(std::io::Error::other)?
+ .file_descriptor_set();
- println!("cargo:rustc-env=GIT_HASH={sha}");
- }
+ let file_descriptor_path = PathBuf::from(env::var_os("OUT_DIR").expect("OUT_DIR not set"))
+ .join("file_descriptor_set.bin");
+ fs::write(&file_descriptor_path, file_descriptor_set.encode_to_vec()).unwrap();
- {
- let proto_paths = [
- "proto/history.proto",
- "proto/search.proto",
- "proto/control.proto",
- "proto/semantic.proto",
- ];
- let proto_include_dirs = ["proto"];
-
- let file_descriptor_set = Compiler::new(proto_include_dirs)
- .map_err(std::io::Error::other)?
- .include_source_info(true)
- .include_imports(true)
- .open_files(proto_paths)
- .map_err(std::io::Error::other)?
- .file_descriptor_set();
-
- let file_descriptor_path = PathBuf::from(env::var_os("OUT_DIR").expect("OUT_DIR not set"))
- .join("file_descriptor_set.bin");
- fs::write(&file_descriptor_path, file_descriptor_set.encode_to_vec()).unwrap();
-
- tonic_prost_build::configure()
- .build_server(true)
- .file_descriptor_set_path(&file_descriptor_path)
- .skip_protoc_run()
- .compile_protos(&proto_paths, &proto_include_dirs)?;
- }
+ tonic_prost_build::configure()
+ .build_server(true)
+ .file_descriptor_set_path(&file_descriptor_path)
+ .skip_protoc_run()
+ .compile_protos(&proto_paths, &proto_include_dirs)?;
Ok(())
}
diff --git a/crates/daemon/src/client.rs b/crates/daemon/src/client.rs
index 2ea7ffc5..5cccb5ff 100644
--- a/crates/daemon/src/client.rs
+++ b/crates/daemon/src/client.rs
@@ -9,7 +9,7 @@ use hyper_util::rt::TokioIo;
use tokio::net::UnixStream;
use tracing::{Level, instrument, span};
-use crate::atuin_daemon::generated;
+use crate::generated;
use crate::{
atuin_client::{
database::Context,
@@ -41,12 +41,12 @@ use crate::{
},
};
-pub(crate) struct HistoryClient {
+pub struct HistoryClient {
client: HistoryServiceClient<Channel>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-pub(crate) enum DaemonClientErrorKind {
+pub enum DaemonClientErrorKind {
Connect,
Unavailable,
Unimplemented,
@@ -54,7 +54,7 @@ pub(crate) enum DaemonClientErrorKind {
}
#[must_use]
-pub(crate) fn classify_error(error: &eyre::Report) -> DaemonClientErrorKind {
+pub fn classify_error(error: &eyre::Report) -> DaemonClientErrorKind {
for cause in error.chain() {
if cause.downcast_ref::<tonic::transport::Error>().is_some() {
return DaemonClientErrorKind::Connect;
@@ -75,7 +75,7 @@ pub(crate) fn classify_error(error: &eyre::Report) -> DaemonClientErrorKind {
// Wrap the grpc client
impl HistoryClient {
#[cfg(unix)]
- pub(crate) async fn new(path: String) -> Result<Self> {
+ pub async fn new(path: String) -> Result<Self> {
use eyre::Context;
let log_path = path.clone();
@@ -100,7 +100,7 @@ impl HistoryClient {
Ok(Self { client })
}
- pub(crate) async fn start_history(&mut self, h: History) -> Result<StartHistoryReply> {
+ pub async fn start_history(&mut self, h: History) -> Result<StartHistoryReply> {
let req = StartHistoryRequest {
command: h.command,
cwd: h.cwd,
@@ -114,7 +114,7 @@ impl HistoryClient {
Ok(self.client.start_history(req).await?.into_inner())
}
- pub(crate) async fn end_history(
+ pub async fn end_history(
&mut self,
id: String,
duration: u64,
@@ -125,11 +125,11 @@ impl HistoryClient {
Ok(self.client.end_history(req).await?.into_inner())
}
- pub(crate) async fn status(&mut self) -> Result<StatusReply> {
+ pub async fn status(&mut self) -> Result<StatusReply> {
Ok(self.client.status(StatusRequest {}).await?.into_inner())
}
- pub(crate) async fn tail_history(&mut self) -> Result<tonic::Streaming<TailHistoryReply>> {
+ pub async fn tail_history(&mut self) -> Result<tonic::Streaming<TailHistoryReply>> {
Ok(self
.client
.tail_history(TailHistoryRequest {})
@@ -137,19 +137,19 @@ impl HistoryClient {
.into_inner())
}
- pub(crate) async fn shutdown(&mut self) -> Result<bool> {
+ pub async fn shutdown(&mut self) -> Result<bool> {
let resp = self.client.shutdown(ShutdownRequest {}).await?.into_inner();
Ok(resp.accepted)
}
}
-pub(crate) struct SearchClient {
+pub struct SearchClient {
client: SearchServiceClient<Channel>,
}
impl SearchClient {
#[cfg(unix)]
- pub(crate) async fn new(path: String) -> Result<Self> {
+ pub async fn new(path: String) -> Result<Self> {
let log_path = path.clone();
let channel = Endpoint::try_from("http://atuin_local_daemon:0")?
.connect_with_connector(service_fn(move |_: Uri| {
@@ -173,7 +173,7 @@ impl SearchClient {
}
#[instrument(skip_all, level = Level::TRACE, name = "daemon_client_search", fields(query = %query, query_id = query_id))]
- pub(crate) async fn search(
+ pub async fn search(
&mut self,
query: String,
query_id: u64,
@@ -222,13 +222,13 @@ impl From<Context> for RpcSearchContext {
}
}
-pub(crate) struct SemanticClient {
+pub struct SemanticClient {
client: SemanticServiceClient<Channel>,
}
impl SemanticClient {
#[cfg(unix)]
- pub(crate) async fn new(path: String) -> Result<Self> {
+ pub async fn new(path: String) -> Result<Self> {
let log_path = path.clone();
let channel = Endpoint::try_from("http://atuin_local_daemon:0")?
.connect_with_connector(service_fn(move |_: Uri| {
@@ -252,11 +252,11 @@ impl SemanticClient {
}
#[cfg(unix)]
- pub(crate) async fn from_settings(settings: &Settings) -> Result<Self> {
+ pub async fn from_settings(settings: &Settings) -> Result<Self> {
Self::new(settings.daemon.socket_path.clone()).await
}
- pub(crate) async fn record_commands(
+ pub async fn record_commands(
&mut self,
captures: Vec<CommandCapture>,
) -> Result<RecordCommandsReply> {
@@ -272,14 +272,14 @@ impl SemanticClient {
/// Client for the Control gRPC service.
///
/// Used to inject events into a running daemon from external processes.
-pub(crate) struct ControlClient {
+pub struct ControlClient {
client: ControlServiceClient<Channel>,
}
impl ControlClient {
/// Connect to the daemon's control service.
#[cfg(unix)]
- pub(crate) async fn new(path: String) -> Result<Self> {
+ pub async fn new(path: String) -> Result<Self> {
let log_path = path.clone();
let channel = Endpoint::try_from("http://atuin_local_daemon:0")?
.connect_with_connector(service_fn(move |_: Uri| {
@@ -304,12 +304,12 @@ impl ControlClient {
/// Connect using settings.
#[cfg(unix)]
- pub(crate) async fn from_settings(settings: &Settings) -> Result<Self> {
+ pub async fn from_settings(settings: &Settings) -> Result<Self> {
Self::new(settings.daemon.socket_path.clone()).await
}
/// Send an event to the daemon.
- pub(crate) async fn send_event(&mut self, event: DaemonEvent) -> Result<()> {
+ pub async fn send_event(&mut self, event: DaemonEvent) -> Result<()> {
let proto_event = daemon_event_to_proto(event);
let request = SendEventRequest {
event: Some(proto_event),
diff --git a/crates/daemon/src/components/history.rs b/crates/daemon/src/components/history.rs
index b4f91b06..a75ff774 100644
--- a/crates/daemon/src/components/history.rs
+++ b/crates/daemon/src/components/history.rs
@@ -15,7 +15,7 @@ use tokio_stream::Stream;
use tonic::{Request, Response, Status};
use tracing::{Level, instrument};
-use crate::atuin_daemon::{
+use crate::{
daemon::{Component, DaemonHandle},
events::DaemonEvent,
generated::history::{
@@ -35,7 +35,7 @@ const DAEMON_PROTOCOL_VERSION: u32 = 1;
/// - Saves completed commands to the database and record store
/// - Emits history events for other components (e.g., search indexing)
/// - Provides the History gRPC service
-pub(crate) struct HistoryComponent {
+pub struct HistoryComponent {
inner: Arc<HistoryComponentInner>,
}
@@ -52,7 +52,7 @@ struct HistoryComponentInner {
impl HistoryComponent {
/// Create a new history component.
- pub(crate) fn new() -> Self {
+ pub fn new() -> Self {
Self {
inner: Arc::new(HistoryComponentInner {
running: DashMap::new(),
@@ -65,7 +65,7 @@ impl HistoryComponent {
/// Get the gRPC service for this component.
///
/// This returns a tonic service that can be added to a gRPC server.
- pub(crate) fn grpc_service(&self) -> HistoryServer<HistoryGrpcService> {
+ pub fn grpc_service(&self) -> HistoryServer<HistoryGrpcService> {
HistoryServer::new(HistoryGrpcService {
inner: self.inner.clone(),
})
@@ -111,7 +111,7 @@ impl Component for HistoryComponent {
/// The gRPC service implementation.
///
/// This is a thin wrapper that delegates to the component's shared state.
-pub(crate) struct HistoryGrpcService {
+pub struct HistoryGrpcService {
inner: Arc<HistoryComponentInner>,
}
diff --git a/crates/daemon/src/components/mod.rs b/crates/daemon/src/components/mod.rs
index 5a93fbc1..447e31df 100644
--- a/crates/daemon/src/components/mod.rs
+++ b/crates/daemon/src/components/mod.rs
@@ -14,12 +14,12 @@
//! - [`semantic::SemanticComponent`]: In-memory semantic command captures
//! - [`sync::SyncComponent`]: Cloud sync
-pub(crate) mod history;
-pub(crate) mod search;
-pub(crate) mod semantic;
-pub(crate) mod sync;
+pub mod history;
+pub mod search;
+pub mod semantic;
+pub mod sync;
-pub(crate) use history::HistoryComponent;
-pub(crate) use search::SearchComponent;
-pub(crate) use semantic::SemanticComponent;
-pub(crate) use sync::SyncComponent;
+pub use history::HistoryComponent;
+pub use search::SearchComponent;
+pub use semantic::SemanticComponent;
+pub use sync::SyncComponent;
diff --git a/crates/daemon/src/components/search.rs b/crates/daemon/src/components/search.rs
index bcd60cc4..91f2db17 100644
--- a/crates/daemon/src/components/search.rs
+++ b/crates/daemon/src/components/search.rs
@@ -12,7 +12,7 @@ use tonic::{Request, Response, Status, Streaming};
use tracing::{Level, debug, info, instrument, span, trace};
use uuid::Uuid;
-use crate::atuin_daemon::{
+use crate::{
daemon::{Component, DaemonHandle},
events::DaemonEvent,
generated::search::{
@@ -34,7 +34,7 @@ const FRECENCY_REFRESH_INTERVAL_SECS: u64 = 60;
/// - Loads history from the database on startup
/// - Updates the index when history events occur
/// - Provides the Search gRPC service
-pub(crate) struct SearchComponent {
+pub struct SearchComponent {
index: Arc<RwLock<SearchIndex>>,
handle: RwLock<Option<DaemonHandle>>,
loader_handle: Option<tokio::task::JoinHandle<()>>,
@@ -43,7 +43,7 @@ pub(crate) struct SearchComponent {
impl SearchComponent {
/// Create a new search component.
- pub(crate) fn new() -> Self {
+ pub fn new() -> Self {
Self {
index: Arc::new(RwLock::new(SearchIndex::new())),
handle: RwLock::new(None),
@@ -53,7 +53,7 @@ impl SearchComponent {
}
/// Get the gRPC service for this component.
- pub(crate) fn grpc_service(&self) -> SearchServer<SearchGrpcService> {
+ pub fn grpc_service(&self) -> SearchServer<SearchGrpcService> {
SearchServer::new(SearchGrpcService {
index: self.index.clone(),
})
@@ -276,7 +276,7 @@ impl Component for SearchComponent {
}
/// The gRPC service implementation.
-pub(crate) struct SearchGrpcService {
+pub struct SearchGrpcService {
index: Arc<RwLock<SearchIndex>>,
}
@@ -398,7 +398,7 @@ fn convert_filter_mode(
}
#[cfg(not(windows))]
-pub(crate) fn with_trailing_slash(s: &str) -> String {
+pub fn with_trailing_slash(s: &str) -> String {
if s.ends_with('/') {
s.to_string()
} else {
diff --git a/crates/daemon/src/components/semantic.rs b/crates/daemon/src/components/semantic.rs
index e1d376de..02f5c3d1 100644
--- a/crates/daemon/src/components/semantic.rs
+++ b/crates/daemon/src/components/semantic.rs
@@ -9,13 +9,13 @@ use std::fmt::{Display, Formatter};
use std::sync::Arc;
use crate::atuin_client::history::{History, HistoryId};
-use crate::atuin_daemon::generated::semantic;
+use crate::generated::semantic;
use eyre::Result;
use tokio::sync::Mutex;
use tonic::{Request, Response, Status, Streaming};
use tracing::{Level, instrument};
-use crate::atuin_daemon::{
+use crate::{
daemon::{Component, DaemonHandle},
events::DaemonEvent,
generated::semantic::{
@@ -30,7 +30,7 @@ const MAX_BYTES_PER_SESSION: usize = 32 * 1024 * 1024;
const MAX_PENDING_HISTORIES: usize = 128;
/// Stores completed command captures and associates them with history events.
-pub(crate) struct SemanticComponent {
+pub struct SemanticComponent {
inner: Arc<SemanticComponentInner>,
}
@@ -84,7 +84,7 @@ struct SemanticCommandRecord {
}
impl SemanticComponent {
- pub(crate) fn new() -> Self {
+ pub fn new() -> Self {
Self {
inner: Arc::new(SemanticComponentInner {
state: Mutex::new(SemanticState::default()),
@@ -92,7 +92,7 @@ impl SemanticComponent {
}
}
- pub(crate) fn grpc_service(&self) -> SemanticServer<SemanticGrpcService> {
+ pub fn grpc_service(&self) -> SemanticServer<SemanticGrpcService> {
SemanticServer::new(SemanticGrpcService {
inner: self.inner.clone(),
})
@@ -453,7 +453,7 @@ impl Display for SessionId {
}
}
-pub(crate) struct SemanticGrpcService {
+pub struct SemanticGrpcService {
inner: Arc<SemanticComponentInner>,
}
diff --git a/crates/daemon/src/components/sync.rs b/crates/daemon/src/components/sync.rs
index 20d49839..e898e8bd 100644
--- a/crates/daemon/src/components/sync.rs
+++ b/crates/daemon/src/components/sync.rs
@@ -11,7 +11,7 @@ use tokio::time::{self, MissedTickBehavior};
use crate::atuin_client::{history::store::HistoryStore, record::sync, settings::Settings};
-use crate::atuin_daemon::{
+use crate::{
daemon::{Component, DaemonHandle},
events::DaemonEvent,
};
@@ -41,14 +41,14 @@ enum SyncState {
/// - Implements exponential backoff on sync failures
/// - Responds to [`ForceSync`] events for immediate sync
/// - Emits SyncCompleted/SyncFailed events
-pub(crate) struct SyncComponent {
+pub struct SyncComponent {
task_handle: Option<tokio::task::JoinHandle<()>>,
command_tx: Option<mpsc::Sender<SyncCommand>>,
}
impl SyncComponent {
/// Create a new sync component.
- pub(crate) fn new() -> Self {
+ pub fn new() -> Self {
Self {
task_handle: None,
command_tx: None,
diff --git a/crates/daemon/src/control/mod.rs b/crates/daemon/src/control/mod.rs
index 79398d61..fcc2a0b8 100644
--- a/crates/daemon/src/control/mod.rs
+++ b/crates/daemon/src/control/mod.rs
@@ -23,18 +23,18 @@ use crate::{
///
/// This service is used by external processes to inject events into the daemon.
/// It's not a component - it's part of the daemon's core infrastructure.
-pub(crate) struct ControlService {
+pub struct ControlService {
handle: DaemonHandle,
}
impl ControlService {
/// Create a new control service with the given daemon handle.
- pub(crate) fn new(handle: DaemonHandle) -> Self {
+ pub fn new(handle: DaemonHandle) -> Self {
Self { handle }
}
/// Get a tonic server for this service.
- pub(crate) fn into_server(self) -> ControlServer<Self> {
+ pub fn into_server(self) -> ControlServer<Self> {
ControlServer::new(self)
}
}
diff --git a/crates/daemon/src/daemon.rs b/crates/daemon/src/daemon.rs
index 80aaeef8..8f0a5957 100644
--- a/crates/daemon/src/daemon.rs
+++ b/crates/daemon/src/daemon.rs
@@ -17,7 +17,7 @@ use crate::atuin_client::{
use eyre::{Context, Result};
use tokio::sync::{RwLock, broadcast};
-use crate::atuin_daemon::events::DaemonEvent;
+use crate::events::DaemonEvent;
// ============================================================================
// DaemonState
@@ -27,7 +27,7 @@ use crate::atuin_daemon::events::DaemonEvent;
///
/// This contains all the resources that components and services need access to.
/// The state is wrapped in an `Arc` and accessed via [`DaemonHandle`].
-pub(crate) struct DaemonState {
+pub struct DaemonState {
// Event bus
event_tx: broadcast::Sender<DaemonEvent>,
@@ -72,7 +72,7 @@ pub(crate) struct DaemonState {
/// let history = handle.history_db().load(id).await?;
/// ```
#[derive(Clone)]
-pub(crate) struct DaemonHandle {
+pub struct DaemonHandle {
state: Arc<DaemonState>,
}
@@ -83,7 +83,7 @@ impl DaemonHandle {
///
/// This is fire-and-forget - if no receivers are listening (which shouldn't
/// happen in normal operation), the event is dropped silently.
- pub(crate) fn emit(&self, event: DaemonEvent) {
+ pub fn emit(&self, event: DaemonEvent) {
if let Err(e) = self.state.event_tx.send(event) {
tracing::warn!("failed to emit event (no receivers?): {e}");
}
@@ -94,12 +94,12 @@ impl DaemonHandle {
/// Returns a receiver that will receive all events emitted after this call.
/// Useful for components that need to listen for events outside of the
/// normal `handle_event` callback flow.
- pub(crate) fn subscribe(&self) -> broadcast::Receiver<DaemonEvent> {
+ pub fn subscribe(&self) -> broadcast::Receiver<DaemonEvent> {
self.state.event_tx.subscribe()
}
/// Request graceful shutdown of the daemon.
- pub(crate) fn shutdown(&self) {
+ pub fn shutdown(&self) {
self.emit(DaemonEvent::ShutdownRequested);
}
@@ -109,7 +109,7 @@ impl DaemonHandle {
///
/// This acquires a read lock on the settings. For most use cases, clone
/// the settings if you need to hold onto them.
- pub(crate) async fn settings(&self) -> tokio::sync::RwLockReadGuard<'_, Settings> {
+ pub async fn settings(&self) -> tokio::sync::RwLockReadGuard<'_, Settings> {
self.state.settings.read().await
}
@@ -117,26 +117,26 @@ impl DaemonHandle {
///
/// Use this when settings have already been loaded (e.g., from a file watcher)
/// to avoid parsing the config file twice.
- pub(crate) async fn apply_settings(&self, settings: Settings) {
+ pub async fn apply_settings(&self, settings: Settings) {
*self.state.settings.write().await = settings;
self.emit(DaemonEvent::SettingsReloaded);
tracing::info!("settings applied");
}
/// Get the encryption key.
- pub(crate) fn encryption_key(&self) -> &[u8; 32] {
+ pub fn encryption_key(&self) -> &[u8; 32] {
&self.state.encryption_key
}
// ---- Database ----
/// Get a reference to the history database.
- pub(crate) fn history_db(&self) -> &HistoryDatabase {
+ pub fn history_db(&self) -> &HistoryDatabase {
&self.state.history_db
}
/// Get a reference to the record store.
- pub(crate) fn store(&self) -> &SqliteStore {
+ pub fn store(&self) -> &SqliteStore {
&self.state.store
}
}
@@ -171,7 +171,7 @@ impl std::fmt::Debug for DaemonHandle {
/// # Example
///
/// ```ignore
-/// pub(crate) struct MyComponent {
+/// pub struct MyComponent {
/// handle: Option<DaemonHandle>,
/// }
///
@@ -203,7 +203,7 @@ impl std::fmt::Debug for DaemonHandle {
/// }
/// ```
#[tonic::async_trait]
-pub(crate) trait Component: Send + Sync {
+pub trait Component: Send + Sync {
/// Human-readable name for logging and debugging.
fn name(&self) -> &'static str;
@@ -247,21 +247,21 @@ pub(crate) trait Component: Send + Sync {
///
/// Events emitted during handling are queued and processed in subsequent
/// iterations, ensuring the loop eventually drains.
-pub(crate) struct Daemon {
+pub struct Daemon {
components: Vec<Box<dyn Component>>,
handle: DaemonHandle,
}
impl Daemon {
/// Create a new daemon builder.
- pub(crate) fn builder(settings: Settings) -> DaemonBuilder {
+ pub fn builder(settings: Settings) -> DaemonBuilder {
DaemonBuilder::new(settings)
}
/// Get a clone of the daemon handle.
///
/// The handle can be used to emit events, access settings, etc.
- pub(crate) fn handle(&self) -> DaemonHandle {
+ pub fn handle(&self) -> DaemonHandle {
self.handle.clone()
}
@@ -269,7 +269,7 @@ impl Daemon {
///
/// This must be called before `run_event_loop()`. It initializes all
/// registered components with the daemon handle.
- pub(crate) async fn start_components(&mut self) -> Result<()> {
+ pub async fn start_components(&mut self) -> Result<()> {
for component in &mut self.components {
tracing::info!(component = component.name(), "starting component");
component
@@ -284,7 +284,7 @@ impl Daemon {
///
/// This processes events until a [`ShutdownRequested`] event is received.
/// Components must be started first via `start_components()`.
- pub(crate) async fn run_event_loop(&mut self) -> Result<()> {
+ pub async fn run_event_loop(&mut self) -> Result<()> {
let mut event_rx = self.handle.subscribe();
loop {
match event_rx.recv().await {
@@ -314,7 +314,7 @@ impl Daemon {
/// Stop all components.
///
/// This performs graceful shutdown of all components.
- pub(crate) async fn stop_components(&mut self) {
+ pub async fn stop_components(&mut self) {
for component in &mut self.components {
tracing::info!(component = component.name(), "stopping component");
if let Err(e) = component.stop().await {
@@ -361,7 +361,7 @@ impl Daemon {
///
/// daemon.run().await?;
/// ```
-pub(crate) struct DaemonBuilder {
+pub struct DaemonBuilder {
settings: Settings,
store: Option<SqliteStore>,
history_db: Option<HistoryDatabase>,
@@ -370,7 +370,7 @@ pub(crate) struct DaemonBuilder {
impl DaemonBuilder {
/// Create a new daemon builder with the given settings.
- pub(crate) fn new(settings: Settings) -> Self {
+ pub fn new(settings: Settings) -> Self {
Self {
settings,
store: None,
@@ -380,13 +380,13 @@ impl DaemonBuilder {
}
/// Set the record store.
- pub(crate) fn store(mut self, store: SqliteStore) -> Self {
+ pub fn store(mut self, store: SqliteStore) -> Self {
self.store = Some(store);
self
}
/// Set the history database.
- pub(crate) fn history_db(mut self, db: HistoryDatabase) -> Self {
+ pub fn history_db(mut self, db: HistoryDatabase) -> Self {
self.history_db = Some(db);
self
}
@@ -394,7 +394,7 @@ impl DaemonBuilder {
/// Register a component.
///
/// Components are started in registration order and stopped in reverse order.
- pub(crate) fn component(mut self, component: impl Component + 'static) -> Self {
+ pub fn component(mut self, component: impl Component + 'static) -> Self {
self.components.push(Box::new(component));
self
}
@@ -402,7 +402,7 @@ impl DaemonBuilder {
/// Build the daemon.
///
/// This loads the encryption key and creates the daemon state.
- pub(crate) fn build(self) -> Result<Daemon> {
+ pub fn build(self) -> Result<Daemon> {
let store = self.store.ok_or_else(|| eyre::eyre!("store is required"))?;
let history_db = self
.history_db
diff --git a/crates/daemon/src/events.rs b/crates/daemon/src/events.rs
index d379277d..32ed1ff1 100644
--- a/crates/daemon/src/events.rs
+++ b/crates/daemon/src/events.rs
@@ -8,14 +8,14 @@
//! Control gRPC service.
use crate::atuin_client::history::{History, HistoryId};
-use crate::atuin_common::record::RecordId;
+use turtle_common::record::RecordId;
/// Events that flow through the daemon's event bus.
///
/// Events are broadcast to all components. Each component decides which
/// events it cares about in its `handle_event` implementation.
#[derive(Debug, Clone)]
-pub(crate) enum DaemonEvent {
+pub enum DaemonEvent {
// ---- History lifecycle ----
/// A command has started running.
HistoryStarted(History),
diff --git a/crates/daemon/src/generated.rs b/crates/daemon/src/generated.rs
index a3ea4d9d..6620e94c 100644
--- a/crates/daemon/src/generated.rs
+++ b/crates/daemon/src/generated.rs
@@ -11,14 +11,14 @@
)]
/// Semantic command capture gRPC service types.
-pub(crate) mod semantic {
+pub mod semantic {
tonic::include_proto!("semantic");
}
/// Search module for the daemon gRPC search service.
///
/// This module provides fuzzy search over command history using Nucleo.
-pub(crate) mod search {
+pub mod search {
// Include the generated proto code
tonic::include_proto!("search");
}
@@ -26,7 +26,7 @@ pub(crate) mod search {
/// 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 {
// Include the generated proto code
tonic::include_proto!("history");
}
@@ -35,10 +35,10 @@ pub(crate) mod history {
///
/// This module provides the gRPC service that allows external processes
/// (like CLI commands) to inject events into the daemon's event bus.
-pub(crate) mod control {
+pub mod control {
// Include the generated proto code
tonic::include_proto!("control");
// Re-export the service
- pub(crate) use crate::atuin_daemon::control::ControlService;
+ pub use crate::control::ControlService;
}
diff --git a/crates/daemon/src/lib.rs b/crates/daemon/src/lib.rs
index 5f0f489e..1abf0314 100644
--- a/crates/daemon/src/lib.rs
+++ b/crates/daemon/src/lib.rs
@@ -3,31 +3,31 @@ use crate::atuin_client::record::sqlite_store::SqliteStore;
use crate::atuin_client::settings::{Settings, watcher::global_settings_watcher};
use eyre::Result;
-pub(crate) mod client;
-pub(crate) mod components;
-pub(crate) mod control;
-pub(crate) mod daemon;
-pub(crate) mod events;
-pub(crate) mod search;
-pub(crate) mod server;
+pub mod client;
+pub mod components;
+pub mod control;
+pub mod daemon;
+pub mod events;
+pub mod search;
+pub mod server;
-pub(crate) mod generated;
+pub mod generated;
// Re-export core daemon types for convenience
-pub(crate) use daemon::Daemon;
-pub(crate) use events::DaemonEvent;
+pub use daemon::Daemon;
+pub use events::DaemonEvent;
// Re-export components
-pub(crate) use components::{HistoryComponent, SearchComponent, SemanticComponent, SyncComponent};
+pub use components::{HistoryComponent, SearchComponent, SemanticComponent, SyncComponent};
// Re-export client helpers
-pub(crate) use client::SemanticClient;
+pub use client::SemanticClient;
/// Boot the daemon using the new component-based architecture.
///
/// This creates a daemon with the standard components (history, search, sync),
/// starts the gRPC server with their services, and runs the event loop.
-pub(crate) async fn boot(
+pub async fn boot(
settings: Settings,
store: SqliteStore,
history_db: HistoryDatabase,
diff --git a/crates/daemon/src/search/mod.rs b/crates/daemon/src/search/mod.rs
index 02c79c9c..b4d03bcd 100644
--- a/crates/daemon/src/search/mod.rs
+++ b/crates/daemon/src/search/mod.rs
@@ -37,16 +37,16 @@ fn format_uuid_bytes(bytes: &[u8; 16]) -> String {
/// Pre-computed frecency data for O(1) lookup.
#[derive(Debug, Clone, Default)]
-pub(crate) struct FrecencyData {
+pub struct FrecencyData {
/// Total number of times this command was used.
- pub(crate) count: u32,
+ pub count: u32,
/// Most recent usage timestamp (unix seconds).
- pub(crate) last_used: i64,
+ pub last_used: i64,
}
impl FrecencyData {
/// Record a new usage of this command.
- pub(crate) fn record_use(&mut self, timestamp: i64) {
+ pub fn record_use(&mut self, timestamp: i64) {
self.count += 1;
if timestamp > self.last_used {
self.last_used = timestamp;
@@ -65,7 +65,7 @@ impl FrecencyData {
/// A multiplier of 0.0 disables that component, 1.0 is unchanged, 2.0 doubles weight.
/// Values like 0.5 reduce weight by half, 1.5 increases by 50%, etc.
#[instrument(level = Level::TRACE, name = "index_frecency_compute")]
- pub(crate) fn compute(&self, now: i64, recency_mul: f64, frequency_mul: f64) -> u32 {
+ pub fn compute(&self, now: i64, recency_mul: f64, frequency_mul: f64) -> u32 {
if self.count == 0 {
return 0;
}
@@ -100,13 +100,13 @@ impl FrecencyData {
}
/// Data for a unique command.
-pub(crate) struct CommandData {
+pub struct CommandData {
/// History ID of the most recent invocation (16-byte UUID).
most_recent_id: [u8; 16],
/// Timestamp of the most recent invocation.
most_recent_timestamp: i64,
/// Pre-computed global frecency.
- pub(crate) global_frecency: FrecencyData,
+ pub global_frecency: FrecencyData,
// Pre-computed indexes for O(1) filter lookups
// Using HashSet instead of DashSet since CommandData lives inside DashMap (already synchronized)
@@ -121,7 +121,7 @@ pub(crate) struct CommandData {
impl CommandData {
/// Create a new [`CommandData`] from a history entry.
/// Returns None if the history entry has invalid UUIDs.
- pub(crate) fn new(history: &History, interner: &ThreadedRodeo) -> Option<Self> {
+ pub fn new(history: &History, interner: &ThreadedRodeo) -> Option<Self> {
let history_id = parse_uuid_bytes(&history.id.0)?;
let session = parse_uuid_bytes(&history.session)?;
let timestamp = history.timestamp.unix_timestamp();
@@ -153,7 +153,7 @@ impl CommandData {
/// Add an invocation from a history entry.
/// Returns false if the history entry has invalid UUIDs.
- pub(crate) fn add_invocation(&mut self, history: &History, interner: &ThreadedRodeo) -> bool {
+ pub fn add_invocation(&mut self, history: &History, interner: &ThreadedRodeo) -> bool {
let Some(history_id) = parse_uuid_bytes(&history.id.0) else {
return false;
};
@@ -182,13 +182,13 @@ impl CommandData {
}
/// Get the most recent history ID for this command.
- pub(crate) fn most_recent_id(&self) -> String {
+ pub fn most_recent_id(&self) -> String {
format_uuid_bytes(&self.most_recent_id)
}
/// Check if any invocation matches a directory filter (exact match).
/// O(1) lookup using pre-computed index.
- pub(crate) fn has_invocation_in_dir(&self, dir: &str, interner: &ThreadedRodeo) -> bool {
+ pub fn has_invocation_in_dir(&self, dir: &str, interner: &ThreadedRodeo) -> bool {
interner
.get(dir)
.is_some_and(|spur| self.directories.contains(&spur))
@@ -196,7 +196,7 @@ impl CommandData {
/// Check if any invocation matches a directory prefix (workspace/git root).
/// O(n) where n = number of unique directories for this command.
- pub(crate) fn has_invocation_in_workspace(
+ pub fn has_invocation_in_workspace(
&self,
prefix: &str,
interner: &ThreadedRodeo,
@@ -208,7 +208,7 @@ impl CommandData {
/// Check if any invocation matches a hostname.
/// O(1) lookup using pre-computed index.
- pub(crate) fn has_invocation_on_host(&self, hostname: &str, interner: &ThreadedRodeo) -> bool {
+ pub fn has_invocation_on_host(&self, hostname: &str, interner: &ThreadedRodeo) -> bool {
interner
.get(hostname)
.is_some_and(|spur| self.hosts.contains(&spur))
@@ -216,14 +216,14 @@ impl CommandData {
/// Check if any invocation matches a session.
/// O(1) lookup using pre-computed index.
- pub(crate) fn has_invocation_in_session(&self, session: &str) -> bool {
+ pub fn has_invocation_in_session(&self, session: &str) -> bool {
parse_uuid_bytes(session).is_some_and(|bytes| self.sessions.contains(&bytes))
}
}
/// Filter mode for search queries.
#[derive(Debug, Clone, PartialEq, Eq)]
-pub(crate) enum IndexFilterMode {
+pub enum IndexFilterMode {
/// No filtering - search all commands.
Global,
/// Filter to commands run in a specific directory.
@@ -238,15 +238,15 @@ pub(crate) enum IndexFilterMode {
/// Context for search queries.
#[derive(Debug, Clone, Default)]
-pub(crate) struct QueryContext {
+pub struct QueryContext {
#[expect(dead_code)]
- pub(crate) cwd: Option<String>,
+ pub cwd: Option<String>,
#[expect(dead_code)]
- pub(crate) git_root: Option<String>,
+ pub git_root: Option<String>,
#[expect(dead_code)]
- pub(crate) hostname: Option<String>,
+ pub hostname: Option<String>,
#[expect(dead_code)]
- pub(crate) session_id: Option<String>,
+ pub session_id: Option<String>,
}
/// Shareable frecency map: command -> frecency score.
@@ -261,7 +261,7 @@ type FrecencyMap = Arc<HashMap<Arc<str>, u32>>;
/// Global frecency is precomputed by a background task and used for scoring.
/// If frecency data is not available, search still works but without frecency ranking;
/// although this should never happen due to precomputing the frecency map.
-pub(crate) struct SearchIndex {
+pub struct SearchIndex {
/// Map from command text to command data.
/// Using `DashMap` for concurrent read/write access, wrapped in Arc for sharing with scorer.
/// Keys are Arc<str> to enable zero-copy sharing with `frecency_map`.
@@ -278,7 +278,7 @@ pub(crate) struct SearchIndex {
impl SearchIndex {
/// Create a new empty search index.
- pub(crate) fn new() -> Self {
+ pub fn new() -> Self {
let nucleo_config = atuin_nucleo::Config::DEFAULT;
// Single column for command text
let nucleo = Nucleo::<String>::new(nucleo_config, Arc::new(|| {}), None, 1);
@@ -297,7 +297,7 @@ impl SearchIndex {
///
/// If the command already exists, updates its invocation data.
/// If it's a new command, adds it to both the map and Nucleo.
- pub(crate) fn add_history(&self, history: &History) {
+ pub fn add_history(&self, history: &History) {
let command = history.command.as_str();
// DashMap with Arc<str> keys can be looked up with &str via Borrow trait
@@ -320,14 +320,14 @@ impl SearchIndex {
}
/// Add multiple history entries to the index.
- pub(crate) fn add_histories(&self, histories: &[History]) {
+ pub fn add_histories(&self, histories: &[History]) {
for history in histories {
self.add_history(history);
}
}
/// Get the number of unique commands in the index.
- pub(crate) fn command_count(&self) -> usize {
+ pub fn command_count(&self) -> usize {
self.commands.len()
}
@@ -340,7 +340,7 @@ impl SearchIndex {
clippy::significant_drop_tightening,
reason = "The nucleo early drop is a false-positive"
)]
- pub(crate) async fn search(
+ pub async fn search(
&self,
query: &str,
filter_mode: IndexFilterMode,
@@ -403,7 +403,7 @@ impl SearchIndex {
/// - `frequency_score_multiplier`: Weight for frequency component
/// - `frecency_score_multiplier`: Overall multiplier for final score
#[instrument(skip_all, level = Level::DEBUG, name = "rebuild_frecency")]
- pub(crate) async fn rebuild_frecency(&self, search_settings: &Search) {
+ pub async fn rebuild_frecency(&self, search_settings: &Search) {
let now = OffsetDateTime::now_utc().unix_timestamp();
let mut frecency_map: HashMap<Arc<str>, u32> = HashMap::new();
diff --git a/crates/daemon/src/server.rs b/crates/daemon/src/server.rs
index 36954cca..335f8260 100644
--- a/crates/daemon/src/server.rs
+++ b/crates/daemon/src/server.rs
@@ -21,7 +21,7 @@ use crate::{
/// This starts the gRPC server in the background and returns immediately.
/// The server will shut down when a [`ShutdownRequested`] event is received.
#[cfg(unix)]
-pub(crate) fn run_grpc_server(
+pub fn run_grpc_server(
settings: &Settings,
history_service: HistoryServer<HistoryGrpcService>,
search_service: SearchServer<SearchGrpcService>,
@@ -82,7 +82,7 @@ pub(crate) fn run_grpc_server(
let mut rx = handle.subscribe();
loop {
- use crate::atuin_daemon::DaemonEvent;
+ use crate::DaemonEvent;
match rx.recv().await {
Err(_) | Ok(DaemonEvent::ShutdownRequested) => break,
diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml
index 982f32b1..c9ae44da 100644
--- a/crates/server/Cargo.toml
+++ b/crates/server/Cargo.toml
@@ -12,6 +12,7 @@ homepage = { workspace = true }
repository = { workspace = true }
[dependencies]
+turtle-common = { workspace = true }
async-trait = "0.1.58"
atuin-nucleo-matcher = { workspace = true }
atuin-nucleo = { workspace = true }
diff --git a/crates/server/src/database/db/mod.rs b/crates/server/src/database/db/mod.rs
index 77bd0c61..c95a2ed4 100644
--- a/crates/server/src/database/db/mod.rs
+++ b/crates/server/src/database/db/mod.rs
@@ -2,11 +2,9 @@ use std::collections::HashMap;
use rand::Rng;
-use crate::{
- atuin_common::record::{EncryptedData, HostId, Record, RecordIdx, RecordStatus},
- atuin_server::database::{DbError, DbResult, DbSettings, models::User},
-};
+use crate::database::{DbError, DbResult, DbSettings, models::User};
use sqlx::postgres::PgPoolOptions;
+use turtle_common::record::{EncryptedData, HostId, Record, RecordIdx, RecordStatus};
use tracing::instrument;
use uuid::Uuid;
@@ -17,7 +15,7 @@ mod wrappers;
const MIN_PG_VERSION: u32 = 14;
#[derive(Clone)]
-pub(crate) struct ServerPostgres {
+pub struct ServerPostgres {
pool: sqlx::Pool<sqlx::postgres::Postgres>,
/// Optional read replica pool for read-only queries
read_pool: Option<sqlx::Pool<sqlx::postgres::Postgres>>,
@@ -32,7 +30,7 @@ impl ServerPostgres {
}
impl ServerPostgres {
- pub(crate) async fn new(settings: &DbSettings) -> DbResult<Self> {
+ pub async fn new(settings: &DbSettings) -> DbResult<Self> {
let pool = PgPoolOptions::new()
.max_connections(100)
.connect(settings.db_uri.as_str())
@@ -93,7 +91,7 @@ impl ServerPostgres {
}
#[instrument(skip_all)]
- pub(crate) async fn add_records(
+ pub async fn add_records(
&self,
user: &User,
records: &[Record<EncryptedData>],
@@ -110,7 +108,7 @@ impl ServerPostgres {
let mut heads = HashMap::<(HostId, &str), u64>::new();
for i in records {
- let id = crate::atuin_common::utils::uuid_v7();
+ let id = turtle_common::utils::uuid_v7();
let result = sqlx::query(
"
@@ -169,7 +167,7 @@ impl ServerPostgres {
}
#[instrument(skip_all)]
- pub(crate) async fn next_records(
+ pub async fn next_records(
&self,
user: &User,
host: HostId,
@@ -222,7 +220,7 @@ impl ServerPostgres {
Ok(ret)
}
- pub(crate) async fn status(&self, user: &User) -> DbResult<RecordStatus> {
+ pub async fn status(&self, user: &User) -> DbResult<RecordStatus> {
// If IDX_CACHE_ROLLOUT is set, then we
// 1. Read the value of the var, use it as a % chance of using the cache
// 2. If we use the cache, just read from the cache table
diff --git a/crates/server/src/database/db/wrappers.rs b/crates/server/src/database/db/wrappers.rs
index 0315e331..8054289a 100644
--- a/crates/server/src/database/db/wrappers.rs
+++ b/crates/server/src/database/db/wrappers.rs
@@ -1,7 +1,7 @@
-use crate::atuin_common::record::{EncryptedData, Host, Record};
+use turtle_common::record::{EncryptedData, Host, Record};
use sqlx::{Row, postgres::PgRow};
-pub(crate) struct DbRecord(pub Record<EncryptedData>);
+pub struct DbRecord(pub Record<EncryptedData>);
impl<'a> ::sqlx::FromRow<'a, PgRow> for DbRecord {
fn from_row(row: &'a PgRow) -> ::sqlx::Result<Self> {
diff --git a/crates/server/src/database/mod.rs b/crates/server/src/database/mod.rs
index 43fe5c3b..c05fa783 100644
--- a/crates/server/src/database/mod.rs
+++ b/crates/server/src/database/mod.rs
@@ -1,12 +1,12 @@
-pub(crate) mod db;
-pub(crate) mod models;
+pub mod db;
+pub mod models;
use std::fmt::{Debug, Display};
use serde::{Deserialize, Serialize};
#[derive(Debug)]
-pub(crate) enum DbError {
+pub enum DbError {
NotFound,
Other(eyre::Report),
}
@@ -43,24 +43,24 @@ impl From<sqlx::Error> for DbError {
impl std::error::Error for DbError {}
-pub(crate) type DbResult<T> = Result<T, DbError>;
+pub type DbResult<T> = Result<T, DbError>;
#[derive(Debug, PartialEq)]
-pub(crate) enum DbType {
+pub enum DbType {
Postgres,
Unknown,
}
#[derive(Clone, Deserialize, Serialize)]
-pub(crate) struct DbSettings {
- pub(crate) db_uri: String,
+pub struct DbSettings {
+ pub db_uri: String,
/// Optional URI for read replicas. If set, read-only queries will use this connection.
- pub(crate) read_db_uri: Option<String>,
+ pub read_db_uri: Option<String>,
}
impl DbSettings {
- pub(crate) fn db_type(&self) -> DbType {
+ pub fn db_type(&self) -> DbType {
if self.db_uri.starts_with("postgres://") || self.db_uri.starts_with("postgresql://") {
DbType::Postgres
} else {
diff --git a/crates/server/src/database/models.rs b/crates/server/src/database/models.rs
index 3fa6f471..9f6241ae 100644
--- a/crates/server/src/database/models.rs
+++ b/crates/server/src/database/models.rs
@@ -1,5 +1,5 @@
use uuid::Uuid;
-pub(crate) struct User {
- pub(crate) id: Uuid,
+pub struct User {
+ pub id: Uuid,
}
diff --git a/crates/server/src/handlers/mod.rs b/crates/server/src/handlers/mod.rs
index c4332f80..e24bad14 100644
--- a/crates/server/src/handlers/mod.rs
+++ b/crates/server/src/handlers/mod.rs
@@ -1,13 +1,13 @@
-use crate::atuin_common::api::{ErrorResponse, IndexResponse};
+use turtle_common::api::{ErrorResponse, IndexResponse};
use axum::{Json, extract::State, http, response::IntoResponse};
use crate::router::AppState;
-pub(crate) mod v0;
+pub mod v0;
const VERSION: &str = env!("CARGO_PKG_VERSION");
-pub(crate) async fn index(state: State<AppState>) -> Json<IndexResponse> {
+pub async fn index(state: State<AppState>) -> Json<IndexResponse> {
let homage = r#""Through the fathomless deeps of space swims the star turtle Great A'Tuin, bearing on its back the four giant elephants who carry on their shoulders the mass of the Discworld." -- Sir Terry Pratchett"#;
let version = state
@@ -28,12 +28,12 @@ impl IntoResponse for ErrorResponseStatus<'_> {
}
}
-pub(crate) struct ErrorResponseStatus<'a> {
- pub(crate) error: ErrorResponse<'a>,
- pub(crate) status: http::StatusCode,
+pub struct ErrorResponseStatus<'a> {
+ pub error: ErrorResponse<'a>,
+ pub status: http::StatusCode,
}
-pub(crate) trait RespExt<'a> {
+pub trait RespExt<'a> {
fn with_status(self, status: http::StatusCode) -> ErrorResponseStatus<'a>;
fn reply(reason: &'a str) -> Self;
}
diff --git a/crates/server/src/handlers/v0/mod.rs b/crates/server/src/handlers/v0/mod.rs
index 78fb47b8..2066636c 100644
--- a/crates/server/src/handlers/v0/mod.rs
+++ b/crates/server/src/handlers/v0/mod.rs
@@ -1 +1 @@
-pub(crate) mod record;
+pub mod record;
diff --git a/crates/server/src/handlers/v0/record.rs b/crates/server/src/handlers/v0/record.rs
index 0381ded8..f7758c1a 100644
--- a/crates/server/src/handlers/v0/record.rs
+++ b/crates/server/src/handlers/v0/record.rs
@@ -8,10 +8,10 @@ use crate::{
router::{AppState, UserAuth},
};
-use crate::atuin_common::record::{EncryptedData, HostId, Record, RecordIdx, RecordStatus};
+use turtle_common::record::{EncryptedData, HostId, Record, RecordIdx, RecordStatus};
#[instrument(skip_all, fields(user.id = user.id.to_string()))]
-pub(crate) async fn post(
+pub async fn post(
UserAuth(user): UserAuth,
state: State<AppState>,
Json(records): Json<Vec<Record<EncryptedData>>>,
@@ -50,7 +50,7 @@ pub(crate) async fn post(
}
#[instrument(skip_all, fields(user.id = user.id.to_string()))]
-pub(crate) async fn index(
+pub async fn index(
UserAuth(user): UserAuth,
state: State<AppState>,
) -> Result<Json<RecordStatus>, ErrorResponseStatus<'static>> {
@@ -75,7 +75,7 @@ pub(crate) async fn index(
}
#[derive(Deserialize)]
-pub(crate) struct NextParams {
+pub struct NextParams {
host: HostId,
tag: String,
start: Option<RecordIdx>,
@@ -83,7 +83,7 @@ pub(crate) struct NextParams {
}
#[instrument(skip_all, fields(user.id = user.id.to_string()))]
-pub(crate) async fn next(
+pub async fn next(
params: Query<NextParams>,
UserAuth(user): UserAuth,
state: State<AppState>,
diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs
index a4b10acf..726afeea 100644
--- a/crates/server/src/lib.rs
+++ b/crates/server/src/lib.rs
@@ -5,14 +5,14 @@ use axum::{Router, serve};
use database::db::ServerPostgres;
use eyre::{Context, Result};
-pub(crate) mod database;
+pub mod database;
mod handlers;
mod metrics;
mod router;
-pub(crate) use settings::Settings;
+pub use settings::Settings;
-pub(crate) mod settings;
+pub mod settings;
use tokio::net::TcpListener;
use tokio::signal;
@@ -31,7 +31,7 @@ async fn shutdown_signal() {
eprintln!("Shutting down gracefully...");
}
-pub(crate) async fn launch(settings: Settings, addr: SocketAddr) -> Result<()> {
+pub async fn launch(settings: Settings, addr: SocketAddr) -> Result<()> {
launch_with_tcp_listener(
settings,
TcpListener::bind(addr)
@@ -42,7 +42,7 @@ pub(crate) async fn launch(settings: Settings, addr: SocketAddr) -> Result<()> {
.await
}
-pub(crate) async fn launch_with_tcp_listener(
+pub async fn launch_with_tcp_listener(
settings: Settings,
listener: TcpListener,
shutdown: impl Future<Output = ()> + Send + 'static,
@@ -58,7 +58,7 @@ pub(crate) async fn launch_with_tcp_listener(
// The separate listener means it's much easier to ensure metrics are not accidentally exposed to
// the public.
-pub(crate) async fn launch_metrics_server(host: String, port: u16) -> Result<()> {
+pub async fn launch_metrics_server(host: String, port: u16) -> Result<()> {
let listener = TcpListener::bind((host, port))
.await
.context("failed to bind metrics tcp")?;
diff --git a/crates/server/src/main.rs b/crates/server/src/main.rs
new file mode 100644
index 00000000..94d6d143
--- /dev/null
+++ b/crates/server/src/main.rs
@@ -0,0 +1,61 @@
+use std::net::SocketAddr;
+
+use turtle_server::{Settings, database::DbType, launch, launch_metrics_server};
+
+use clap::Parser;
+use eyre::{Context, Result, eyre};
+
+#[derive(Parser, Clone, Debug)]
+#[command(infer_subcommands = true)]
+pub enum Cmd {
+ /// Start the server
+ Start {
+ /// The host address to bind
+ #[clap(long)]
+ host: Option<String>,
+
+ /// The port to bind
+ #[clap(long, short)]
+ port: Option<u16>,
+ },
+
+ /// Print server example configuration
+ DefaultConfig,
+}
+
+impl Cmd {
+ pub async fn run(self) -> Result<()> {
+ match self {
+ Self::Start { host, port } => {
+ let settings = Settings::new().wrap_err("could not load server settings")?;
+ let host = host.as_ref().unwrap_or(&settings.host).clone();
+ let port = port.unwrap_or(settings.port);
+ let addr = SocketAddr::new(host.parse()?, port);
+
+ if settings.metrics.enable {
+ tokio::spawn(launch_metrics_server(
+ settings.metrics.host.clone(),
+ settings.metrics.port,
+ ));
+ }
+
+ match settings.db_settings.db_type() {
+ DbType::Postgres => launch(settings, addr).await,
+ DbType::Unknown => {
+ Err(eyre!("db_uri must start with postgres:// or sqlite://"))
+ }
+ }
+ }
+ Self::DefaultConfig => {
+ // TODO(@bpeetz): Add this back <2026-06-11>
+ println!("TODO");
+ Ok(())
+ }
+ }
+ }
+}
+
+#[tokio::main]
+async fn main() -> Result<()> {
+ Cmd::parse().run().await
+}
diff --git a/crates/server/src/metrics.rs b/crates/server/src/metrics.rs
index 6380bef1..987af807 100644
--- a/crates/server/src/metrics.rs
+++ b/crates/server/src/metrics.rs
@@ -7,7 +7,7 @@ use axum::{
};
use metrics_exporter_prometheus::{Matcher, PrometheusBuilder, PrometheusHandle};
-pub(crate) fn setup_metrics_recorder() -> PrometheusHandle {
+pub fn setup_metrics_recorder() -> PrometheusHandle {
const EXPONENTIAL_SECONDS: &[f64] = &[
0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0,
];
@@ -25,7 +25,7 @@ pub(crate) fn setup_metrics_recorder() -> PrometheusHandle {
/// Middleware to record some common HTTP metrics
/// Generic over B to allow for arbitrary body types (eg Vec<u8>, Streams, a deserialized thing, etc)
/// Someday tower-http might provide a metrics middleware: <https://github.com/tower-rs/tower-http/issues/57>
-pub(crate) async fn track_metrics(req: Request, next: Next) -> impl IntoResponse {
+pub async fn track_metrics(req: Request, next: Next) -> impl IntoResponse {
let start = Instant::now();
let path = req.extensions().get::<MatchedPath>().map_or_else(
diff --git a/crates/server/src/router.rs b/crates/server/src/router.rs
index 2a5c5f15..b0b98ecc 100644
--- a/crates/server/src/router.rs
+++ b/crates/server/src/router.rs
@@ -1,7 +1,6 @@
-use crate::{
- atuin_common::api::{ATUIN_CARGO_VERSION, ATUIN_HEADER_VERSION, ErrorResponse},
- atuin_server::database::{db::ServerPostgres, models::User},
-};
+use crate::database::{db::ServerPostgres, models::User};
+use turtle_common::api::{ATUIN_CARGO_VERSION, ATUIN_HEADER_VERSION, ErrorResponse};
+
use axum::{
Router,
extract::{FromRequestParts, Path, Request},
@@ -22,7 +21,7 @@ use crate::{
settings::Settings,
};
-pub(crate) struct UserAuth(pub(crate) User);
+pub struct UserAuth(pub User);
impl FromRequestParts<AppState> for UserAuth {
type Rejection = ErrorResponseStatus<'static>;
@@ -66,12 +65,12 @@ async fn semver(request: Request, next: Next) -> Response {
}
#[derive(Clone)]
-pub(crate) struct AppState {
- pub(crate) database: ServerPostgres,
- pub(crate) settings: Settings,
+pub struct AppState {
+ pub database: ServerPostgres,
+ pub settings: Settings,
}
-pub(crate) fn router(database: ServerPostgres, settings: Settings) -> Router {
+pub fn router(database: ServerPostgres, settings: Settings) -> Router {
let routes = Router::new()
.route("/", get(handlers::index))
.route("/api/v0/{user_id}/record", post(handlers::v0::record::post))
diff --git a/crates/server/src/settings.rs b/crates/server/src/settings.rs
index 6a32fb9b..e6626b15 100644
--- a/crates/server/src/settings.rs
+++ b/crates/server/src/settings.rs
@@ -9,11 +9,11 @@ use tracing::info;
use crate::database::DbSettings;
#[derive(Clone, Debug, Deserialize, Serialize)]
-pub(crate) struct Metrics {
+pub struct Metrics {
#[serde(alias = "enabled")]
- pub(crate) enable: bool,
- pub(crate) host: String,
- pub(crate) port: u16,
+ pub enable: bool,
+ pub host: String,
+ pub port: u16,
}
impl Default for Metrics {
@@ -27,29 +27,29 @@ impl Default for Metrics {
}
#[derive(Clone, Debug, Deserialize, Serialize)]
-pub(crate) struct Settings {
- pub(crate) host: String,
- pub(crate) port: u16,
- pub(crate) path: String,
- pub(crate) max_history_length: usize,
- pub(crate) max_record_size: usize,
- pub(crate) page_size: i64,
- pub(crate) metrics: Metrics,
+pub struct Settings {
+ pub host: String,
+ pub port: u16,
+ pub path: String,
+ pub max_history_length: usize,
+ pub max_record_size: usize,
+ pub page_size: i64,
+ pub metrics: Metrics,
/// Advertise a version that is not what we are _actually_ running
/// Many clients compare their version with api.atuin.sh, and if they differ, notify the user
/// that an update is available.
/// Now that we take beta releases, we should be able to advertise a different version to avoid
/// notifying users when the server runs something that is not a stable release.
- pub(crate) fake_version: Option<String>,
+ pub fake_version: Option<String>,
#[serde(flatten)]
#[expect(clippy::struct_field_names)]
- pub(crate) db_settings: DbSettings,
+ pub db_settings: DbSettings,
}
impl Settings {
- pub(crate) fn new() -> Result<Self> {
+ pub fn new() -> Result<Self> {
// create the config file if it does not exist
let mut config_builder = Config::builder()
.set_default("host", "127.0.0.1")?