diff options
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")? |
