aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorBenedikt Peetz <benedikt.peetz@b-peetz.de>2026-07-24 17:56:36 +0200
committerBenedikt Peetz <benedikt.peetz@b-peetz.de>2026-07-24 17:56:36 +0200
commit9cdc575666faad4c162f4b4cf519bfdd1fd528dc (patch)
tree9287963ad11e237ca74edbd54161c03f3309927b
parentchore: Last big refactoring (diff)
downloadatuin-9cdc575666faad4c162f4b4cf519bfdd1fd528dc.zip
feat: Finalize design for fish-shell integration
Diffstat (limited to '')
-rw-r--r--Cargo.lock65
-rw-r--r--Cargo.toml2
-rw-r--r--crates/client/Cargo.toml4
-rw-r--r--crates/client/src/atuin_client/settings/mod.rs12
-rw-r--r--crates/client/src/atuin_history/stats.rs2
-rw-r--r--crates/client/src/command/client/daemon.rs2
-rw-r--r--crates/client/src/command/client/history/end.rs30
-rw-r--r--crates/client/src/command/client/history/list.rs39
-rw-r--r--crates/client/src/command/client/history/mod.rs14
-rw-r--r--crates/client/src/command/client/history/start.rs18
-rw-r--r--crates/client/src/command/client/history/tail.rs29
-rw-r--r--crates/client/src/command/client/info.rs2
-rw-r--r--crates/client/src/command/client/stats.rs20
-rw-r--r--crates/client/src/command/client/sync.rs17
-rw-r--r--crates/client/src/command/client/wrapped.rs5
-rw-r--r--crates/client/src/main.rs14
-rw-r--r--crates/common/src/utils.rs15
-rw-r--r--crates/daemon/Cargo.toml2
-rw-r--r--crates/daemon/src/aclient/database/mod.rs14
-rw-r--r--crates/daemon/src/aclient/history/mod.rs31
-rw-r--r--crates/daemon/src/aclient/history/store.rs8
-rw-r--r--crates/daemon/src/aclient/settings/mod.rs11
-rw-r--r--crates/daemon/src/api/control.rs33
-rw-r--r--crates/daemon/src/api/history.rs80
-rw-r--r--crates/daemon/src/daemon.rs14
-rw-r--r--crates/daemon/src/events.rs8
-rw-r--r--crates/daemon/src/main.rs15
-rw-r--r--crates/daemon/src/server.rs9
-rw-r--r--crates/turtle/Cargo.toml3
-rw-r--r--crates/turtle/build.rs3
-rw-r--r--crates/turtle/proto/control.proto4
-rw-r--r--crates/turtle/proto/history.proto34
-rw-r--r--crates/turtle/src/client/mod.rs108
-rw-r--r--crates/turtle/src/history/builder.rs11
-rw-r--r--crates/turtle/src/history/mod.rs17
-rw-r--r--crates/turtle/src/lib.rs9
36 files changed, 485 insertions, 219 deletions
diff --git a/Cargo.lock b/Cargo.lock
index 0649d85e..57f15ffb 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -143,37 +143,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
-name = "atuin"
-version = "18.16.1"
-dependencies = [
- "clap",
- "clap_complete",
- "clap_complete_nushell",
- "colored",
- "config",
- "crossterm",
- "eyre",
- "fs-err",
- "interim",
- "log",
- "regex",
- "rustix",
- "serde",
- "serde_json",
- "serde_regex",
- "serde_with",
- "shellexpand",
- "time",
- "tokio",
- "toml_edit",
- "tracing",
- "tracing-subscriber",
- "turtle",
- "turtle-common",
- "unicode-segmentation",
-]
-
-[[package]]
name = "autocfg"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3747,6 +3716,37 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
name = "turtle"
version = "18.16.1"
dependencies = [
+ "clap",
+ "clap_complete",
+ "clap_complete_nushell",
+ "colored",
+ "config",
+ "crossterm",
+ "eyre",
+ "fs-err",
+ "interim",
+ "log",
+ "regex",
+ "rustix",
+ "serde",
+ "serde_json",
+ "serde_regex",
+ "serde_with",
+ "shellexpand",
+ "time",
+ "tokio",
+ "toml_edit",
+ "tracing",
+ "tracing-subscriber",
+ "turtle-api",
+ "turtle-common",
+ "unicode-segmentation",
+]
+
+[[package]]
+name = "turtle-api"
+version = "18.16.1"
+dependencies = [
"eyre",
"hyper-util",
"prost",
@@ -3761,6 +3761,7 @@ dependencies = [
"tower",
"turtle-common",
"typed-builder",
+ "uuid",
]
[[package]]
@@ -3813,7 +3814,7 @@ dependencies = [
"tonic",
"tracing",
"tracing-subscriber",
- "turtle",
+ "turtle-api",
"turtle-common",
"uuid",
]
diff --git a/Cargo.toml b/Cargo.toml
index 465d1403..2d53c80b 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -18,7 +18,7 @@ repository = "https://github.com/atuinsh/atuin"
readme = "README.md"
[workspace.dependencies]
-turtle = { path = "crates/turtle" }
+turtle-api = { path = "crates/turtle" }
turtle-common = { path = "crates/common" }
turtle-daemon = { path = "crates/daemon" }
diff --git a/crates/client/Cargo.toml b/crates/client/Cargo.toml
index 93599cd6..10ed1001 100644
--- a/crates/client/Cargo.toml
+++ b/crates/client/Cargo.toml
@@ -1,5 +1,5 @@
[package]
-name = "atuin"
+name = "turtle"
edition = "2024"
description = "atuin - magical shell history"
readme = "./README.md"
@@ -34,7 +34,7 @@ tokio = { workspace = true }
toml_edit = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
-turtle = { workspace = true }
+turtle-api = { workspace = true }
turtle-common = { workspace = true }
unicode-segmentation = { workspace = true }
diff --git a/crates/client/src/atuin_client/settings/mod.rs b/crates/client/src/atuin_client/settings/mod.rs
index 5a3a1525..21ac3778 100644
--- a/crates/client/src/atuin_client/settings/mod.rs
+++ b/crates/client/src/atuin_client/settings/mod.rs
@@ -187,9 +187,8 @@ impl Settings {
let record_store_path = data_dir.join("records.db");
let kv_path = data_dir.join("kv.db");
let scripts_path = data_dir.join("scripts.db");
- let socket_path = utils::runtime_dir().join("atuin.sock");
+ let socket_path = utils::daemon_socket_path();
let pidfile_path = data_dir.join("atuin-daemon.pid");
- let logs_dir = utils::logs_dir();
let key_path = data_dir.join("key");
@@ -254,7 +253,6 @@ impl Settings {
.set_default("daemon.systemd_socket", false)?
.set_default("daemon.tcp_port", 8889)?
.set_default("logs.enabled", true)?
- .set_default("logs.dir", logs_dir.to_str())?
.set_default("logs.level", "info")?
.set_default("logs.search.file", "search.log")?
.set_default("logs.daemon.file", "daemon.log")?
@@ -587,13 +585,7 @@ mod tests {
scripts_db_path,
custom_dir.join("scripts.db").to_str().unwrap()
);
- assert_eq!(
- daemon_socket_path,
- turtle_common::utils::runtime_dir()
- .join("atuin.sock")
- .to_str()
- .unwrap()
- );
+
assert_eq!(
daemon_pidfile_path,
custom_dir.join("atuin-daemon.pid").to_str().unwrap()
diff --git a/crates/client/src/atuin_history/stats.rs b/crates/client/src/atuin_history/stats.rs
index cf6671c5..40be14f9 100644
--- a/crates/client/src/atuin_history/stats.rs
+++ b/crates/client/src/atuin_history/stats.rs
@@ -2,7 +2,7 @@ use std::collections::{HashMap, HashSet};
use crossterm::style::{Color, ResetColor, SetAttribute, SetForegroundColor};
use serde::{Deserialize, Serialize};
-use turtle::history::History;
+use turtle_api::history::History;
use unicode_segmentation::UnicodeSegmentation;
use crate::atuin_client::settings::Settings;
diff --git a/crates/client/src/command/client/daemon.rs b/crates/client/src/command/client/daemon.rs
index 08ce7a96..e9b0ea4c 100644
--- a/crates/client/src/command/client/daemon.rs
+++ b/crates/client/src/command/client/daemon.rs
@@ -1,7 +1,7 @@
use clap::Subcommand;
use eyre::Result;
-use turtle::client::{Probe, probe};
+use turtle_api::client::{Probe, probe};
use crate::atuin_client::settings::Settings;
diff --git a/crates/client/src/command/client/history/end.rs b/crates/client/src/command/client/history/end.rs
index 0e1e6b91..290f7697 100644
--- a/crates/client/src/command/client/history/end.rs
+++ b/crates/client/src/command/client/history/end.rs
@@ -1,28 +1,34 @@
+use std::time::Duration;
+
use crate::atuin_client::settings::Settings;
use eyre::{Result, eyre};
-use turtle::client::{HistoryClient, daemon_matches_expected, daemon_mismatch_message};
+use turtle_api::client::{HistoryClient, daemon_matches_expected, daemon_mismatch_message};
pub(super) async fn handle(
settings: &Settings,
id: &str,
exit: i64,
- duration: Option<u64>,
+ duration: Option<Duration>,
) -> Result<()> {
- end_history(settings, id.to_string(), duration.unwrap_or(0), exit).await?;
+ end_history(
+ settings,
+ id.to_string(),
+ duration.unwrap_or(Duration::ZERO),
+ exit,
+ )
+ .await?;
Ok(())
}
-async fn end_history(settings: &Settings, id: String, duration: u64, exit: i64) -> Result<()> {
- match async {
- HistoryClient::new(settings.daemon.socket_path.clone())
- .await?
- .end_history(id.clone(), duration, exit)
- .await
- }
- .await
- {
+async fn end_history(settings: &Settings, id: String, duration: Duration, exit: i64) -> Result<()> {
+ let response = HistoryClient::new(settings.daemon.socket_path.clone())
+ .await?
+ .end_history(id.clone(), duration, exit)
+ .await;
+
+ match response {
Ok(resp) => {
if daemon_matches_expected(resp.protocol) {
return Ok(());
diff --git a/crates/client/src/command/client/history/list.rs b/crates/client/src/command/client/history/list.rs
new file mode 100644
index 00000000..0fc59f6a
--- /dev/null
+++ b/crates/client/src/command/client/history/list.rs
@@ -0,0 +1,39 @@
+use std::{fmt::Display, time::Duration};
+
+use crate::{atuin_client::settings::Settings, command::client::history::format_duration_into};
+
+use eyre::Result;
+use turtle_api::client::HistoryClient;
+
+pub(super) async fn handle(settings: &Settings) -> Result<()> {
+ const CSI: &str = "\x1b[";
+ const CSE: &str = "m";
+ fn col(v: impl Display, num: u32) -> String {
+ format!("{CSI}{num}{CSE}{v}{CSI}0{CSE}")
+ }
+
+ struct F(Duration);
+ impl Display for F {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ format_duration_into(self.0, f)
+ }
+ }
+
+ let mut client = HistoryClient::new(settings.daemon.socket_path.clone()).await?;
+
+ let hists = client.history(None).await?;
+
+ for hist in hists {
+ println!(
+ "{}@{}: {} at {} for {} [{}]",
+ hist.author,
+ hist.hostname,
+ col(hist.command, 36),
+ col(hist.cwd, 32),
+ F(hist.duration),
+ col(hist.exit, 31)
+ );
+ }
+
+ Ok(())
+}
diff --git a/crates/client/src/command/client/history/mod.rs b/crates/client/src/command/client/history/mod.rs
index b6bfe6a1..f32ad83d 100644
--- a/crates/client/src/command/client/history/mod.rs
+++ b/crates/client/src/command/client/history/mod.rs
@@ -7,11 +7,12 @@ use std::{
use clap::Subcommand;
use eyre::Result;
use time::macros::format_description;
-use turtle::history::History;
+use turtle_api::history::History;
use crate::atuin_client::settings::Settings;
mod end;
+mod list;
mod start;
mod tail;
@@ -43,12 +44,16 @@ pub(crate) enum Cmd {
#[arg(long, short)]
exit: i64,
+ /// The duration this command ran, specified as nano seconds.
#[arg(long, short)]
duration: Option<u64>,
},
/// Stream history events from the daemon as they are received
Tail,
+
+ /// Very basic listing of tracked history.
+ List,
}
impl Cmd {
@@ -74,10 +79,11 @@ impl Cmd {
Ok(())
}
- Self::End { id, exit, duration } => end::handle(settings, &id, exit, duration).await,
- Self::Tail => {
- return tail::handle(settings).await;
+ Self::End { id, exit, duration } => {
+ end::handle(settings, &id, exit, duration.map(Duration::from_nanos)).await
}
+ Self::Tail => tail::handle(settings).await,
+ Self::List => list::handle(settings).await,
}
}
}
diff --git a/crates/client/src/command/client/history/start.rs b/crates/client/src/command/client/history/start.rs
index cb8f1b0c..c462755e 100644
--- a/crates/client/src/command/client/history/start.rs
+++ b/crates/client/src/command/client/history/start.rs
@@ -9,7 +9,7 @@ use crate::{
use eyre::{Result, eyre};
use time::OffsetDateTime;
use tracing::debug;
-use turtle::{
+use turtle_api::{
client::{HistoryClient, daemon_matches_expected, daemon_mismatch_message},
history::{History, SettingsFilter},
};
@@ -51,7 +51,7 @@ pub(super) async fn handle(
Ok(id) => id,
Err(e) => {
debug!("failed to start history via daemon: {e}");
- h.id.0.clone()
+ h.id.to_string()
}
};
@@ -59,14 +59,12 @@ pub(super) async fn handle(
}
async fn start_history(settings: &Settings, history: History) -> Result<String> {
- match async {
- HistoryClient::new(settings.daemon.socket_path.clone())
- .await?
- .start_history(history.clone())
- .await
- }
- .await
- {
+ let response = HistoryClient::new(settings.daemon.socket_path.clone())
+ .await?
+ .start_history(history.clone())
+ .await;
+
+ match response {
Ok(resp) => {
if daemon_matches_expected(resp.protocol) {
return Ok(resp.id);
diff --git a/crates/client/src/command/client/history/tail.rs b/crates/client/src/command/client/history/tail.rs
index 212d7fcd..2cad5dd6 100644
--- a/crates/client/src/command/client/history/tail.rs
+++ b/crates/client/src/command/client/history/tail.rs
@@ -7,7 +7,7 @@ use colored::Colorize;
use eyre::{Context, Result, bail};
use serde::Serialize;
use time::OffsetDateTime;
-use turtle::{
+use turtle_api::{
client::{
HistoryClient, HistoryEventKind, Probe, TailHistoryReply, history_entry_to_history, probe,
},
@@ -56,9 +56,7 @@ struct TailJsonHistory<'a> {
#[serde(skip_serializing_if = "Option::is_none")]
exit: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
- duration_ns: Option<i64>,
- #[serde(skip_serializing_if = "Option::is_none")]
- duration: Option<String>,
+ duration: Option<Duration>,
#[serde(skip_serializing_if = "Option::is_none")]
success: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -98,7 +96,7 @@ impl TailEvent {
let payload = TailJsonEvent {
event: self.kind.as_str(),
history: TailJsonHistory {
- id: &self.history.id.0,
+ id: &self.history.id.to_string(),
timestamp: format_history_time(self.history.timestamp, tz)?,
timestamp_unix_ns: u64::try_from(self.history.timestamp.unix_timestamp_nanos())
.context("history timestamp predates unix epoch")?,
@@ -111,8 +109,7 @@ impl TailEvent {
author: &self.history.author,
intent: self.history.intent.as_deref(),
exit: self.exit_value(),
- duration_ns: self.duration_value(),
- duration: self.duration_value().map(format_duration_ns),
+ duration: self.duration_value(),
success: self.success_value(),
finished_at: self
.finished_at()
@@ -158,7 +155,7 @@ impl TailEvent {
&format_history_time(self.history.timestamp, tz)
.unwrap_or_else(|_| "invalid".to_owned()),
);
- push_pretty_field(&mut out, "history", &self.history.id.0);
+ push_pretty_field(&mut out, "history", &self.history.id.to_string());
push_pretty_field(&mut out, "session", &self.history.session);
push_pretty_field(&mut out, "exit", &self.exit_display());
push_pretty_field(&mut out, "duration", &self.duration_display());
@@ -204,7 +201,7 @@ impl TailEvent {
matches!(self.kind, TailKind::Ended).then_some(self.history.exit)
}
- fn duration_value(&self) -> Option<i64> {
+ fn duration_value(&self) -> Option<Duration> {
matches!(self.kind, TailKind::Ended).then_some(self.history.duration)
}
@@ -214,8 +211,12 @@ impl TailEvent {
fn finished_at(&self) -> Option<OffsetDateTime> {
self.duration_value()
- .filter(|duration| *duration >= 0)
- .map(time::Duration::nanoseconds)
+ .filter(|duration| *duration >= Duration::ZERO)
+ .map(|d| {
+ time::Duration::nanoseconds_i128(
+ i128::try_from(d.as_nanos()).expect("to be small enough"),
+ )
+ })
.and_then(|duration| self.history.timestamp.checked_add(duration))
}
@@ -229,7 +230,7 @@ impl TailEvent {
fn duration_display(&self) -> String {
match self.duration_value() {
- Some(duration) if duration >= 0 => format_duration_ns(duration),
+ Some(duration) if duration >= Duration::ZERO => format_duration_ns(duration),
Some(_) => "unknown".bright_yellow().to_string(),
None => "running".bright_yellow().to_string(),
}
@@ -274,7 +275,7 @@ fn push_pretty_field(out: &mut String, label: &str, value: &str) {
}
}
-fn format_duration_ns(duration_ns: i64) -> String {
+fn format_duration_ns(duration: Duration) -> String {
struct F(Duration);
impl Display for F {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
@@ -282,7 +283,7 @@ fn format_duration_ns(duration_ns: i64) -> String {
}
}
- F(Duration::from_nanos(duration_ns.max(0).cast_unsigned())).to_string()
+ F(duration).to_string()
}
fn format_history_time(timestamp: OffsetDateTime, tz: Timezone) -> Result<String> {
diff --git a/crates/client/src/command/client/info.rs b/crates/client/src/command/client/info.rs
index c4839c38..1af8ee39 100644
--- a/crates/client/src/command/client/info.rs
+++ b/crates/client/src/command/client/info.rs
@@ -2,7 +2,7 @@ use crate::atuin_client::settings::Settings;
use crate::{SHA, VERSION};
use eyre::Result;
-use turtle::client::ControlClient;
+use turtle_api::client::ControlClient;
pub(crate) async fn run(settings: &Settings) -> Result<()> {
let config = turtle_common::utils::config_dir();
diff --git a/crates/client/src/command/client/stats.rs b/crates/client/src/command/client/stats.rs
index 88734bb6..9f74ecc3 100644
--- a/crates/client/src/command/client/stats.rs
+++ b/crates/client/src/command/client/stats.rs
@@ -2,12 +2,11 @@ use clap::Parser;
use eyre::Result;
use interim::{Dialect, parse_date_string};
use time::{Duration, OffsetDateTime, Time};
-use turtle::client::{HistoryClient, Range};
+use turtle_api::client::{HistoryClient, Range};
use crate::atuin_client::settings::Settings;
use crate::atuin_history::stats::{compute, pretty_print};
-use crate::command::current_session;
fn parse_ngram_size(s: &str) -> Result<usize, String> {
let value = s
@@ -38,7 +37,6 @@ pub(crate) struct Cmd {
impl Cmd {
pub(crate) async fn run(&self, settings: &Settings) -> Result<()> {
- let session = current_session()?;
let mut client = HistoryClient::new(settings.daemon.socket_path.clone()).await?;
let words = if self.period.is_empty() {
@@ -50,30 +48,32 @@ impl Cmd {
let now = OffsetDateTime::now_utc().to_offset(settings.timezone.0);
let last_night = now.replace_time(Time::MIDNIGHT);
- let history = if words.as_str() == "all" {
- client.history(session, None).await?
+ let range = if words.as_str() == "all" {
+ None
} else if words.trim() == "today" {
let start = last_night;
let end = start + Duration::days(1);
- client.history(session, Some(Range { start, end })).await?
+ Some(Range { start, end })
} else if words.trim() == "month" {
let end = last_night;
let start = end - Duration::days(31);
- client.history(session, Some(Range { start, end })).await?
+ Some(Range { start, end })
} else if words.trim() == "week" {
let end = last_night;
let start = end - Duration::days(7);
- client.history(session, Some(Range { start, end })).await?
+ Some(Range { start, end })
} else if words.trim() == "year" {
let end = last_night;
let start = end - Duration::days(365);
- client.history(session, Some(Range { start, end })).await?
+ Some(Range { start, end })
} else {
let start = parse_date_string(&words, now, Dialect::Uk)?;
let end = start + Duration::days(1);
- client.history(session, Some(Range { start, end })).await?
+ Some(Range { start, end })
};
+ let history = client.history(range).await?;
+
let stats = compute(settings, &history, self.count, self.ngram_size);
if let Some(stats) = stats {
diff --git a/crates/client/src/command/client/sync.rs b/crates/client/src/command/client/sync.rs
index 77ae97ba..7297c659 100644
--- a/crates/client/src/command/client/sync.rs
+++ b/crates/client/src/command/client/sync.rs
@@ -1,7 +1,8 @@
use clap::Subcommand;
use eyre::{Result, bail};
-use turtle::client::{Probe, probe};
+use tracing::info;
+use turtle_api::client::{Probe, probe};
use crate::atuin_client::settings::Settings;
@@ -74,8 +75,18 @@ async fn perform_cmd(settings: &Settings) -> Result<()> {
match probe(settings.daemon.socket_path.clone()).await {
Probe::Ready(mut control_client) => {
let reply = control_client.force_sync().await?;
- if !reply.accepted {
- bail!("Daemon refused to accept sync request");
+
+ match reply.error {
+ Some(err) => {
+ bail!("Daemon failed to sync: {err}");
+ }
+ None => {
+ info!(
+ down = reply.downloaded,
+ up = reply.uploaded,
+ "Sync completed."
+ );
+ }
}
}
Probe::NeedsRestart(msg) => {
diff --git a/crates/client/src/command/client/wrapped.rs b/crates/client/src/command/client/wrapped.rs
index 8c15a5d3..a47b81a1 100644
--- a/crates/client/src/command/client/wrapped.rs
+++ b/crates/client/src/command/client/wrapped.rs
@@ -2,7 +2,7 @@ use crossterm::style::{ResetColor, SetAttribute};
use eyre::Result;
use std::collections::{HashMap, HashSet};
use time::{Date, Duration, Month, OffsetDateTime, Time};
-use turtle::{
+use turtle_api::{
client::{HistoryClient, Range},
history::History,
};
@@ -274,7 +274,6 @@ fn print_fun_facts(wrapped_stats: &WrappedStats, stats: &Stats, year: i32) {
}
pub(crate) async fn run(year: Option<i32>, settings: &Settings) -> Result<()> {
- let session = current_session()?;
let mut client = HistoryClient::new(settings.daemon.socket_path.clone()).await?;
let now = OffsetDateTime::now_utc().to_offset(settings.timezone.0);
@@ -300,7 +299,7 @@ pub(crate) async fn run(year: Option<i32>, settings: &Settings) -> Result<()> {
now.offset(),
);
- let history = client.history(session, Some(Range { start, end })).await?;
+ let history = client.history(Some(Range { start, end })).await?;
if history.is_empty() {
println!(
"Your history for {year} is empty!\nMaybe 'atuin import' could help you import your previous history 🪄"
diff --git a/crates/client/src/main.rs b/crates/client/src/main.rs
index 05f328e2..4ec97cef 100644
--- a/crates/client/src/main.rs
+++ b/crates/client/src/main.rs
@@ -11,6 +11,7 @@ use clap::builder::styling::{AnsiColor, Effects};
use eyre::Result;
use command::AtuinCmd;
+use tracing_subscriber::EnvFilter;
use tracing_subscriber::util::SubscriberInitExt;
mod command;
@@ -60,7 +61,18 @@ impl Atuin {
}
fn main() -> Result<()> {
- if let Err(e) = tracing_subscriber::registry().try_init() {
+ if let Err(e) = tracing_subscriber::fmt()
+ .with_file(true)
+ .with_line_number(true)
+ .with_level(true)
+ .without_time()
+ .with_env_filter(
+ EnvFilter::builder()
+ .from_env_lossy()
+ .add_directive("turtle=debug".parse().unwrap()),
+ )
+ .try_init()
+ {
eprintln!("failed to initialize logging: {e}");
}
diff --git a/crates/common/src/utils.rs b/crates/common/src/utils.rs
index 7f46f223..b50328a9 100644
--- a/crates/common/src/utils.rs
+++ b/crates/common/src/utils.rs
@@ -91,33 +91,36 @@ pub fn in_git_repo(path: &str) -> Option<PathBuf> {
// 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.
+#[must_use]
pub fn home_dir() -> PathBuf {
directories::BaseDirs::new()
.map(|d| d.home_dir().to_path_buf())
.expect("could not determine home directory")
}
+#[must_use]
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")
+ config_dir.join("turtle")
}
+#[must_use]
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")
+ data_dir.join("turtle")
}
#[must_use]
-pub fn runtime_dir() -> PathBuf {
- env::var("XDG_RUNTIME_DIR").map_or_else(|_| data_dir(), PathBuf::from)
+pub fn daemon_socket_path() -> PathBuf {
+ runtime_dir().join("turtle.sock")
}
#[must_use]
-pub fn logs_dir() -> PathBuf {
- home_dir().join(".atuin").join("logs")
+pub fn runtime_dir() -> PathBuf {
+ env::var("XDG_RUNTIME_DIR").map_or_else(|_| data_dir(), PathBuf::from)
}
#[must_use]
diff --git a/crates/daemon/Cargo.toml b/crates/daemon/Cargo.toml
index cbc4c223..e514ee03 100644
--- a/crates/daemon/Cargo.toml
+++ b/crates/daemon/Cargo.toml
@@ -43,7 +43,7 @@ tokio-stream = { workspace = true }
tonic = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
-turtle = { workspace = true }
+turtle-api = { workspace = true }
turtle-common = { workspace = true }
uuid = { workspace = true }
diff --git a/crates/daemon/src/aclient/database/mod.rs b/crates/daemon/src/aclient/database/mod.rs
index b112b076..f24eb777 100644
--- a/crates/daemon/src/aclient/database/mod.rs
+++ b/crates/daemon/src/aclient/database/mod.rs
@@ -1,4 +1,4 @@
-use std::{path::Path, str::FromStr};
+use std::{path::Path, str::FromStr, time::Duration};
use fs_err::{self as fs};
use sql_builder::{SqlBuilder, SqlName};
@@ -8,7 +8,7 @@ use sqlx::{
};
use time::OffsetDateTime;
use tracing::debug;
-use turtle::history::{History, HistoryId};
+use turtle_api::history::{History, HistoryId};
use turtle_common::utils;
use crate::aclient::utils::setup_db;
@@ -59,9 +59,9 @@ impl ClientSqlite {
"insert or ignore into history(id, timestamp, duration, exit, command, cwd, session, hostname, author, intent, deleted_at)
values(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
)
- .bind(h.id.0.as_str())
+ .bind(h.id.to_string().as_str())
.bind(h.timestamp.unix_timestamp_nanos() as i64)
- .bind(h.duration)
+ .bind(h.duration.as_nanos() as i64)
.bind(h.exit)
.bind(h.command.as_str())
.bind(h.cwd.as_str())
@@ -81,7 +81,7 @@ impl ClientSqlite {
id: HistoryId,
) -> Result<()> {
sqlx::query("delete from history where id = ?1")
- .bind(id.0.as_str())
+ .bind(id.to_string().as_str())
.execute(&mut **tx)
.await?;
@@ -107,7 +107,9 @@ impl ClientSqlite {
))
.unwrap(),
)
- .duration(row.get("duration"))
+ .duration(Duration::from_nanos(
+ u64::try_from(row.get::<i64, _>("duration")).expect("to be small enough"),
+ ))
.exit(row.get("exit"))
.command(row.get("command"))
.cwd(row.get("cwd"))
diff --git a/crates/daemon/src/aclient/history/mod.rs b/crates/daemon/src/aclient/history/mod.rs
index ae7654a6..35abc89d 100644
--- a/crates/daemon/src/aclient/history/mod.rs
+++ b/crates/daemon/src/aclient/history/mod.rs
@@ -1,7 +1,9 @@
+use std::time::Duration;
+
use rmp::decode::DecodeStringError;
use rmp::decode::ValueReadError;
use rmp::{Marker, decode::Bytes};
-use turtle::history::History;
+use turtle_api::history::History;
use turtle_common::record::DecryptedData;
@@ -40,9 +42,12 @@ impl HistoryExt for History {
let include_intent = self.intent.is_some();
encode::write_array_len(&mut output, 10 + u32::from(include_intent))?;
- encode::write_str(&mut output, &self.id.0)?;
+ encode::write_str(&mut output, &self.id.to_string())?;
encode::write_u64(&mut output, self.timestamp.unix_timestamp_nanos() as u64)?;
- encode::write_sint(&mut output, self.duration)?;
+ encode::write_sint(
+ &mut output,
+ i64::try_from(self.duration.as_nanos()).expect("should be small enough"),
+ )?;
encode::write_sint(&mut output, self.exit)?;
encode::write_str(&mut output, &self.command)?;
encode::write_str(&mut output, &self.cwd)?;
@@ -107,7 +112,11 @@ impl HistoryExt for History {
let mut bytes = Bytes::new(bytes);
let timestamp = decode::read_u64(&mut bytes).map_err(error_report)?;
- let duration = decode::read_int(&mut bytes).map_err(error_report)?;
+ let duration = decode::read_int(&mut bytes)
+ .map(|int: i64| {
+ Duration::from_nanos(u64::try_from(int).expect("should be small enough"))
+ })
+ .map_err(error_report)?;
let exit = decode::read_int(&mut bytes).map_err(error_report)?;
let bytes = bytes.remaining_slice();
@@ -171,7 +180,9 @@ impl HistoryExt for History {
let mut bytes = Bytes::new(bytes);
let timestamp = decode::read_u64(&mut bytes).map_err(error_report)?;
- let duration = decode::read_int(&mut bytes).map_err(error_report)?;
+ let duration = decode::read_int(&mut bytes)
+ .map(|int: i64| Duration::from_nanos(u64::try_from(int).expect("to be small enough")))
+ .map_err(error_report)?;
let exit = decode::read_int(&mut bytes).map_err(error_report)?;
let bytes = bytes.remaining_slice();
@@ -228,6 +239,8 @@ impl HistoryExt for History {
#[cfg(test)]
mod tests {
+ use std::time::Duration;
+
use time::macros::datetime;
use crate::aclient::history::{HISTORY_VERSION, HistoryExt};
@@ -239,7 +252,7 @@ mod tests {
let history = History {
id: "66d16cbee7cd47538e5c5b8b44e9006e".to_owned().into(),
timestamp: datetime!(2023-05-28 18:35:40.633872 +00:00),
- duration: 49_206_000,
+ duration: Duration::from_nanos(49_206_000),
exit: 0,
command: "git status".to_owned(),
cwd: "/Users/conrad.ludgate/Documents/code/atuin".to_owned(),
@@ -267,7 +280,7 @@ mod tests {
let history = History {
id: "66d16cbee7cd47538e5c5b8b44e9006e".to_owned().into(),
timestamp: datetime!(2023-05-28 18:35:40.633872 +00:00),
- duration: 49_206_000,
+ duration: Duration::from_nanos(49_206_000),
exit: 0,
command: "git status".to_owned(),
cwd: "/Users/conrad.ludgate/Documents/code/atuin".to_owned(),
@@ -291,7 +304,7 @@ mod tests {
let history = History {
id: "66d16cbee7cd47538e5c5b8b44e9006e".to_owned().into(),
timestamp: datetime!(2023-05-28 18:35:40.633872 +00:00),
- duration: 49_206_000,
+ duration: Duration::from_nanos(49_206_000),
exit: 0,
command: "git status".to_owned(),
cwd: "/Users/conrad.ludgate/Documents/code/atuin".to_owned(),
@@ -333,7 +346,7 @@ mod tests {
let current = History {
id: "66d16cbee7cd47538e5c5b8b44e9006e".to_owned().into(),
timestamp: datetime!(2023-05-28 18:35:40.633872 +00:00),
- duration: 49_206_000,
+ duration: Duration::from_nanos(49_206_000),
exit: 0,
command: "git status".to_owned(),
cwd: "/Users/conrad.ludgate/Documents/code/atuin".to_owned(),
diff --git a/crates/daemon/src/aclient/history/store.rs b/crates/daemon/src/aclient/history/store.rs
index 952f2070..a6d6a627 100644
--- a/crates/daemon/src/aclient/history/store.rs
+++ b/crates/daemon/src/aclient/history/store.rs
@@ -1,6 +1,6 @@
use eyre::{Result, bail, eyre};
use rmp::decode::Bytes;
-use turtle::history::{History, HistoryId};
+use turtle_api::history::{History, HistoryId};
use crate::aclient::{
database::ClientSqlite,
@@ -56,7 +56,7 @@ impl HistoryRecord {
Self::Delete(id) => {
// 1 -> a history delete
encode::write_u8(&mut output, 1)?;
- encode::write_str(&mut output, id.0.as_str())?;
+ encode::write_str(&mut output, id.to_string().as_str())?;
}
}
@@ -221,6 +221,8 @@ impl HistoryStore {
#[cfg(test)]
mod tests {
+ use std::time::Duration;
+
use time::macros::datetime;
use turtle_common::record::DecryptedData;
@@ -244,7 +246,7 @@ mod tests {
let history = History {
id: "018cd4fe81757cd2aee65cd7861f9c81".to_owned().into(),
timestamp: datetime!(2024-01-04 00:00:00.000000 +00:00),
- duration: 100,
+ duration: Duration::from_nanos(100),
exit: 0,
command: "ls".to_owned(),
cwd: "/Users/ellie/src/github.com/atuinsh/atuin".to_owned(),
diff --git a/crates/daemon/src/aclient/settings/mod.rs b/crates/daemon/src/aclient/settings/mod.rs
index ef3f1dd0..379ee563 100644
--- a/crates/daemon/src/aclient/settings/mod.rs
+++ b/crates/daemon/src/aclient/settings/mod.rs
@@ -252,9 +252,8 @@ impl Settings {
let kv_path = data_dir.join("kv.db");
let scripts_path = data_dir.join("scripts.db");
let ai_sessions_path = data_dir.join("ai_sessions.db");
- let socket_path = utils::runtime_dir().join("atuin.sock");
+ let socket_path = utils::daemon_socket_path();
let pidfile_path = data_dir.join("atuin-daemon.pid");
- let logs_dir = utils::logs_dir();
let key_path = data_dir.join("key");
let meta_path = data_dir.join("meta.db");
@@ -320,7 +319,6 @@ impl Settings {
.set_default("daemon.systemd_socket", false)?
.set_default("daemon.tcp_port", 8889)?
.set_default("logs.enabled", true)?
- .set_default("logs.dir", logs_dir.to_str())?
.set_default("logs.level", "info")?
.set_default("logs.search.file", "search.log")?
.set_default("logs.daemon.file", "daemon.log")?
@@ -583,13 +581,6 @@ mod tests {
);
assert_eq!(meta_db_path, custom_dir.join("meta.db").to_str().unwrap());
assert_eq!(
- daemon_socket_path,
- turtle_common::utils::runtime_dir()
- .join("atuin.sock")
- .to_str()
- .unwrap()
- );
- assert_eq!(
daemon_pidfile_path,
custom_dir.join("atuin-daemon.pid").to_str().unwrap()
);
diff --git a/crates/daemon/src/api/control.rs b/crates/daemon/src/api/control.rs
index ff19f593..8277f434 100644
--- a/crates/daemon/src/api/control.rs
+++ b/crates/daemon/src/api/control.rs
@@ -6,7 +6,7 @@ use tokio::time::{self, MissedTickBehavior};
use tonic::{Request, Response, Status};
use tracing::{Level, instrument};
-use turtle::generated::{
+use turtle_api::generated::{
DAEMON_PROTOCOL_VERSION,
control::{
ForceSyncReply, ForceSyncRequest, PathsReply, PathsRequest, StatusReply, StatusRequest,
@@ -91,9 +91,36 @@ impl Control for ControlService {
&self,
_request: Request<ForceSyncRequest>,
) -> Result<Response<ForceSyncReply>, Status> {
- let reply = ForceSyncReply { accepted: true };
-
self.handle.emit(DaemonEvent::ForceSync);
+ let event = self
+ .handle
+ .wait_for(|e| {
+ matches!(
+ e,
+ DaemonEvent::SyncFailed { .. } | DaemonEvent::SyncCompleted { .. }
+ )
+ })
+ .await
+ .map_err(|e| {
+ Status::internal(format!("failed to wait for sync response event: {e:?}"))
+ })?;
+
+ let reply = match event {
+ DaemonEvent::SyncCompleted {
+ uploaded,
+ downloaded,
+ } => ForceSyncReply {
+ error: None,
+ uploaded: uploaded as u32,
+ downloaded: downloaded as u32,
+ },
+ DaemonEvent::SyncFailed { error } => ForceSyncReply {
+ error: Some(error),
+ uploaded: 0,
+ downloaded: 0,
+ },
+ _ => unreachable!(),
+ };
Ok(Response::new(reply))
}
diff --git a/crates/daemon/src/api/history.rs b/crates/daemon/src/api/history.rs
index 6165464d..0b373604 100644
--- a/crates/daemon/src/api/history.rs
+++ b/crates/daemon/src/api/history.rs
@@ -1,10 +1,10 @@
-use std::pin::Pin;
+use std::{pin::Pin, time::Duration};
use dashmap::DashMap;
use eyre::Result;
use time::OffsetDateTime;
use tokio_stream::Stream;
-use tonic::{Request, Response, Status};
+use tonic::{IntoRequest, Request, Response, Status};
use tracing::{Level, instrument};
use crate::{
@@ -12,12 +12,16 @@ use crate::{
daemon::DaemonHandle,
events::DaemonEvent,
};
-use turtle::{
+use turtle_api::{
+ client::{
+ proto_duration_to_std, proto_timestamp_to_time, std_to_proto_duration,
+ time_to_proto_timestamp,
+ },
generated::{
DAEMON_PROTOCOL_VERSION,
history::{
- EndHistoryReply, EndHistoryRequest, HistoryEntry, HistoryEventKind, HistoryReply,
- HistoryRequest, StartHistoryReply, StartHistoryRequest, TailHistoryReply,
+ AddHistoryRequest, EndHistoryReply, EndHistoryRequest, HistoryEntry, HistoryEventKind,
+ HistoryReply, HistoryRequest, StartHistoryReply, StartHistoryRequest, TailHistoryReply,
TailHistoryRequest,
history_server::{History as HistorySvc, HistoryServer},
},
@@ -60,8 +64,8 @@ impl HistoryService {
fn history_to_reply(history: History) -> HistoryEntry {
HistoryEntry {
- timestamp: history.timestamp.unix_timestamp_nanos() as u64,
- id: history.id.0,
+ timestamp: time_to_proto_timestamp(history.timestamp),
+ id: history.id.to_string(),
command: history.command,
cwd: history.cwd,
session: history.session,
@@ -69,7 +73,7 @@ fn history_to_reply(history: History) -> HistoryEntry {
author: history.author,
intent: history.intent.unwrap_or_default(),
exit: history.exit,
- duration: history.duration,
+ duration: std_to_proto_duration(history.duration),
}
}
@@ -85,8 +89,8 @@ impl HistorySvc for HistoryService {
let req = request.into_inner();
let entries = if let Some(range) = req.range {
- let from = OffsetDateTime::from_unix_timestamp(range.start as i64).unwrap();
- let to = OffsetDateTime::from_unix_timestamp(range.end as i64).unwrap();
+ let from = proto_timestamp_to_time(range.start);
+ let to = proto_timestamp_to_time(range.end);
self.handle.history_db().range(from, to).await
} else {
@@ -101,18 +105,41 @@ impl HistorySvc for HistoryService {
}
#[instrument(skip_all, level = Level::INFO)]
+ async fn add_history(
+ &self,
+ request: Request<AddHistoryRequest>,
+ ) -> Result<Response<EndHistoryReply>, Status> {
+ let req = request.into_inner();
+ let start_req = req.start.expect("is some");
+
+ let start_response = self
+ .start_history(start_req.into_request())
+ .await?
+ .into_inner();
+ let end_responnse = self
+ .end_history(
+ EndHistoryRequest {
+ id: start_response.id,
+ exit: req.exit,
+ duration: req.duration,
+ }
+ .into_request(),
+ )
+ .await?;
+
+ Ok(end_responnse)
+ }
+
+ #[instrument(skip_all, level = Level::INFO)]
async fn start_history(
&self,
request: Request<StartHistoryRequest>,
) -> Result<Response<StartHistoryReply>, Status> {
+ tokio::time::sleep(Duration::from_secs(5)).await;
+
let req = request.into_inner();
- let timestamp = OffsetDateTime::from_unix_timestamp_nanos(i128::from(req.timestamp))
- .map_err(|_| {
- Status::invalid_argument(
- "failed to parse timestamp as unix time (expected nanos since epoch)",
- )
- })?;
+ let timestamp = proto_timestamp_to_time(req.timestamp);
let h: History = History::daemon()
.timestamp(timestamp)
@@ -121,7 +148,7 @@ impl HistorySvc for HistoryService {
.session(req.session)
.hostname(req.hostname)
.author(req.author)
- .intent(req.intent)
+ .intent(req.intent.unwrap_or_default())
.build()
.into();
@@ -146,16 +173,15 @@ impl HistorySvc for HistoryService {
request: Request<EndHistoryRequest>,
) -> Result<Response<EndHistoryReply>, Status> {
let req = request.into_inner();
- let id = HistoryId(req.id);
+ let id = HistoryId::from(req.id);
if let Some((_, mut history)) = self.running.remove(&id) {
history.exit = req.exit;
- history.duration = match req.duration {
- 0 => i64::try_from(
- (OffsetDateTime::now_utc() - history.timestamp).whole_nanoseconds(),
- )
- .expect("failed to convert calculated duration to i64"),
- value => i64::try_from(value).expect("failed to get i64 duration"),
+ history.duration = match proto_duration_to_std(req.duration) {
+ Duration::ZERO => Duration::from_nanos_u128(
+ (OffsetDateTime::now_utc() - history.timestamp).whole_nanoseconds() as u128,
+ ),
+ value => value,
};
self.handle
@@ -164,7 +190,11 @@ impl HistorySvc for HistoryService {
.await
.map_err(|e| Status::internal(format!("failed to write to db: {e:?}")))?;
- tracing::info!(id = id.0, duration = history.duration, "end history");
+ tracing::info!(
+ id = id.to_string(),
+ duration = history.duration.as_nanos(),
+ "end history"
+ );
let (record_id, idx) = self
.history_store
diff --git a/crates/daemon/src/daemon.rs b/crates/daemon/src/daemon.rs
index 1c3afcde..70e65c1e 100644
--- a/crates/daemon/src/daemon.rs
+++ b/crates/daemon/src/daemon.rs
@@ -87,6 +87,20 @@ impl DaemonHandle {
tracing::warn!("failed to emit event (no receivers?): {e}");
}
}
+ pub(crate) async fn wait_for(&self, matches: fn(&DaemonEvent) -> bool) -> Result<DaemonEvent> {
+ let mut rx = self.subscribe();
+ loop {
+ match rx.recv().await {
+ Ok(e) if matches(&e) => {
+ return Ok(e);
+ }
+ Err(err) => {
+ return Err(err).context("while waiting for events");
+ }
+ Ok(_) => (),
+ }
+ }
+ }
/// Subscribe to the event bus.
///
diff --git a/crates/daemon/src/events.rs b/crates/daemon/src/events.rs
index 3b6fa5d8..654e56cb 100644
--- a/crates/daemon/src/events.rs
+++ b/crates/daemon/src/events.rs
@@ -7,15 +7,14 @@
//! External processes (like CLI commands) can also inject events via the
//! Control gRPC service.
-use turtle::history::History;
+use turtle_api::history::History;
/// 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)]
+#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum DaemonEvent {
- // ---- History lifecycle ----
/// A command has started running.
HistoryStarted(History),
@@ -25,11 +24,9 @@ pub(crate) enum DaemonEvent {
/// Sync completed successfully.
SyncCompleted {
/// Number of records uploaded.
- #[expect(unused)]
uploaded: usize,
/// Number of records downloaded.
- #[expect(unused)]
downloaded: usize,
},
@@ -42,7 +39,6 @@ pub(crate) enum DaemonEvent {
/// Request an immediate sync (external trigger).
ForceSync,
- // ---- Lifecycle ----
/// Request graceful shutdown of the daemon.
ShutdownRequested,
}
diff --git a/crates/daemon/src/main.rs b/crates/daemon/src/main.rs
index 9d5a5333..8fd3c119 100644
--- a/crates/daemon/src/main.rs
+++ b/crates/daemon/src/main.rs
@@ -14,7 +14,7 @@ use clap::Parser;
use eyre::WrapErr;
use eyre::{Result, bail};
use fs4::fs_std::FileExt;
-use tracing_subscriber::util::SubscriberInitExt;
+use tracing_subscriber::EnvFilter;
use crate::{
aclient::{database::ClientSqlite, record::sqlite_store::SqliteStore, settings::Settings},
@@ -43,7 +43,18 @@ enum Cmd {
#[tokio::main]
async fn main() -> Result<()> {
- if let Err(e) = tracing_subscriber::registry().try_init() {
+ if let Err(e) = tracing_subscriber::fmt()
+ .with_file(true)
+ .with_line_number(true)
+ .with_level(true)
+ .without_time()
+ .with_env_filter(
+ EnvFilter::builder()
+ .from_env_lossy()
+ .add_directive("turtle_daemon=debug".parse().unwrap()),
+ )
+ .try_init()
+ {
eprintln!("failed to initialize logging: {e}");
}
diff --git a/crates/daemon/src/server.rs b/crates/daemon/src/server.rs
index 6747f276..d3427769 100644
--- a/crates/daemon/src/server.rs
+++ b/crates/daemon/src/server.rs
@@ -2,7 +2,7 @@ use std::{os::unix::net::SocketAddr, path::PathBuf};
use eyre::Result;
use eyre::{OptionExt, WrapErr};
-use turtle::generated::{
+use turtle_api::generated::{
control::control_server::ControlServer, history::history_server::HistoryServer,
};
@@ -57,7 +57,12 @@ pub(crate) fn run_grpc_server(
(UnixListener::from_std(listener)?, false)
} else {
tracing::info!("listening on unix socket {socket_path:?}");
- (UnixListener::bind(socket_path.clone())?, true)
+ (
+ UnixListener::bind(socket_path.clone()).with_context(|| {
+ format!("Failed to bind to unix socket at: {socket_path}")
+ })?,
+ true,
+ )
};
let uds_stream = UnixListenerStream::new(uds);
diff --git a/crates/turtle/Cargo.toml b/crates/turtle/Cargo.toml
index d5397f4e..3ad7340f 100644
--- a/crates/turtle/Cargo.toml
+++ b/crates/turtle/Cargo.toml
@@ -1,5 +1,5 @@
[package]
-name = "turtle"
+name = "turtle-api"
edition = "2024"
description = "turtle - library for sqlite shell history"
readme = "./README.md"
@@ -12,6 +12,7 @@ homepage = { workspace = true }
repository = { workspace = true }
[dependencies]
+uuid = { workspace = true }
eyre = { workspace = true }
hyper-util = { workspace = true }
prost = { workspace = true }
diff --git a/crates/turtle/build.rs b/crates/turtle/build.rs
index 62612968..2946438c 100644
--- a/crates/turtle/build.rs
+++ b/crates/turtle/build.rs
@@ -28,5 +28,8 @@ fn main() -> Result<(), std::io::Error> {
.skip_protoc_run()
.compile_protos(&proto_paths, &proto_include_dirs)?;
+ println!("cargo::rerun-if-changed=proto/control.proto");
+ println!("cargo::rerun-if-changed=proto/history.proto");
+
Ok(())
}
diff --git a/crates/turtle/proto/control.proto b/crates/turtle/proto/control.proto
index a8026cb8..f1656d73 100644
--- a/crates/turtle/proto/control.proto
+++ b/crates/turtle/proto/control.proto
@@ -14,7 +14,9 @@ service Control {
message ForceSyncRequest {}
message ForceSyncReply {
- bool accepted = 1;
+ optional string error = 1;
+ uint32 uploaded = 2;
+ uint32 downloaded = 3;
}
message StatusRequest {}
diff --git a/crates/turtle/proto/history.proto b/crates/turtle/proto/history.proto
index 850b16b9..90fcf55f 100644
--- a/crates/turtle/proto/history.proto
+++ b/crates/turtle/proto/history.proto
@@ -4,6 +4,7 @@ package history;
service History {
rpc StartHistory(StartHistoryRequest) returns (StartHistoryReply);
rpc EndHistory(EndHistoryRequest) returns (EndHistoryReply);
+ rpc AddHistory(AddHistoryRequest) returns (EndHistoryReply);
rpc TailHistory(TailHistoryRequest) returns (stream TailHistoryReply);
@@ -11,14 +12,20 @@ service History {
rpc History(HistoryRequest) returns (HistoryReply);
}
+message AddHistoryRequest {
+ StartHistoryRequest start = 1;
+ int64 exit = 2;
+ Duration duration = 3;
+}
+
message StartHistoryRequest {
- uint64 timestamp = 1; // nanosecond unix epoch
+ Timestamp timestamp = 1;
string command = 2;
string cwd = 3;
string session = 4;
string hostname = 5;
string author = 6;
- string intent = 7;
+ optional string intent = 7;
}
message StartHistoryReply {
string id = 1;
@@ -28,8 +35,9 @@ message StartHistoryReply {
message EndHistoryRequest {
string id = 1;
+
int64 exit = 2;
- uint64 duration = 3;
+ Duration duration = 3;
}
message EndHistoryReply {
string id = 1;
@@ -52,7 +60,7 @@ enum HistoryEventKind {
}
message HistoryEntry {
- uint64 timestamp = 1; // nanosecond unix epoch
+ Timestamp timestamp = 1;
string id = 2;
string command = 3;
string cwd = 4;
@@ -61,17 +69,25 @@ message HistoryEntry {
string author = 7;
string intent = 8;
int64 exit = 9;
- int64 duration = 10;
+ Duration duration = 10;
}
message Range {
- uint64 start = 1;
- uint64 end = 2;
+ Timestamp start = 1;
+ Timestamp end = 2;
+}
+
+message Timestamp {
+ // Encoded as nanoseconds since the UNIX epoch.
+ uint64 value = 1;
+}
+message Duration {
+ // Encoded as nanoseconds.
+ uint64 value = 1;
}
message HistoryRequest {
- string session = 1;
- optional Range range = 2;
+ Range range = 2;
}
message HistoryReply {
repeated HistoryEntry entries = 1;
diff --git a/crates/turtle/src/client/mod.rs b/crates/turtle/src/client/mod.rs
index ec97c994..a2322fdd 100644
--- a/crates/turtle/src/client/mod.rs
+++ b/crates/turtle/src/client/mod.rs
@@ -1,14 +1,13 @@
use eyre::{Context as EyreContext, Result};
-use time::OffsetDateTime;
use tonic::Code;
use tonic::transport::{Channel, Endpoint, Uri};
use tower::service_fn;
use hyper_util::rt::TokioIo;
-#[cfg(unix)]
use tokio::net::UnixStream;
+use crate::generated::history::AddHistoryRequest;
use crate::generated::{
self, DAEMON_PROTOCOL_VERSION,
control::{
@@ -34,14 +33,49 @@ fn normalize_optional_field(value: &str) -> Option<String> {
}
}
+/// The protobuf compile (for some reason) supports not actually sending a request with all fields
+/// (so every field is either fetched from the wire or set to a default).
+/// For custom messages, there are no defaults and thus they get generated as `Option`s.
+/// Our code will (obviously) never leave out a required (!) field in a message, and thus we can
+/// just unwrap all the pointless options.
+fn unwrap_proto_option<T>(field: Option<T>) -> T {
+ field.expect("should be some (see comment above)")
+}
+
+#[must_use]
+pub fn proto_duration_to_std(proto: Option<generated::history::Duration>) -> std::time::Duration {
+ std::time::Duration::from_nanos(unwrap_proto_option(proto).value)
+}
+
+#[must_use]
+pub fn proto_timestamp_to_time(proto: Option<generated::history::Timestamp>) -> OffsetDateTime {
+ OffsetDateTime::from_unix_timestamp_nanos(i128::from(unwrap_proto_option(proto).value))
+ .expect("Daemon history timestamp should always be valid")
+}
+
+#[must_use]
+pub fn time_to_proto_timestamp(t: OffsetDateTime) -> Option<generated::history::Timestamp> {
+ Some(generated::history::Timestamp {
+ value: t.unix_timestamp_nanos() as u64,
+ })
+}
+
+#[must_use]
+pub fn std_to_proto_duration(s: std::time::Duration) -> Option<generated::history::Duration> {
+ Some(generated::history::Duration {
+ value: s.as_nanos() as u64,
+ })
+}
+
+#[must_use]
pub fn history_entry_to_history(entry: HistoryEntry) -> History {
- let timestamp = OffsetDateTime::from_unix_timestamp_nanos(i128::from(entry.timestamp))
- .expect("Daemon history timestamp should always be valid");
+ let timestamp = proto_timestamp_to_time(entry.timestamp);
+ let duration = proto_duration_to_std(entry.duration);
History {
id: entry.id.into(),
timestamp,
- duration: entry.duration,
+ duration,
exit: entry.exit,
command: entry.command,
cwd: entry.cwd,
@@ -135,12 +169,14 @@ pub struct Range {
pub end: OffsetDateTime,
}
+pub use time::Duration;
+pub use time::OffsetDateTime;
+
// Wrap the grpc client
impl HistoryClient {
#[cfg(unix)]
pub async fn new(path: String) -> Result<Self> {
use eyre::Context;
-
let log_path = path.clone();
let channel = Endpoint::try_from("http://atuin_local_daemon:0")?
.connect_with_connector(service_fn(move |_: Uri| {
@@ -169,20 +205,57 @@ impl HistoryClient {
cwd: h.cwd,
hostname: h.hostname,
session: h.session,
- timestamp: h.timestamp.unix_timestamp_nanos() as u64,
+ timestamp: time_to_proto_timestamp(h.timestamp),
author: h.author,
- intent: h.intent.unwrap_or_default(),
+ intent: h.intent,
};
Ok(self.client.start_history(req).await?.into_inner())
}
- pub async fn history(&mut self, session: String, range: Option<Range>) -> Result<Vec<History>> {
+ pub async fn end_history(
+ &mut self,
+ id: String,
+ duration: std::time::Duration,
+ exit: i64,
+ ) -> Result<EndHistoryReply> {
+ let req = EndHistoryRequest {
+ id,
+ exit,
+ duration: std_to_proto_duration(duration),
+ };
+
+ Ok(self.client.end_history(req).await?.into_inner())
+ }
+
+ pub async fn add_history(
+ &mut self,
+ h: History,
+ duration: std::time::Duration,
+ exit: i64,
+ ) -> Result<EndHistoryReply> {
+ let req = AddHistoryRequest {
+ start: Some(StartHistoryRequest {
+ timestamp: time_to_proto_timestamp(h.timestamp),
+ command: h.command,
+ cwd: h.cwd,
+ session: h.session,
+ hostname: h.hostname,
+ author: h.author,
+ intent: h.intent,
+ }),
+ exit,
+ duration: std_to_proto_duration(duration),
+ };
+
+ Ok(self.client.add_history(req).await?.into_inner())
+ }
+
+ pub async fn history(&mut self, range: Option<Range>) -> Result<Vec<History>> {
let req = HistoryRequest {
- session,
range: range.map(|r| generated::history::Range {
- start: r.start.unix_timestamp() as u64,
- end: r.end.unix_timestamp() as u64,
+ start: time_to_proto_timestamp(r.start),
+ end: time_to_proto_timestamp(r.end),
}),
};
@@ -195,17 +268,6 @@ impl HistoryClient {
.collect())
}
- pub async fn end_history(
- &mut self,
- id: String,
- duration: u64,
- exit: i64,
- ) -> Result<EndHistoryReply> {
- let req = EndHistoryRequest { id, exit, duration };
-
- Ok(self.client.end_history(req).await?.into_inner())
- }
-
pub async fn tail_history(&mut self) -> Result<tonic::Streaming<TailHistoryReply>> {
Ok(self
.client
diff --git a/crates/turtle/src/history/builder.rs b/crates/turtle/src/history/builder.rs
index 08d26f7f..57970301 100644
--- a/crates/turtle/src/history/builder.rs
+++ b/crates/turtle/src/history/builder.rs
@@ -1,3 +1,5 @@
+use std::time::Duration;
+
use typed_builder::TypedBuilder;
use super::History;
@@ -12,7 +14,7 @@ pub struct HistoryFromDb {
command: String,
cwd: String,
exit: i64,
- duration: i64,
+ duration: Duration,
session: String,
hostname: String,
author: String,
@@ -48,14 +50,19 @@ pub struct HistoryDaemonCapture {
timestamp: time::OffsetDateTime,
#[builder(setter(into))]
command: String,
+
#[builder(setter(into))]
cwd: String,
+
#[builder(setter(into))]
session: String,
+
#[builder(setter(into))]
hostname: String,
+
#[builder(default, setter(strip_option, into))]
author: Option<String>,
+
#[builder(default, setter(strip_option, into))]
intent: Option<String>,
}
@@ -67,7 +74,7 @@ impl From<HistoryDaemonCapture> for History {
captured.command,
captured.cwd,
-1,
- -1,
+ Duration::from_nanos(0),
captured.session,
captured.hostname,
captured.author,
diff --git a/crates/turtle/src/history/mod.rs b/crates/turtle/src/history/mod.rs
index 27755bbe..3617bc54 100644
--- a/crates/turtle/src/history/mod.rs
+++ b/crates/turtle/src/history/mod.rs
@@ -2,6 +2,8 @@ use core::fmt::Formatter;
use regex::RegexSet;
use std::env;
use std::fmt::Display;
+use std::time::Duration;
+use uuid::Uuid;
use turtle_common::utils::uuid_v7;
@@ -15,8 +17,8 @@ mod secrets;
const HISTORY_AUTHOR_ENV: &str = "ATUIN_HISTORY_AUTHOR";
const HISTORY_INTENT_ENV: &str = "ATUIN_HISTORY_INTENT";
-#[derive(Clone, Debug, Eq, PartialEq, Hash)]
-pub struct HistoryId(pub String);
+#[derive(Clone, Debug, Eq, PartialEq, Hash, Copy)]
+pub struct HistoryId(Uuid);
impl Display for HistoryId {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
@@ -26,7 +28,12 @@ impl Display for HistoryId {
impl From<String> for HistoryId {
fn from(s: String) -> Self {
- Self(s)
+ Self(Uuid::parse_str(&s).expect("should be a valid uuid"))
+ }
+}
+impl From<&str> for HistoryId {
+ fn from(s: &str) -> Self {
+ Self(Uuid::parse_str(s).expect("should be a valid uuid"))
}
}
@@ -54,7 +61,7 @@ pub struct History {
pub timestamp: OffsetDateTime,
/// How long the command took to run.
- pub duration: i64,
+ pub duration: Duration,
/// The exit code of the command.
pub exit: i64,
@@ -106,7 +113,7 @@ impl History {
command: String,
cwd: String,
exit: i64,
- duration: i64,
+ duration: Duration,
session: String,
hostname: String,
author: Option<String>,
diff --git a/crates/turtle/src/lib.rs b/crates/turtle/src/lib.rs
index fbee6761..31702837 100644
--- a/crates/turtle/src/lib.rs
+++ b/crates/turtle/src/lib.rs
@@ -1,3 +1,12 @@
+#![expect(
+ clippy::cast_possible_truncation,
+ clippy::cast_sign_loss,
+ clippy::missing_errors_doc,
+ clippy::missing_panics_doc
+)]
+
pub mod client;
pub mod generated;
pub mod history;
+
+pub use uuid::Uuid;