chore(rustfmt): update Rustfmt config (#3945)

Update `.rustfmt.toml` to be more opinionated. Formatting now requires
nightly Rust; use `cargo +nightly fmt`.
This commit is contained in:
atuin-bot
2026-08-18 16:47:11 -07:00
committed by GitHub
parent ef576f5a86
commit ad81d1f54f
272 changed files with 3939 additions and 7437 deletions
+3 -3
View File
@@ -178,10 +178,10 @@ jobs:
runs-on: depot-ubuntu-24.04
steps:
- uses: actions/checkout@v6
- name: Install latest rust
- name: Install nightly rust
uses: dtolnay/rust-toolchain@master
with:
toolchain: 1.94.0
toolchain: nightly
components: rustfmt
- name: Format
run: cargo fmt -- --check
run: cargo +nightly fmt -- --check
+1
View File
@@ -12,6 +12,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
# --- rust conveniences ---
RUN rustup component add rust-analyzer clippy rustfmt && \
rustup toolchain install nightly --profile minimal --component rustfmt && \
cargo install cargo-nextest --locked || true
# Use mold by default. This has to live in $CARGO_HOME (/usr/local/cargo in this
+3 -3
View File
@@ -266,14 +266,14 @@ jobs:
steps:
- uses: actions/checkout@v7
- name: Install latest rust
- name: Install nightly rust
uses: dtolnay/rust-toolchain@master
with:
toolchain: 1.97.0
toolchain: nightly
components: rustfmt
- name: Format
run: cargo fmt -- --check
run: cargo +nightly fmt -- --check
deny:
runs-on: depot-ubuntu-24.04
+16 -3
View File
@@ -1,4 +1,17 @@
comment_width = 100
#error_on_line_overflow = true
#error_on_unformatted = true
format_macro_bodies = true
format_macro_matchers = true
format_strings = true
group_imports = "StdExternalCrate"
imports_granularity = "Module"
max_width = 100
newline_style = "Unix"
overflow_delimited_expr = true
reorder_imports = true
# uncomment once stable
#imports_granularity = "crate"
#group_imports = "StdExternalCrate"
struct_lit_width = 18
style_edition = "2024"
unstable_features = true
use_field_init_shorthand = true
use_small_heuristics = "Off"
+2 -2
View File
@@ -48,7 +48,7 @@ atuin-server-sqlite SQLite implementation (sqlx)
- `#![deny(unsafe_code)]` on client/common, `#![forbid(unsafe_code)]` on server.
- Clippy: `pedantic` + `nursery` on main crate. CI enforces `-D warnings -D clippy::redundant_clone`, on both the default targets and `--tests`.
- Rustdoc: CI runs `cargo doc --document-private-items --no-deps --workspace` with `RUSTDOCFLAGS=-D warnings`. Broken intra-doc links fail the build.
- Format: `cargo fmt`. Only non-default: `reorder_imports = true`.
- Format: `cargo +nightly fmt`. `.rustfmt.toml` uses nightly-only options, so formatting requires the nightly toolchain even though the project builds on stable 1.97.0.
- IDs: UUIDv7 (time-ordered), newtype wrappers (`HistoryId`, `RecordId`, `HostId`).
- Serialization: MessagePack for encrypted payloads, JSON for API, TOML for config.
- Storage traits: `Database` (client), `Store` (record store), `Database` (server) -- all `async_trait`.
@@ -80,6 +80,6 @@ cargo build
cargo test
cargo clippy -- -D warnings -D clippy::redundant_clone
cargo clippy --tests -- -D warnings -D clippy::redundant_clone
cargo fmt --check
cargo +nightly fmt --check
RUSTDOCFLAGS="-D warnings" cargo doc --document-private-items --no-deps --workspace
```
+3 -1
View File
@@ -41,11 +41,13 @@ These variable exports can be added in a local `.envrc` file, read by [direnv](h
## Editor setup (optional)
`cargo fmt` is the source of truth and CI enforces it. We also have a:
`cargo +nightly fmt` is the source of truth and CI enforces it. A nightly version of Rust is required for formatting because Atuin's `.rustfmt.toml` uses nightly-only options.
- [`.editorconfig`](https://editorconfig.org/) which sets sane defaults for `.rs` files.
- [`.nvim.lua`](https://github.com/atuinsh/atuin/blob/main/.nvim.lua) which configures neovim. Note you need `vim.o.exrc = true` and `nvim >= 0.11+`. You can disable it if you prefer, please see the docs at the top of that file.
If you format via rust-analyzer (LSP integration in your editor), make sure it uses nightly Rustfmt by setting [`rust-analyzer.rustfmt.overrideCommand`](https://rust-analyzer.github.io/book/configuration.html#rustfmt.overrideCommand) to `["rustfmt", "+nightly"]`.
## PRs
It can speed up the review cycle if you consent to maintainers pushing to your branch. This will only be in the case of small fixes or adjustments, and not anything large. If you feel OK with this, please check the box on the template!
+35 -56
View File
@@ -1,11 +1,12 @@
use atuin_client::database::{Database, Sqlite};
use eyre::{Context as _, Result, bail};
use tracing::{debug, info};
use crate::context::{AppContext, ClientContext};
use crate::fsm::AgentFsm;
use crate::session::{LocalSessionService, SessionManager, SessionService};
use crate::tui::app::{AiApp, ExitOutcome, IoContext};
use crate::tui::state::ConversationEvent;
use atuin_client::database::{Database, Sqlite};
use eyre::{Context as _, Result, bail};
use tracing::{debug, info};
pub(crate) async fn run(
initial_command: Option<String>,
@@ -107,11 +108,13 @@ async fn ensure_hub_session(settings: &atuin_client::settings::Settings) -> Resu
println!("Atuin AI requires authenticating with Atuin Hub.");
if will_sync {
println!(
"Once logged in, your shell history will be synchronized via Atuin Hub if auto_sync is enabled or when manually syncing."
"Once logged in, your shell history will be synchronized via Atuin Hub if auto_sync \
is enabled or when manually syncing."
);
}
println!(
"If you have an existing Atuin sync account, you can log in with your existing credentials."
"If you have an existing Atuin sync account, you can log in with your existing \
credentials."
);
println!("Press enter to begin (or esc to cancel).");
if !wait_for_login_confirmation()? {
@@ -186,20 +189,14 @@ async fn run_inline_tui(
}
};
let cwd = std::env::current_dir()
.ok()
.map(|p| p.to_string_lossy().into_owned());
let git_root_str = ctx
.git_root
.as_ref()
.map(|p| p.to_string_lossy().into_owned());
let cwd = std::env::current_dir().ok().map(|p| p.to_string_lossy().into_owned());
let git_root_str = ctx.git_root.as_ref().map(|p| p.to_string_lossy().into_owned());
let session_window_mins = settings.ai.session_continue_minutes.max(0); // treat negative values as 0 to avoid confusion
let max_age_secs: i64 = session_window_mins * 60;
let resumable = service
.find_resumable(cwd.as_deref(), git_root_str.as_deref(), max_age_secs)
.await?;
let resumable =
service.find_resumable(cwd.as_deref(), git_root_str.as_deref(), max_age_secs).await?;
// ─── Build FSM ───────────────────────────────────────────────
let (session_mgr, mut fsm, file_tracker, edit_permissions) = if let Some(stored) = resumable {
@@ -211,8 +208,10 @@ async fn run_inline_tui(
if has_api_content {
events.push(ConversationEvent::SystemContext {
content: "[Note: The user has started a new invocation of Atuin AI. Prior messages from this session are from an earlier invocation.]".to_string(),
});
content: "[Note: The user has started a new invocation of Atuin AI. Prior \
messages from this session are from an earlier invocation.]"
.to_string(),
});
let view_start = events.len();
let last_time = last_event_ts.and_then(|ts| chrono::DateTime::from_timestamp(ts, 0));
@@ -225,9 +224,8 @@ async fn run_inline_tui(
Default::default()
};
let ep = if let Ok(Some(json)) = mgr
.get_metadata(crate::edit_permissions::METADATA_KEY)
.await
let ep = if let Ok(Some(json)) =
mgr.get_metadata(crate::edit_permissions::METADATA_KEY).await
&& let Ok(cache) = crate::edit_permissions::EditPermissionCache::from_json(&json)
{
cache
@@ -264,26 +262,16 @@ async fn run_inline_tui(
// `ai.model` is read once at startup, so /model in another running
// session doesn't retarget this one mid-conversation.
fsm.ctx.model = settings
.ai
.model
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(String::from);
fsm.ctx.model =
settings.ai.model.as_deref().map(str::trim).filter(|s| !s.is_empty()).map(String::from);
// ─── Snapshot store ─────────────────────────────────────────
let snapshot_dir = atuin_common::utils::data_dir()
.join("ai")
.join("snapshots")
.join(session_mgr.session_id());
let snapshot_dir =
atuin_common::utils::data_dir().join("ai").join("snapshots").join(session_mgr.session_id());
let snapshot_store = crate::snapshots::SnapshotStore::open(snapshot_dir).ok();
// ─── Discover skills ───────────────────────────────────────
let project_root = ctx
.git_root
.clone()
.or_else(|| std::env::current_dir().ok());
let project_root = ctx.git_root.clone().or_else(|| std::env::current_dir().ok());
let skill_registry = crate::skills::SkillRegistry::discover(project_root.as_deref()).await;
// ─── Resume notice (frozen at startup) ──────────────────────
@@ -291,7 +279,8 @@ async fn run_inline_tui(
Some(t) => {
let human = chrono_humanize::HumanTime::from(t - chrono::Utc::now());
format!(
" Continuing previous session (last active {human}) - type /new to start a new session"
" Continuing previous session (last active {human}) - type /new to start a new \
session"
)
}
None => " Continuing previous session - type /new to start a new session".to_string(),
@@ -301,10 +290,8 @@ async fn run_inline_tui(
let mut slash_registry = crate::tui::slash::SlashCommandRegistry::default();
let mut skill_names = std::collections::HashSet::new();
for skill in skill_registry.all() {
slash_registry.register(crate::tui::slash::SlashCommand::new(
&skill.name,
&skill.description,
));
slash_registry
.register(crate::tui::slash::SlashCommand::new(&skill.name, &skill.description));
skill_names.insert(skill.name.clone());
}
@@ -338,9 +325,8 @@ async fn run_inline_tui(
);
let options =
eye_declare::RunOptions::default().keyboard(eye_declare::KeyboardProtocol::Enhanced);
let outcome = eye_declare::driver_tokio::run_with(app, options)
.await
.context("failed running AI TUI")?;
let outcome =
eye_declare::driver_tokio::run_with(app, options).await.context("failed running AI TUI")?;
// The app (and with it the last persist sender) dropped when the run
// loop returned; wait for the worker to drain its queue so the final
@@ -361,11 +347,8 @@ enum SetupChoice {
}
fn prompt_ai_setup() -> Result<SetupChoice> {
use crossterm::{
cursor,
event::{self, Event, KeyCode, KeyEventKind},
terminal,
};
use crossterm::event::{self, Event, KeyCode, KeyEventKind};
use crossterm::{cursor, terminal};
let options = ["Enable Atuin AI", "Disable ? Keybind", "Cancel"];
let mut selected: usize = 0;
@@ -433,10 +416,8 @@ fn render_setup_options(
options: &[&str],
selected: usize,
) -> Result<()> {
use crossterm::{
style::Stylize,
terminal::{Clear, ClearType},
};
use crossterm::style::Stylize;
use crossterm::terminal::{Clear, ClearType};
for (i, option) in options.iter().enumerate() {
if i == selected {
@@ -477,10 +458,8 @@ async fn set_ai_enabled(enabled: bool) -> Result<()> {
}
fn wait_for_login_confirmation() -> Result<bool> {
use crossterm::{
event::{self, Event, KeyCode},
terminal::{disable_raw_mode, enable_raw_mode},
};
use crossterm::event::{self, Event, KeyCode};
use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
enable_raw_mode().context("failed enabling raw mode for login prompt")?;
struct Guard;
+1 -6
View File
@@ -58,12 +58,7 @@ impl AppContext {
}
caps.push("client_v1_load_skill".to_string());
if let Ok(extra) = std::env::var("ATUIN_AI__ADDITIONAL_CAPS") {
caps.extend(
extra
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty()),
);
caps.extend(extra.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()));
}
caps
}
+15 -59
View File
@@ -49,10 +49,8 @@ impl ContextWindowBuilder {
// This is safe because the combining logic (Text + ToolCall merging)
// only operates within a single assistant response, which never
// spans turn boundaries.
let turn_messages: Vec<Vec<serde_json::Value>> = turns
.iter()
.map(|range| events_to_messages(&events[range.clone()]))
.collect();
let turn_messages: Vec<Vec<serde_json::Value>> =
turns.iter().map(|range| events_to_messages(&events[range.clone()])).collect();
let turn_chars: Vec<usize> = turn_messages.iter().map(|m| estimate_chars(m)).collect();
let total_chars: usize = turn_chars.iter().sum();
@@ -154,9 +152,10 @@ fn estimate_chars(messages: &[serde_json::Value]) -> usize {
#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;
use super::*;
fn user(content: &str) -> ConversationEvent {
ConversationEvent::UserMessage {
content: content.to_string(),
@@ -342,10 +341,7 @@ mod tests {
let messages = builder.build(&events);
// Should have prefix (turn 1) + marker + last turn (turn 3)
assert!(
messages.len() >= 3,
"should have at least prefix + marker + tail"
);
assert!(messages.len() >= 3, "should have at least prefix + marker + tail");
// First message should be from turn 1
assert_eq!(messages[0]["content"], "first");
@@ -393,9 +389,7 @@ mod tests {
// Verify turn 2 (the tool call turn) was dropped
let has_tool_use = messages.iter().any(|m| {
m["content"]
.as_array()
.is_some_and(|arr| arr.iter().any(|b| b["type"] == "tool_use"))
m["content"].as_array().is_some_and(|arr| arr.iter().any(|b| b["type"] == "tool_use"))
});
assert!(!has_tool_use, "tool call turn should have been truncated");
@@ -427,10 +421,7 @@ mod tests {
let marker_cost = estimate_chars(std::slice::from_ref(&truncation_marker()));
let budget = total - turn2_chars + marker_cost + 5;
assert!(
budget < total,
"budget must be less than total for truncation to trigger"
);
assert!(budget < total, "budget must be less than total for truncation to trigger");
let builder = ContextWindowBuilder::new(budget);
let messages = builder.build(&events);
@@ -438,43 +429,13 @@ mod tests {
// Should have: prefix (t1: 2 msgs) + marker (1 msg) + t3 (2 msgs) + t4 (2 msgs) = 7
// (turn 2 dropped)
assert_eq!(messages.len(), 7);
assert!(
messages[0]["content"]
.as_str()
.unwrap()
.starts_with("turn-1-user-")
);
assert!(
messages[1]["content"]
.as_str()
.unwrap()
.starts_with("turn-1-response-")
);
assert!(messages[0]["content"].as_str().unwrap().starts_with("turn-1-user-"));
assert!(messages[1]["content"].as_str().unwrap().starts_with("turn-1-response-"));
assert!(messages[2]["content"].as_str().unwrap().contains("omitted"));
assert!(
messages[3]["content"]
.as_str()
.unwrap()
.starts_with("turn-3-user-")
);
assert!(
messages[4]["content"]
.as_str()
.unwrap()
.starts_with("turn-3-response-")
);
assert!(
messages[5]["content"]
.as_str()
.unwrap()
.starts_with("turn-4-user-")
);
assert!(
messages[6]["content"]
.as_str()
.unwrap()
.starts_with("turn-4-response-")
);
assert!(messages[3]["content"].as_str().unwrap().starts_with("turn-3-user-"));
assert!(messages[4]["content"].as_str().unwrap().starts_with("turn-3-response-"));
assert!(messages[5]["content"].as_str().unwrap().starts_with("turn-4-user-"));
assert!(messages[6]["content"].as_str().unwrap().starts_with("turn-4-response-"));
}
#[test]
@@ -488,9 +449,7 @@ mod tests {
// No truncation marker
assert_eq!(messages.len(), 4);
assert!(
!messages
.iter()
.any(|m| m["content"].as_str().is_some_and(|s| s.contains("omitted")))
!messages.iter().any(|m| m["content"].as_str().is_some_and(|s| s.contains("omitted")))
);
}
@@ -548,9 +507,6 @@ mod tests {
);
// Turn 2 was dropped entirely, so no tool IDs should be present
assert!(
!tool_use_ids.contains(&"tc1"),
"dropped turn's tool_use should not appear"
);
assert!(!tool_use_ids.contains(&"tc1"), "dropped turn's tool_use should not appear");
}
}
+6 -18
View File
@@ -68,10 +68,7 @@ impl EditPreview {
merged_groups.push(current_group);
// Build structured hunks from merged groups
let hunks = merged_groups
.into_iter()
.map(|group| build_hunk(&group, &input))
.collect();
let hunks = merged_groups.into_iter().map(|group| build_hunk(&group, &input)).collect();
EditPreview { hunks }
}
@@ -121,11 +118,7 @@ impl WritePreview {
pub fn from_content(content: &str) -> Self {
let all_lines: Vec<&str> = content.lines().collect();
let total_lines = all_lines.len();
let lines = all_lines
.into_iter()
.take(WRITE_PREVIEW_LINES)
.map(String::from)
.collect();
let lines = all_lines.into_iter().take(WRITE_PREVIEW_LINES).map(String::from).collect();
WritePreview { lines, total_lines }
}
@@ -199,9 +192,10 @@ fn token_text(input: &InternedInput<&str>, is_before: bool, idx: u32) -> String
#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;
use super::*;
#[test]
fn no_changes_produces_empty_preview() {
let preview = EditPreview::compute("hello\nworld\n", "hello\nworld\n");
@@ -308,14 +302,8 @@ mod tests {
assert_eq!(preview.hunks.len(), 1);
let hunk = &preview.hunks[0];
assert!(
hunk.lines
.contains(&DiffLine::Removed("key1 = old1".into()))
);
assert!(
hunk.lines
.contains(&DiffLine::Removed("key2 = old2".into()))
);
assert!(hunk.lines.contains(&DiffLine::Removed("key1 = old1".into())));
assert!(hunk.lines.contains(&DiffLine::Removed("key2 = old2".into())));
assert!(hunk.lines.contains(&DiffLine::Added("key1 = new1".into())));
assert!(hunk.lines.contains(&DiffLine::Added("key2 = new2".into())));
}
+26 -19
View File
@@ -13,14 +13,12 @@ use crate::tui::ConversationEvent;
/// suitable for database storage.
pub(crate) fn serialize_event(event: &ConversationEvent) -> (String, String) {
match event {
ConversationEvent::UserMessage { content } => (
"user_message".to_string(),
serde_json::json!({ "content": content }).to_string(),
),
ConversationEvent::Text { content } => (
"text".to_string(),
serde_json::json!({ "content": content }).to_string(),
),
ConversationEvent::UserMessage { content } => {
("user_message".to_string(), serde_json::json!({ "content": content }).to_string())
}
ConversationEvent::Text { content } => {
("text".to_string(), serde_json::json!({ "content": content }).to_string())
}
ConversationEvent::ToolCall { id, name, input } => (
"tool_call".to_string(),
serde_json::json!({
@@ -60,10 +58,9 @@ pub(crate) fn serialize_event(event: &ConversationEvent) -> (String, String) {
})
.to_string(),
),
ConversationEvent::SystemContext { content } => (
"system_context".to_string(),
serde_json::json!({ "content": content }).to_string(),
),
ConversationEvent::SystemContext { content } => {
("system_context".to_string(), serde_json::json!({ "content": content }).to_string())
}
ConversationEvent::SkillInvocation {
name,
arguments,
@@ -109,16 +106,19 @@ pub(crate) fn deserialize_event(event_type: &str, event_data: &str) -> Result<Co
.and_then(Value::as_bool)
.ok_or_else(|| eyre!("tool_result missing 'is_error' field"))?,
remote: data.get("remote").and_then(Value::as_bool).unwrap_or(false),
content_length: data
.get("content_length")
.and_then(Value::as_u64)
.map(|v| v as usize),
content_length: data.get("content_length").and_then(Value::as_u64).map(|v| v as usize),
}),
"out_of_band_output" => Ok(ConversationEvent::OutOfBandOutput {
name: json_string(&data, "name")?,
command: data
.get("command")
.and_then(|v| if v.is_null() { None } else { v.as_str() })
.and_then(|v| {
if v.is_null() {
None
} else {
v.as_str()
}
})
.map(String::from),
content: json_string(&data, "content")?,
}),
@@ -129,7 +129,13 @@ pub(crate) fn deserialize_event(event_type: &str, event_data: &str) -> Result<Co
name: json_string(&data, "name")?,
arguments: data
.get("arguments")
.and_then(|v| if v.is_null() { None } else { v.as_str() })
.and_then(|v| {
if v.is_null() {
None
} else {
v.as_str()
}
})
.map(String::from),
content: json_string(&data, "content")?,
}),
@@ -146,9 +152,10 @@ fn json_string(data: &Value, field: &str) -> Result<String> {
#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;
use super::*;
fn round_trip(event: &ConversationEvent) -> ConversationEvent {
let (event_type, event_data) = serialize_event(event);
deserialize_event(&event_type, &event_data).unwrap()
+22 -46
View File
@@ -52,13 +52,10 @@ impl FileReadTracker {
let content_hash = hash_content(content);
let mtime_ms = system_time_to_ms(mtime);
self.reads.insert(
path,
FileReadState {
content_hash,
mtime_ms,
},
);
self.reads.insert(path, FileReadState {
content_hash,
mtime_ms,
});
}
/// Check whether a file is fresh (unchanged since last read).
@@ -100,13 +97,10 @@ impl FileReadTracker {
let content_hash = hash_content(new_content);
let mtime_ms = system_time_to_ms(new_mtime);
self.reads.insert(
path.to_path_buf(),
FileReadState {
content_hash,
mtime_ms,
},
);
self.reads.insert(path.to_path_buf(), FileReadState {
content_hash,
mtime_ms,
});
}
/// Serialize to JSON for session metadata persistence.
@@ -121,9 +115,7 @@ impl FileReadTracker {
}
fn system_time_to_ms(t: SystemTime) -> i64 {
t.duration_since(SystemTime::UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0)
t.duration_since(SystemTime::UNIX_EPOCH).map(|d| d.as_millis() as i64).unwrap_or(0)
}
fn hash_content(content: &[u8]) -> u64 {
@@ -132,11 +124,13 @@ fn hash_content(content: &[u8]) -> u64 {
#[cfg(test)]
mod tests {
use super::*;
use rstest::*;
use std::io::Write;
use rstest::*;
use tempfile::NamedTempFile;
use super::*;
#[fixture]
fn recorded() -> (FileReadTracker, NamedTempFile) {
let mut tracker = FileReadTracker::default();
@@ -154,20 +148,14 @@ mod tests {
#[rstest]
fn record_and_check_fresh(#[from(recorded)] (tracker, tmp): (FileReadTracker, NamedTempFile)) {
assert!(matches!(
tracker.check_freshness(tmp.path()).unwrap(),
FreshnessCheck::Fresh
));
assert!(matches!(tracker.check_freshness(tmp.path()).unwrap(), FreshnessCheck::Fresh));
}
#[test]
fn check_not_read() {
let tracker = FileReadTracker::default();
let path = PathBuf::from("/nonexistent/file.txt");
assert!(matches!(
tracker.check_freshness(&path).unwrap(),
FreshnessCheck::NotRead
));
assert!(matches!(tracker.check_freshness(&path).unwrap(), FreshnessCheck::NotRead));
}
#[rstest]
@@ -180,10 +168,7 @@ mod tests {
// Modify the file
std::fs::write(tmp.path(), "modified").unwrap();
assert!(matches!(
tracker.check_freshness(tmp.path()).unwrap(),
FreshnessCheck::Stale
));
assert!(matches!(tracker.check_freshness(tmp.path()).unwrap(), FreshnessCheck::Stale));
}
#[rstest]
@@ -196,29 +181,20 @@ mod tests {
let new_mtime = std::fs::metadata(tmp.path()).unwrap().modified().unwrap();
tracker.update_after_edit(tmp.path(), new_content, new_mtime);
assert!(matches!(
tracker.check_freshness(tmp.path()).unwrap(),
FreshnessCheck::Fresh
));
assert!(matches!(tracker.check_freshness(tmp.path()).unwrap(), FreshnessCheck::Fresh));
}
#[test]
fn roundtrip_json() {
let mut tracker = FileReadTracker::default();
tracker.reads.insert(
PathBuf::from("/some/file.toml"),
FileReadState {
content_hash: 12345,
mtime_ms: 1700000000000,
},
);
tracker.reads.insert(PathBuf::from("/some/file.toml"), FileReadState {
content_hash: 12345,
mtime_ms: 1700000000000,
});
let json = tracker.to_json().unwrap();
let restored = FileReadTracker::from_json(&json).unwrap();
assert_eq!(restored.reads.len(), 1);
assert_eq!(
restored.reads[&PathBuf::from("/some/file.toml")].content_hash,
12345
);
assert_eq!(restored.reads[&PathBuf::from("/some/file.toml")].content_hash, 12345);
}
}
+12 -4
View File
@@ -44,7 +44,9 @@ pub(crate) enum Effect {
tool: ClientToolCall,
},
/// Kill a running tool (send interrupt to shell command).
AbortTool { tool_id: String },
AbortTool {
tool_id: String,
},
/// Look up the command string behind an atuin_output call's history id
/// in the local history db, for display (the model only passes a UUID).
/// Resolves via `Event::OutputCommandResolved`.
@@ -70,9 +72,13 @@ pub(crate) enum Effect {
disposition: RuleDisposition,
},
/// Cache a session-scoped file permission grant.
CacheSessionGrant { path: PathBuf },
CacheSessionGrant {
path: PathBuf,
},
/// Persist the selected model alias to `ai.model` in config.toml.
SaveModelSelection { alias: String },
SaveModelSelection {
alias: String,
},
/// Archive current session and start fresh (IO only — state already updated by FSM).
ArchiveSession,
@@ -100,7 +106,9 @@ pub(crate) enum TimeoutKind {
/// Dangerous command confirmation dialog auto-dismiss.
Confirmation,
/// Shell tool execution timeout — abort the tool if it's still running.
ToolExecution { tool_id: String },
ToolExecution {
tool_id: String,
},
}
/// What to do when exiting the TUI.
+26 -44
View File
@@ -15,15 +15,14 @@ mod tests;
use std::collections::HashMap;
use effects::{Effect, ExitAction, PermissionTarget, TimeoutKind};
use events::{Event, PermissionChoice, PermissionResponse};
use serde_json::Value;
use tools::{ToolManager, ToolState};
use crate::context_window::ContextWindowBuilder;
use crate::tui::state::ConversationEvent;
use effects::{Effect, ExitAction, PermissionTarget, TimeoutKind};
use events::{Event, PermissionChoice, PermissionResponse};
use tools::{ToolManager, ToolState};
// ============================================================================
// State
// ============================================================================
@@ -45,7 +44,9 @@ pub(crate) enum AgentState {
},
/// A conversation turn is in progress.
Turn { stream: StreamPhase },
Turn {
stream: StreamPhase,
},
/// Unrecoverable error. User can retry or exit.
Error(String),
@@ -57,7 +58,9 @@ pub(crate) enum StreamPhase {
/// Request sent, awaiting first stream frame.
Connecting,
/// Actively receiving streamed response.
Streaming { status: Option<StreamingStatus> },
Streaming {
status: Option<StreamingStatus>,
},
/// Stream connection has ended (Done received).
Done,
}
@@ -246,9 +249,7 @@ impl AgentFsm {
// This event fires from the stream response headers, rather than having to wait until the end
// of a turn when StreamDone arrives, which can be lost for cancelled turns.
if let Event::SessionIdReceived(session_id) = &event {
self.ctx
.session_id
.get_or_insert_with(|| session_id.clone());
self.ctx.session_id.get_or_insert_with(|| session_id.clone());
}
match (&self.state, event) {
@@ -329,10 +330,7 @@ impl AgentFsm {
}
(AgentState::Idle { .. }, Event::ConfirmationTimeout { timeout_id }) => {
if self
.state_confirmation()
.is_some_and(|c| c.timeout_id == timeout_id)
{
if self.state_confirmation().is_some_and(|c| c.timeout_id == timeout_id) {
self.state = AgentState::Idle { confirmation: None };
}
vec![]
@@ -599,9 +597,7 @@ impl AgentFsm {
// Clear any pending execution timeout for this tool
self.ctx.tool_timeout_ids.retain(|_, tid| tid != id);
}
ids.into_iter()
.map(|tool_id| Effect::AbortTool { tool_id })
.collect()
ids.into_iter().map(|tool_id| Effect::AbortTool { tool_id }).collect()
}
(
@@ -647,7 +643,9 @@ impl AgentFsm {
// Add context so the LLM knows what happened
if !pending.is_empty() {
self.ctx.events.push(ConversationEvent::SystemContext {
content: "The user cancelled the previous generation. Tool calls that were in progress have been aborted.".to_string(),
content: "The user cancelled the previous generation. Tool calls that \
were in progress have been aborted."
.to_string(),
});
}
@@ -766,9 +764,7 @@ impl AgentFsm {
fn start_turn(&mut self, msg: String) -> Vec<Effect> {
// A message submitted while the picker was loading dismisses it.
self.ctx.model_picker = None;
self.ctx
.events
.push(ConversationEvent::UserMessage { content: msg });
self.ctx.events.push(ConversationEvent::UserMessage { content: msg });
// Don't clear tools — completed tools persist for rendering history.
// Tools are only cleared on /new (session reset).
self.ctx.current_response.clear();
@@ -795,9 +791,7 @@ impl AgentFsm {
let text = std::mem::take(&mut self.ctx.current_response);
let trimmed = text.trim_start().to_string();
if !trimmed.is_empty() {
self.ctx
.events
.push(ConversationEvent::Text { content: trimmed });
self.ctx.events.push(ConversationEvent::Text { content: trimmed });
}
}
@@ -853,7 +847,8 @@ impl AgentFsm {
self.ctx.events.push(ConversationEvent::ToolResult {
tool_use_id: id.clone(),
content: format!(
"Tool not enabled: capability '{required_cap}' was not advertised by this client"
"Tool not enabled: capability '{required_cap}' was not advertised by this \
client"
),
is_error: true,
remote: false,
@@ -990,9 +985,7 @@ impl AgentFsm {
PermissionChoice::AlwaysAllow => {
tracked.state = ToolState::Executing;
let tool = tracked.tool.clone();
let scope = tool
.resolved_file_path()
.map(|p| p.to_string_lossy().to_string());
let scope = tool.resolved_file_path().map(|p| p.to_string_lossy().to_string());
let rule = crate::permissions::rule::Rule {
tool: tool.rule_name().to_string(),
scope,
@@ -1141,9 +1134,7 @@ impl AgentFsm {
if let crate::tools::ClientToolCall::Shell(ref shell) = tool {
let timeout_id = self.ctx.next_timeout_id();
self.ctx
.tool_timeout_ids
.insert(timeout_id, tool_id.clone());
self.ctx.tool_timeout_ids.insert(timeout_id, tool_id.clone());
effects.push(Effect::ScheduleTimeout {
timeout_id,
duration: std::time::Duration::from_secs(shell.timeout_secs),
@@ -1158,12 +1149,9 @@ impl AgentFsm {
/// If so, either continue the conversation or go Idle.
fn check_turn_completion(&mut self) -> Vec<Effect> {
// Stream must be done
if !matches!(
self.state,
AgentState::Turn {
stream: StreamPhase::Done
}
) {
if !matches!(self.state, AgentState::Turn {
stream: StreamPhase::Done
}) {
return vec![];
}
@@ -1212,10 +1200,7 @@ impl AgentFsm {
/// Get the most recent suggested command from the conversation.
/// Get the most recent command from the current invocation only.
fn current_command(&self) -> Option<String> {
self.current_invocation_events()
.rev()
.find_map(|e| e.as_command())
.map(|s| s.to_string())
self.current_invocation_events().rev().find_map(|e| e.as_command()).map(|s| s.to_string())
}
/// Check if the most recent command is dangerous.
@@ -1226,10 +1211,7 @@ impl AgentFsm {
if let ConversationEvent::ToolCall { name, input, .. } = e
&& name == "suggest_command"
{
let danger = input
.get("danger")
.and_then(|v| v.as_str())
.unwrap_or("low");
let danger = input.get("danger").and_then(|v| v.as_str()).unwrap_or("low");
Some(danger == "high" || danger == "medium" || danger == "med")
} else {
None
+49 -140
View File
@@ -1,20 +1,16 @@
//! Pure FSM transition tests. No IO, no async.
use serde_json::json;
use effects::{Effect, ExitAction};
use events::{Event, PermissionChoice, PermissionResponse};
use rstest::{fixture, rstest};
use serde_json::json;
use super::tools::{InterruptReason, ToolPreviewData};
use super::*;
use effects::{Effect, ExitAction};
use events::{Event, PermissionChoice, PermissionResponse};
#[fixture]
fn new_fsm() -> AgentFsm {
AgentFsm::new(
vec!["client_v1_read_file".to_string()],
"test-inv".to_string(),
)
AgentFsm::new(vec!["client_v1_read_file".to_string()], "test-inv".to_string())
}
// ============================================================================
@@ -25,12 +21,9 @@ fn new_fsm() -> AgentFsm {
fn user_submit_starts_turn(#[from(new_fsm)] mut fsm: AgentFsm) {
let effects = fsm.handle(Event::UserSubmit("hello".into()));
assert!(matches!(
fsm.state,
AgentState::Turn {
stream: StreamPhase::Connecting
}
));
assert!(matches!(fsm.state, AgentState::Turn {
stream: StreamPhase::Connecting
}));
assert_eq!(effects.len(), 1);
assert!(matches!(effects[0], Effect::StartStream { .. }));
// User message was pushed to events
@@ -46,12 +39,9 @@ fn stream_started_transitions_to_streaming(#[from(new_fsm)] mut fsm: AgentFsm) {
let effects = fsm.handle(Event::StreamStarted);
assert!(matches!(
fsm.state,
AgentState::Turn {
stream: StreamPhase::Streaming { status: None }
}
));
assert!(matches!(fsm.state, AgentState::Turn {
stream: StreamPhase::Streaming { status: None }
}));
assert!(effects.is_empty());
}
@@ -167,10 +157,7 @@ fn stream_tool_call_tracks_tool_and_emits_check_permission(#[from(new_fsm)] mut
#[rstest]
fn atuin_output_call_emits_command_lookup_and_stores_result() {
let mut fsm = AgentFsm::new(
vec!["client_v1_atuin_output".to_string()],
"test-inv".to_string(),
);
let mut fsm = AgentFsm::new(vec!["client_v1_atuin_output".to_string()], "test-inv".to_string());
fsm.handle(Event::UserSubmit("show output".into()));
fsm.handle(Event::StreamStarted);
@@ -255,17 +242,10 @@ fn tool_done_after_stream_done_continues_conversation(#[from(new_fsm)] mut fsm:
});
// Turn complete → continuation
assert!(matches!(
fsm.state,
AgentState::Turn {
stream: StreamPhase::Connecting
}
));
assert!(
effects
.iter()
.any(|e| matches!(e, Effect::StartStream { .. }))
);
assert!(matches!(fsm.state, AgentState::Turn {
stream: StreamPhase::Connecting
}));
assert!(effects.iter().any(|e| matches!(e, Effect::StartStream { .. })));
}
#[rstest]
@@ -290,12 +270,9 @@ fn continuation_turn_without_new_tools_goes_idle(#[from(new_fsm)] mut fsm: Agent
outcome: crate::tools::ToolOutcome::Success("contents".into()),
preview: None,
});
assert!(matches!(
fsm.state,
AgentState::Turn {
stream: StreamPhase::Connecting
}
));
assert!(matches!(fsm.state, AgentState::Turn {
stream: StreamPhase::Connecting
}));
// Continuation stream: text only, no new tools
fsm.handle(Event::StreamStarted);
@@ -307,11 +284,7 @@ fn continuation_turn_without_new_tools_goes_idle(#[from(new_fsm)] mut fsm: Agent
// Should go Idle, NOT start another continuation
assert_eq!(fsm.state, AgentState::Idle { confirmation: None });
assert!(effects.iter().any(|e| matches!(e, Effect::Persist)));
assert!(
!effects
.iter()
.any(|e| matches!(e, Effect::StartStream { .. }))
);
assert!(!effects.iter().any(|e| matches!(e, Effect::StartStream { .. })));
}
#[rstest]
@@ -336,12 +309,9 @@ fn tool_done_before_stream_done_stays_in_turn(#[from(new_fsm)] mut fsm: AgentFsm
});
// Still in Turn — stream phase is Streaming, not Done
assert!(matches!(
fsm.state,
AgentState::Turn {
stream: StreamPhase::Streaming { .. }
}
));
assert!(matches!(fsm.state, AgentState::Turn {
stream: StreamPhase::Streaming { .. }
}));
assert!(effects.is_empty());
}
@@ -418,11 +388,7 @@ fn cancel_during_turn_with_pending_tools(#[from(new_fsm)] mut fsm: AgentFsm) {
let effects = fsm.handle(Event::Cancel);
assert_eq!(fsm.state, AgentState::Idle { confirmation: None });
assert!(
effects
.iter()
.any(|e| matches!(e, Effect::AbortTool { .. }))
);
assert!(effects.iter().any(|e| matches!(e, Effect::AbortTool { .. })));
// Error ToolResult injected
assert!(fsm.ctx.events.iter().any(|e| matches!(
e,
@@ -479,17 +445,10 @@ fn dangerous_command_enters_confirmation(#[from(new_fsm)] mut fsm: AgentFsm) {
let effects = fsm.handle(Event::ExecuteCommand);
assert!(matches!(
fsm.state,
AgentState::Idle {
confirmation: Some(_)
}
));
assert!(
effects
.iter()
.any(|e| matches!(e, Effect::ScheduleTimeout { .. }))
);
assert!(matches!(fsm.state, AgentState::Idle {
confirmation: Some(_)
}));
assert!(effects.iter().any(|e| matches!(e, Effect::ScheduleTimeout { .. })));
}
#[rstest]
@@ -551,17 +510,10 @@ fn retry_from_error_starts_new_stream(#[from(new_fsm)] mut fsm: AgentFsm) {
let effects = fsm.handle(Event::Retry);
assert!(matches!(
fsm.state,
AgentState::Turn {
stream: StreamPhase::Connecting
}
));
assert!(
effects
.iter()
.any(|e| matches!(e, Effect::StartStream { .. }))
);
assert!(matches!(fsm.state, AgentState::Turn {
stream: StreamPhase::Connecting
}));
assert!(effects.iter().any(|e| matches!(e, Effect::StartStream { .. })));
}
// ============================================================================
@@ -592,17 +544,10 @@ fn permission_deny_completes_turn_and_continues(#[from(new_fsm)] mut fsm: AgentF
// Turn should complete since all tools resolved and stream is done
// → continuation needed (there was a tool result to send back)
assert!(matches!(
fsm.state,
AgentState::Turn {
stream: StreamPhase::Connecting
}
));
assert!(
effects
.iter()
.any(|e| matches!(e, Effect::StartStream { .. }))
);
assert!(matches!(fsm.state, AgentState::Turn {
stream: StreamPhase::Connecting
}));
assert!(effects.iter().any(|e| matches!(e, Effect::StartStream { .. })));
// Error result was injected
assert!(fsm.ctx.events.iter().any(|e| matches!(
e,
@@ -642,17 +587,10 @@ fn unknown_tool_call_gets_error_result(#[from(new_fsm)] mut fsm: AgentFsm) {
// turn like a completed tool: the continuation starts immediately so
// the model sees the error and can retry.
assert!(fsm.ctx.tools.get("t1").is_none());
assert!(matches!(
fsm.state,
AgentState::Turn {
stream: StreamPhase::Connecting
}
));
assert!(
effects
.iter()
.any(|e| matches!(e, Effect::StartStream { .. }))
);
assert!(matches!(fsm.state, AgentState::Turn {
stream: StreamPhase::Connecting
}));
assert!(effects.iter().any(|e| matches!(e, Effect::StartStream { .. })));
}
#[rstest]
@@ -679,12 +617,9 @@ fn unknown_tool_alongside_real_tool_waits_for_both(#[from(new_fsm)] mut fsm: Age
// Still waiting on the real tool — the pre-answered unknown call must
// not complete the turn early.
assert!(matches!(
fsm.state,
AgentState::Turn {
stream: StreamPhase::Done
}
));
assert!(matches!(fsm.state, AgentState::Turn {
stream: StreamPhase::Done
}));
let effects = fsm.handle(Event::ToolExecutionDone {
tool_id: "t1".into(),
@@ -693,11 +628,7 @@ fn unknown_tool_alongside_real_tool_waits_for_both(#[from(new_fsm)] mut fsm: Age
});
// Both results now ride the same continuation.
assert!(
effects
.iter()
.any(|e| matches!(e, Effect::StartStream { .. }))
);
assert!(effects.iter().any(|e| matches!(e, Effect::StartStream { .. })));
}
// ============================================================================
@@ -707,10 +638,7 @@ fn unknown_tool_alongside_real_tool_waits_for_both(#[from(new_fsm)] mut fsm: Age
#[fixture]
fn fsm_with_shell() -> AgentFsm {
AgentFsm::new(
vec![
"client_v1_read_file".to_string(),
"client_v1_execute_shell_command".to_string(),
],
vec!["client_v1_read_file".to_string(), "client_v1_execute_shell_command".to_string()],
"test-inv".to_string(),
)
}
@@ -740,11 +668,7 @@ fn shell_tool_schedules_execution_timeout(#[from(fsm_with_shell)] mut fsm: Agent
});
// Should have ExecuteTool + ScheduleTimeout
assert!(
effects
.iter()
.any(|e| matches!(e, Effect::ExecuteTool { .. }))
);
assert!(effects.iter().any(|e| matches!(e, Effect::ExecuteTool { .. })));
assert!(effects.iter().any(|e| matches!(
e,
Effect::ScheduleTimeout { kind: effects::TimeoutKind::ToolExecution { tool_id }, .. }
@@ -768,16 +692,8 @@ fn read_tool_does_not_schedule_timeout(#[from(new_fsm)] mut fsm: AgentFsm) {
response: PermissionResponse::Allowed,
});
assert!(
effects
.iter()
.any(|e| matches!(e, Effect::ExecuteTool { .. }))
);
assert!(
!effects
.iter()
.any(|e| matches!(e, Effect::ScheduleTimeout { .. }))
);
assert!(effects.iter().any(|e| matches!(e, Effect::ExecuteTool { .. })));
assert!(!effects.iter().any(|e| matches!(e, Effect::ScheduleTimeout { .. })));
assert!(fsm.ctx.tool_timeout_ids.is_empty());
}
@@ -885,9 +801,7 @@ fn timeout_respects_llm_specified_duration(#[from(fsm_with_shell)] mut fsm: Agen
response: PermissionResponse::Allowed,
});
let timeout_effect = effects
.iter()
.find(|e| matches!(e, Effect::ScheduleTimeout { .. }));
let timeout_effect = effects.iter().find(|e| matches!(e, Effect::ScheduleTimeout { .. }));
assert!(matches!(
timeout_effect,
Some(Effect::ScheduleTimeout { duration, .. }) if *duration == std::time::Duration::from_secs(120)
@@ -918,12 +832,7 @@ fn cancel_clears_timeout_mappings(#[from(fsm_with_shell)] mut fsm: AgentFsm) {
"[Timed out after 60s]",
Some("[Interrupted by user]")
)]
#[case::user_interrupt(
Event::InterruptTools,
InterruptReason::User,
"[Interrupted by user]",
None
)]
#[case::user_interrupt(Event::InterruptTools, InterruptReason::User, "[Interrupted by user]", None)]
fn interrupt_propagates_reason_to_preview_and_llm(
#[from(fsm_with_shell)] mut fsm: AgentFsm,
#[case] trigger: Event,
+3 -11
View File
@@ -142,25 +142,17 @@ impl ToolManager {
/// synchronously at receipt (unknown tool, missing capability) and never
/// enter the execution lifecycle.
pub fn all_resolved(&self, tool_ids: &[String]) -> bool {
tool_ids
.iter()
.all(|id| self.get(id).is_none_or(|t| t.is_resolved()))
tool_ids.iter().all(|id| self.get(id).is_none_or(|t| t.is_resolved()))
}
/// Find the first tool awaiting user permission.
pub fn awaiting_permission(&self) -> Option<&TrackedTool> {
self.tools
.iter()
.find(|t| t.state == ToolState::AwaitingPermission)
self.tools.iter().find(|t| t.state == ToolState::AwaitingPermission)
}
/// Get IDs of all non-resolved tools (for cancel).
pub fn pending_ids(&self) -> Vec<String> {
self.tools
.iter()
.filter(|t| !t.is_resolved())
.map(|t| t.id.clone())
.collect()
self.tools.iter().filter(|t| !t.is_resolved()).map(|t| t.id.clone()).collect()
}
/// Get IDs of all currently executing tools (for interrupt/abort).
+6 -14
View File
@@ -53,12 +53,7 @@ fn format_attribution(history: &History) -> String {
}
fn format_timestamp(history: &History, local_offset: UtcOffset) -> String {
history
.timestamp
.to_offset(local_offset)
.display()
.ymd_hms()
.to_string()
history.timestamp.to_offset(local_offset).display().ymd_hms().to_string()
}
fn format_duration(nanos: i64) -> String {
@@ -66,12 +61,7 @@ fn format_duration(nanos: i64) -> String {
return String::new();
}
format!(
", {}",
std::time::Duration::saturating_from_nanos_i64(nanos)
.display()
.stopwatch()
)
format!(", {}", std::time::Duration::saturating_from_nanos_i64(nanos).display().stopwatch())
}
#[cfg(test)]
@@ -103,7 +93,8 @@ mod tests {
fn formats_last_command() {
assert_eq!(
format_last_command(&history(1_234_000_000), UtcOffset::UTC),
"History ID: 018f011c-9a0a-7000-8000-000000000001 - `cargo test`\n[1970-01-01 00:00:00] (in `/repo`, exit 2), 1.234s"
"History ID: 018f011c-9a0a-7000-8000-000000000001 - `cargo test`\n[1970-01-01 \
00:00:00] (in `/repo`, exit 2), 1.234s"
);
}
@@ -111,7 +102,8 @@ mod tests {
fn formats_history_search_result() {
assert_eq!(
format_history_search_result(3, &history(0), UtcOffset::UTC),
"## #3. (History ID: 018f011c-9a0a-7000-8000-000000000001):\n`cargo test`\n[1970-01-01 00:00:00] (in `/repo`, exit 2)\n"
"## #3. (History ID: 018f011c-9a0a-7000-8000-000000000001):\n`cargo \
test`\n[1970-01-01 00:00:00] (in `/repo`, exit 2)\n"
);
}
+10 -18
View File
@@ -59,10 +59,7 @@ impl ServerHandler for AtuinMcp {
.await
}
name => {
return Err(ErrorData::invalid_params(
format!("unknown tool: {name}"),
None,
));
return Err(ErrorData::invalid_params(format!("unknown tool: {name}"), None));
}
};
@@ -180,21 +177,19 @@ fn tool_definitions() -> Vec<Tool> {
vec![
Tool::new(
"atuin_history",
"Search the user's shell command history, recorded by Atuin. \
Fuzzy-matches the query against past commands and returns the most relevant \
entries, each with a history ID, timestamp, working directory, exit code, \
and duration. Commands run by AI agents are annotated with the agent's name \
and stated intent. Pass a history ID to atuin_output to see what a command \
printed.",
"Search the user's shell command history, recorded by Atuin. Fuzzy-matches the query \
against past commands and returns the most relevant entries, each with a history ID, \
timestamp, working directory, exit code, and duration. Commands run by AI agents are \
annotated with the agent's name and stated intent. Pass a history ID to atuin_output \
to see what a command printed.",
history_schema,
)
.annotate(ToolAnnotations::new().read_only(true)),
Tool::new(
"atuin_output",
"Fetch the captured terminal output of a previously executed \
command, identified by a history ID from atuin_history results. Output \
capture requires the Atuin daemon; output is only available for recent \
commands captured while the daemon was running.",
"Fetch the captured terminal output of a previously executed command, identified by a \
history ID from atuin_history results. Output capture requires the Atuin daemon; \
output is only available for recent commands captured while the daemon was running.",
output_schema,
)
.annotate(ToolAnnotations::new().read_only(true)),
@@ -212,10 +207,7 @@ mod tests {
assert_eq!(names, ["atuin_history", "atuin_output"]);
for tool in &tools {
assert_eq!(
tool.annotations.as_ref().unwrap().read_only_hint,
Some(true)
);
assert_eq!(tool.annotations.as_ref().unwrap().read_only_hint, Some(true));
assert!(tool.input_schema.contains_key("required"));
}
}
+2 -7
View File
@@ -44,10 +44,7 @@ pub(crate) async fn fetch_models(endpoint: &reqwest::Url, token: &str) -> Result
eyre::bail!("model list request failed ({status})");
}
response
.json::<ModelList>()
.await
.context("failed to parse model list")
response.json::<ModelList>().await.context("failed to parse model list")
}
/// Persist the chosen alias to `ai.model` in config.toml so it becomes the
@@ -55,9 +52,7 @@ pub(crate) async fn fetch_models(endpoint: &reqwest::Url, token: &str) -> Result
/// read at startup.
pub(crate) async fn save_model_selection(alias: &str) -> Result<()> {
let config_file = atuin_client::settings::Settings::get_config_path()?;
let config_str = tokio::fs::read_to_string(&config_file)
.await
.unwrap_or_default();
let config_str = tokio::fs::read_to_string(&config_file).await.unwrap_or_default();
let mut doc = config_str.parse::<toml_edit::DocumentMut>()?;
if !doc.contains_key("ai") {
+3 -5
View File
@@ -1,6 +1,7 @@
use eyre::Result;
use crate::{permissions::file::RuleFile, tools::PermissibleToolCall};
use crate::permissions::file::RuleFile;
use crate::tools::PermissibleToolCall;
pub(crate) struct PermissionRequest<'t> {
call: &'t (dyn PermissibleToolCall + Send + Sync),
@@ -58,10 +59,7 @@ impl PermissionChecker {
}
if request.call.all_covered_by(&file.content.permissions.allow) {
tracing::debug!(
"Permission 'ALLOW' by rules in file: {}",
file.path.display()
);
tracing::debug!("Permission 'ALLOW' by rules in file: {}", file.path.display());
return Ok(PermissionResponse::Allowed);
}
}
+7 -11
View File
@@ -50,9 +50,7 @@ impl TryFrom<&str> for Rule {
fn try_from(value: &str) -> Result<Self, Self::Error> {
let value = value.trim();
let re = RULE_RE.get_or_init(|| Regex::new(r"^(\w+)(?:\((.*)\))?$").unwrap());
let caps = re
.captures(value)
.ok_or(RuleError::InvalidRule(value.to_string()))?;
let caps = re.captures(value).ok_or(RuleError::InvalidRule(value.to_string()))?;
let tool = caps.get(1).unwrap().as_str().to_string();
let scope = caps.get(2).map(|m| m.as_str().to_string());
Ok(Rule { tool, scope })
@@ -61,9 +59,10 @@ impl TryFrom<&str> for Rule {
#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;
use super::*;
#[rstest]
#[case::bare_tool("Read", "Read", None)]
#[case::wildcard_scope("Read(*)", "Read", Some("*"))]
@@ -71,13 +70,10 @@ mod tests {
#[case::shell_with_space("Shell(git commit *)", "Shell", Some("git commit *"))]
#[case::nested_parens("Shell(echo ())", "Shell", Some("echo ()"))]
fn parses_valid_rule(#[case] input: &str, #[case] tool: &str, #[case] scope: Option<&str>) {
assert_eq!(
Rule::try_from(input).unwrap(),
Rule {
tool: tool.to_string(),
scope: scope.map(String::from),
}
);
assert_eq!(Rule::try_from(input).unwrap(), Rule {
tool: tool.to_string(),
scope: scope.map(String::from),
});
}
#[rstest]
+7 -6
View File
@@ -58,9 +58,10 @@ pub(crate) fn parse_shell_command(code: &str, shell: ShellKind) -> ParsedShellCo
#[cfg(feature = "tree-sitter")]
mod ts {
use super::{ParsedShellCommand, ShellCommand, parse_fallback};
use tree_sitter::{Parser, Tree};
use super::{ParsedShellCommand, ShellCommand, parse_fallback};
fn bash_parser() -> Parser {
let mut parser = Parser::new();
parser
@@ -177,9 +178,7 @@ mod ts {
fn fish_parser() -> Parser {
let mut parser = Parser::new();
parser
.set_language(&tree_sitter_fish::language())
.expect("failed to set fish language");
parser.set_language(&tree_sitter_fish::language()).expect("failed to set fish language");
parser
}
@@ -456,9 +455,10 @@ fn find_subslice(haystack: &[&str], needle: &[&str]) -> Option<usize> {
#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;
use super::*;
fn names(cmds: &[ShellCommand]) -> Vec<&str> {
cmds.iter().map(|c| c.name.as_str()).collect()
}
@@ -564,9 +564,10 @@ mod tests {
#[cfg(all(test, feature = "tree-sitter"))]
mod adversarial {
use super::*;
use rstest::rstest;
use super::*;
fn cmd_names(cmds: &[ShellCommand]) -> Vec<&str> {
cmds.iter().map(|c| c.name.as_str()).collect()
}
+1 -4
View File
@@ -112,10 +112,7 @@ async fn load_permissions_file(file_path: &Path) -> Result<Option<RuleFile>> {
let content: RuleFileContent = toml::from_str(&raw)?;
// Use the file's parent as the rule file path (for logging/debugging)
let path = file_path
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| file_path.to_path_buf());
let path = file_path.parent().map(Path::to_path_buf).unwrap_or_else(|| file_path.to_path_buf());
Ok(Some(RuleFile { path, content }))
}
+3 -4
View File
@@ -89,9 +89,10 @@ pub(crate) fn global_permissions_path() -> std::path::PathBuf {
#[cfg(test)]
mod tests {
use super::*;
use rstest::*;
use super::*;
#[fixture]
fn perm_file() -> (tempfile::TempDir, std::path::PathBuf) {
let dir = tempfile::tempdir().unwrap();
@@ -152,9 +153,7 @@ allow = ["AtuinHistory"]
tool: "AtuinHistory".to_string(),
scope: None,
};
write_rule(&file, &rule, RuleDisposition::Allow)
.await
.unwrap();
write_rule(&file, &rule, RuleDisposition::Allow).await.unwrap();
let content = tokio::fs::read_to_string(&file).await.unwrap();
// Should appear exactly once
+13 -50
View File
@@ -99,9 +99,7 @@ impl SessionService for LocalSessionService {
git_root: Option<&str>,
max_age_secs: i64,
) -> Result<Option<StoredSession>> {
self.store
.find_resumable_session(directory, git_root, max_age_secs)
.await
self.store.find_resumable_session(directory, git_root, max_age_secs).await
}
async fn load_events(&self, session_id: &str) -> Result<Vec<StoredEvent>> {
@@ -118,14 +116,7 @@ impl SessionService for LocalSessionService {
event_data: &str,
) -> Result<()> {
self.store
.append_event(
session_id,
event_id,
parent_id,
invocation_id,
event_type,
event_data,
)
.append_event(session_id, event_id, parent_id, invocation_id, event_type, event_data)
.await
}
@@ -134,9 +125,7 @@ impl SessionService for LocalSessionService {
session_id: &str,
server_session_id: &str,
) -> Result<()> {
self.store
.update_server_session_id(session_id, server_session_id)
.await
self.store.update_server_session_id(session_id, server_session_id).await
}
async fn archive(&self, session_id: &str) -> Result<()> {
@@ -215,13 +204,7 @@ impl SessionManager {
pub async fn resume(
service: Box<dyn SessionService>,
stored: &StoredSession,
) -> Result<(
Self,
Vec<ConversationEvent>,
Option<String>,
Option<i64>,
String,
)> {
) -> Result<(Self, Vec<ConversationEvent>, Option<String>, Option<i64>, String)> {
let invocation_id = atuin_common::utils::uuid_v7().to_string();
let stored_events = service.load_events(&stored.id).await?;
@@ -229,10 +212,7 @@ impl SessionManager {
let mut last_event_id = None;
let mut last_event_ts = None;
for se in &stored_events {
events.push(event_serde::deserialize_event(
&se.event_type,
&se.event_data,
)?);
events.push(event_serde::deserialize_event(&se.event_type, &se.event_data)?);
last_event_id = Some(se.id.clone());
last_event_ts = Some(se.created_at);
}
@@ -248,13 +228,7 @@ impl SessionManager {
persisted_to_db: true,
};
Ok((
manager,
events,
stored.server_session_id.clone(),
last_event_ts,
invocation_id,
))
Ok((manager, events, stored.server_session_id.clone(), last_event_ts, invocation_id))
}
/// Ensure the session row exists in the database.
@@ -302,9 +276,7 @@ impl SessionManager {
/// Persist the server session ID if it has changed.
pub async fn persist_server_session_id(&mut self, server_session_id: &str) -> Result<()> {
self.ensure_persisted().await?;
self.service
.update_server_session_id(&self.session_id, server_session_id)
.await
self.service.update_server_session_id(&self.session_id, server_session_id).await
}
/// Archive the current session (for `/new` command).
@@ -352,9 +324,7 @@ impl SessionManager {
/// Write a metadata value for the current session.
pub async fn set_metadata(&mut self, key: &str, value: &str) -> Result<()> {
self.ensure_persisted().await?;
self.service
.set_metadata(&self.session_id, key, value)
.await
self.service.set_metadata(&self.session_id, key, value).await
}
/// Write the usage cache for a user key. Not tied to the current
@@ -366,16 +336,13 @@ impl SessionManager {
#[cfg(test)]
mod tests {
use super::*;
use rstest::*;
use super::*;
#[fixture]
async fn service() -> Box<dyn SessionService> {
Box::new(
LocalSessionService::open("sqlite::memory:", 2.0)
.await
.unwrap(),
)
Box::new(LocalSessionService::open("sqlite::memory:", 2.0).await.unwrap())
}
#[rstest]
@@ -406,9 +373,7 @@ mod tests {
let svc = service.await;
let session_id = atuin_common::utils::uuid_v7().to_string();
svc.create_session(&session_id, Some("/project"), Some("/project"))
.await
.unwrap();
svc.create_session(&session_id, Some("/project"), Some("/project")).await.unwrap();
let events = vec![
ConversationEvent::UserMessage {
@@ -436,9 +401,7 @@ mod tests {
parent = Some(eid);
}
svc.update_server_session_id(&session_id, "srv-abc")
.await
.unwrap();
svc.update_server_session_id(&session_id, "srv-abc").await.unwrap();
// Now find and resume the session with a fresh service connection
let stored = svc
+2 -1
View File
@@ -165,9 +165,10 @@ bind "?" _atuin_ai_question_mark
#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;
use super::*;
#[rstest]
#[case::zsh(
ZSH_INIT,
+6 -14
View File
@@ -61,11 +61,8 @@ fn split_frontmatter(content: &str) -> Option<(&str, String)> {
let after_open = after_open.strip_prefix('\n').unwrap_or(after_open);
// Find the closing `---`
let close_pos = after_open
.lines()
.enumerate()
.find(|(_, line)| line.trim() == "---")
.map(|(i, _)| {
let close_pos =
after_open.lines().enumerate().find(|(_, line)| line.trim() == "---").map(|(i, _)| {
after_open
.lines()
.take(i)
@@ -76,10 +73,7 @@ fn split_frontmatter(content: &str) -> Option<(&str, String)> {
let yaml_str = &after_open[..close_pos];
let rest = &after_open[close_pos..];
// Skip the closing `---` line
let body = rest
.strip_prefix("---")
.unwrap_or(rest)
.trim_start_matches(|c: char| c != '\n');
let body = rest.strip_prefix("---").unwrap_or(rest).trim_start_matches(|c: char| c != '\n');
let body = body.strip_prefix('\n').unwrap_or(body);
Some((yaml_str, body.to_string()))
@@ -113,9 +107,10 @@ fn extract_fields(doc: &yaml_rust2::Yaml) -> Frontmatter {
#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;
use super::*;
#[rstest]
#[case::basic(
"\
@@ -189,10 +184,7 @@ Body.
let parsed = parse(content);
assert_eq!(parsed.frontmatter.name.as_deref(), expected_name);
assert_eq!(parsed.frontmatter.description.as_deref(), expected_desc);
assert_eq!(
parsed.frontmatter.disable_model_invocation,
expected_disable
);
assert_eq!(parsed.frontmatter.disable_model_invocation, expected_disable);
if let Some(b) = expected_body_trimmed {
assert_eq!(parsed.body.trim(), b);
}
+19 -31
View File
@@ -9,9 +9,8 @@ pub(crate) mod walker;
use std::path::Path;
use atuin_common::string::EllipsizeExt as _;
use atuin_common::string::Measure;
use atuin_common::string::ellipsis::{Indicator, Pos};
use atuin_common::string::{EllipsizeExt as _, Measure};
use eyre::{Result, eyre};
use crate::user_context::interpolate;
@@ -78,10 +77,8 @@ impl SkillRegistry {
continue;
}
let description = fm
.description
.or_else(|| first_paragraph(&parsed.body))
.unwrap_or_default();
let description =
fm.description.or_else(|| first_paragraph(&parsed.body)).unwrap_or_default();
skills.push(SkillDescriptor {
name,
@@ -124,11 +121,8 @@ impl SkillRegistry {
}
pub fn server_skills_with_budget(&self, budget: usize) -> (Vec<SkillSummary>, Option<String>) {
let eligible: Vec<&SkillDescriptor> = self
.skills
.iter()
.filter(|s| !s.disable_model_invocation)
.collect();
let eligible: Vec<&SkillDescriptor> =
self.skills.iter().filter(|s| !s.disable_model_invocation).collect();
let mut summaries = Vec::new();
let mut used = 0;
@@ -139,11 +133,7 @@ impl SkillRegistry {
// packed into a byte-bounded LLM prompt budget.
let truncated_desc = skill
.description
.ellipsize(
Measure::Bytes(MAX_DESCRIPTION_LEN),
Pos::End,
Indicator::ASCII,
)
.ellipsize(Measure::Bytes(MAX_DESCRIPTION_LEN), Pos::End, Indicator::ASCII)
.to_string();
let entry_size = skill.name.len() + truncated_desc.len() + PER_ENTRY_OVERHEAD;
@@ -179,9 +169,7 @@ impl SkillRegistry {
/// shell interpolation runs. If `$ARGUMENTS` does not appear in the body
/// and arguments were provided, they are appended as `ARGUMENTS: <value>`.
pub async fn load(&self, name: &str, shell: &str, arguments: Option<&str>) -> Result<String> {
let skill = self
.get(name)
.ok_or_else(|| eyre!("Unknown skill: {name}"))?;
let skill = self.get(name).ok_or_else(|| eyre!("Unknown skill: {name}"))?;
let content = tokio::fs::read_to_string(&skill.source_path).await?;
let parsed = frontmatter::parse(&content);
@@ -237,21 +225,23 @@ fn first_paragraph(body: &str) -> Option<String> {
return None;
}
let para: String = trimmed
.lines()
.take_while(|line| !line.trim().is_empty())
.collect::<Vec<_>>()
.join(" ");
let para: String =
trimmed.lines().take_while(|line| !line.trim().is_empty()).collect::<Vec<_>>().join(" ");
let para = para.trim().to_string();
if para.is_empty() { None } else { Some(para) }
if para.is_empty() {
None
} else {
Some(para)
}
}
#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;
use super::*;
/// Build a `SkillDescriptor` from its distinguishing fields, deriving the
/// source path from the name.
fn descriptor(name: &str, description: &str, disabled: bool) -> SkillDescriptor {
@@ -272,10 +262,7 @@ mod tests {
}
#[rstest]
#[case(
"Hello world\nSecond line\n\nNew paragraph",
Some("Hello world Second line")
)]
#[case("Hello world\nSecond line\n\nNew paragraph", Some("Hello world Second line"))]
#[case("", None)]
#[case("\n\n", None)]
#[case("Single line", Some("Single line"))]
@@ -397,7 +384,8 @@ mod tests {
std::fs::create_dir_all(&skill_dir2).unwrap();
std::fs::write(
skill_dir2.join("SKILL.md"),
"---\nname: release\ndescription: >\n Multi-line\n description here.\n---\n\nRelease steps.\n",
"---\nname: release\ndescription: >\n Multi-line\n description \
here.\n---\n\nRelease steps.\n",
)
.unwrap();
+3 -10
View File
@@ -74,11 +74,8 @@ async fn scan_dir(dir: &Path, is_project: bool, out: &mut Vec<RawSkillFile>) {
// Check for SKILL.md directly in this directory
let skill_path = path.join(SKILL_FILENAME);
if skill_path.is_file() {
let dir_name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.to_string();
let dir_name =
path.file_name().and_then(|n| n.to_str()).unwrap_or("unknown").to_string();
match tokio::fs::read_to_string(&skill_path).await {
Ok(content) => {
@@ -168,11 +165,7 @@ mod tests {
#[tokio::test]
async fn missing_directories_handled() {
let files = discover(
Some(Path::new("/does/not/exist")),
Path::new("/also/missing"),
)
.await;
let files = discover(Some(Path::new("/does/not/exist")), Path::new("/also/missing")).await;
assert!(files.is_empty());
}
}
+14 -41
View File
@@ -129,9 +129,7 @@ pub(crate) fn sanitize_path(path: &Path) -> String {
&s
}
});
s.replace('%', "%25")
.replace('/', "%2F")
.replace('\\', "%5C")
s.replace('%', "%25").replace('/', "%2F").replace('\\', "%5C")
}
/// Write a file atomically using temp-file-then-rename.
@@ -140,9 +138,7 @@ pub(crate) fn sanitize_path(path: &Path) -> String {
/// content, fsyncs, then renames into place. Preserves permissions from
/// the original file if it exists.
pub(crate) fn atomic_write_file(target: &Path, content: &[u8]) -> Result<()> {
let dir = target
.parent()
.ok_or_else(|| eyre!("target path has no parent directory"))?;
let dir = target.parent().ok_or_else(|| eyre!("target path has no parent directory"))?;
fs_err::create_dir_all(dir)?;
let mut tmp = tempfile::NamedTempFile::new_in(dir)?;
@@ -154,13 +150,8 @@ pub(crate) fn atomic_write_file(target: &Path, content: &[u8]) -> Result<()> {
std::fs::set_permissions(tmp.path(), meta.permissions())?;
}
tmp.persist(target).map_err(|e| {
eyre!(
"failed to persist atomic write to {}: {}",
target.display(),
e
)
})?;
tmp.persist(target)
.map_err(|e| eyre!("failed to persist atomic write to {}: {}", target.display(), e))?;
Ok(())
}
@@ -178,9 +169,10 @@ fn format_iso8601(dt: OffsetDateTime) -> String {
#[cfg(test)]
mod tests {
use super::*;
use rstest::*;
use super::*;
// ── sanitize_path ──────────────────────────────────────────
#[rstest]
@@ -299,9 +291,7 @@ mod tests {
),
) {
let file_path = Path::new("/Users/me/.config/foo.toml");
let created = store
.ensure_snapshot(file_path, b"[key]\nval = 1\n")
.unwrap();
let created = store.ensure_snapshot(file_path, b"[key]\nval = 1\n").unwrap();
assert!(created);
assert!(store.has_snapshot(file_path));
@@ -309,10 +299,7 @@ mod tests {
// Snapshot file on disk
let expected_file = session_dir.join("Users%2Fme%2F.config%2Ffoo.toml");
assert!(expected_file.exists());
assert_eq!(
std::fs::read_to_string(&expected_file).unwrap(),
"[key]\nval = 1\n"
);
assert_eq!(std::fs::read_to_string(&expected_file).unwrap(), "[key]\nval = 1\n");
// Manifest on disk
let manifest_path = session_dir.join("manifest.json");
@@ -322,10 +309,7 @@ mod tests {
let files = manifest["files"].as_object().unwrap();
assert_eq!(files.len(), 1);
let entry = &files["Users%2Fme%2F.config%2Ffoo.toml"];
assert_eq!(
entry["original_path"].as_str().unwrap(),
"/Users/me/.config/foo.toml"
);
assert_eq!(entry["original_path"].as_str().unwrap(), "/Users/me/.config/foo.toml");
assert_eq!(entry["size_bytes"].as_u64().unwrap(), 14);
}
@@ -346,10 +330,7 @@ mod tests {
// Original content preserved, not overwritten
let snapshot_file = session_dir.join("etc%2Fhosts");
assert_eq!(
std::fs::read_to_string(snapshot_file).unwrap(),
"first content"
);
assert_eq!(std::fs::read_to_string(snapshot_file).unwrap(), "first content");
}
#[test]
@@ -360,9 +341,7 @@ mod tests {
// First store: create a snapshot
{
let mut store = SnapshotStore::open(session_dir.clone()).unwrap();
store
.ensure_snapshot(Path::new("/etc/hosts"), b"127.0.0.1")
.unwrap();
store.ensure_snapshot(Path::new("/etc/hosts"), b"127.0.0.1").unwrap();
}
// Second store (simulates new CLI invocation): should see existing snapshot
@@ -370,9 +349,7 @@ mod tests {
let mut store = SnapshotStore::open(session_dir).unwrap();
assert!(store.has_snapshot(Path::new("/etc/hosts")));
let created = store
.ensure_snapshot(Path::new("/etc/hosts"), b"new content")
.unwrap();
let created = store.ensure_snapshot(Path::new("/etc/hosts"), b"new content").unwrap();
assert!(!created);
}
}
@@ -385,12 +362,8 @@ mod tests {
SnapshotStore,
),
) {
store
.ensure_snapshot(Path::new("/etc/hosts"), b"hosts content")
.unwrap();
store
.ensure_snapshot(Path::new("/Users/me/.bashrc"), b"bashrc content")
.unwrap();
store.ensure_snapshot(Path::new("/etc/hosts"), b"hosts content").unwrap();
store.ensure_snapshot(Path::new("/Users/me/.bashrc"), b"bashrc content").unwrap();
assert!(store.has_snapshot(Path::new("/etc/hosts")));
assert!(store.has_snapshot(Path::new("/Users/me/.bashrc")));
+24 -83
View File
@@ -6,7 +6,8 @@ use eyre::{Result, eyre};
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePool, SqlitePoolOptions};
use time::OffsetDateTime;
use crate::{session::CachedUsageSnapshot, usage::UsageSnapshot};
use crate::session::CachedUsageSnapshot;
use crate::usage::UsageSnapshot;
// Database row mappings — all columns are kept even if not yet read in
// non-test code, since they're part of the schema and used in tests.
@@ -36,16 +37,8 @@ pub(crate) struct StoredEvent {
}
/// Row type returned by session queries (avoids clippy::type_complexity).
type SessionRow = (
String,
Option<String>,
Option<String>,
Option<String>,
Option<String>,
i64,
i64,
Option<i64>,
);
type SessionRow =
(String, Option<String>, Option<String>, Option<String>, Option<String>, i64, i64, Option<i64>);
/// Row type returned by event queries.
type EventRow = (String, String, Option<String>, String, String, String, i64);
@@ -225,7 +218,8 @@ impl AiSessionStore {
let mut tx = self.pool.begin().await?;
sqlx::query(
"INSERT INTO session_events (id, session_id, parent_id, invocation_id, event_type, event_data, created_at)
"INSERT INTO session_events (id, session_id, parent_id, invocation_id, event_type, \
event_data, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
)
.bind(event_id)
@@ -376,9 +370,10 @@ impl AiSessionStore {
#[cfg(test)]
mod tests {
use rstest::*;
use super::*;
use crate::usage::UsageSnapshot;
use rstest::*;
#[fixture]
async fn store() -> AiSessionStore {
@@ -388,10 +383,7 @@ mod tests {
#[fixture]
async fn store_with_s1(#[future] store: AiSessionStore) -> AiSessionStore {
let store = store.await;
store
.create_session("s1", Some("/tmp"), None)
.await
.unwrap();
store.create_session("s1", Some("/tmp"), None).await.unwrap();
store
}
@@ -426,25 +418,11 @@ mod tests {
let store = store_with_s1.await;
store
.append_event(
"s1",
"e1",
None,
"inv1",
"user_message",
r#"{"content":"hello"}"#,
)
.append_event("s1", "e1", None, "inv1", "user_message", r#"{"content":"hello"}"#)
.await
.unwrap();
store
.append_event(
"s1",
"e2",
Some("e1"),
"inv1",
"text",
r#"{"content":"hi there"}"#,
)
.append_event("s1", "e2", Some("e1"), "inv1", "text", r#"{"content":"hi there"}"#)
.await
.unwrap();
@@ -464,15 +442,10 @@ mod tests {
#[tokio::test]
async fn test_find_resumable_session(#[future] store: AiSessionStore) {
let store = store.await;
store
.create_session("s1", Some("/home/user/project"), None)
.await
.unwrap();
store.create_session("s1", Some("/home/user/project"), None).await.unwrap();
let found = store
.find_resumable_session(Some("/home/user/project"), None, 3600)
.await
.unwrap();
let found =
store.find_resumable_session(Some("/home/user/project"), None, 3600).await.unwrap();
assert!(found.is_some());
assert_eq!(found.unwrap().id, "s1");
}
@@ -482,11 +455,7 @@ mod tests {
async fn test_find_resumable_by_git_root(#[future] store: AiSessionStore) {
let store = store.await;
store
.create_session(
"s1",
Some("/home/user/project/sub"),
Some("/home/user/project"),
)
.create_session("s1", Some("/home/user/project/sub"), Some("/home/user/project"))
.await
.unwrap();
@@ -502,16 +471,10 @@ mod tests {
#[tokio::test]
async fn test_find_resumable_skips_archived(#[future] store: AiSessionStore) {
let store = store.await;
store
.create_session("s1", Some("/tmp"), None)
.await
.unwrap();
store.create_session("s1", Some("/tmp"), None).await.unwrap();
store.archive_session("s1").await.unwrap();
let found = store
.find_resumable_session(Some("/tmp"), None, 3600)
.await
.unwrap();
let found = store.find_resumable_session(Some("/tmp"), None, 3600).await.unwrap();
assert!(found.is_none());
}
@@ -519,15 +482,9 @@ mod tests {
#[tokio::test]
async fn test_find_resumable_no_match_different_dir(#[future] store: AiSessionStore) {
let store = store.await;
store
.create_session("s1", Some("/home/user/project"), None)
.await
.unwrap();
store.create_session("s1", Some("/home/user/project"), None).await.unwrap();
let found = store
.find_resumable_session(Some("/other/dir"), None, 3600)
.await
.unwrap();
let found = store.find_resumable_session(Some("/other/dir"), None, 3600).await.unwrap();
assert!(found.is_none());
}
@@ -547,10 +504,7 @@ mod tests {
async fn test_update_server_session_id(#[future] store_with_s1: AiSessionStore) {
let store = store_with_s1.await;
store
.update_server_session_id("s1", "server-abc")
.await
.unwrap();
store.update_server_session_id("s1", "server-abc").await.unwrap();
let session = store.get_session("s1").await.unwrap().unwrap();
assert_eq!(session.server_session_id.as_deref(), Some("server-abc"));
@@ -562,11 +516,7 @@ mod tests {
let store = store_with_s1.await;
let before = store.get_session("s1").await.unwrap().unwrap();
store
.find_resumable_session(Some("/tmp"), None, 3600)
.await
.unwrap()
.unwrap();
store.find_resumable_session(Some("/tmp"), None, 3600).await.unwrap().unwrap();
let after = store.get_session("s1").await.unwrap().unwrap();
assert_eq!(before.updated_at, after.updated_at);
@@ -613,18 +563,9 @@ mod tests {
async fn test_events_ordered_chronologically(#[future] store_with_s1: AiSessionStore) {
let store = store_with_s1.await;
store
.append_event("s1", "e1", None, "inv1", "user_message", "{}")
.await
.unwrap();
store
.append_event("s1", "e2", Some("e1"), "inv1", "text", "{}")
.await
.unwrap();
store
.append_event("s1", "e3", Some("e2"), "inv2", "user_message", "{}")
.await
.unwrap();
store.append_event("s1", "e1", None, "inv1", "user_message", "{}").await.unwrap();
store.append_event("s1", "e2", Some("e1"), "inv1", "text", "{}").await.unwrap();
store.append_event("s1", "e3", Some("e2"), "inv2", "user_message", "{}").await.unwrap();
let events = store.load_events("s1").await.unwrap();
assert_eq!(events.len(), 3);
+3 -14
View File
@@ -4,17 +4,14 @@
use atuin_client::history::History;
use atuin_client::settings::AiCapabilities;
use crate::context::history_output_capability_available;
use atuin_common::url::UrlAppendExt;
use eventsource_stream::Eventsource;
use eyre::Result;
use futures::StreamExt;
use reqwest::Url;
use reqwest::header::USER_AGENT;
use crate::context::ClientContext;
use crate::context::{ClientContext, history_output_capability_available};
pub(crate) static APP_USER_AGENT: &str = concat!("atuin/", env!("CARGO_PKG_VERSION"));
@@ -76,10 +73,7 @@ impl ChatRequest {
invocation_id: String,
model: Option<String>,
) -> Self {
let mut caps = vec![
"client_invocations".to_string(),
"client_v1_load_skill".to_string(),
];
let mut caps = vec!["client_invocations".to_string(), "client_v1_load_skill".to_string()];
if capabilities.enable_history_search.unwrap_or(true) {
caps.push("client_v1_atuin_history".to_string());
}
@@ -97,12 +91,7 @@ impl ChatRequest {
caps.push("client_v1_atuin_output".to_string());
}
if let Ok(extra) = std::env::var("ATUIN_AI__ADDITIONAL_CAPS") {
caps.extend(
extra
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty()),
);
caps.extend(extra.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()));
}
Self {
+1 -4
View File
@@ -122,8 +122,5 @@ const ALL_DESCRIPTORS: &[&ToolDescriptor] = &[
/// Look up a tool descriptor by its canonical wire name.
/// Returns None for unknown tool names.
pub(crate) fn by_name(name: &str) -> Option<&'static ToolDescriptor> {
ALL_DESCRIPTORS
.iter()
.find(|d| d.canonical_names.contains(&name))
.copied()
ALL_DESCRIPTORS.iter().find(|d| d.canonical_names.contains(&name)).copied()
}
+68 -142
View File
@@ -1,9 +1,7 @@
use std::{
io::BufRead,
num::NonZeroU16,
path::{Path, PathBuf},
time::Duration,
};
use std::io::BufRead;
use std::num::NonZeroU16;
use std::path::{Path, PathBuf};
use std::time::Duration;
use atuin_client::history::AuthorPattern;
use atuin_common::ansi::{self, Vt100ParserExt as _};
@@ -27,9 +25,7 @@ use crate::permissions::rule::Rule;
/// `?`, and `[...]` via `glob_match`.
fn path_matches_scope(path: &Path, scope: &str) -> bool {
let path = if path.is_relative() {
std::env::current_dir()
.map(|cwd| cwd.join(path))
.unwrap_or_else(|_| path.to_path_buf())
std::env::current_dir().map(|cwd| cwd.join(path)).unwrap_or_else(|_| path.to_path_buf())
} else {
path.to_path_buf()
};
@@ -179,15 +175,13 @@ impl TryFrom<(&str, &serde_json::Value)> for ClientToolCall {
"edit_file" => Ok(ClientToolCall::Edit(EditToolCall::try_from(input)?)),
"write_file" => Ok(ClientToolCall::Write(WriteToolCall::try_from(input)?)),
"execute_shell_command" => Ok(ClientToolCall::Shell(ShellToolCall::try_from(input)?)),
"atuin_history" => Ok(ClientToolCall::AtuinHistory(
AtuinHistoryToolCall::try_from(input)?,
)),
"atuin_output" => Ok(ClientToolCall::AtuinOutput(AtuinOutputToolCall::try_from(
input,
)?)),
"load_skill" => Ok(ClientToolCall::LoadSkill(LoadSkillToolCall::try_from(
input,
)?)),
"atuin_history" => {
Ok(ClientToolCall::AtuinHistory(AtuinHistoryToolCall::try_from(input)?))
}
"atuin_output" => {
Ok(ClientToolCall::AtuinOutput(AtuinOutputToolCall::try_from(input)?))
}
"load_skill" => Ok(ClientToolCall::LoadSkill(LoadSkillToolCall::try_from(input)?)),
_ => Err(eyre::eyre!("Unknown tool call: {name}")),
}
}
@@ -284,10 +278,8 @@ impl TryFrom<&serde_json::Value> for ReadToolCall {
type Error = eyre::Error;
fn try_from(value: &serde_json::Value) -> Result<Self, Self::Error> {
let path = value
.get("file_path")
.and_then(|v| v.as_str())
.ok_or(eyre::eyre!("Missing path"))?;
let path =
value.get("file_path").and_then(|v| v.as_str()).ok_or(eyre::eyre!("Missing path"))?;
let offset = value.get("offset").and_then(|v| v.as_u64()).unwrap_or(0);
let limit = value
@@ -366,7 +358,8 @@ impl ReadToolCall {
if numbered.len() > 100_000 {
ToolOutcome::Error(format!(
"Error: file is too large to read ({} bytes in {} lines); use view_range to read a subset of the file",
"Error: file is too large to read ({} bytes in {} lines); use view_range \
to read a subset of the file",
numbered.len(),
lines.len()
))
@@ -424,10 +417,7 @@ impl TryFrom<&serde_json::Value> for EditToolCall {
.and_then(|v| v.as_str())
.ok_or(eyre::eyre!("Missing new_string"))?;
let replace_all = value
.get("replace_all")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let replace_all = value.get("replace_all").and_then(|v| v.as_bool()).unwrap_or(false);
Ok(EditToolCall {
path: expand_path(path),
@@ -507,16 +497,15 @@ impl EditToolCall {
Ok(FreshnessCheck::Stale) => {
return (
ToolOutcome::Error(
"File has been modified since read, either by the user or by a linter. Read it again before attempting to edit it.".to_string(),
"File has been modified since read, either by the user or by a linter. \
Read it again before attempting to edit it."
.to_string(),
),
None,
);
}
Err(e) => {
return (
ToolOutcome::Error(format!("Error checking file state: {e}")),
None,
);
return (ToolOutcome::Error(format!("Error checking file state: {e}")), None);
}
Ok(FreshnessCheck::Fresh) => {}
}
@@ -533,7 +522,8 @@ impl EditToolCall {
if match_count == 0 {
return (
ToolOutcome::Error(format!(
"old_string not found in {}. Make sure it matches exactly, including whitespace and indentation.",
"old_string not found in {}. Make sure it matches exactly, including \
whitespace and indentation.",
resolved_path.display()
)),
None,
@@ -543,7 +533,9 @@ impl EditToolCall {
if match_count > 1 && !self.replace_all {
return (
ToolOutcome::Error(format!(
"Found {match_count} matches of old_string in {}, but replace_all is false. Either provide more context to make the match unique, or set replace_all to true.",
"Found {match_count} matches of old_string in {}, but replace_all is false. \
Either provide more context to make the match unique, or set replace_all to \
true.",
resolved_path.display()
)),
None,
@@ -612,15 +604,10 @@ impl TryFrom<&serde_json::Value> for WriteToolCall {
.and_then(|v| v.as_str())
.ok_or(eyre::eyre!("Missing file_path"))?;
let content = value
.get("content")
.and_then(|v| v.as_str())
.ok_or(eyre::eyre!("Missing content"))?;
let content =
value.get("content").and_then(|v| v.as_str()).ok_or(eyre::eyre!("Missing content"))?;
let overwrite = value
.get("overwrite")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let overwrite = value.get("overwrite").and_then(|v| v.as_bool()).unwrap_or(false);
Ok(WriteToolCall {
path: expand_path(path),
@@ -659,7 +646,8 @@ impl WriteToolCall {
if resolved_path.exists() && !self.overwrite {
return (
ToolOutcome::Error(format!(
"File already exists: {}. Set overwrite to true to replace it, or use edit_file to make targeted changes.",
"File already exists: {}. Set overwrite to true to replace it, or use \
edit_file to make targeted changes.",
resolved_path.display()
)),
None,
@@ -676,7 +664,11 @@ impl WriteToolCall {
}
let line_count = self.content.lines().count();
let verb = if existed { "Overwrote" } else { "Created" };
let verb = if existed {
"Overwrote"
} else {
"Created"
};
(
ToolOutcome::Success(format!(
"{verb} {} ({line_count} lines).",
@@ -722,28 +714,15 @@ impl TryFrom<&serde_json::Value> for ShellToolCall {
fn try_from(value: &serde_json::Value) -> Result<Self, Self::Error> {
let dir = value.get("dir").and_then(|v| v.as_str());
let command = value
.get("command")
.and_then(|v| v.as_str())
.ok_or(eyre::eyre!("Missing command"))?;
let command =
value.get("command").and_then(|v| v.as_str()).ok_or(eyre::eyre!("Missing command"))?;
let shell = value
.get("shell")
.and_then(|v| v.as_str())
.unwrap_or("bash")
.to_string();
let shell = value.get("shell").and_then(|v| v.as_str()).unwrap_or("bash").to_string();
let timeout_secs = value
.get("timeout")
.and_then(|v| v.as_u64())
.filter(|&v| v > 0)
.unwrap_or(30)
.min(600);
let timeout_secs =
value.get("timeout").and_then(|v| v.as_u64()).filter(|&v| v > 0).unwrap_or(30).min(600);
let description = value
.get("description")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let description = value.get("description").and_then(|v| v.as_str()).map(|s| s.to_string());
Ok(ShellToolCall {
dir: dir.map(expand_path),
@@ -1034,21 +1013,12 @@ impl TryFrom<&serde_json::Value> for AtuinHistoryToolCall {
})
.collect::<Result<Vec<HistorySearchFilterMode>>>()?;
let query = value
.get("query")
.and_then(|v| v.as_str())
.ok_or(eyre::eyre!("Missing query"))?;
let query =
value.get("query").and_then(|v| v.as_str()).ok_or(eyre::eyre!("Missing query"))?;
let limit = value
.get("limit")
.and_then(|v| v.as_i64())
.unwrap_or(10)
.clamp(1, 50);
let limit = value.get("limit").and_then(|v| v.as_i64()).unwrap_or(10).clamp(1, 50);
let only_failed = value
.get("only_failed")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let only_failed = value.get("only_failed").and_then(|v| v.as_bool()).unwrap_or(false);
let authors = match value.get("authors") {
Some(authors) => authors
@@ -1110,8 +1080,8 @@ impl AtuinHistoryToolCall {
&& context.session.is_empty()
{
return ToolOutcome::Error(
"Session-scoped search is unavailable: $ATUIN_SESSION is not set, so there is \
no shell session to scope to. Use another filter mode."
"Session-scoped search is unavailable: $ATUIN_SESSION is not set, so there is no \
shell session to scope to. Use another filter mode."
.to_string(),
);
}
@@ -1124,13 +1094,7 @@ impl AtuinHistoryToolCall {
};
let results = match db
.search(
DbSearchMode::Fuzzy,
filter_mode,
&context,
&self.query,
filter_options,
)
.search(DbSearchMode::Fuzzy, filter_mode, &context, &self.query, filter_options)
.await
{
Ok(results) => results,
@@ -1175,11 +1139,8 @@ impl TryFrom<&serde_json::Value> for AtuinOutputToolCall {
.and_then(|v| Uuid::parse_str(v).ok())
.ok_or(eyre::eyre!("Missing or invalid history ID"))?;
let ranges = value
.get("ranges")
.and_then(|v| v.as_array())
.map(Vec::as_slice)
.unwrap_or(&[]);
let ranges =
value.get("ranges").and_then(|v| v.as_array()).map(Vec::as_slice).unwrap_or(&[]);
let ranges = ranges
.iter()
@@ -1192,9 +1153,8 @@ impl TryFrom<&serde_json::Value> for AtuinOutputToolCall {
let start = range[0]
.as_i64()
.ok_or_else(|| eyre::eyre!("Range start must be an integer"))?;
let end = range[1]
.as_i64()
.ok_or_else(|| eyre::eyre!("Range end must be an integer"))?;
let end =
range[1].as_i64().ok_or_else(|| eyre::eyre!("Range end must be an integer"))?;
Ok((start, end))
})
@@ -1219,14 +1179,8 @@ impl PermissibleToolCall for AtuinOutputToolCall {
}
fn format_output_lines_for_llm(lines: &[atuin_daemon::semantic::OutputLine]) -> String {
let width = lines
.iter()
.map(|line| line.line_number)
.max()
.unwrap_or(1)
.max(1)
.ilog10() as usize
+ 1;
let width =
lines.iter().map(|line| line.line_number).max().unwrap_or(1).max(1).ilog10() as usize + 1;
let mut formatted = Vec::with_capacity(lines.len());
let mut previous_line_number = None;
@@ -1258,10 +1212,7 @@ impl AtuinOutputToolCall {
};
let history_id = self.history_id.as_simple().to_string();
let response = match client
.command_output(history_id.clone(), self.ranges.clone())
.await
{
let response = match client.command_output(history_id.clone(), self.ranges.clone()).await {
Ok(response) => response,
Err(e) => return ToolOutcome::Error(format!("Failed to fetch command output: {e}")),
};
@@ -1291,10 +1242,7 @@ impl AtuinOutputToolCall {
response.total_bytes, response.output_observed_bytes, response.total_lines
)
} else {
format!(
"{} bytes, {} lines",
response.total_bytes, response.total_lines
)
format!("{} bytes, {} lines", response.total_bytes, response.total_lines)
};
ToolOutcome::Success(format!(
@@ -1312,10 +1260,8 @@ impl TryFrom<&serde_json::Value> for LoadSkillToolCall {
type Error = eyre::Error;
fn try_from(value: &serde_json::Value) -> Result<Self, Self::Error> {
let name = value
.get("name")
.and_then(|v| v.as_str())
.ok_or(eyre::eyre!("Missing skill name"))?;
let name =
value.get("name").and_then(|v| v.as_str()).ok_or(eyre::eyre!("Missing skill name"))?;
Ok(LoadSkillToolCall {
name: name.to_string(),
@@ -1335,10 +1281,11 @@ impl PermissibleToolCall for LoadSkillToolCall {
#[cfg(test)]
mod tests {
use super::*;
use atuin_common::filter;
use rstest::*;
use super::*;
fn read_rule(scope: Option<&str>) -> Rule {
Rule {
tool: "Read".to_string(),
@@ -1391,10 +1338,7 @@ mod tests {
let call = AtuinHistoryToolCall::try_from(&input).unwrap();
assert!(call.only_failed);
assert_eq!(
call.authors.items(),
filter::Items::Some([AuthorPattern::AllAgent].as_slice())
);
assert_eq!(call.authors.items(), filter::Items::Some([AuthorPattern::AllAgent].as_slice()));
}
#[rstest]
@@ -1405,10 +1349,7 @@ mod tests {
let call = AtuinOutputToolCall::try_from(&input)?;
assert_eq!(
call.history_id.as_simple().to_string(),
"018f0000000070008000000000000000"
);
assert_eq!(call.history_id.as_simple().to_string(), "018f0000000070008000000000000000");
assert!(call.ranges.is_empty());
Ok(())
}
@@ -1537,10 +1478,7 @@ mod tests {
)
)]
fn read_scope_glob(#[case] path: &str, #[case] scope: &str, #[case] expected: bool) {
assert_eq!(
read_tool(path).matches_rule(&read_rule(Some(scope))),
expected
);
assert_eq!(read_tool(path).matches_rule(&read_rule(Some(scope))), expected);
}
#[rstest]
@@ -1549,11 +1487,7 @@ mod tests {
fn relative_multi_segment_glob(#[case] scope: &str, #[case] expected: bool) {
// This matches against the path relative to cwd
let cwd = std::env::current_dir().unwrap();
let abs = cwd
.join("crates")
.join("atuin-ai")
.join("src")
.join("lib.rs");
let abs = cwd.join("crates").join("atuin-ai").join("src").join("lib.rs");
let tool = read_tool(abs.to_str().unwrap());
assert_eq!(tool.matches_rule(&read_rule(Some(scope))), expected);
}
@@ -1916,10 +1850,7 @@ mod tests {
let (outcome, new_bytes) = call2.execute(&file_path, &tracker);
assert!(matches!(outcome, ToolOutcome::Success(_)));
assert!(new_bytes.is_some());
assert_eq!(
std::fs::read_to_string(&file_path).unwrap(),
"key1 = xxx\nkey2 = yyy\n"
);
assert_eq!(std::fs::read_to_string(&file_path).unwrap(), "key1 = xxx\nkey2 = yyy\n");
}
#[rstest]
@@ -1961,10 +1892,7 @@ mod tests {
}
// File should be unchanged (the user's edit preserved)
assert_eq!(
std::fs::read_to_string(&file_path).unwrap(),
"value = user_changed\n"
);
assert_eq!(std::fs::read_to_string(&file_path).unwrap(), "value = user_changed\n");
}
#[rstest]
@@ -1995,9 +1923,7 @@ mod tests {
// Second edit — snapshot should NOT be recreated
let content_before_second = std::fs::read(&file_path).unwrap();
let created = store
.ensure_snapshot(&file_path, &content_before_second)
.unwrap();
let created = store.ensure_snapshot(&file_path, &content_before_second).unwrap();
assert!(!created); // idempotent — already snapshotted
}
+96 -253
View File
@@ -7,7 +7,6 @@ use std::path::PathBuf;
use std::time::{Duration, Instant};
use atuin_client::settings::Settings;
use crossterm::event::{KeyCode, KeyEventKind};
use eye_declare::{
App, Ctx, Element, ElementExt, Fluent, Focus, FocusHandle, InputEvent, Keymap, Task, col, key,
@@ -17,11 +16,10 @@ use ratatui_core::style::{Color, Modifier, Style};
use tokio::sync::mpsc::UnboundedSender;
use tui_textarea::TextArea;
use crate::fsm::StreamPhase;
use crate::fsm::effects::{Effect, ExitAction, PermissionTarget, TimeoutKind};
use crate::fsm::events::{Event, PermissionChoice, PermissionResponse};
use crate::fsm::tools::ToolPreviewData;
use crate::fsm::{AgentFsm, AgentState};
use crate::fsm::{AgentFsm, AgentState, StreamPhase};
use crate::tools::{ClientToolCall, PermissibleToolCall};
use crate::tui::events::PermissionResult;
use crate::tui::persist::PersistJob;
@@ -231,12 +229,9 @@ impl AiApp {
}
fn has_confirmation(&self) -> bool {
matches!(
self.fsm.state,
AgentState::Idle {
confirmation: Some(_)
}
)
matches!(self.fsm.state, AgentState::Idle {
confirmation: Some(_)
})
}
fn input_active(&self) -> bool {
@@ -256,9 +251,7 @@ impl AiApp {
/// Whether the visible conversation carries a suggested command.
fn has_command(&self) -> bool {
self.visible_events()
.iter()
.any(|e| e.as_command().is_some())
self.visible_events().iter().any(|e| e.as_command().is_some())
}
fn visible_events(&self) -> &[crate::tui::state::ConversationEvent] {
@@ -288,12 +281,7 @@ impl AiApp {
/// events past the pushed frontier.
fn live_turns(&self) -> Vec<UiTurn> {
let events = &self.fsm.ctx.events;
let start = self
.fsm
.ctx
.view_start_index
.max(self.pushed_events)
.min(events.len());
let start = self.fsm.ctx.view_start_index.max(self.pushed_events).min(events.len());
let mut builder = TurnBuilder::new(&self.fsm.ctx.tools);
for event in &events[start..] {
builder.add_event(event);
@@ -320,9 +308,7 @@ impl AiApp {
Some(query) => {
let mut results = self.slash_registry.search_fuzzy(&query);
results.sort_by(|a, b| {
b.relevance
.partial_cmp(&a.relevance)
.unwrap_or(std::cmp::Ordering::Equal)
b.relevance.partial_cmp(&a.relevance).unwrap_or(std::cmp::Ordering::Equal)
});
self.slash_results = results;
}
@@ -376,11 +362,8 @@ impl AiApp {
Effect::AbortStream => self.streaming = None,
Effect::Persist => self.persist(),
Effect::TurnEnded => {
let elapsed = self
.turn_started_at
.take()
.map(|t| t.elapsed())
.unwrap_or_default();
let elapsed =
self.turn_started_at.take().map(|t| t.elapsed()).unwrap_or_default();
self.responded = Some(Responded {
elapsed,
tip: self.turn_tip.take(),
@@ -420,7 +403,9 @@ impl AiApp {
tool_id,
history_id,
} => {
let Some(io) = &self.io else { continue };
let Some(io) = &self.io else {
continue;
};
let db = io.app_ctx.history_db.clone();
ctx.perform(async move {
use atuin_client::database::Database as _;
@@ -455,7 +440,9 @@ impl AiApp {
rule,
disposition,
} => {
let Some(io) = &self.io else { continue };
let Some(io) = &self.io else {
continue;
};
let file_path = match target {
PermissionTarget::Project => {
let project_root = io
@@ -480,13 +467,11 @@ impl AiApp {
});
}
Effect::LoadSkill { name, arguments } => {
let Some(io) = &self.io else { continue };
let Some(io) = &self.io else {
continue;
};
let registry = io.skill_registry.clone();
let shell = io
.client_ctx
.shell
.clone()
.unwrap_or_else(|| "sh".to_string());
let shell = io.client_ctx.shell.clone().unwrap_or_else(|| "sh".to_string());
ctx.perform(async move {
let content = crate::tui::tools_exec::load_skill_content(
&registry,
@@ -504,7 +489,9 @@ impl AiApp {
.detach();
}
Effect::FetchModels => {
let Some(io) = &self.io else { continue };
let Some(io) = &self.io else {
continue;
};
let endpoint = io.app_ctx.endpoint.clone();
let token = io.app_ctx.token.clone();
ctx.perform(async move {
@@ -516,7 +503,9 @@ impl AiApp {
.detach();
}
Effect::SaveModelSelection { alias } => {
let Some(_io) = &self.io else { continue };
let Some(_io) = &self.io else {
continue;
};
tokio::spawn(async move {
if let Err(e) = crate::models::save_model_selection(&alias).await {
tracing::error!("Failed to save model selection: {e}");
@@ -536,12 +525,7 @@ impl AiApp {
/// Keep the prompt cursor tied to the tool being asked about: a new
/// question starts from the top.
fn sync_permission_prompt(&mut self) {
let current = self
.fsm
.ctx
.tools
.awaiting_permission()
.map(|t| t.id.clone());
let current = self.fsm.ctx.tools.awaiting_permission().map(|t| t.id.clone());
if current != self.permission_prompt_for {
self.permission_prompt_for = current;
self.permission_select = SelectState::default();
@@ -619,12 +603,8 @@ impl AiApp {
ClientToolCall::Shell(shell_call) => {
let (interrupt_tx, interrupt_rx) = tokio::sync::oneshot::channel();
self.tool_interrupts.insert(tool_id.clone(), interrupt_tx);
ctx.spawn(crate::tui::tools_exec::shell_stream(
tool_id,
shell_call,
interrupt_rx,
))
.detach();
ctx.spawn(crate::tui::tools_exec::shell_stream(tool_id, shell_call, interrupt_rx))
.detach();
}
ClientToolCall::Edit(edit_call) => {
let resolved = edit_call.resolved_path();
@@ -647,8 +627,7 @@ impl AiApp {
&& let Some(io) = &mut self.io
&& let Ok(mtime) = std::fs::metadata(&resolved).and_then(|m| m.modified())
{
io.file_tracker
.update_after_edit(&resolved, new_bytes, mtime);
io.file_tracker.update_after_edit(&resolved, new_bytes, mtime);
}
let preview = match (&old_content, &new_content) {
@@ -687,8 +666,7 @@ impl AiApp {
&& let Some(io) = &mut self.io
&& let Ok(mtime) = std::fs::metadata(&resolved).and_then(|m| m.modified())
{
io.file_tracker
.update_after_edit(&resolved, new_bytes, mtime);
io.file_tracker.update_after_edit(&resolved, new_bytes, mtime);
}
let preview = (!outcome.is_error()).then(|| {
@@ -730,7 +708,9 @@ impl AiApp {
);
}
ClientToolCall::AtuinHistory(history_call) => {
let Some(io) = &self.io else { return };
let Some(io) = &self.io else {
return;
};
let db = io.app_ctx.history_db.clone();
ctx.perform(async move {
let outcome = history_call.execute(&db).await;
@@ -754,13 +734,11 @@ impl AiApp {
.detach();
}
ClientToolCall::LoadSkill(skill_call) => {
let Some(io) = &self.io else { return };
let Some(io) = &self.io else {
return;
};
let registry = io.skill_registry.clone();
let shell = io
.client_ctx
.shell
.clone()
.unwrap_or_else(|| "sh".to_string());
let shell = io.client_ctx.shell.clone().unwrap_or_else(|| "sh".to_string());
ctx.perform(async move {
let content = crate::tui::tools_exec::load_skill_content(
&registry,
@@ -815,7 +793,9 @@ impl AiApp {
/// channel order, so a later snapshot can never be overwritten by an
/// earlier one.
fn persist(&self) {
let Some(io) = &self.io else { return };
let Some(io) = &self.io else {
return;
};
let _ = io.persist.send(PersistJob::Session {
events: self.fsm.ctx.events.clone(),
server_session_id: self.fsm.ctx.session_id.clone(),
@@ -836,11 +816,7 @@ impl AiApp {
if turns.is_empty() {
return;
}
if self.is_busy()
&& matches!(
turns.last().map(|t| &t.kind),
Some(UiTurnKind::Agent { .. })
)
if self.is_busy() && matches!(turns.last().map(|t| &t.kind), Some(UiTurnKind::Agent { .. }))
{
// Mid-stream: the whole in-flight agent turn stays in the tail
// (statuses and text still mutate). It seals when the FSM
@@ -853,19 +829,12 @@ impl AiApp {
&& let Some(notice) = self.resume_notice.take()
{
ctx.push(
text(notice).style(
Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::ITALIC),
),
text(notice)
.style(Style::default().fg(Color::DarkGray).add_modifier(Modifier::ITALIC)),
);
}
for (i, turn) in turns.iter().enumerate() {
ctx.push(view::turn_view(
turn,
self.pushed_turns == 0 && i == 0,
None,
));
ctx.push(view::turn_view(turn, self.pushed_turns == 0 && i == 0, None));
self.pushed_turns += 1;
}
self.pushed_events = self.fsm.ctx.events.len();
@@ -974,11 +943,8 @@ impl AiApp {
/// Pull the next relevant tip for a starting turn.
fn pull_tip(&mut self) -> Option<&'static Tip> {
let has_context_files = self
.io
.as_ref()
.map(|io| io.user_context_cache.has_gathered())
.unwrap_or(None);
let has_context_files =
self.io.as_ref().map(|io| io.user_context_cache.has_gathered()).unwrap_or(None);
let tip_ctx = TipContext {
settings: &self.settings,
model_set: self.fsm.ctx.model.is_some(),
@@ -1151,16 +1117,11 @@ impl App for AiApp {
Some(crate::fsm::ModelPicker::Ready(list)) if asking.is_none() => Some(list),
_ => None,
};
let picker_loading = matches!(
self.fsm.ctx.model_picker,
Some(crate::fsm::ModelPicker::Loading)
);
let picker_loading =
matches!(self.fsm.ctx.model_picker, Some(crate::fsm::ModelPicker::Loading));
let show_input = asking.is_none() && ready_picker.is_none() && !self.exiting;
let needs_pending_banner = busy
&& !matches!(
turns.last().map(|t| &t.kind),
Some(UiTurnKind::Agent { .. })
);
let needs_pending_banner =
busy && !matches!(turns.last().map(|t| &t.kind), Some(UiTurnKind::Agent { .. }));
let status_text = if let AgentState::Turn {
stream: StreamPhase::Streaming {
@@ -1175,15 +1136,11 @@ impl App for AiApp {
col()
.when_some(
(self.pushed_turns == 0)
.then_some(self.resume_notice.clone())
.flatten(),
(self.pushed_turns == 0).then_some(self.resume_notice.clone()).flatten(),
|c, notice| {
c.child(
text(notice).style(
Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::ITALIC),
Style::default().fg(Color::DarkGray).add_modifier(Modifier::ITALIC),
),
)
},
@@ -1250,10 +1207,7 @@ impl App for AiApp {
self.is_input_blank() && self.has_command() && self.input_active(),
&self.slash_results,
))
.child(view::status_bar_view(
self.fsm.ctx.model.as_deref(),
self.usage.as_ref(),
))
.child(view::status_bar_view(self.fsm.ctx.model.as_deref(), self.usage.as_ref()))
})
}
@@ -1283,9 +1237,7 @@ impl App for AiApp {
);
if matches!(self.fsm.state, AgentState::Error(_)) {
km = km
.on(key(KeyCode::Enter), Msg::Retry)
.on(key(KeyCode::Char('r')), Msg::Retry);
km = km.on(key(KeyCode::Enter), Msg::Retry).on(key(KeyCode::Char('r')), Msg::Retry);
}
if self.has_confirmation() {
@@ -1296,10 +1248,7 @@ impl App for AiApp {
km = km
.on(key(KeyCode::Enter), Msg::ConfirmPermission)
.merge(SelectState::keymap().map(Msg::PermissionSelect));
} else if matches!(
self.fsm.ctx.model_picker,
Some(crate::fsm::ModelPicker::Ready(_))
) {
} else if matches!(self.fsm.ctx.model_picker, Some(crate::fsm::ModelPicker::Ready(_))) {
km = km
.on(key(KeyCode::Enter), Msg::ConfirmModel)
.merge(SelectState::keymap().map(Msg::ModelSelect));
@@ -1337,13 +1286,14 @@ fn capitalize(s: &str) -> String {
#[cfg(test)]
mod tests {
use super::*;
use crate::tui::state::ConversationEvent;
use crossterm::event::{KeyEvent, KeyModifiers};
use eye_declare::Runtime;
use eye_declare_engine::test_terminal::TestTerminal;
use rstest::{fixture, rstest};
use super::*;
use crate::tui::state::ConversationEvent;
fn fixture_app() -> AiApp {
let mut fsm = AgentFsm::new(vec![], "test-invocation".into());
fsm.ctx.events.push(ConversationEvent::UserMessage {
@@ -1380,9 +1330,7 @@ mod tests {
}
fn press_mod(&mut self, code: KeyCode, modifiers: KeyModifiers) -> Option<ExitOutcome> {
let (bytes, exit) = self
.rt
.handle(InputEvent::Key(KeyEvent::new(code, modifiers)));
let (bytes, exit) = self.rt.handle(InputEvent::Key(KeyEvent::new(code, modifiers)));
self.term.feed(&bytes);
exit
}
@@ -1414,9 +1362,7 @@ mod tests {
/// Scrollback + viewport, for content that has scrolled off.
fn all_lines(&self) -> String {
[self.term.scrollback_lines(), self.term.viewport_lines()]
.concat()
.join("\n")
[self.term.scrollback_lines(), self.term.viewport_lines()].concat().join("\n")
}
}
@@ -1439,18 +1385,9 @@ mod tests {
fn tail_renders_conversation_turns(#[from(harness)] h: Harness) {
let screen = h.screen();
assert!(screen.contains(" You"), "user label missing:\n{screen}");
assert!(
screen.contains("list my files"),
"user text missing:\n{screen}"
);
assert!(
screen.contains(" Atuin AI"),
"agent label missing:\n{screen}"
);
assert!(
screen.contains("Use ls to list files."),
"agent markdown missing:\n{screen}"
);
assert!(screen.contains("list my files"), "user text missing:\n{screen}");
assert!(screen.contains(" Atuin AI"), "agent label missing:\n{screen}");
assert!(screen.contains("Use ls to list files."), "agent markdown missing:\n{screen}");
}
#[rstest]
@@ -1468,8 +1405,7 @@ mod tests {
h.press(KeyCode::Esc);
assert!(
!h.all_lines()
.contains("Generate a command or ask a question"),
!h.all_lines().contains("Generate a command or ask a question"),
"input box still on screen after exit:\n{}",
h.all_lines()
);
@@ -1534,10 +1470,7 @@ mod tests {
h.type_str("/he");
let screen = h.screen();
assert!(
screen.contains("Show help information"),
"suggestion missing:\n{screen}"
);
assert!(screen.contains("Show help information"), "suggestion missing:\n{screen}");
h.press(KeyCode::Tab);
assert_eq!(h.app().input.borrow().lines(), ["/help".to_string()]);
@@ -1548,10 +1481,7 @@ mod tests {
h.type_str("a");
h.press_mod(KeyCode::Char('j'), KeyModifiers::CONTROL);
h.type_str("b");
assert_eq!(
h.app().input.borrow().lines(),
["a".to_string(), "b".to_string()]
);
assert_eq!(h.app().input.borrow().lines(), ["a".to_string(), "b".to_string()]);
}
#[rstest]
@@ -1565,12 +1495,8 @@ mod tests {
fn two_message_fsm() -> AgentFsm {
let mut fsm = AgentFsm::new(vec![], "test-invocation".into());
for (q, a) in [("first question", "one"), ("second question", "two")] {
fsm.ctx
.events
.push(ConversationEvent::UserMessage { content: q.into() });
fsm.ctx
.events
.push(ConversationEvent::Text { content: a.into() });
fsm.ctx.events.push(ConversationEvent::UserMessage { content: q.into() });
fsm.ctx.events.push(ConversationEvent::Text { content: a.into() });
}
fsm
}
@@ -1690,15 +1616,8 @@ mod tests {
let app = h.app();
assert!(!app.is_busy(), "turn should be over");
assert!(
app.pushed_turns > pushed_mid,
"agent turn should seal to scrollback on Done"
);
assert_eq!(
app.pushed_events,
app.fsm.ctx.events.len(),
"frontier should cover all events"
);
assert!(app.pushed_turns > pushed_mid, "agent turn should seal to scrollback on Done");
assert_eq!(app.pushed_events, app.fsm.ctx.events.len(), "frontier should cover all events");
// The sealed turn is on the terminal, and the live tail no longer
// repeats it.
let all = h.all_lines();
@@ -1733,10 +1652,7 @@ mod tests {
});
let screen = h.screen();
assert!(
screen.contains("Responded in"),
"responded line missing:\n{screen}"
);
assert!(screen.contains("Responded in"), "responded line missing:\n{screen}");
assert!(
screen.contains("└ Tip: press Esc to interrupt a response"),
"turn's tip should ride along onto the responded line:\n{screen}"
@@ -1751,10 +1667,7 @@ mod tests {
!screen.contains("Responded in"),
"responded line should clear on new turn:\n{screen}"
);
assert!(
screen.contains("└ Tip:"),
"second turn should show a tip\n{screen}"
);
assert!(screen.contains("└ Tip:"), "second turn should show a tip\n{screen}");
assert!(
!screen.contains("└ Tip: press Esc to interrupt a response"),
"second turn should pull the next tip:\n{screen}"
@@ -1792,10 +1705,7 @@ mod tests {
let all = h.all_lines();
assert!(!all.contains("Tip:"), "tips disabled but shown:\n{all}");
assert!(
all.contains("Responded in"),
"responded line should still show:\n{all}"
);
assert!(all.contains("Responded in"), "responded line should still show:\n{all}");
}
#[rstest]
@@ -1822,20 +1732,10 @@ mod tests {
h.stream(Event::StreamError("connection lost".into()));
assert!(matches!(h.app().fsm.state, AgentState::Error(_)));
assert_eq!(
h.app().pushed_turns,
pushed_before,
"failed turn must stay live for retry"
);
assert_eq!(h.app().pushed_turns, pushed_before, "failed turn must stay live for retry");
let screen = h.screen();
assert!(
screen.contains("Error: connection lost"),
"error line missing:\n{screen}"
);
assert!(
screen.contains("[Enter]/[r] Retry"),
"retry footer:\n{screen}"
);
assert!(screen.contains("Error: connection lost"), "error line missing:\n{screen}");
assert!(screen.contains("[Enter]/[r] Retry"), "retry footer:\n{screen}");
h.press(KeyCode::Char('r'));
assert!(h.app().is_busy(), "retry should restart the turn");
@@ -1888,10 +1788,7 @@ mod tests {
#[from(harness_awaiting_shell_permission)] h: Harness,
) {
let screen = h.screen();
assert!(
screen.contains("Atuin AI would like to run: "),
"prompt line missing:\n{screen}"
);
assert!(screen.contains("Atuin AI would like to run: "), "prompt line missing:\n{screen}");
assert!(screen.contains("echo hi"), "tool desc missing:\n{screen}");
assert!(screen.contains("Allow"), "options missing:\n{screen}");
assert!(
@@ -1937,10 +1834,7 @@ mod tests {
let app = h.app();
assert!(app.fsm.ctx.tools.awaiting_permission().is_none());
assert!(
app.tool_interrupts.is_empty(),
"denied tool must not execute"
);
assert!(app.tool_interrupts.is_empty(), "denied tool must not execute");
assert!(
app.fsm
.ctx
@@ -1978,10 +1872,7 @@ mod tests {
outcome: crate::tools::ToolOutcome::Success("done".into()),
preview: None,
});
assert!(
h.app().tool_interrupts.is_empty(),
"completion must prune the interrupt sender"
);
assert!(h.app().tool_interrupts.is_empty(), "completion must prune the interrupt sender");
// With nothing executing, Ctrl+C means quit again.
assert_eq!(
h.press_mod(KeyCode::Char('c'), KeyModifiers::CONTROL),
@@ -1995,10 +1886,8 @@ mod tests {
let mut file = tempfile::NamedTempFile::new().unwrap();
writeln!(file, "hello from the file").unwrap();
let mut h = Harness::new(app_with(AgentFsm::new(
vec!["client_v1_read_file".into()],
"t".into(),
)));
let mut h =
Harness::new(app_with(AgentFsm::new(vec!["client_v1_read_file".into()], "t".into())));
h.type_str("read it");
h.press(KeyCode::Enter);
h.stream(Event::StreamStarted);
@@ -2036,9 +1925,7 @@ mod tests {
assert!(rt.app().is_busy(), "initial prompt should start a turn");
assert!(rt.app().pushed_turns > 0, "user turn should be pushed");
let all = [term.scrollback_lines(), term.viewport_lines()]
.concat()
.join("\n");
let all = [term.scrollback_lines(), term.viewport_lines()].concat().join("\n");
assert!(all.contains("what is atuin?"), "prompt missing:\n{all}");
}
@@ -2067,18 +1954,11 @@ mod tests {
h.press(KeyCode::Enter);
// Headless: FetchModels degrades, picker sits in Loading.
assert!(
h.screen().contains("Loading models…"),
"loading spinner missing:\n{}",
h.screen()
);
assert!(h.screen().contains("Loading models…"), "loading spinner missing:\n{}", h.screen());
h.stream(Event::ModelListLoaded(Ok(model_list())));
let screen = h.screen();
assert!(
screen.contains("Select a model:"),
"picker missing:\n{screen}"
);
assert!(screen.contains("Select a model:"), "picker missing:\n{screen}");
assert!(
screen.contains("Smart — balanced (current)"),
"server default should be marked current:\n{screen}"
@@ -2110,11 +1990,7 @@ mod tests {
assert_eq!(h.press(KeyCode::Esc), None, "Esc closes, not exits");
assert!(h.app().fsm.ctx.model_picker.is_none());
assert_eq!(
h.app().model_select.cursor,
0,
"cursor resets when the picker closes"
);
assert_eq!(h.app().model_select.cursor, 0, "cursor resets when the picker closes");
}
#[rstest]
@@ -2125,10 +2001,7 @@ mod tests {
h.stream(Event::ModelListLoaded(Ok(model_list())));
h.type_str("stray keys");
assert!(
h.app().is_input_blank(),
"editor must not receive keys while the picker is open"
);
assert!(h.app().is_input_blank(), "editor must not receive keys while the picker is open");
}
#[rstest]
@@ -2152,10 +2025,7 @@ mod tests {
let screen = h.screen();
assert!(screen.contains("92%"), "usage percent missing:\n{screen}");
assert!(
screen.contains("resets in"),
"reset delta missing:\n{screen}"
);
assert!(screen.contains("resets in"), "reset delta missing:\n{screen}");
}
/// Run one full exchange so the frontier is past zero, then /new.
@@ -2178,10 +2048,7 @@ mod tests {
#[rstest]
fn new_session_notice_renders(#[from(harness_after_new_session)] h: Harness) {
let all = h.all_lines();
assert!(
all.contains("Started a new session."),
"/new notice missing:\n{all}"
);
assert!(all.contains("Started a new session."), "/new notice missing:\n{all}");
}
#[rstest]
@@ -2209,27 +2076,16 @@ mod tests {
session_id: "s2".into(),
});
let all = h.all_lines();
assert!(
all.contains("the second answer"),
"response vanished on stream end:\n{all}"
);
assert!(all.contains("the second answer"), "response vanished on stream end:\n{all}");
}
#[rstest]
fn skill_dispatches_even_when_registered_for_autocomplete() {
let mut registry = SlashCommandRegistry::default();
let mut skill_names = HashSet::new();
registry.register(crate::tui::slash::SlashCommand::new(
"release",
"Cut a release",
));
registry.register(crate::tui::slash::SlashCommand::new("release", "Cut a release"));
skill_names.insert("release".to_string());
let app = AiApp::headless(
AgentFsm::new(vec![], "t".into()),
None,
registry,
skill_names,
);
let app = AiApp::headless(AgentFsm::new(vec![], "t".into()), None, registry, skill_names);
assert!(matches!(
app.dispatch_submit("/release 1.2".into()),
@@ -2242,22 +2098,12 @@ mod tests {
fn builtin_takes_precedence_over_skill_with_same_name() {
let mut registry = SlashCommandRegistry::default();
let mut skill_names = HashSet::new();
registry.register(crate::tui::slash::SlashCommand::new(
"reload",
"A skill shadowing /reload",
));
registry
.register(crate::tui::slash::SlashCommand::new("reload", "A skill shadowing /reload"));
skill_names.insert("reload".to_string());
let app = AiApp::headless(
AgentFsm::new(vec![], "t".into()),
None,
registry,
skill_names,
);
let app = AiApp::headless(AgentFsm::new(vec![], "t".into()), None, registry, skill_names);
assert!(matches!(
app.dispatch_submit("/reload".into()),
Event::SlashCommand { .. }
));
assert!(matches!(app.dispatch_submit("/reload".into()), Event::SlashCommand { .. }));
}
#[rstest]
@@ -2279,10 +2125,7 @@ mod tests {
.iter()
.position(|l| l.contains("Continuing previous session"))
.expect("notice missing");
let user_row = lines
.iter()
.position(|l| l.contains(" You"))
.expect("user label missing");
let user_row = lines.iter().position(|l| l.contains(" You")).expect("user label missing");
assert!(notice_row < user_row);
}
}
+2 -3
View File
@@ -57,9 +57,8 @@ pub(crate) fn spawn_persist_worker(
tracing::warn!("failed to persist server session ID: {e}");
}
if let Some(json) = file_tracker
&& let Err(e) = session_mgr
.set_metadata(crate::file_tracker::METADATA_KEY, &json)
.await
&& let Err(e) =
session_mgr.set_metadata(crate::file_tracker::METADATA_KEY, &json).await
{
tracing::warn!("failed to persist file tracker: {e}");
}
+1 -3
View File
@@ -32,9 +32,7 @@ impl SelectState {
}
pub fn keymap() -> Keymap<SelectMsg> {
keymap()
.on(key(KeyCode::Up), SelectMsg::Up)
.on(key(KeyCode::Down), SelectMsg::Down)
keymap().on(key(KeyCode::Up), SelectMsg::Up).on(key(KeyCode::Down), SelectMsg::Down)
}
}
+9 -3
View File
@@ -7,9 +7,13 @@
#[derive(Debug, Clone)]
pub(crate) enum ConversationEvent {
/// User message (what the user typed)
UserMessage { content: String },
UserMessage {
content: String,
},
/// Text content from assistant (streamed or complete)
Text { content: String },
Text {
content: String,
},
/// Tool call from assistant
ToolCall {
id: String,
@@ -35,7 +39,9 @@ pub(crate) enum ConversationEvent {
},
/// Context injected for the LLM that is not rendered in the TUI.
/// Converted to a user message in the API protocol.
SystemContext { content: String },
SystemContext {
content: String,
},
/// A skill was loaded and its content injected into the conversation.
/// Serialized as a full user message for the API but rendered compactly
/// in the TUI (just the `/name args` invocation line).
+6 -13
View File
@@ -33,13 +33,7 @@ pub(crate) const TIPS: &[Tip] = &[
Tip {
id: "ctrl-c-interrupt",
text: "Ctrl+C interrupts a running command",
relevant: Some(|ctx| {
ctx.settings
.ai
.capabilities
.enable_command_execution
.unwrap_or(true)
}),
relevant: Some(|ctx| ctx.settings.ai.capabilities.enable_command_execution.unwrap_or(true)),
},
Tip {
id: "model",
@@ -58,7 +52,8 @@ pub(crate) const TIPS: &[Tip] = &[
},
Tip {
id: "send-cwd",
text: "You can include your working directory in AI requests automatically: `atuin config set ai.opening.send_cwd true`",
text: "You can include your working directory in AI requests automatically: `atuin config \
set ai.opening.send_cwd true`",
relevant: Some(|ctx| {
let ai = &ctx.settings.ai;
!ai.opening.send_cwd.or(ai.send_cwd).unwrap_or(false)
@@ -71,7 +66,8 @@ pub(crate) const TIPS: &[Tip] = &[
},
Tip {
id: "send-last-command",
text: "Send your last command to Atuin AI automatically with `atuin config set ai.opening.send_last_command true`",
text: "Send your last command to Atuin AI automatically with `atuin config set \
ai.opening.send_last_command true`",
relevant: Some(|ctx| !ctx.settings.ai.opening.send_last_command.unwrap_or(false)),
},
Tip {
@@ -165,10 +161,7 @@ mod tests {
.filter(|t| t.relevant.is_none_or(|applies| applies(&c)))
.map(|t| t.id)
.collect();
assert!(
relevant_ids.len() >= 2,
"test needs at least two relevant tips"
);
assert!(relevant_ids.len() >= 2, "test needs at least two relevant tips");
let mut rotation = TipRotation::starting_at(0);
// Pull two full cycles: tips come back in order, then wrap.
+7 -14
View File
@@ -106,9 +106,8 @@ mod tests {
#[tokio::test]
async fn shell_stream_yields_previews_then_outcome() {
let (_interrupt_tx, interrupt_rx) = oneshot::channel();
let msgs: Vec<Msg> = shell_stream("t1".into(), shell_call("echo hello"), interrupt_rx)
.collect()
.await;
let msgs: Vec<Msg> =
shell_stream("t1".into(), shell_call("echo hello"), interrupt_rx).collect().await;
let mut saw_preview_with_output = false;
let mut done: Option<&Event> = None;
@@ -125,10 +124,7 @@ mod tests {
other => panic!("unexpected message: {other:?}"),
}
}
assert!(
saw_preview_with_output,
"expected echoed output in a preview"
);
assert!(saw_preview_with_output, "expected echoed output in a preview");
let Some(Event::ToolExecutionDone {
tool_id, outcome, ..
}) = done
@@ -137,13 +133,10 @@ mod tests {
};
assert_eq!(tool_id, "t1");
assert!(
matches!(
outcome,
ToolOutcome::Structured {
exit_code: Some(0),
..
}
),
matches!(outcome, ToolOutcome::Structured {
exit_code: Some(0),
..
}),
"expected clean exit, got {outcome:?}"
);
}
+4 -13
View File
@@ -28,11 +28,8 @@ pub(crate) fn new_textarea() -> TextArea<'static> {
textarea.set_cursor_line_style(Style::default());
textarea.set_wrap_mode(tui_textarea::WrapMode::Word);
textarea.set_placeholder_text("Type a message...");
textarea.set_placeholder_style(
Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::ITALIC),
);
textarea
.set_placeholder_style(Style::default().fg(Color::DarkGray).add_modifier(Modifier::ITALIC));
textarea.set_max_rows(MAX_INPUT_ROWS);
textarea
}
@@ -72,9 +69,7 @@ pub(crate) fn input_area<'a>(
slash_results: &[SlashCommandSearchResult],
) -> AnyElement<'a> {
let border_style = Style::default().fg(Color::DarkGray);
let title_style = Style::default()
.fg(Color::Gray)
.add_modifier(Modifier::BOLD);
let title_style = Style::default().fg(Color::Gray).add_modifier(Modifier::BOLD);
col()
.child(
@@ -93,11 +88,7 @@ pub(crate) fn input_area<'a>(
)
})
.children(
slash_results
.iter()
.take(4)
.enumerate()
.map(|(i, result)| slash_row(result, i == 0)),
slash_results.iter().take(4).enumerate().map(|(i, result)| slash_row(result, i == 0)),
)
.pad_top(1)
.any()
+70 -111
View File
@@ -23,7 +23,6 @@ mod trunc;
pub(crate) mod turn;
use trunc::{command_spinner, truncated_line};
use turn::{
ConfidenceLevel, DangerLevel, OutOfBandOutputDetails, SuggestedCommandDetails, ToolCallDetails,
ToolGroup, ToolGroupKind, ToolRenderData, ToolResultStatus, ToolSummary, UiEvent, UiTurn,
@@ -61,9 +60,7 @@ fn md(source: impl Into<String>) -> Markdown {
code_block: Style::default().fg(Color::Green),
bold: Style::default().add_modifier(Modifier::BOLD),
italic: Style::default().add_modifier(Modifier::ITALIC),
heading: Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
heading: Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
table_border: Style::default().fg(Color::DarkGray),
table_header: Style::default().add_modifier(Modifier::BOLD),
})
@@ -104,7 +101,11 @@ pub(crate) fn user_turn_view(events: &[UiEvent], first_turn: bool) -> AnyElement
UiEvent::Text { content } => Some(text(content.clone()).pad_left(2)),
_ => None,
}))
.pad_top(if first_turn { 0 } else { 1 })
.pad_top(if first_turn {
0
} else {
1
})
.any()
}
@@ -119,24 +120,16 @@ pub(crate) fn agent_turn_view(
col()
.child(text(" Atuin AI ").style(label_style))
.children(events.iter().enumerate().map(|(i, event)| {
col()
.when(i > 0, |c| c.child(text("")))
.child(event_view(event))
}))
.children(
events.iter().enumerate().map(|(i, event)| {
col().when(i > 0, |c| c.child(text(""))).child(event_view(event))
}),
)
.when_some(working, |c, working| {
c.child(
spinner(working.status.unwrap_or(""))
.spinner_style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)
.label_style(
Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::BOLD),
)
.spinner_style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD))
.label_style(Style::default().fg(Color::DarkGray).add_modifier(Modifier::BOLD))
.pad_left(2)
.pad_top(1),
)
@@ -164,9 +157,7 @@ pub(crate) fn responded_view(
/// the line it hangs off.
fn tip_line(tip: &'static Tip) -> impl Element + 'static {
let dim = Style::default().fg(Color::DarkGray);
row()
.fixed(2, text("└ ").style(dim))
.fill(text(format!("Tip: {}", tip.text)).style(dim))
row().fixed(2, text("└ ").style(dim)).fill(text(format!("Tip: {}", tip.text)).style(dim))
}
/// Humanized elapsed time: sub-minute in seconds ("3.2 seconds",
@@ -246,10 +237,7 @@ fn tool_status_view(name: &str, status: &ToolResultStatus) -> AnyElement<'static
ToolResultStatus::Success => tool_spinner(format!("Ran: {name}"), true).any(),
ToolResultStatus::Error { content } => text("✗ ")
.style(Style::default().fg(Color::Red))
.span(
format!("{name}: {}", error_label(content)),
Style::default().fg(Color::Red),
)
.span(format!("{name}: {}", error_label(content)), Style::default().fg(Color::Red))
.any(),
}
}
@@ -276,19 +264,21 @@ fn shell_tool_view(command: &str, preview: Option<&ToolPreview>) -> AnyElement<'
match preview {
Some(preview) => col()
.child(command_spinner(
if done { "Ran: " } else { "Running: " },
if done {
"Ran: "
} else {
"Running: "
},
command,
done,
))
.child(
row()
.fixed(2, text("└ ").style(Style::default().fg(Color::DarkGray)))
.fill(
viewport(preview.lines.iter().cloned())
.height((preview.lines.len() as u16).clamp(1, MAX_SHELL_PREVIEW_LINES))
.style(Style::default().fg(Color::Gray))
.wrap(false),
),
row().fixed(2, text("└ ").style(Style::default().fg(Color::DarkGray))).fill(
viewport(preview.lines.iter().cloned())
.height((preview.lines.len() as u16).clamp(1, MAX_SHELL_PREVIEW_LINES))
.style(Style::default().fg(Color::Gray))
.wrap(false),
),
)
.child(shell_tool_footer(preview, done))
.any(),
@@ -307,9 +297,7 @@ fn shell_tool_footer(preview: &ToolPreview, done: bool) -> AnyElement<'static> {
.any();
}
if !done {
return text("[Ctrl+C] Interrupt")
.style(Style::default().fg(Color::DarkGray))
.any();
return text("[Ctrl+C] Interrupt").style(Style::default().fg(Color::DarkGray)).any();
}
if let Some(code) = preview.exit_code {
let style = if code == 0 {
@@ -347,9 +335,7 @@ fn file_edit_tool_view(
col()
.child(status_line)
.child(
col()
.children(preview.hunks.iter().map(|hunk| hunk_view(hunk, gutter_w)))
.pad_left(2),
col().children(preview.hunks.iter().map(|hunk| hunk_view(hunk, gutter_w))).pad_left(2),
)
.any()
}
@@ -400,14 +386,8 @@ fn file_write_tool_view(
(ToolResultStatus::Success, Some(p)) => format!(" ({} lines)", p.total_lines),
_ => String::new(),
};
let status_line = tool_status_line(
status,
"Writing",
"Wrote",
"Write",
&display_path,
&line_info,
);
let status_line =
tool_status_line(status, "Writing", "Wrote", "Write", &display_path, &line_info);
let Some(preview) = preview else {
return status_line;
@@ -480,7 +460,11 @@ fn group_view(group: &ToolGroup) -> AnyElement<'static> {
/// Tree-connector marker: `└ ` for the first visible row, spaces after.
fn tree_marker(is_first: bool) -> &'static str {
if is_first { "└ " } else { " " }
if is_first {
"└ "
} else {
" "
}
}
/// 2-char status marker column: ✓ / ✗ / blank.
@@ -505,10 +489,7 @@ fn group_row_view(
content: AnyElement<'static>,
) -> AnyElement<'static> {
row()
.fixed(
2,
text(tree_marker(is_first)).style(Style::default().fg(Color::DarkGray)),
)
.fixed(2, text(tree_marker(is_first)).style(Style::default().fg(Color::DarkGray)))
.fixed(2, status_marker_view(status))
.fill(content)
.any()
@@ -568,21 +549,15 @@ fn history_search_row(is_first: bool, details: &ToolCallDetails) -> AnyElement<'
let content = if query.trim().is_empty() {
text("recent commands")
.style(
Style::default()
.fg(Color::Gray)
.add_modifier(Modifier::ITALIC),
)
.style(Style::default().fg(Color::Gray).add_modifier(Modifier::ITALIC))
.when(!filter_label.is_empty(), |t| {
t.span(" ", Style::default())
.span(&filter_label, filter_style)
t.span(" ", Style::default()).span(&filter_label, filter_style)
})
.any()
} else {
text(query)
.when(!filter_label.is_empty(), |t| {
t.span(" ", Style::default())
.span(&filter_label, filter_style)
t.span(" ", Style::default()).span(&filter_label, filter_style)
})
.any()
};
@@ -615,10 +590,8 @@ fn format_filter_modes(modes: &[HistorySearchFilterMode]) -> String {
// ───────────────────────────────────────────────────────────────────
fn suggested_command_view(details: &SuggestedCommandDetails) -> AnyElement<'static> {
let is_dangerous = matches!(
details.danger_level,
DangerLevel::High(_) | DangerLevel::Medium(_)
);
let is_dangerous =
matches!(details.danger_level, DangerLevel::High(_) | DangerLevel::Medium(_));
let danger_notes = details.danger_level.notes().cloned();
let danger_style = match details.danger_level {
DangerLevel::High(_) => Style::default().fg(Color::Red),
@@ -632,10 +605,8 @@ fn suggested_command_view(details: &SuggestedCommandDetails) -> AnyElement<'stat
DangerLevel::Unknown(_) => "Unknown",
};
let low_confidence = matches!(
details.confidence_level,
ConfidenceLevel::Low(_) | ConfidenceLevel::Medium(_)
);
let low_confidence =
matches!(details.confidence_level, ConfidenceLevel::Low(_) | ConfidenceLevel::Medium(_));
let confidence_level = match details.confidence_level {
ConfidenceLevel::Low(_) => "Low",
ConfidenceLevel::Medium(_) => "Medium",
@@ -666,41 +637,33 @@ fn suggested_command_view(details: &SuggestedCommandDetails) -> AnyElement<'stat
.pad_left(2),
)
})
.when_some(
is_dangerous.then_some(danger_notes).flatten(),
|c, notes| {
c.child(
row()
.fixed(2, text("└").style(Style::default().fg(Color::DarkGray)))
.fill(md(notes))
.pad_left(2),
)
},
)
.when_some(is_dangerous.then_some(danger_notes).flatten(), |c, notes| {
c.child(
row()
.fixed(2, text("└").style(Style::default().fg(Color::DarkGray)))
.fill(md(notes))
.pad_left(2),
)
})
.when(low_confidence, |c| {
c.child(
text("Confidence: ")
.style(Style::default().fg(Color::Blue))
.span(
confidence_level,
Style::default()
.fg(Color::Blue)
.add_modifier(Modifier::BOLD),
Style::default().fg(Color::Blue).add_modifier(Modifier::BOLD),
)
.pad_left(2),
)
})
.when_some(
low_confidence.then_some(confidence_notes).flatten(),
|c, notes| {
c.child(
row()
.fixed(2, text("└").style(Style::default().fg(Color::DarkGray)))
.fill(md(notes))
.pad_left(2),
)
},
)
.when_some(low_confidence.then_some(confidence_notes).flatten(), |c, notes| {
c.child(
row()
.fixed(2, text("└").style(Style::default().fg(Color::DarkGray)))
.fill(md(notes))
.pad_left(2),
)
})
.any()
}
@@ -720,10 +683,7 @@ pub(crate) fn permission_options(
match tool {
ClientToolCall::Edit(_) | ClientToolCall::Write(_) => vec![
("Allow", PermissionResult::Allow),
(
"Allow this file for this session",
PermissionResult::AllowFileForSession,
),
("Allow this file for this session", PermissionResult::AllowFileForSession),
("Always allow", PermissionResult::AlwaysAllow),
("Deny", PermissionResult::Deny),
],
@@ -765,10 +725,7 @@ pub(crate) fn permission_prompt_view(
// command is context here, not the thing being approved, so one
// middle-elided line beats wrapping.
ClientToolCall::AtuinOutput(tool) => {
let desc = tool
.command
.clone()
.unwrap_or_else(|| tool.history_id.to_string());
let desc = tool.command.clone().unwrap_or_else(|| tool.history_id.to_string());
truncated_line(prefix.clone(), Style::default(), desc, desc_style).any()
}
ClientToolCall::LoadSkill(tool) => wrapped(format!("skill: {}", tool.name)),
@@ -800,7 +757,11 @@ pub(crate) fn model_picker_view(
.models
.iter()
.map(|m| {
let marker = if m.alias == in_use { " (current)" } else { "" };
let marker = if m.alias == in_use {
" (current)"
} else {
""
};
format!("{} — {}{}", m.name, m.description, marker)
})
.collect();
@@ -837,10 +798,7 @@ pub(crate) fn status_bar_view(
let left = text(" Model: ")
.style(Style::default())
.span(
model.unwrap_or("default"),
Style::default().add_modifier(Modifier::BOLD),
)
.span(model.unwrap_or("default"), Style::default().add_modifier(Modifier::BOLD))
.span(" (/model to change)", Style::default().fg(Color::Gray));
let Some((pct, resets_in)) = usage else {
@@ -890,9 +848,10 @@ fn format_path_for_display(path: &std::path::Path) -> String {
#[cfg(test)]
mod tests {
use super::format_elapsed;
use std::time::Duration;
use super::format_elapsed;
#[test]
fn elapsed_formats_by_magnitude() {
assert_eq!(format_elapsed(Duration::from_millis(600)), "0.6 seconds");
+21 -12
View File
@@ -11,9 +11,8 @@
use std::time::Duration;
use atuin_common::string::EllipsizeExt as _;
use atuin_common::string::Measure;
use atuin_common::string::ellipsis::{Indicator, Pos};
use atuin_common::string::{EllipsizeExt as _, Measure};
use eye_declare::{Element, Spinner};
use ratatui_core::buffer::Buffer;
use ratatui_core::layout::Rect;
@@ -25,9 +24,7 @@ use unicode_width::UnicodeWidthStr as _;
/// spaces, and the middle is elided when it still doesn't fit.
fn fit_middle(command: &str, cols: usize) -> String {
let one_line = command.split_whitespace().collect::<Vec<_>>().join(" ");
one_line
.ellipsize(Measure::Columns(cols), Pos::Middle, Indicator::UNICODE)
.to_string()
one_line.ellipsize(Measure::Columns(cols), Pos::Middle, Indicator::UNICODE).to_string()
}
/// A tool spinner labeled `prefix` + command, the command middle-elided to
@@ -55,10 +52,13 @@ impl CommandSpinner {
fn spinner_at(&self, width: u16) -> Spinner {
// Marker glyph + trailing space while animating; a done spinner
// with a hidden checkmark has no marker (see Spinner::render).
let marker_cols = if self.done { 0 } else { 2 };
let budget = (width as usize)
.saturating_sub(marker_cols)
.saturating_sub(self.prefix.width());
let marker_cols = if self.done {
0
} else {
2
};
let budget =
(width as usize).saturating_sub(marker_cols).saturating_sub(self.prefix.width());
let label = format!("{}{}", self.prefix, fit_middle(&self.command, budget));
super::tool_spinner(label, self.done).hide_checkmark()
}
@@ -66,7 +66,11 @@ impl CommandSpinner {
impl Element for CommandSpinner {
fn height(&self, width: u16) -> u16 {
if width == 0 { 0 } else { 1 }
if width == 0 {
0
} else {
1
}
}
fn render(&self, area: Rect, buf: &mut Buffer) {
@@ -104,7 +108,11 @@ pub(crate) fn truncated_line(
impl Element for TruncatedLine {
fn height(&self, width: u16) -> u16 {
if width == 0 { 0 } else { 1 }
if width == 0 {
0
} else {
1
}
}
fn render(&self, area: Rect, buf: &mut Buffer) {
@@ -130,9 +138,10 @@ impl Element for TruncatedLine {
#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;
use super::*;
fn render_line(el: &impl Element, width: u16) -> String {
let area = Rect::new(0, 0, width, 1);
let mut buf = Buffer::empty(area);
+40 -60
View File
@@ -1,8 +1,7 @@
use std::path::PathBuf;
use crate::fsm::tools::ToolManager;
use crate::tools::descriptor;
use crate::tools::{ClientToolCall, HistorySearchFilterMode, ToolPreview};
use crate::tools::{ClientToolCall, HistorySearchFilterMode, ToolPreview, descriptor};
use crate::tui::ConversationEvent;
/// Server-sent danger level for a suggested command
@@ -106,9 +105,7 @@ pub(crate) struct ToolGroup {
impl ToolGroup {
/// True if any call in the group is still pending.
pub(crate) fn any_pending(&self) -> bool {
self.calls
.iter()
.any(|c| c.status == ToolResultStatus::Pending)
self.calls.iter().any(|c| c.status == ToolResultStatus::Pending)
}
}
@@ -135,7 +132,9 @@ pub(crate) enum ToolRenderData {
preview: Option<ToolPreview>,
},
/// File read operation.
FileRead { path: PathBuf },
FileRead {
path: PathBuf,
},
/// File edit (str_replace) operation.
FileEdit {
path: PathBuf,
@@ -152,7 +151,9 @@ pub(crate) enum ToolRenderData {
filter_modes: Vec<HistorySearchFilterMode>,
},
/// Skill loading — read-only, auto-approved.
SkillLoad { _name: String },
SkillLoad {
_name: String,
},
/// Server-side tool — no client rendering data available.
Remote,
}
@@ -214,9 +215,15 @@ pub(crate) struct UiTurn {
#[derive(Debug)]
pub(crate) enum UiTurnKind {
User { events: Vec<UiEvent> },
Agent { events: Vec<UiEvent> },
OutOfBand { events: Vec<UiEvent> },
User {
events: Vec<UiEvent>,
},
Agent {
events: Vec<UiEvent>,
},
OutOfBand {
events: Vec<UiEvent>,
},
}
pub(crate) struct TurnBuilder<'a> {
@@ -388,11 +395,7 @@ impl<'a> TurnBuilder<'a> {
}
fn add_suggested_command(&mut self, input: &serde_json::Value) {
let command = input
.get("command")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let command = input.get("command").and_then(|v| v.as_str()).unwrap_or("").to_string();
if command.is_empty() {
return;
@@ -401,39 +404,23 @@ impl<'a> TurnBuilder<'a> {
self.start_agent_turn();
{
let events = self.current_events_mut();
let danger_level = input
.get("danger")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let danger_level =
input.get("danger").and_then(|v| v.as_str()).unwrap_or("").to_string();
let confidence_level = input
.get("confidence")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let confidence_level =
input.get("confidence").and_then(|v| v.as_str()).unwrap_or("").to_string();
let danger_notes = input
.get("danger_notes")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let danger_notes =
input.get("danger_notes").and_then(|v| v.as_str()).unwrap_or("").to_string();
let confidence_notes = input
.get("confidence_notes")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let confidence_notes =
input.get("confidence_notes").and_then(|v| v.as_str()).unwrap_or("").to_string();
let danger = DangerLevel::from((&danger_level, &danger_notes));
let confidence = ConfidenceLevel::from((&confidence_level, &confidence_notes));
events.push(UiEvent::SuggestedCommand(SuggestedCommandDetails {
command: input
.get("command")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
command: input.get("command").and_then(|v| v.as_str()).unwrap_or("").to_string(),
danger_level: danger,
confidence_level: confidence,
}));
@@ -444,13 +431,12 @@ impl<'a> TurnBuilder<'a> {
let render_data = self.build_render_data(id, name);
self.start_agent_turn();
self.current_events_mut()
.push(UiEvent::ToolCall(ToolCallDetails {
tool_use_id: id.to_string(),
name: name.to_string(),
status: ToolResultStatus::Pending,
render_data,
}));
self.current_events_mut().push(UiEvent::ToolCall(ToolCallDetails {
tool_use_id: id.to_string(),
name: name.to_string(),
status: ToolResultStatus::Pending,
render_data,
}));
}
/// Build tool-type-specific render data from the ToolTracker.
@@ -513,11 +499,10 @@ impl<'a> TurnBuilder<'a> {
fn add_out_of_band_output(&mut self, _name: &str, command: Option<&str>, content: &str) {
self.start_out_of_band_turn();
self.current_events_mut()
.push(UiEvent::OutOfBandOutput(OutOfBandOutputDetails {
command: command.map(|c| c.to_string()),
content: content.to_string(),
}));
self.current_events_mut().push(UiEvent::OutOfBandOutput(OutOfBandOutputDetails {
command: command.map(|c| c.to_string()),
content: content.to_string(),
}));
}
}
@@ -553,11 +538,8 @@ impl ToolSummary {
pub(crate) fn summary(&self) -> String {
if self.any_pending() {
// Find the last pending tool for the active verb
if let Some(pending) = self
.tool_calls
.iter()
.rev()
.find(|t| t.status == ToolResultStatus::Pending)
if let Some(pending) =
self.tool_calls.iter().rev().find(|t| t.status == ToolResultStatus::Pending)
{
return Self::progressive_verb(&pending.name);
}
@@ -572,9 +554,7 @@ impl ToolSummary {
/// Determines if the spinner should be spinning
pub(crate) fn any_pending(&self) -> bool {
self.tool_calls
.iter()
.any(|tool_call| tool_call.status == ToolResultStatus::Pending)
self.tool_calls.iter().any(|tool_call| tool_call.status == ToolResultStatus::Pending)
}
/// Present-tense progressive verb for a tool name (e.g. "Searching...")
+4 -9
View File
@@ -106,17 +106,15 @@ pub(crate) async fn fetch_usage(endpoint: &reqwest::Url, token: &str) -> Result<
eyre::bail!("usage request failed ({status})");
}
response
.json::<UsageSnapshot>()
.await
.context("failed to parse usage response")
response.json::<UsageSnapshot>().await.context("failed to parse usage response")
}
#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;
use super::*;
#[test]
fn deserializes_server_payload() {
// Shape documented in the hub's CliUsageController / credits_payload.
@@ -146,10 +144,7 @@ mod tests {
};
let json = serde_json::to_string(&snapshot).unwrap();
assert_eq!(
serde_json::from_str::<UsageSnapshot>(&json).unwrap(),
snapshot
);
assert_eq!(serde_json::from_str::<UsageSnapshot>(&json).unwrap(), snapshot);
}
#[test]
@@ -98,9 +98,7 @@ pub(crate) async fn interpolate(source: &str, shell: &str) -> String {
for cmd in &commands {
let shell = shell.to_string();
let body = cmd.body.clone();
handles.push(tokio::spawn(
async move { run_command(&shell, &body).await },
));
handles.push(tokio::spawn(async move { run_command(&shell, &body).await }));
}
// Collect results.
@@ -137,10 +135,7 @@ pub(crate) async fn interpolate(source: &str, shell: &str) -> String {
async fn run_command(shell: &str, body: &str) -> String {
let result = tokio::time::timeout(
COMMAND_TIMEOUT,
tokio::process::Command::new(shell)
.arg("-c")
.arg(body)
.output(),
tokio::process::Command::new(shell).arg("-c").arg(body).output(),
)
.await;
@@ -164,28 +159,23 @@ async fn run_command(shell: &str, body: &str) -> String {
}
}
Ok(Err(e)) => format!("[error: {e}]"),
Err(_) => format!(
"[error: command timed out after {}s]",
COMMAND_TIMEOUT.as_secs()
),
Err(_) => format!("[error: command timed out after {}s]", COMMAND_TIMEOUT.as_secs()),
}
}
#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;
use super::*;
#[test]
fn parse_inline_command() {
let source = "Branch: !`git branch --show-current`";
let cmds = parse_commands(source);
assert_eq!(cmds.len(), 1);
assert_eq!(cmds[0].body, "git branch --show-current");
assert_eq!(
&source[cmds[0].range.clone()],
"!`git branch --show-current`"
);
assert_eq!(&source[cmds[0].range.clone()], "!`git branch --show-current`");
}
#[rstest]
+3 -9
View File
@@ -92,9 +92,7 @@ impl UserContextCache {
/// the cached value is only ever replaced wholesale — it can't be torn.
/// Recover with the inner value rather than propagating the panic.
fn lock(&self) -> std::sync::MutexGuard<'_, CacheSlot> {
self.inner
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
self.inner.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
}
@@ -181,9 +179,7 @@ mod tests {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("TERMINAL.md");
// The embedded sleep holds the gather open while we invalidate.
tokio::fs::write(&file, "!`sleep 0.5 && echo one`")
.await
.unwrap();
tokio::fs::write(&file, "!`sleep 0.5 && echo one`").await.unwrap();
let cache = UserContextCache::default();
@@ -214,9 +210,7 @@ mod tests {
async fn slow_gather_does_not_overwrite_concurrent_result() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("TERMINAL.md");
tokio::fs::write(&file, "!`sleep 0.5 && echo one`")
.await
.unwrap();
tokio::fs::write(&file, "!`sleep 0.5 && echo one`").await.unwrap();
let cache = UserContextCache::default();
+6 -11
View File
@@ -15,13 +15,11 @@ const BATCH_SIZES: [usize; 2] = [1, 100];
#[divan::bench(args = BATCH_SIZES, min_time = 1)]
fn serialize(bencher: divan::Bencher, n: usize) {
bencher
.with_inputs(|| records(n))
.bench_values(|records: Vec<HistoryRecord>| {
for record in &records {
divan::black_box(record.serialize().unwrap());
}
});
bencher.with_inputs(|| records(n)).bench_values(|records: Vec<HistoryRecord>| {
for record in &records {
divan::black_box(record.serialize().unwrap());
}
});
}
#[divan::bench(args = BATCH_SIZES, min_time = 1)]
@@ -44,8 +42,5 @@ fn deserialize(bencher: divan::Bencher, n: usize) {
fn records(n: usize) -> Vec<HistoryRecord> {
let mut ctx = BenchCtx::new();
BenchHistory::count(&mut ctx, n)
.into_iter()
.map(HistoryRecord::Create)
.collect()
BenchHistory::count(&mut ctx, n).into_iter().map(HistoryRecord::Create).collect()
}
+3 -2
View File
@@ -1,8 +1,9 @@
use crate::_util::context::BenchCtx;
use crate::history::BenchHistory;
use atuin_client::database::DbSearchMode;
use atuin_client::ordering::reorder_fuzzy;
use crate::_util::context::BenchCtx;
use crate::history::BenchHistory;
/// reorder_fuzzy is invoked on every keystroke during interactive fuzzy search, so keeping the
/// performance in check is important.
///
@@ -33,18 +33,10 @@ impl BenchRecord {
let host = Host::new(HostId(uuid_v7()));
let version: String = "v1".into();
let tag = uuid_v7().simple().to_string();
let data: String = ctx
.rng()
.sample_iter(&Alphanumeric)
.take(Self::PAYLOAD_SIZE)
.map(char::from)
.collect();
let key: String = ctx
.rng()
.sample_iter(&Alphanumeric)
.take(Self::KEY_SIZE)
.map(char::from)
.collect();
let data: String =
ctx.rng().sample_iter(&Alphanumeric).take(Self::PAYLOAD_SIZE).map(char::from).collect();
let key: String =
ctx.rng().sample_iter(&Alphanumeric).take(Self::KEY_SIZE).map(char::from).collect();
(0..n as u64)
.map(|idx| {
@@ -74,9 +66,7 @@ impl BenchSqliteStore {
async fn new() -> Self {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("bench.db");
let store = SqliteStore::new(db_path, Self::SQL_TIMEOUT_S)
.await
.unwrap();
let store = SqliteStore::new(db_path, Self::SQL_TIMEOUT_S).await.unwrap();
Self {
_temp_dir: dir,
@@ -101,7 +91,6 @@ fn push_batch(bencher: divan::Bencher, n: usize) {
(db, records)
})
.bench_values(|(db, records)| {
rt.block_on(db.sqlite_store.push_batch(records.iter()))
.unwrap();
rt.block_on(db.sqlite_store.push_batch(records.iter())).unwrap();
});
}
+28 -61
View File
@@ -3,12 +3,6 @@ use std::env;
use std::sync::Arc;
use std::time::Duration;
use eyre::{Result, bail};
use reqwest::{
Response, StatusCode, Url,
header::{AUTHORIZATION, HeaderMap, HeaderName, HeaderValue, USER_AGENT},
};
use atuin_common::url::UrlAppendExt;
use atuin_domain::api::{
ATUIN_CARGO_VERSION, ATUIN_HEADER_VERSION, ATUIN_VERSION, ChangePasswordRequest, ErrorResponse,
@@ -19,7 +13,9 @@ use atuin_domain::caps::{CapClient, CapMismatch, CapabilitiesExt};
use atuin_domain::record::{
EncryptedData, HostId, Record, RecordId, RecordIdx, RecordStatus, RecordTag,
};
use eyre::{Result, bail};
use reqwest::header::{AUTHORIZATION, HeaderMap, HeaderName, HeaderValue, USER_AGENT};
use reqwest::{Response, StatusCode, Url};
use reqwest_middleware::ClientWithMiddleware;
use semver::Version;
@@ -82,7 +78,8 @@ pub(crate) fn client_builder(extra_headers: &HashMap<String, String>) -> reqwest
if !same_origin {
attempt.error(
"refusing to follow cross-origin redirect: extra_headers are configured and will not be sent to a different origin",
"refusing to follow cross-origin redirect: extra_headers are configured and will \
not be sent to a different origin",
)
} else if attempt.previous().len() > 10 {
attempt.error("too many redirects")
@@ -173,11 +170,7 @@ pub async fn latest_version() -> Result<Version> {
let url = crate::settings::DEFAULT_SYNC_URL.clone();
let client = reqwest::Client::new();
let resp = client
.get(url)
.header(USER_AGENT, APP_USER_AGENT)
.send()
.await?;
let resp = client.get(url).header(USER_AGENT, APP_USER_AGENT).send().await?;
let resp = handle_resp_error(resp).await?;
let index = resp.json::<IndexResponse>().await?;
@@ -203,7 +196,8 @@ pub fn ensure_version(response: &Response) -> Result<bool> {
// If the client is newer than the server
if version.major < ATUIN_VERSION.major {
println!(
"Atuin version mismatch! In order to successfully sync, the server needs to run a newer version of Atuin"
"Atuin version mismatch! In order to successfully sync, the server needs to run a \
newer version of Atuin"
);
println!("Client: {ATUIN_CARGO_VERSION}");
println!("Server: {version}");
@@ -239,12 +233,14 @@ async fn handle_resp_error(resp: Response) -> Result<Response> {
}
bail!(
"There was an error with the atuin sync service at {url}, server error {status}: {reason}.\nIf the problem persists, contact the host"
"There was an error with the atuin sync service at {url}, server error {status}: \
{reason}.\nIf the problem persists, contact the host"
);
}
bail!(
"There was an error with the atuin sync service at {url}, Status {status:?}.\nResponse body: {body}\nIf the problem persists, contact the host"
"There was an error with the atuin sync service at {url}, Status \
{status:?}.\nResponse body: {body}\nIf the problem persists, contact the host"
);
}
@@ -260,14 +256,9 @@ pub fn caps_client(
headers.insert(USER_AGENT, APP_USER_AGENT.parse()?);
headers.insert(ATUIN_HEADER_VERSION, ATUIN_CARGO_VERSION.parse()?);
let http = client_builder(extra_headers)
.default_headers(headers)
.build()?;
let http = client_builder(extra_headers).default_headers(headers).build()?;
Ok(CapClient::new(
sync_addr.append_path("api/v0/capabilities")?,
http,
))
Ok(CapClient::new(sync_addr.append_path("api/v0/capabilities")?, http))
}
impl Client {
@@ -374,9 +365,7 @@ impl Client {
/// Confirm a packfile body upload with the server.
async fn confirm_packfile(&self, manifest_id: RecordId) -> Result<()> {
let path = format!("api/v0/packfiles/{}/confirm", manifest_id.0);
let url = self
.sync_addr
.append(path.split('/').filter(|s| !s.is_empty()))?;
let url = self.sync_addr.append(path.split('/').filter(|s| !s.is_empty()))?;
let resp = self.client.post(url).send().await?;
handle_resp_error(resp).await?;
Ok(())
@@ -389,12 +378,7 @@ impl Client {
packfile: impl Into<reqwest::Body>,
) -> Result<()> {
// Not self.client: S3 rejects presigned requests that also carry an Authorization header.
let resp = self
.lfs_client
.put(upload_url.clone())
.body(packfile)
.send()
.await?;
let resp = self.lfs_client.put(upload_url.clone()).body(packfile).send().await?;
handle_resp_error(resp).await?;
Ok(())
}
@@ -402,9 +386,7 @@ impl Client {
async fn get_packfile_download_url(&self, manifest_id: RecordId) -> Result<Url> {
// `append_path` takes `&'static str`; the manifest id is dynamic, so inline its logic.
let path = format!("api/v0/packfiles/{}", manifest_id.0);
let url = self
.sync_addr
.append(path.split('/').filter(|s| !s.is_empty()))?;
let url = self.sync_addr.append(path.split('/').filter(|s| !s.is_empty()))?;
let resp = self.client.get(url).send().await?;
let resp = handle_resp_error(resp).await?;
@@ -506,9 +488,10 @@ impl Client {
#[cfg(test)]
mod tests {
use super::*;
use rstest::*;
use super::*;
#[fixture]
fn extra_headers() -> HashMap<String, String> {
let mut extra = HashMap::new();
@@ -564,7 +547,8 @@ mod tests {
serve_one(
&listener,
format!(
"HTTP/1.1 302 Found\r\nLocation: http://127.0.0.1:{}/\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
"HTTP/1.1 302 Found\r\nLocation: http://127.0.0.1:{}/\r\nContent-Length: \
0\r\nConnection: close\r\n\r\n",
port + 1
),
)
@@ -572,16 +556,9 @@ mod tests {
});
let client = client_builder(&extra_headers).build().unwrap();
let err = client
.get(format!("http://127.0.0.1:{port}/"))
.send()
.await
.unwrap_err();
let err = client.get(format!("http://127.0.0.1:{port}/")).send().await.unwrap_err();
assert!(
err.is_redirect(),
"expected a redirect policy error: {err:?}"
);
assert!(err.is_redirect(), "expected a redirect policy error: {err:?}");
}
#[rstest]
@@ -596,7 +573,8 @@ mod tests {
serve_one(
&listener,
format!(
"HTTP/1.1 302 Found\r\nLocation: http://127.0.0.1:{port}/ok\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
"HTTP/1.1 302 Found\r\nLocation: \
http://127.0.0.1:{port}/ok\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
),
)
.await;
@@ -608,11 +586,7 @@ mod tests {
});
let client = client_builder(&extra_headers).build().unwrap();
let resp = client
.get(format!("http://127.0.0.1:{port}/"))
.send()
.await
.unwrap();
let resp = client.get(format!("http://127.0.0.1:{port}/")).send().await.unwrap();
assert_eq!(resp.status(), 200);
assert_eq!(resp.url().path(), "/ok");
@@ -645,15 +619,8 @@ mod tests {
let addr: Url = server.uri().parse().unwrap();
let caps = caps_client(&addr, &HashMap::new()).unwrap();
let client = Client::new(
addr,
AuthToken::Token("t".into()),
30,
30,
&HashMap::new(),
caps,
)
.unwrap();
let client =
Client::new(addr, AuthToken::Token("t".into()), 30, 30, &HashMap::new(), caps).unwrap();
// The client observes the server's advertised packfile cap; a second read stays warm
// (the mock expects a single capabilities fetch).
+17 -38
View File
@@ -1,15 +1,15 @@
use std::collections::HashMap;
use enum_dispatch::enum_dispatch;
use eyre::{Context, Result, bail};
use reqwest::{StatusCode, Url, header::USER_AGENT};
use serde::Deserialize;
use atuin_common::url::UrlAppendExt;
use atuin_domain::api::{
ATUIN_CARGO_VERSION, ATUIN_HEADER_VERSION, ChangePasswordRequest, LoginRequest, LoginResponse,
RegisterResponse,
};
use enum_dispatch::enum_dispatch;
use eyre::{Context, Result, bail};
use reqwest::header::USER_AGENT;
use reqwest::{StatusCode, Url};
use serde::Deserialize;
use crate::settings::Settings;
@@ -44,10 +44,7 @@ pub enum MutateResponse {
/// CLI commands use this trait so they don't need to know which backend is
/// active — they just prompt for input and call these methods.
#[enum_dispatch]
#[allow(
async_fn_in_trait,
reason = "only used within our code and we don't need it to be Send"
)]
#[allow(async_fn_in_trait, reason = "only used within our code and we don't need it to be Send")]
pub trait AuthClient: Send + Sync {
/// Log in with username + password, optionally providing a TOTP code.
async fn login(
@@ -87,10 +84,7 @@ pub enum AnyAuthClient {
pub async fn auth_client(settings: &Settings) -> AnyAuthClient {
if settings.is_hub_sync() {
let endpoint = settings.hub_endpoint();
AnyAuthClient::Hub(HubAuthClient::new(
&endpoint,
settings.hub_session_token().await.ok(),
))
AnyAuthClient::Hub(HubAuthClient::new(&endpoint, settings.hub_session_token().await.ok()))
} else {
AnyAuthClient::Legacy(LegacyAuthClient::new(
&settings.sync_address,
@@ -132,16 +126,10 @@ impl LegacyAuthClient {
}
fn authenticated_client(&self) -> Result<reqwest::Client> {
let token = self
.session_token
.as_deref()
.ok_or_else(|| eyre::eyre!("Not logged in"))?;
let token = self.session_token.as_deref().ok_or_else(|| eyre::eyre!("Not logged in"))?;
let mut headers = crate::api_client::extra_headers_map(&self.extra_headers)?;
headers.insert(
reqwest::header::AUTHORIZATION,
format!("Token {token}").parse()?,
);
headers.insert(reqwest::header::AUTHORIZATION, format!("Token {token}").parse()?);
headers.insert(USER_AGENT, APP_USER_AGENT.parse()?);
headers.insert(ATUIN_HEADER_VERSION, ATUIN_CARGO_VERSION.parse()?);
@@ -232,11 +220,8 @@ impl AuthClient for LegacyAuthClient {
let client = self.authenticated_client()?;
let url = self.address.append(["account"])?;
let resp = client
.delete(url)
.json(&serde_json::json!({ "password": password }))
.send()
.await?;
let resp =
client.delete(url).json(&serde_json::json!({ "password": password })).send().await?;
match resp.status().as_u16() {
200 => Ok(MutateResponse::Success),
@@ -373,16 +358,13 @@ impl AuthClient for HubAuthClient {
totp_code: Option<&str>,
) -> Result<MutateResponse> {
let hub_token = self.hub_token.as_deref().ok_or_else(|| {
eyre::eyre!(
"Not logged in to Atuin Hub. \
Please run 'atuin login' to authenticate."
)
eyre::eyre!("Not logged in to Atuin Hub. Please run 'atuin login' to authenticate.")
})?;
if !hub_token.starts_with("atapi_") {
bail!(
"Your Hub session token is invalid. \
Please run 'atuin login' to re-authenticate with Atuin Hub."
"Your Hub session token is invalid. Please run 'atuin login' to re-authenticate \
with Atuin Hub."
);
}
@@ -444,16 +426,13 @@ impl AuthClient for HubAuthClient {
totp_code: Option<&str>,
) -> Result<MutateResponse> {
let hub_token = self.hub_token.as_deref().ok_or_else(|| {
eyre::eyre!(
"Not logged in to Atuin Hub. \
Please run 'atuin login' to authenticate."
)
eyre::eyre!("Not logged in to Atuin Hub. Please run 'atuin login' to authenticate.")
})?;
if !hub_token.starts_with("atapi_") {
bail!(
"Your Hub session token is invalid. \
Please run 'atuin login' to re-authenticate with Atuin Hub."
"Your Hub session token is invalid. Please run 'atuin login' to re-authenticate \
with Atuin Hub."
);
}
+124 -307
View File
@@ -1,38 +1,29 @@
use std::{
env,
path::{Path, PathBuf},
str::FromStr,
time::Duration,
};
use std::env;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::time::Duration;
use crate::history::{AuthorPattern, KNOWN_AGENTS};
use async_trait::async_trait;
use atuin_common::filter::{self, OrFilter};
use atuin_common::time::OffsetDateTimeExt;
use atuin_common::utils;
use fs_err as fs;
use itertools::Itertools;
use sql_builder::{SqlBuilder, SqlName, bind::Bind, esc, quote};
use sqlx::{
Result, Row,
sqlite::{
SqliteConnectOptions, SqliteJournalMode, SqlitePool, SqlitePoolOptions, SqliteRow,
SqliteSynchronous,
},
use sql_builder::bind::Bind;
use sql_builder::{SqlBuilder, SqlName, esc, quote};
use sqlx::sqlite::{
SqliteConnectOptions, SqliteJournalMode, SqlitePool, SqlitePoolOptions, SqliteRow,
SqliteSynchronous,
};
use sqlx::{Result, Row};
use time::OffsetDateTime;
use uuid::Uuid;
use crate::{
history::{HistoryId, HistoryStats},
utils::get_host_user,
};
use super::{
history::History,
ordering,
settings::{FilterMode, SearchMode, Settings},
};
use super::history::History;
use super::ordering;
use super::settings::{FilterMode, SearchMode, Settings};
use crate::history::{AuthorPattern, HistoryId, HistoryStats, KNOWN_AGENTS};
use crate::utils::get_host_user;
#[derive(Clone)]
pub struct Context {
@@ -89,7 +80,8 @@ pub async fn query_context() -> eyre::Result<Context> {
pub async fn current_context() -> eyre::Result<Context> {
if env::var("ATUIN_SESSION").is_err() {
return Err(eyre::eyre!(
"Failed to find $ATUIN_SESSION in the environment. Check that you have correctly set up your shell."
"Failed to find $ATUIN_SESSION in the environment. Check that you have correctly set \
up your shell."
));
}
@@ -116,30 +108,19 @@ fn apply_author_filter(sql: &mut SqlBuilder, authors: OrFilter<&[AuthorPattern]>
filter::Items::Some(a) => a,
};
let author_expr = "CASE \
WHEN author IS NULL OR trim(author) = '' THEN \
CASE \
WHEN instr(hostname, ':') > 0 THEN substr(hostname, instr(hostname, ':') + 1) \
ELSE hostname \
END \
ELSE author \
END";
let author_expr = "CASE WHEN author IS NULL OR trim(author) = '' THEN CASE WHEN \
instr(hostname, ':') > 0 THEN substr(hostname, instr(hostname, ':') + 1) \
ELSE hostname END ELSE author END";
let mut agent_list: Option<String> = None;
let get_agent_list = || KNOWN_AGENTS.iter().map(quote).join(", ");
let mut conditions = authors.iter().map(|author| match author {
AuthorPattern::AllUser => {
format!(
"{author_expr} NOT IN ({})",
agent_list.get_or_insert_with(get_agent_list)
)
format!("{author_expr} NOT IN ({})", agent_list.get_or_insert_with(get_agent_list))
}
AuthorPattern::AllAgent => {
format!(
"{author_expr} IN ({})",
agent_list.get_or_insert_with(get_agent_list)
)
format!("{author_expr} IN ({})", agent_list.get_or_insert_with(get_agent_list))
}
AuthorPattern::Name(name) => {
format!("{author_expr} = {}", quote(name))
@@ -299,7 +280,8 @@ impl Sqlite {
if utils::broken_symlink(path) {
eprintln!(
"Atuin: Sqlite db path ({path:?}) is a broken symlink. Unable to read or create replacement."
"Atuin: Sqlite db path ({path:?}) is a broken symlink. Unable to read or create \
replacement."
);
std::process::exit(1);
}
@@ -329,9 +311,7 @@ impl Sqlite {
}
pub async fn sqlite_version(&self) -> Result<String> {
sqlx::query_scalar("SELECT sqlite_version()")
.fetch_one(&self.pool)
.await
sqlx::query_scalar("SELECT sqlite_version()").fetch_one(&self.pool).await
}
async fn setup_db(pool: &SqlitePool) -> Result<()> {
@@ -477,7 +457,8 @@ impl Database for Sqlite {
sqlx::query(
"update history
set timestamp = ?2, duration = ?3, exit = ?4, command = ?5, cwd = ?6, session = ?7, hostname = ?8, author = ?9, intent = ?10, deleted_at = ?11
set timestamp = ?2, duration = ?3, exit = ?4, command = ?5, cwd = ?6, session = \
?7, hostname = ?8, author = ?9, intent = ?10, deleted_at = ?11
where id = ?1",
)
.bind(h.id.0.as_str())
@@ -490,7 +471,7 @@ impl Database for Sqlite {
.bind(h.hostname.as_str())
.bind(h.author.as_str())
.bind(h.intent.as_deref())
.bind(h.deleted_at.map(|t|t.unix_timestamp_nanos() as i64))
.bind(h.deleted_at.map(|t| t.unix_timestamp_nanos() as i64))
.execute(&self.pool)
.await?;
@@ -574,11 +555,12 @@ impl Database for Sqlite {
debug!("listing history from {:?} to {:?}", from, to);
let res = sqlx::query(
"select * from history where timestamp >= ?1 and timestamp <= ?2 order by timestamp asc",
"select * from history where timestamp >= ?1 and timestamp <= ?2 order by timestamp \
asc",
)
.bind(from.unix_timestamp_nanos() as i64)
.bind(to.unix_timestamp_nanos() as i64)
.map(Self::query_history)
.map(Self::query_history)
.fetch_all(&self.pool)
.await?;
@@ -716,25 +698,17 @@ impl Database for Sqlite {
sql.and_where("command regexp ?".bind(&regex));
}
filter_options
.exit
.map(|exit| sql.and_where_eq("exit", exit));
filter_options.exit.map(|exit| sql.and_where_eq("exit", exit));
filter_options
.exclude_exit
.map(|exclude_exit| sql.and_where_ne("exit", exclude_exit));
filter_options.exclude_exit.map(|exclude_exit| sql.and_where_ne("exit", exclude_exit));
if filter_options.only_failed {
sql.and_where("exit != 0 AND exit != -1");
}
filter_options
.cwd
.map(|cwd| sql.and_where_eq("cwd", quote(cwd)));
filter_options.cwd.map(|cwd| sql.and_where_eq("cwd", quote(cwd)));
filter_options
.exclude_cwd
.map(|exclude_cwd| sql.and_where_ne("cwd", quote(exclude_cwd)));
filter_options.exclude_cwd.map(|exclude_cwd| sql.and_where_ne("cwd", quote(exclude_cwd)));
if let Some(before) = filter_options.before {
let parsed =
@@ -791,13 +765,9 @@ impl Database for Sqlite {
format!("SELECT * FROM ({inner}) f ORDER BY f.timestamp {order}{tail}")
} else {
format!(
"SELECT * FROM ({inner}) f \
WHERE NOT EXISTS ( \
SELECT 1 FROM ({inner}) f2 \
WHERE f2.command = f.command \
AND (f2.timestamp, f2.id) > (f.timestamp, f.id) \
) \
ORDER BY f.timestamp {order}{tail}"
"SELECT * FROM ({inner}) f WHERE NOT EXISTS ( SELECT 1 FROM ({inner}) f2 WHERE \
f2.command = f.command AND (f2.timestamp, f2.id) > (f.timestamp, f.id) ) ORDER \
BY f.timestamp {order}{tail}"
)
};
@@ -919,10 +889,7 @@ impl Database for Sqlite {
average.field("avg(duration)").and_where("command = ?1");
let mut exits = SqlBuilder::select_from("history");
exits
.fields(&["exit", "count(1) as count"])
.and_where("command = ?1")
.group_by("exit");
exits.fields(&["exit", "count(1) as count"]).and_where("command = ?1").group_by("exit");
// rewrite the following with sqlbuilder
let mut day_of_week = SqlBuilder::select_from("history");
@@ -954,9 +921,8 @@ impl Database for Sqlite {
let average = average.sql().expect("issue in stats previous query");
let exits = exits.sql().expect("issue in stats exits query");
let day_of_week = day_of_week.sql().expect("issue in stats day of week query");
let duration_over_time = duration_over_time
.sql()
.expect("issue in stats duration over time query");
let duration_over_time =
duration_over_time.sql().expect("issue in stats duration over time query");
// The queries are all independent, so run them concurrently on the pool.
let (prev, next, total, average, exits, day_of_week, duration_over_time): (
@@ -978,27 +944,17 @@ impl Database for Sqlite {
.bind(&h.session)
.map(Self::query_history)
.fetch_optional(&self.pool),
sqlx::query_as(sqlx::AssertSqlSafe(total))
.bind(&h.command)
.fetch_one(&self.pool),
sqlx::query_as(sqlx::AssertSqlSafe(average))
.bind(&h.command)
.fetch_one(&self.pool),
sqlx::query_as(sqlx::AssertSqlSafe(exits))
.bind(&h.command)
.fetch_all(&self.pool),
sqlx::query_as(sqlx::AssertSqlSafe(day_of_week))
.bind(&h.command)
.fetch_all(&self.pool),
sqlx::query_as(sqlx::AssertSqlSafe(total)).bind(&h.command).fetch_one(&self.pool),
sqlx::query_as(sqlx::AssertSqlSafe(average)).bind(&h.command).fetch_one(&self.pool),
sqlx::query_as(sqlx::AssertSqlSafe(exits)).bind(&h.command).fetch_all(&self.pool),
sqlx::query_as(sqlx::AssertSqlSafe(day_of_week)).bind(&h.command).fetch_all(&self.pool),
sqlx::query_as(sqlx::AssertSqlSafe(duration_over_time))
.bind(&h.command)
.fetch_all(&self.pool),
)?;
let duration_over_time = duration_over_time
.iter()
.map(|f| (f.0.clone(), f.1.round() as i64))
.collect();
let duration_over_time =
duration_over_time.iter().map(|f| (f.0.clone(), f.1.round() as i64)).collect();
Ok(HistoryStats {
next,
@@ -1073,9 +1029,7 @@ impl Paged {
// We want to deduplicate on command, but the user can search via cwd, hostname, and session.
// Without those fields, filter modes won't work right. With those fields, we get duplicates.
// This must be handled upstream.
query
.group_by("command, cwd, hostname, session")
.having("max(timestamp)");
query.group_by("command, cwd, hostname, session").having("max(timestamp)");
}
query.limit(self.page_size);
@@ -1229,11 +1183,13 @@ impl<'a> Iterator for QueryTokenizer<'a> {
#[cfg(test)]
mod test {
use std::time::{Duration, Instant};
use rstest::{fixture, rstest};
use time::format_description::well_known::Rfc3339;
use super::*;
use crate::settings::test_local_timeout;
use rstest::{fixture, rstest};
use std::time::{Duration, Instant};
use time::format_description::well_known::Rfc3339;
fn new_context() -> Context {
Context {
@@ -1247,9 +1203,7 @@ mod test {
#[fixture]
async fn empty_db() -> Sqlite {
Sqlite::new("sqlite::memory:", test_local_timeout())
.await
.unwrap()
Sqlite::new("sqlite::memory:", test_local_timeout()).await.unwrap()
}
async fn assert_search_eq(
@@ -1262,15 +1216,9 @@ mod test {
let context = new_context();
let results = db
.search(
mode,
filter_mode,
&context,
query,
OptFilters {
..Default::default()
},
)
.search(mode, filter_mode, &context, query, OptFilters {
..Default::default()
})
.await?;
assert_eq!(
@@ -1290,9 +1238,8 @@ mod test {
query: &str,
expected_commands: Vec<&str>,
) {
let results = assert_search_eq(db, mode, filter_mode, query, expected_commands.len())
.await
.unwrap();
let results =
assert_search_eq(db, mode, filter_mode, query, expected_commands.len()).await.unwrap();
let commands: Vec<&str> = results.iter().map(|a| a.command.as_str()).collect();
assert_eq!(commands, expected_commands);
}
@@ -1370,18 +1317,12 @@ mod test {
save_history_item(&db, "ls /home/frank").await;
// No range -> everything.
let all = db
.list(&[], &context, None, false, false, None)
.await
.unwrap();
let all = db.list(&[], &context, None, false, false, None).await.unwrap();
assert_eq!(all.len(), 2);
// A zero-width window on the item's exact timestamp matches it, because the
// bounds are inclusive (`timestamp >= from AND timestamp <= to`).
let hits = db
.list(&[], &context, None, false, false, Some((at, at)))
.await
.unwrap();
let hits = db.list(&[], &context, None, false, false, Some((at, at))).await.unwrap();
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].command, "ls /home/ellie");
}
@@ -1397,10 +1338,7 @@ mod test {
let bravo = save_history_item(&db, "echo bravo").await;
let _charlie = save_history_item(&db, "echo charlie").await;
let loaded = db
.load_active(&[alpha.id.clone(), bravo.id.clone()])
.await
.unwrap();
let loaded = db.load_active(&[alpha.id.clone(), bravo.id.clone()]).await.unwrap();
let mut commands: Vec<String> = loaded.into_iter().map(|h| h.command).collect();
commands.sort();
@@ -1437,10 +1375,7 @@ mod test {
alpha.command = String::new();
db.update(&alpha).await.unwrap();
let loaded = db
.load_active(&[alpha.id.clone(), bravo.id.clone()])
.await
.unwrap();
let loaded = db.load_active(&[alpha.id.clone(), bravo.id.clone()]).await.unwrap();
assert_eq!(loaded.len(), 1);
assert_eq!(loaded[0].command, "echo bravo");
@@ -1484,9 +1419,7 @@ mod test {
}
async fn db_with(commands: &[&str]) -> Sqlite {
let mut db = Sqlite::new("sqlite::memory:", test_local_timeout())
.await
.unwrap();
let mut db = Sqlite::new("sqlite::memory:", test_local_timeout()).await.unwrap();
for command in commands {
new_history_item(&mut db, command).await.unwrap();
@@ -1510,41 +1443,25 @@ mod test {
) {
let t = OffsetDateTime::from_unix_timestamp(1708330400).unwrap();
let mut db = Sqlite::new("sqlite::memory:", test_local_timeout())
.await
.unwrap();
new_history_item_at(&mut db, "ls /home/ellie", Some(t))
.await
.unwrap();
new_history_item_at(&mut db, "ls /home/frank", None)
.await
.unwrap();
let mut db = Sqlite::new("sqlite::memory:", test_local_timeout()).await.unwrap();
new_history_item_at(&mut db, "ls /home/ellie", Some(t)).await.unwrap();
new_history_item_at(&mut db, "ls /home/frank", None).await.unwrap();
let context = new_context();
let stamp = |seconds: i64| {
(t + time::Duration::seconds(seconds))
.format(&Rfc3339)
.unwrap()
};
let stamp = |seconds: i64| (t + time::Duration::seconds(seconds)).format(&Rfc3339).unwrap();
let (after, before) = match offsets {
Some((after, before)) => (Some(stamp(after)), Some(stamp(before))),
None => (None, None),
};
let results = db
.search(
DbSearchMode::FullText,
FilterMode::Global,
&context,
"",
OptFilters {
after: after.as_deref(),
before: before.as_deref(),
include_duplicates: true,
..Default::default()
},
)
.search(DbSearchMode::FullText, FilterMode::Global, &context, "", OptFilters {
after: after.as_deref(),
before: before.as_deref(),
include_duplicates: true,
..Default::default()
})
.await
.unwrap();
@@ -1567,16 +1484,10 @@ mod test {
let context = new_context();
let hits = db
.search(
DbSearchMode::FullText,
FilterMode::Global,
&context,
"",
OptFilters {
include_duplicates,
..Default::default()
},
)
.search(DbSearchMode::FullText, FilterMode::Global, &context, "", OptFilters {
include_duplicates,
..Default::default()
})
.await
.unwrap();
@@ -1598,15 +1509,8 @@ mod test {
_ => unreachable!(),
}
let result = db
.search(
DbSearchMode::FullText,
FilterMode::Global,
&context,
"",
filters,
)
.await;
let result =
db.search(DbSearchMode::FullText, FilterMode::Global, &context, "", filters).await;
assert!(result.is_err(), "unparsable `{which}` filter must error");
}
@@ -1619,15 +1523,9 @@ mod test {
async fn test_search_prefix(#[case] query: &str, #[case] expected: usize) {
let db = db_with(&["ls /home/ellie"]).await;
assert_search_eq(
&db,
DbSearchMode::Prefix,
FilterMode::Global,
query,
expected,
)
.await
.unwrap();
assert_search_eq(&db, DbSearchMode::Prefix, FilterMode::Global, query, expected)
.await
.unwrap();
}
#[rstest]
@@ -1649,15 +1547,9 @@ mod test {
async fn test_search_fulltext(#[case] query: &str, #[case] expected: usize) {
let db = db_with(&["ls /home/ellie"]).await;
assert_search_eq(
&db,
DbSearchMode::FullText,
FilterMode::Global,
query,
expected,
)
.await
.unwrap();
assert_search_eq(&db, DbSearchMode::FullText, FilterMode::Global, query, expected)
.await
.unwrap();
}
#[rstest]
@@ -1694,15 +1586,9 @@ mod test {
])
.await;
assert_search_eq(
&db,
DbSearchMode::Fuzzy,
FilterMode::Global,
query,
expected,
)
.await
.unwrap();
assert_search_eq(&db, DbSearchMode::Fuzzy, FilterMode::Global, query, expected)
.await
.unwrap();
}
#[rstest]
@@ -1719,21 +1605,13 @@ mod test {
new_history_item(&mut db, "corburl").await.unwrap();
// if fuzzy reordering is on, it should come back in a more sensible order
assert_search_commands(
&db,
DbSearchMode::Fuzzy,
FilterMode::Global,
"curl",
vec!["curl", "corburl"],
)
assert_search_commands(&db, DbSearchMode::Fuzzy, FilterMode::Global, "curl", vec![
"curl", "corburl",
])
.await;
assert_search_eq(&db, DbSearchMode::Fuzzy, FilterMode::Global, "xxxx", 0)
.await
.unwrap();
assert_search_eq(&db, DbSearchMode::Fuzzy, FilterMode::Global, "", 2)
.await
.unwrap();
assert_search_eq(&db, DbSearchMode::Fuzzy, FilterMode::Global, "xxxx", 0).await.unwrap();
assert_search_eq(&db, DbSearchMode::Fuzzy, FilterMode::Global, "", 2).await.unwrap();
}
#[rstest]
@@ -1769,9 +1647,7 @@ mod test {
#[case::daemon_fuzzy(SearchMode::DaemonFuzzy)]
#[tokio::test(flavor = "multi_thread")]
async fn test_search_interactive_only_modes_rank_like_fuzzy(#[case] mode: SearchMode) {
let mut db = Sqlite::new("sqlite::memory:", test_local_timeout())
.await
.unwrap();
let mut db = Sqlite::new("sqlite::memory:", test_local_timeout()).await.unwrap();
// "corburl" is strictly newer, so an unranked query would return it first and the assertion
// below would fail.
@@ -1779,17 +1655,11 @@ mod test {
new_history_item_at(&mut db, "curl", Some(now - time::Duration::seconds(10)))
.await
.unwrap();
new_history_item_at(&mut db, "corburl", Some(now))
.await
.unwrap();
new_history_item_at(&mut db, "corburl", Some(now)).await.unwrap();
assert_search_commands(
&db,
mode.closest_db_mode(),
FilterMode::Global,
"curl",
vec!["curl", "corburl"],
)
assert_search_commands(&db, mode.closest_db_mode(), FilterMode::Global, "curl", vec![
"curl", "corburl",
])
.await;
}
@@ -1800,9 +1670,7 @@ mod test {
#[case::trailing_space("screen ")]
#[tokio::test(flavor = "multi_thread")]
async fn test_search_fuzzy_trailing_space(#[case] query: &str) {
let mut db = Sqlite::new("sqlite::memory:", test_local_timeout())
.await
.unwrap();
let mut db = Sqlite::new("sqlite::memory:", test_local_timeout()).await.unwrap();
let now = OffsetDateTime::now_utc();
let irssi = "screen irssi";
@@ -1810,12 +1678,8 @@ mod test {
let ls_ld = "ls -ld secrets/rendered";
let screen_r = "screen -r";
new_history_item_at(&mut db, irssi, Some(now - time::Duration::days(5)))
.await
.unwrap();
new_history_item_at(&mut db, ls_l, Some(now - time::Duration::days(4)))
.await
.unwrap();
new_history_item_at(&mut db, irssi, Some(now - time::Duration::days(5))).await.unwrap();
new_history_item_at(&mut db, ls_l, Some(now - time::Duration::days(4))).await.unwrap();
new_history_item_at(
&mut db,
ls_ld,
@@ -1823,13 +1687,10 @@ mod test {
)
.await
.unwrap();
new_history_item_at(&mut db, screen_r, Some(now - time::Duration::hours(1)))
.await
.unwrap();
new_history_item_at(&mut db, screen_r, Some(now - time::Duration::hours(1))).await.unwrap();
let results = assert_search_eq(&db, DbSearchMode::Fuzzy, FilterMode::Global, query, 4)
.await
.unwrap();
let results =
assert_search_eq(&db, DbSearchMode::Fuzzy, FilterMode::Global, query, 4).await.unwrap();
assert_eq!(
results[0].command,
screen_r,
@@ -1855,25 +1716,15 @@ mod test {
#[case] close: &str,
#[case] far: &str,
) {
let mut db = Sqlite::new("sqlite::memory:", test_local_timeout())
.await
.unwrap();
let mut db = Sqlite::new("sqlite::memory:", test_local_timeout()).await.unwrap();
let now = OffsetDateTime::now_utc();
new_history_item_at(&mut db, close, Some(now - time::Duration::days(5)))
.await
.unwrap();
new_history_item_at(&mut db, far, Some(now - time::Duration::hours(1)))
.await
.unwrap();
new_history_item_at(&mut db, close, Some(now - time::Duration::days(5))).await.unwrap();
new_history_item_at(&mut db, far, Some(now - time::Duration::hours(1))).await.unwrap();
assert_search_commands(
&db,
DbSearchMode::Fuzzy,
FilterMode::Global,
query,
vec![close, far],
)
assert_search_commands(&db, DbSearchMode::Fuzzy, FilterMode::Global, query, vec![
close, far,
])
.await;
}
@@ -1883,13 +1734,9 @@ mod test {
async fn test_search_fuzzy_operator() {
let db = db_with(&["use screen", "screenshot tool"]).await;
assert_search_commands(
&db,
DbSearchMode::Fuzzy,
FilterMode::Global,
"screen$",
vec!["use screen"],
)
assert_search_commands(&db, DbSearchMode::Fuzzy, FilterMode::Global, "screen$", vec![
"use screen",
])
.await;
}
@@ -1903,9 +1750,7 @@ mod test {
let mut db = db;
// Add 5 history items
for i in 0..5 {
new_history_item(&mut db, &format!("command{}", i))
.await
.unwrap();
new_history_item(&mut db, &format!("command{}", i)).await.unwrap();
}
// Create a paged iterator with page_size of 2
@@ -2003,11 +1848,7 @@ mod test {
.await
.unwrap();
let to_delete = all
.iter()
.find(|h| h.command == "delete_me")
.unwrap()
.clone();
let to_delete = all.iter().find(|h| h.command == "delete_me").unwrap().clone();
db.delete(to_delete).await.unwrap();
// Deletes remove the row outright, so both views should get 2
@@ -2044,21 +1885,13 @@ mod test {
let context = new_context();
for _i in 1..10000 {
new_history_item(&mut db, "i am a duplicated command")
.await
.unwrap();
new_history_item(&mut db, "i am a duplicated command").await.unwrap();
}
let start = Instant::now();
let _results = db
.search(
DbSearchMode::Fuzzy,
FilterMode::Global,
&context,
"",
OptFilters {
..Default::default()
},
)
.search(DbSearchMode::Fuzzy, FilterMode::Global, &context, "", OptFilters {
..Default::default()
})
.await
.unwrap();
let duration = start.elapsed();
@@ -2079,9 +1912,7 @@ mod test {
#[case] shells: [&str; N],
#[case] expected_count: usize,
) {
let db = Sqlite::new("sqlite::memory:", test_local_timeout())
.await
.unwrap();
let db = Sqlite::new("sqlite::memory:", test_local_timeout()).await.unwrap();
for (command, shell) in [
("echo unknown1", None),
@@ -2117,13 +1948,7 @@ mod test {
};
let results = db
.search(
DbSearchMode::FullText,
FilterMode::Global,
&context,
"echo",
filters,
)
.search(DbSearchMode::FullText, FilterMode::Global, &context, "echo", filters)
.await
.unwrap();
@@ -2142,9 +1967,7 @@ mod test {
#[case] authors: [&str; N],
#[case] expected_count: usize,
) {
let db = Sqlite::new("sqlite::memory:", test_local_timeout())
.await
.unwrap();
let db = Sqlite::new("sqlite::memory:", test_local_timeout()).await.unwrap();
for (command, author) in [
("echo alice1", "alice"),
@@ -2178,13 +2001,7 @@ mod test {
};
let results = db
.search(
DbSearchMode::FullText,
FilterMode::Global,
&context,
"echo",
filters,
)
.search(DbSearchMode::FullText, FilterMode::Global, &context, "echo", filters)
.await
.unwrap();
+5 -15
View File
@@ -27,27 +27,19 @@ fn detect_from_os_release() -> Option<String> {
}
fn detect_from_debian_version() -> Option<String> {
std::fs::read_to_string("/etc/debian_version")
.ok()
.map(|v| format!("Debian {}", v.trim()))
std::fs::read_to_string("/etc/debian_version").ok().map(|v| format!("Debian {}", v.trim()))
}
fn detect_from_centos_release() -> Option<String> {
std::fs::read_to_string("/etc/centos-release")
.ok()
.map(|v| v.trim().to_string())
std::fs::read_to_string("/etc/centos-release").ok().map(|v| v.trim().to_string())
}
fn detect_from_redhat_release() -> Option<String> {
std::fs::read_to_string("/etc/redhat-release")
.ok()
.map(|v| v.trim().to_string())
std::fs::read_to_string("/etc/redhat-release").ok().map(|v| v.trim().to_string())
}
fn detect_from_fedora_release() -> Option<String> {
std::fs::read_to_string("/etc/fedora-release")
.ok()
.map(|v| v.trim().to_string())
std::fs::read_to_string("/etc/fedora-release").ok().map(|v| v.trim().to_string())
}
fn detect_from_arch_release() -> Option<String> {
@@ -58,9 +50,7 @@ fn detect_from_arch_release() -> Option<String> {
}
fn detect_from_alpine_release() -> Option<String> {
std::fs::read_to_string("/etc/alpine-release")
.ok()
.map(|v| format!("Alpine {}", v.trim()))
std::fs::read_to_string("/etc/alpine-release").ok().map(|v| format!("Alpine {}", v.trim()))
}
fn detect_from_suse_release() -> Option<String> {
+6 -16
View File
@@ -7,7 +7,6 @@ use atuin_common::rmp::encode::{self, ByteBuf, EncodeError};
use atuin_common::time::OffsetDateTimeExt;
use atuin_common::utils::{normalize_optional_string, uuid_v7};
use atuin_domain::record::DecryptedData;
use eyre::{Result, bail};
use time::OffsetDateTime;
@@ -199,9 +198,7 @@ pub struct HistoryStats {
impl History {
pub(crate) fn author_from_hostname(hostname: &str) -> String {
hostname
.split_once(':')
.map_or_else(|| hostname.to_owned(), |(_, user)| user.to_owned())
hostname.split_once(':').map_or_else(|| hostname.to_owned(), |(_, user)| user.to_owned())
}
#[allow(clippy::too_many_arguments)]
@@ -516,14 +513,14 @@ impl History {
#[cfg(test)]
mod tests {
use atuin_common::filter::OrFilter;
use regex::RegexSet;
use rstest::*;
use time::macros::datetime;
use crate::{history::Version, settings::Settings};
use super::{AuthorPattern, History, all_user_author_filter, is_known_agent};
use atuin_common::filter::OrFilter;
use crate::history::Version;
use crate::settings::Settings;
/// Whether an author filter permits `author`, mirroring the SQL that
/// [`apply_author_filter`](crate::database::OptFilters::authors) builds.
@@ -658,11 +655,7 @@ mod tests {
})]
fn serialize_deserialize_roundtrip(#[case] history: History) {
let serialized = history.serialize().expect("failed to serialize history");
assert_eq!(
&serialized.0[0..3],
[205, 0, 2],
"should encode as history v2"
);
assert_eq!(&serialized.0[0..3], [205, 0, 2], "should encode as history v2");
let deserialized = History::deserialize(&serialized.0, Version::LATEST.name())
.expect("failed to deserialize history");
@@ -741,10 +734,7 @@ mod tests {
if decode_as == source {
assert_eq!(got.unwrap(), expected, "{decode_as}");
} else {
assert!(
got.is_err(),
"unexpected success deserializing as {decode_as}"
);
assert!(got.is_err(), "unexpected success deserializing as {decode_as}");
}
}
}
+28 -49
View File
@@ -1,20 +1,19 @@
use std::{collections::HashSet, fmt::Write, time::Duration};
use std::collections::HashSet;
use std::fmt::Write;
use std::time::Duration;
use atuin_common::encryption::paseto_v4;
use atuin_common::rmp::decode::Bytes;
use atuin_domain::record::{
DecryptedData, Host, HostId, Record, RecordId, RecordIdx, RecordTag, RecordVersion,
};
use eyre::{Result, bail, eyre};
use futures::{Stream, StreamExt, TryStreamExt, future, stream};
use indicatif::{ProgressBar, ProgressState, ProgressStyle};
use crate::{
database::{Database, current_context},
record::sqlite_store::SqliteStore,
};
use atuin_common::encryption::paseto_v4;
use atuin_domain::record::{
DecryptedData, Host, HostId, Record, RecordId, RecordIdx, RecordTag, RecordVersion,
};
use super::{History, HistoryId, Version};
use crate::database::{Database, current_context};
use crate::record::sqlite_store::SqliteStore;
#[derive(Debug, Clone)]
pub struct HistoryStore {
@@ -128,11 +127,8 @@ impl HistoryStore {
async fn push_record(&self, record: HistoryRecord) -> Result<(RecordId, RecordIdx)> {
let bytes = record.serialize()?;
let idx = self
.store
.last(self.host_id, &RecordTag::History)
.await?
.map_or(0, |p| p.idx + 1);
let idx =
self.store.last(self.host_id, &RecordTag::History).await?.map_or(0, |p| p.idx + 1);
let record = Record::builder()
.host(Host::new(self.host_id))
@@ -144,9 +140,7 @@ impl HistoryStore {
let id = record.id;
self.store
.push(&record.encrypt(&self.encryption_key))
.await?;
self.store.push(&record.encrypt(&self.encryption_key)).await?;
Ok((id, idx))
}
@@ -154,11 +148,8 @@ impl HistoryStore {
async fn push_batch(&self, records: impl Iterator<Item = HistoryRecord>) -> Result<()> {
let mut ret = Vec::new();
let idx = self
.store
.last(self.host_id, &RecordTag::History)
.await?
.map_or(0, |p| p.idx + 1);
let idx =
self.store.last(self.host_id, &RecordTag::History).await?.map_or(0, |p| p.idx + 1);
// Could probably _also_ do this as an iterator, but let's see how this is for now.
// optimizing for minimal sqlite transactions, this code can be optimised later
@@ -243,7 +234,9 @@ impl HistoryStore {
if skipped > 0 {
// library code that may run under the TUI or shell hooks, so no stderr here
warn!(
"skipped {skipped} history records that could not be decrypted or decoded. Run `atuin store verify` to check your store, and `atuin store purge` to remove broken records locally."
"skipped {skipped} history records that could not be decrypted or decoded. Run \
`atuin store verify` to check your store, and `atuin store purge` to remove \
broken records locally."
);
}
@@ -336,9 +329,7 @@ impl HistoryStore {
/// Use this when you want the database writes but not the values. Callers that need
/// the created entries should use [`HistoryStore::incremental_build`] directly.
pub async fn build_all(&self, database: &dyn Database, ids: &[RecordId]) -> Result<()> {
self.incremental_build(database, ids)
.try_for_each(|_| future::ready(Ok(())))
.await
self.incremental_build(database, ids).try_for_each(|_| future::ready(Ok(()))).await
}
/// Get a list of history IDs that exist in the store
@@ -412,14 +403,12 @@ mod tests {
use rstest::*;
use time::macros::datetime;
use crate::{
database::Sqlite,
history::{Version, store::HistoryRecord, store::HistoryStore},
record::sqlite_store::SqliteStore,
settings::test_local_timeout,
};
use super::History;
use crate::database::Sqlite;
use crate::history::Version;
use crate::history::store::{HistoryRecord, HistoryStore};
use crate::record::sqlite_store::SqliteStore;
use crate::settings::test_local_timeout;
/// The identical `History` literal used by both async tests.
#[fixture]
@@ -446,9 +435,7 @@ mod tests {
/// and the `HistoryStore` layered on it must originate from a single fixture.
#[fixture]
async fn stores() -> (SqliteStore, HostId, HistoryStore) {
let store = SqliteStore::new(":memory:", test_local_timeout())
.await
.unwrap();
let store = SqliteStore::new(":memory:", test_local_timeout()).await.unwrap();
let host_id = HostId(atuin_common::utils::uuid_v7());
let history_store = HistoryStore::new(store.clone(), host_id, [0u8; 32].into());
(store, host_id, history_store)
@@ -531,10 +518,7 @@ mod tests {
.data(DecryptedData(vec![1, 2, 3]))
.build();
store
.push(&corrupt.encrypt(&[1u8; 32].into()))
.await
.unwrap();
store.push(&corrupt.encrypt(&[1u8; 32].into())).await.unwrap();
let records = history_store.history().await.unwrap();
@@ -555,15 +539,10 @@ mod tests {
// `history.id` (the HistoryId). This distinction is the whole bug.
let (record_id, _) = history_store.push(history.clone()).await.unwrap();
let db = Sqlite::new("sqlite::memory:", test_local_timeout())
.await
.unwrap();
let db = Sqlite::new("sqlite::memory:", test_local_timeout()).await.unwrap();
let created: Vec<History> = history_store
.incremental_build(&db, &[record_id])
.try_collect()
.await
.unwrap();
let created: Vec<History> =
history_store.incremental_build(&db, &[record_id]).try_collect().await.unwrap();
assert_eq!(created.len(), 1);
assert_eq!(created[0], history);
+6 -14
View File
@@ -10,14 +10,14 @@
use std::time::Duration;
use eyre::{Context, Result, bail};
use reqwest::{StatusCode, Url, header::USER_AGENT};
use thiserror::Error;
use atuin_common::url::UrlAppendExt;
use atuin_domain::api::{
ATUIN_CARGO_VERSION, ATUIN_HEADER_VERSION, CliCodeResponse, CliVerifyResponse, ErrorResponse,
};
use eyre::{Context, Result, bail};
use reqwest::header::USER_AGENT;
use reqwest::{StatusCode, Url};
use thiserror::Error;
use crate::settings::Settings;
@@ -185,11 +185,7 @@ pub async fn save_session(token: &str) -> Result<()> {
/// Delete the hub session token (logout from Hub)
pub async fn delete_session() -> Result<()> {
Settings::meta_store()
.await?
.delete_hub_session()
.await
.context("Failed to delete hub session")
Settings::meta_store().await?.delete_hub_session().await.context("Failed to delete hub session")
}
/// Check if the user is logged in with Hub authentication
@@ -266,11 +262,7 @@ async fn handle_resp_error(resp: reqwest::Response) -> Result<reqwest::Response,
return Ok(resp);
}
let reason = resp
.json::<ErrorResponse>()
.await
.ok()
.map(|e| e.reason.into_owned());
let reason = resp.json::<ErrorResponse>().await.ok().map(|e| e.reason.into_owned());
Err(HubError::Status { status, reason })
}
+25 -34
View File
@@ -1,4 +1,5 @@
use std::{path::PathBuf, str};
use std::path::PathBuf;
use std::str;
use async_trait::async_trait;
use directories::UserDirs;
@@ -67,9 +68,7 @@ impl Importer for Bash {
let backfill = timestamp_increment
* u32::try_from(commands_before_first_timestamp).unwrap_or(u32::MAX);
// a timestamp near the start of the representable range would underflow
let mut next_timestamp = first_timestamp
.checked_sub(backfill)
.unwrap_or(first_timestamp);
let mut next_timestamp = first_timestamp.checked_sub(backfill).unwrap_or(first_timestamp);
for line in lines.into_iter() {
match line {
@@ -78,22 +77,20 @@ impl Importer for Bash {
LineType::Timestamp(t) => {
if t < next_timestamp {
warn!(
"Time reversal detected in Bash history! Commands may be ordered incorrectly."
"Time reversal detected in Bash history! Commands may be ordered \
incorrectly."
);
}
next_timestamp = t;
}
LineType::Command(c) => {
let imported = History::import()
.shell("bash")
.timestamp(next_timestamp)
.command(c);
let imported =
History::import().shell("bash").timestamp(next_timestamp).command(c);
h.push(imported.build().into()).await?;
// a timestamp near the end of the representable range would overflow
next_timestamp = next_timestamp
.checked_add(timestamp_increment)
.unwrap_or(next_timestamp);
next_timestamp =
next_timestamp.checked_add(timestamp_increment).unwrap_or(next_timestamp);
}
}
}
@@ -141,9 +138,9 @@ mod test {
use itertools::{Itertools, assert_equal};
use rstest::rstest;
use crate::import::{Importer, tests::TestLoader};
use super::Bash;
use crate::import::Importer;
use crate::import::tests::TestLoader;
#[rstest]
#[case::no_timestamps(
@@ -174,10 +171,7 @@ mod test {
let mut loader = TestLoader::default();
bash.load(&mut loader).await.unwrap();
assert_equal(
loader.buf.iter().map(|h| h.command.as_str()),
expected_commands,
);
assert_equal(loader.buf.iter().map(|h| h.command.as_str()), expected_commands);
assert!(is_strictly_sorted(loader.buf.iter().map(|h| h.timestamp)));
}
@@ -199,14 +193,14 @@ cd ../
let mut loader = TestLoader::default();
bash.load(&mut loader).await.unwrap();
assert_equal(
loader.buf.iter().map(|h| h.command.as_str()),
["git reset", "git clean -dxf", "cd ../"],
);
assert_equal(
loader.buf.iter().map(|h| h.timestamp.unix_timestamp()),
[1672918999, 1672919006, 1672919020],
)
assert_equal(loader.buf.iter().map(|h| h.command.as_str()), [
"git reset",
"git clean -dxf",
"cd ../",
]);
assert_equal(loader.buf.iter().map(|h| h.timestamp.unix_timestamp()), [
1672918999, 1672919006, 1672919020,
])
}
fn is_strictly_sorted<T>(iter: impl IntoIterator<Item = T>) -> bool
@@ -235,10 +229,10 @@ cargo update
let mut loader = TestLoader::default();
bash.load(&mut loader).await.unwrap();
assert_equal(
loader.buf.iter().map(|h| h.command.as_str()),
["cargo install atuin", "cargo update"],
);
assert_equal(loader.buf.iter().map(|h| h.command.as_str()), [
"cargo install atuin",
"cargo update",
]);
}
#[rstest]
@@ -264,9 +258,6 @@ cargo update
// the increment saturates at the maximum representable instant rather than
// wrapping or resetting to the epoch
assert_eq!(
loader.buf.last().unwrap().timestamp.unix_timestamp(),
253_402_300_799
);
assert_eq!(loader.buf.last().unwrap().timestamp.unix_timestamp(), 253_402_300_799);
}
}
+4 -6
View File
@@ -20,10 +20,8 @@ pub struct Fish {
/// see <https://fishshell.com/docs/current/interactive.html#searchable-command-history>
fn default_histpath() -> Result<PathBuf> {
let base = BaseDirs::new().ok_or_else(|| eyre!("could not determine data directory"))?;
let data = std::env::var("XDG_DATA_HOME").map_or_else(
|_| base.home_dir().join(".local").join("share"),
PathBuf::from,
);
let data = std::env::var("XDG_DATA_HOME")
.map_or_else(|_| base.home_dir().join(".local").join("share"), PathBuf::from);
// fish supports multiple history sessions
// If `fish_history` var is missing, or set to `default`, use `fish` as the session
@@ -114,9 +112,9 @@ impl Importer for Fish {
#[cfg(test)]
mod test {
use crate::import::{Importer, tests::TestLoader};
use super::Fish;
use crate::import::Importer;
use crate::import::tests::TestLoader;
#[tokio::test]
async fn parse_out_of_range_timestamp() {
+2 -8
View File
@@ -104,20 +104,14 @@ fn is_file(p: PathBuf) -> Result<PathBuf> {
if p.is_file() {
Ok(p)
} else {
bail!(
"Could not find history file {:?}. Try setting and exporting $HISTFILE",
p
);
bail!("Could not find history file {:?}. Try setting and exporting $HISTFILE", p);
}
}
fn is_dir(p: PathBuf) -> Result<PathBuf> {
if p.is_dir() {
Ok(p)
} else {
bail!(
"Could not find history directory {:?}. Try setting and exporting $HISTFILE",
p
);
bail!("Could not find history directory {:?}. Try setting and exporting $HISTFILE", p);
}
}
+1 -4
View File
@@ -68,10 +68,7 @@ impl Importer for Nu {
let cmd: String = s.replace("<\\n>", "\n");
let entry = History::import()
.shell("nu")
.timestamp(current_timestamp)
.command(cmd);
let entry = History::import().shell("nu").timestamp(current_timestamp).command(cmd);
h.push(entry.build().into()).await?;
}
+8 -17
View File
@@ -7,7 +7,8 @@ use async_trait::async_trait;
use atuin_common::time::OffsetDateTimeExt;
use directories::BaseDirs;
use eyre::{Result, eyre};
use sqlx::{Pool, sqlite::SqlitePool};
use sqlx::Pool;
use sqlx::sqlite::SqlitePool;
use time::OffsetDateTime;
use super::Importer;
@@ -58,12 +59,9 @@ pub struct NuHistDb {
/// Read db at given file, return vector of entries.
async fn hist_from_db(dbpath: PathBuf) -> Result<Vec<HistDbEntry>> {
let connection_str = dbpath.to_str().ok_or_else(|| {
eyre!(
"Invalid path for SQLite database: {}",
dbpath.to_string_lossy()
)
})?;
let connection_str = dbpath
.to_str()
.ok_or_else(|| eyre!("Invalid path for SQLite database: {}", dbpath.to_string_lossy()))?;
let pool = SqlitePool::connect(connection_str).await?;
hist_from_db_conn(pool).await
}
@@ -76,9 +74,8 @@ async fn hist_from_db_conn(pool: Pool<sqlx::Sqlite>) -> Result<Vec<HistDbEntry>>
FROM history
ORDER BY start_timestamp
"#;
let histdb_vec: Vec<HistDbEntry> = sqlx::query_as::<_, HistDbEntry>(query)
.fetch_all(&pool)
.await?;
let histdb_vec: Vec<HistDbEntry> =
sqlx::query_as::<_, HistDbEntry>(query).fetch_all(&pool).await?;
Ok(histdb_vec)
}
@@ -146,13 +143,7 @@ mod test {
/// A corrupt row must degrade to something representable rather than
/// panicking; the command is what matters and must always survive.
#[rstest]
#[case::valid(
1_639_162_832_500,
b"echo hello",
1_639_162_832,
500_000_000,
"echo hello"
)]
#[case::valid(1_639_162_832_500, b"echo hello", 1_639_162_832, 500_000_000, "echo hello")]
#[case::out_of_range_timestamp(i64::MAX, b"echo hello", 0, 0, "echo hello")]
#[case::invalid_utf8_command(0, &[0x65, 0x63, 0x68, 0x6f, 0xff], 0, 0, "echo\u{fffd}")]
fn corrupt_rows_degrade(
+9 -16
View File
@@ -1,7 +1,8 @@
use std::path::PathBuf;
use async_trait::async_trait;
use directories::BaseDirs;
use eyre::{Result, eyre};
use std::path::PathBuf;
use time::{Duration, OffsetDateTime};
use super::{Importer, Loader, count_lines, unix_byte_lines};
@@ -27,17 +28,10 @@ fn get_history_path() -> Result<PathBuf> {
// > or `$Env:HOME/.local/share/powershell/PSReadLine`.
let dir = if cfg!(windows) {
base.data_dir()
.join("Microsoft")
.join("Windows")
.join("PowerShell")
.join("PSReadLine")
base.data_dir().join("Microsoft").join("Windows").join("PowerShell").join("PSReadLine")
} else {
std::env::var("XDG_DATA_HOME")
.map_or_else(
|_| base.home_dir().join(".local").join("share"),
PathBuf::from,
)
.map_or_else(|_| base.home_dir().join(".local").join("share"), PathBuf::from)
.join("powershell")
.join("PSReadLine")
};
@@ -113,10 +107,8 @@ impl Importer for PowerShell {
let offset = Duration::milliseconds(counter);
counter += 1;
let entry = History::import()
.shell("powershell")
.timestamp(start + offset)
.command(cmd);
let entry =
History::import().shell("powershell").timestamp(start + offset).command(cmd);
h.push(entry.build().into()).await?;
}
@@ -135,11 +127,12 @@ fn read_line(s: &[u8]) -> Result<&str> {
#[cfg(test)]
mod test {
use super::*;
use crate::import::tests::TestLoader;
use itertools::assert_equal;
use rstest::rstest;
use super::*;
use crate::import::tests::TestLoader;
const INPUT: &str = r#"cargo install atuin
cargo update
echo "first line`
+13 -17
View File
@@ -1,9 +1,11 @@
use std::{path::PathBuf, str};
use std::path::PathBuf;
use std::str;
use async_trait::async_trait;
use directories::UserDirs;
use eyre::{Result, eyre};
use time::{OffsetDateTime, PrimitiveDateTime, macros::format_description};
use time::macros::format_description;
use time::{OffsetDateTime, PrimitiveDateTime};
use super::{Importer, Loader, get_histfile_path, unix_byte_lines};
use crate::history::History;
@@ -63,10 +65,8 @@ impl Importer for Replxx {
None => {
// replxx uses ETB character (0x17) as line breaker
let cmd = s.replace('\u{0017}', "\n");
let imported = History::import()
.shell("replxx")
.timestamp(timestamp)
.command(cmd);
let imported =
History::import().shell("replxx").timestamp(timestamp).command(cmd);
h.push(imported.build().into()).await?;
}
@@ -92,9 +92,9 @@ fn try_parse_line_as_timestamp(line: &str) -> Option<OffsetDateTime> {
#[cfg(test)]
mod test {
use crate::import::{Importer, tests::TestLoader};
use super::Replxx;
use crate::import::Importer;
use crate::import::tests::TestLoader;
#[tokio::test]
async fn parse_complex() {
@@ -127,16 +127,15 @@ CREATE TABLE test( stamp DateTime('UTC'))ENGINE = MergeTreePARTITION BY toDat
};
}
history!(
1707603388,
"select * from remote('127.0.0.1:20222', view(select 1))"
);
history!(1707603388, "select * from remote('127.0.0.1:20222', view(select 1))");
history!(1707603396, "select * from numbers(10)");
history!(1707603401, "select * from system.numbers");
history!(1707603568, "select 1");
history!(
1708600533,
"CREATE TABLE test\n( stamp DateTime('UTC'))\nENGINE = MergeTree\nPARTITION BY toDate(stamp)\norder by tuple() as select toDateTime('2020-01-01')+number*60 from numbers(80000);"
"CREATE TABLE test\n( stamp DateTime('UTC'))\nENGINE = MergeTree\nPARTITION BY \
toDate(stamp)\norder by tuple() as select toDateTime('2020-01-01')+number*60 from \
numbers(80000);"
);
}
@@ -149,10 +148,7 @@ CREATE TABLE test( stamp DateTime('UTC'))ENGINE = MergeTreePARTITION BY toDat
let replxx = Replxx { bytes };
let mut loader = TestLoader::default();
replxx
.load(&mut loader)
.await
.expect("import must not fail");
replxx.load(&mut loader).await.expect("import must not fail");
let commands: Vec<&str> = loader.buf.iter().map(|h| h.command.as_str()).collect();
assert_eq!(commands, ["select 1", "select 2"]);
+7 -20
View File
@@ -1,12 +1,11 @@
use std::path::PathBuf;
use async_trait::async_trait;
use atuin_common::time::OffsetDateTimeExt;
use atuin_common::utils::uuid_v7;
use directories::UserDirs;
use eyre::{Result, eyre};
use serde::Deserialize;
use atuin_common::time::OffsetDateTimeExt;
use atuin_common::utils::uuid_v7;
use time::OffsetDateTime;
use super::{Importer, Loader, get_histfile_path, unix_byte_lines};
@@ -157,9 +156,10 @@ impl Importer for Resh {
#[cfg(test)]
mod test {
use rstest::rstest;
use super::*;
use crate::import::tests::TestLoader;
use rstest::rstest;
/// resh writes one JSON object per line. Every field on `ReshEntry` is
/// required, so spell them all out once here.
@@ -195,14 +195,8 @@ mod test {
m.insert("timezoneAfter".into(), serde_json::json!("+0000"));
m.insert("realtimeBefore".into(), serde_json::json!(realtime_before));
m.insert("realtimeAfter".into(), serde_json::json!(realtime_after));
m.insert(
"realtimeBeforeLocal".into(),
serde_json::json!(realtime_before),
);
m.insert(
"realtimeAfterLocal".into(),
serde_json::json!(realtime_after),
);
m.insert("realtimeBeforeLocal".into(), serde_json::json!(realtime_before));
m.insert("realtimeAfterLocal".into(), serde_json::json!(realtime_after));
m.insert("realtimeDuration".into(), serde_json::json!(0.0));
m.insert("realtimeSinceSessionStart".into(), serde_json::json!(0.0));
m.insert("realtimeSinceBoot".into(), serde_json::json!(0.0));
@@ -297,14 +291,7 @@ mod test {
let mut loader = TestLoader::default();
resh.load(&mut loader).await.expect("import must not fail");
assert_eq!(
loader
.buf
.iter()
.map(|h| h.command.as_str())
.collect::<Vec<_>>(),
[cmd]
);
assert_eq!(loader.buf.iter().map(|h| h.command.as_str()).collect::<Vec<_>>(), [cmd]);
assert_eq!(loader.buf[0].timestamp.unix_timestamp(), expected_unix_ts);
assert_eq!(loader.buf[0].duration, expected_duration);
}
+3 -6
View File
@@ -9,7 +9,8 @@ use eyre::{Result, eyre};
use serde::Deserialize;
use time::OffsetDateTime;
use uuid::Uuid;
use uuid::timestamp::{Timestamp, context::NoContext};
use uuid::timestamp::Timestamp;
use uuid::timestamp::context::NoContext;
use super::{Importer, Loader, get_histdir_path};
use crate::history::History;
@@ -147,17 +148,13 @@ mod tests {
use time::macros::datetime;
use super::*;
use crate::history::History;
use crate::import::tests::TestLoader;
#[test]
fn test_hist_dir_xonsh() {
let hist_dir = xonsh_hist_dir(Some("/home/user/xonsh_data".to_string())).unwrap();
assert_eq!(
hist_dir,
PathBuf::from("/home/user/xonsh_data/history_json")
);
assert_eq!(hist_dir, PathBuf::from("/home/user/xonsh_data/history_json"));
}
#[tokio::test]
+7 -15
View File
@@ -6,10 +6,12 @@ use atuin_common::time::OffsetDateTimeExt;
use directories::BaseDirs;
use eyre::{Result, eyre};
use futures::TryStreamExt;
use sqlx::{FromRow, Row, sqlite::SqlitePool};
use sqlx::sqlite::SqlitePool;
use sqlx::{FromRow, Row};
use time::OffsetDateTime;
use uuid::Uuid;
use uuid::timestamp::{Timestamp, context::NoContext};
use uuid::timestamp::Timestamp;
use uuid::timestamp::context::NoContext;
use super::{Importer, Loader, get_histfile_path};
use crate::history::History;
@@ -67,10 +69,7 @@ fn xonsh_db_path(xonsh_data_dir: Option<String>) -> Result<PathBuf> {
if hist_file.exists() || cfg!(test) {
Ok(hist_file)
} else {
Err(eyre!(
"Could not find xonsh history db at: {}",
hist_file.to_string_lossy()
))
Err(eyre!("Could not find xonsh history db at: {}", hist_file.to_string_lossy()))
}
}
@@ -89,10 +88,7 @@ impl Importer for XonshSqlite {
let xonsh_data_dir = env::var("XONSH_DATA_DIR").ok();
let db_path = get_histfile_path(|| xonsh_db_path(xonsh_data_dir))?;
let connection_str = db_path.to_str().ok_or_else(|| {
eyre!(
"Invalid path for SQLite database: {}",
db_path.to_string_lossy()
)
eyre!("Invalid path for SQLite database: {}", db_path.to_string_lossy())
})?;
let pool = SqlitePool::connect(connection_str).await?;
@@ -134,17 +130,13 @@ mod tests {
use time::macros::datetime;
use super::*;
use crate::history::History;
use crate::import::tests::TestLoader;
#[test]
fn test_db_path_xonsh() {
let db_path = xonsh_db_path(Some("/home/user/xonsh_data".to_string())).unwrap();
assert_eq!(
db_path,
PathBuf::from("/home/user/xonsh_data/xonsh-history.sqlite")
);
assert_eq!(db_path, PathBuf::from("/home/user/xonsh_data/xonsh-history.sqlite"));
}
#[test]
+18 -39
View File
@@ -76,16 +76,12 @@ impl Entry {
};
};
let time = time
.parse::<i64>()
.ok()
.and_then(|t| OffsetDateTime::from_unix_timestamp(t).ok());
let time =
time.parse::<i64>().ok().and_then(|t| OffsetDateTime::from_unix_timestamp(t).ok());
// use nanos, because why the hell not? we won't display them.
// saturate rather than overflow on an implausible duration
let duration = duration
.parse::<i64>()
.map_or(-1, |t| t.saturating_mul(1_000_000_000));
let duration = duration.parse::<i64>().map_or(-1, |t| t.saturating_mul(1_000_000_000));
Self {
command: command.trim_end().to_owned(),
@@ -139,18 +135,14 @@ impl Importer for Zsh {
let backfill =
u32::try_from(commands_until_timestamp).unwrap_or(u32::MAX) * timestamp_increment;
// a timestamp near the start of the representable range would underflow
let mut timestamp = first_timestamp
.checked_sub(backfill)
.unwrap_or(first_timestamp);
let mut timestamp = first_timestamp.checked_sub(backfill).unwrap_or(first_timestamp);
for entry in entries {
if let Some(time) = entry.timestamp {
timestamp = time;
} else {
// a timestamp near the end of the representable range would overflow
timestamp = timestamp
.checked_add(timestamp_increment)
.unwrap_or(timestamp);
timestamp = timestamp.checked_add(timestamp_increment).unwrap_or(timestamp);
}
let imported = History::import()
@@ -190,9 +182,8 @@ mod test {
use itertools::assert_equal;
use rstest::rstest;
use crate::import::tests::TestLoader;
use super::*;
use crate::import::tests::TestLoader;
#[rstest]
#[case::zero_duration(
@@ -254,10 +245,7 @@ mod test {
let parsed = Entry::parse(line);
assert_eq!(parsed.command, command);
assert_eq!(
parsed.timestamp.map(OffsetDateTime::unix_timestamp),
timestamp
);
assert_eq!(parsed.timestamp.map(OffsetDateTime::unix_timestamp), timestamp);
assert_eq!(parsed.duration, duration);
}
@@ -277,14 +265,11 @@ cargo update
let mut loader = TestLoader::default();
zsh.load(&mut loader).await.unwrap();
assert_equal(
loader.buf.iter().map(|h| h.command.as_str()),
[
"cargo install atuin",
"cargo install atuin; \\\ncargo update",
"cargo :b̷i̶t̴r̵o̴t̴ ̵i̷s̴ ̷r̶e̵a̸l̷",
],
);
assert_equal(loader.buf.iter().map(|h| h.command.as_str()), [
"cargo install atuin",
"cargo install atuin; \\\ncargo update",
"cargo :b̷i̶t̴r̵o̴t̴ ̵i̷s̴ ̷r̶e̵a̸l̷",
]);
}
#[tokio::test]
@@ -299,10 +284,10 @@ cargo update
let mut loader = TestLoader::default();
zsh.load(&mut loader).await.unwrap();
assert_equal(
loader.buf.iter().map(|h| h.command.as_str()),
["cargo install atuin", "cargo update"],
);
assert_equal(loader.buf.iter().map(|h| h.command.as_str()), [
"cargo install atuin",
"cargo update",
]);
}
#[tokio::test]
@@ -333,10 +318,7 @@ cargo update
assert_eq!(loader.buf[0].timestamp.unix_timestamp(), 253_402_300_799);
// the increment saturates at the maximum representable instant rather than
// wrapping or resetting to the epoch
assert_eq!(
loader.buf.last().unwrap().timestamp.unix_timestamp(),
253_402_300_799
);
assert_eq!(loader.buf.last().unwrap().timestamp.unix_timestamp(), 253_402_300_799);
}
#[tokio::test]
@@ -350,9 +332,6 @@ cargo update
let mut loader = TestLoader::default();
zsh.load(&mut loader).await.unwrap();
assert_equal(
loader.buf.iter().map(|h| h.command.as_str()),
["echo 你好", "ls ~/音乐"],
);
assert_equal(loader.buf.iter().map(|h| h.command.as_str()), ["echo 你好", "ls ~/音乐"]);
}
}
+17 -25
View File
@@ -39,7 +39,8 @@ use async_trait::async_trait;
use atuin_common::utils::uuid_v7;
use directories::UserDirs;
use eyre::{Result, eyre};
use sqlx::{Pool, sqlite::SqlitePool};
use sqlx::Pool;
use sqlx::sqlite::SqlitePool;
use time::PrimitiveDateTime;
use super::Importer;
@@ -72,12 +73,9 @@ pub struct ZshHistDb {
/// Read db at given file, return vector of entries.
async fn hist_from_db(dbpath: PathBuf) -> Result<Vec<HistDbEntry>> {
let connection_str = dbpath.to_str().ok_or_else(|| {
eyre!(
"Invalid path for SQLite database: {}",
dbpath.to_string_lossy()
)
})?;
let connection_str = dbpath
.to_str()
.ok_or_else(|| eyre!("Invalid path for SQLite database: {}", dbpath.to_string_lossy()))?;
let pool = SqlitePool::connect(connection_str).await?;
hist_from_db_conn(pool).await
}
@@ -92,9 +90,8 @@ async fn hist_from_db_conn(pool: Pool<sqlx::Sqlite>) -> Result<Vec<HistDbEntry>>
LEFT JOIN places ON history.place_id = places.id
ORDER BY history.start_time
"#;
let histdb_vec: Vec<HistDbEntry> = sqlx::query_as::<_, HistDbEntry>(query)
.fetch_all(&pool)
.await?;
let histdb_vec: Vec<HistDbEntry> =
sqlx::query_as::<_, HistDbEntry>(query).fetch_all(&pool).await?;
Ok(histdb_vec)
}
@@ -118,9 +115,7 @@ impl ZshHistDb {
if histdb_path.exists() {
Ok(histdb_path)
} else {
Err(eyre!(
"Could not find history file. Try setting $HISTDB_FILE"
))
Err(eyre!("Could not find history file. Try setting $HISTDB_FILE"))
}
}
}
@@ -182,9 +177,11 @@ impl Importer for ZshHistDb {
#[cfg(test)]
mod test {
use super::*;
use sqlx::sqlite::SqlitePoolOptions;
use std::env;
use sqlx::sqlite::SqlitePoolOptions;
use super::*;
#[tokio::test(flavor = "multi_thread")]
#[allow(unsafe_code)]
async fn test_env_vars() {
@@ -203,9 +200,10 @@ mod test {
#[tokio::test]
async fn duration_saturates_instead_of_overflowing() {
use crate::import::tests::TestLoader;
use time::macros::datetime;
use crate::import::tests::TestLoader;
// a corrupt duration column must not overflow when scaled to nanoseconds
let histdb = ZshHistDb {
histdb: vec![HistDbEntry {
@@ -222,10 +220,7 @@ mod test {
};
let mut loader = TestLoader::default();
histdb
.load(&mut loader)
.await
.expect("import must not fail");
histdb.load(&mut loader).await.expect("import must not fail");
assert_eq!(loader.buf.len(), 1);
assert_eq!(loader.buf[0].command, "echo hello");
@@ -234,11 +229,8 @@ mod test {
#[tokio::test(flavor = "multi_thread")]
async fn test_import() {
let pool: SqlitePool = SqlitePoolOptions::new()
.min_connections(2)
.connect(":memory:")
.await
.unwrap();
let pool: SqlitePool =
SqlitePoolOptions::new().min_connections(2).connect(":memory:").await.unwrap();
// sql dump directly from a test database.
let db_sql = r#"
+4 -5
View File
@@ -2,7 +2,9 @@ use atuin_common::encryption::paseto_v4;
use atuin_domain::api::LoginRequest;
use eyre::Result;
use crate::{api_client, record::sqlite_store::SqliteStore, settings::Settings};
use crate::api_client;
use crate::record::sqlite_store::SqliteStore;
use crate::settings::Settings;
pub async fn login(
settings: &Settings,
@@ -43,10 +45,7 @@ pub async fn login(
)
.await?;
Settings::meta_store()
.await?
.save_session(&session.session)
.await?;
Settings::meta_store().await?.save_session(&session.session).await?;
Ok(session.session)
}
+4 -2
View File
@@ -1,7 +1,9 @@
use crate::settings;
use atuin_common::logs::{FileConfig, LogConfig};
use std::path::PathBuf;
use atuin_common::logs::{FileConfig, LogConfig};
use crate::settings;
pub trait FromSettings: Sized {
type Output;
fn from_settings(settings: &settings::Logs, child: &settings::LogConfig) -> Self::Output;
+10 -26
View File
@@ -5,7 +5,8 @@ use std::time::Duration;
use atuin_domain::record::HostId;
use eyre::{Result, eyre};
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePool, SqlitePoolOptions};
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
use time::OffsetDateTime;
use time::format_description::well_known::Rfc3339;
use tokio::sync::OnceCell;
use uuid::Uuid;
@@ -107,10 +108,7 @@ impl MetaStore {
}
pub async fn delete(&self, key: &str) -> Result<()> {
sqlx::query("DELETE FROM meta WHERE key = ?1")
.bind(key)
.execute(&self.pool)
.await?;
sqlx::query("DELETE FROM meta WHERE key = ?1").bind(key).execute(&self.pool).await?;
Ok(())
}
@@ -127,8 +125,7 @@ impl MetaStore {
}
let uuid = atuin_common::utils::uuid_v7();
self.set(KEY_HOST_ID, uuid.as_simple().to_string().as_ref())
.await?;
self.set(KEY_HOST_ID, uuid.as_simple().to_string().as_ref()).await?;
Ok(HostId(uuid))
})
@@ -144,11 +141,7 @@ impl MetaStore {
}
pub async fn save_sync_time(&self) -> Result<()> {
self.set(
KEY_LAST_SYNC,
OffsetDateTime::now_utc().format(&Rfc3339)?.as_str(),
)
.await
self.set(KEY_LAST_SYNC, OffsetDateTime::now_utc().format(&Rfc3339)?.as_str()).await
}
pub async fn last_version_check(&self) -> Result<OffsetDateTime> {
@@ -159,11 +152,7 @@ impl MetaStore {
}
pub async fn save_version_check_time(&self) -> Result<()> {
self.set(
KEY_LAST_VERSION_CHECK,
OffsetDateTime::now_utc().format(&Rfc3339)?.as_str(),
)
.await
self.set(KEY_LAST_VERSION_CHECK, OffsetDateTime::now_utc().format(&Rfc3339)?.as_str()).await
}
pub async fn latest_version(&self) -> Result<Option<String>> {
@@ -295,9 +284,10 @@ impl MetaStore {
#[cfg(test)]
mod tests {
use super::*;
use rstest::*;
use super::*;
#[fixture]
async fn store() -> MetaStore {
MetaStore::new("sqlite::memory:", 2.0).await.unwrap()
@@ -357,10 +347,7 @@ mod tests {
store.save_session("tok123").await.unwrap();
assert!(store.logged_in().await.unwrap());
assert_eq!(
store.session_token().await.unwrap(),
Some("tok123".to_string())
);
assert_eq!(store.session_token().await.unwrap(), Some("tok123".to_string()));
store.delete_session().await.unwrap();
assert!(!store.logged_in().await.unwrap());
@@ -372,9 +359,6 @@ mod tests {
assert_eq!(store.latest_version().await.unwrap(), None);
store.save_latest_version("1.2.3").await.unwrap();
assert_eq!(
store.latest_version().await.unwrap(),
Some("1.2.3".to_string())
);
assert_eq!(store.latest_version().await.unwrap(), Some("1.2.3".to_string()));
}
}
+5 -3
View File
@@ -1,6 +1,7 @@
use minspan::minspan;
use super::{database::DbSearchMode, history::History};
use super::database::DbSearchMode;
use super::history::History;
pub fn reorder_fuzzy(mode: DbSearchMode, query: &str, res: Vec<History>) -> Vec<History> {
match mode {
@@ -26,11 +27,12 @@ where
#[cfg(test)]
mod tests {
use super::*;
use crate::history::History;
use rstest::rstest;
use time::OffsetDateTime;
use super::*;
use crate::history::History;
fn hist(command: &str) -> History {
History::capture()
.timestamp(OffsetDateTime::now_utc())
+1 -2
View File
@@ -110,7 +110,6 @@ mod record;
mod sync;
pub use packer::{PackingError, try_pack};
pub use sync::{DownloadError, UploadError, download_packed, upload_packed};
#[cfg(test)]
pub(crate) use record::PackManifestRecordView;
pub use sync::{DownloadError, UploadError, download_packed, upload_packed};
+10 -23
View File
@@ -1,12 +1,13 @@
//! Turns accumulated history into plaintext `packfile` manifest records.
use super::record::{PackManifestData, PackManifestDataV1, ParsingError};
use crate::record::sqlite_store::SqliteStore;
use atuin_domain::caps::PackfileCap;
use atuin_domain::record::{Host, HostId, Record, RecordTag, RecordVersion};
use thiserror::Error;
use tracing::{instrument, trace};
use super::record::{PackManifestData, PackManifestDataV1, ParsingError};
use crate::record::sqlite_store::SqliteStore;
#[derive(Debug, Error)]
pub enum PackingError {
#[error("error accessing the record store: {0}")]
@@ -35,10 +36,7 @@ pub async fn try_pack(
return Ok(());
};
let last_pack = store
.last(host, &RecordTag::Packfile)
.await
.map_err(PackingError::Store)?;
let last_pack = store.last(host, &RecordTag::Packfile).await.map_err(PackingError::Store)?;
// `start` is the first unpacked source idx; `pack_idx` is the next idx in the *packfile*
// stream (a separate sequence). Both come from the same latest manifest record.
@@ -48,11 +46,8 @@ pub async fn try_pack(
};
let mut pack_idx = last_pack.map_or(0, |record| record.idx + 1);
let Some(ceiling) = store
.last(host, tag)
.await
.map_err(PackingError::Store)?
.map(|record| record.idx)
let Some(ceiling) =
store.last(host, tag).await.map_err(PackingError::Store)?.map(|record| record.idx)
else {
trace!("no history yet; nothing to pack");
return Ok(());
@@ -66,10 +61,7 @@ pub async fn try_pack(
let mut cursor = start;
while cursor <= ceiling && ceiling - cursor + 1 >= count {
// The loop guard guarantees at least `count` records remain, so each run is exactly `count`.
let run = store
.next(host, tag, cursor, count)
.await
.map_err(PackingError::Store)?;
let run = store.next(host, tag, cursor, count).await.map_err(PackingError::Store)?;
let Some(end) = run.last().map(|record| record.idx) else {
break;
@@ -101,19 +93,17 @@ pub async fn try_pack(
#[cfg(test)]
mod tests {
use super::*;
use atuin_common::utils::uuid_v7;
use atuin_domain::record::EncryptedData;
use proptest::prelude::*;
use rstest::{fixture, rstest};
use super::*;
use crate::settings::test_local_timeout;
#[fixture]
async fn store() -> SqliteStore {
SqliteStore::new(":memory:", test_local_timeout())
.await
.unwrap()
SqliteStore::new(":memory:", test_local_timeout()).await.unwrap()
}
#[fixture]
@@ -181,10 +171,7 @@ mod tests {
}
fn runtime() -> tokio::runtime::Runtime {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap()
}
proptest! {
+2 -1
View File
@@ -387,12 +387,13 @@ impl<'a> PackManifestRecordView<'a> {
#[cfg(test)]
mod tests {
use super::*;
use atuin_common::utils::uuid_v7;
use atuin_domain::record::Host;
use rstest::{fixture, rstest};
use uuid::Uuid;
use super::*;
#[fixture]
fn key() -> paseto_v4::Key {
paseto_v4::Key::from([7u8; 32])
+16 -48
View File
@@ -18,11 +18,10 @@ use atuin_common::encryption::paseto_v4;
use atuin_domain::record::{EncryptedData, Record, RecordId, RecordTag};
use thiserror::Error;
use crate::{
api_client::Client, packfile::record::ParsingError, record::sqlite_store::SqliteStore,
};
use super::record::{PackManifestRecordView, PackingError, UnpackError};
use crate::api_client::Client;
use crate::packfile::record::ParsingError;
use crate::record::sqlite_store::SqliteStore;
#[derive(Debug, Error)]
pub enum UploadError {
@@ -50,10 +49,7 @@ pub async fn upload_packed(
let (blob, ids) = view.pack_records(store, key.clone()).await?;
client
.upload_packfile(view.record.id, &ids, blob)
.await
.map_err(UploadError::Api)
client.upload_packfile(view.record.id, &ids, blob).await.map_err(UploadError::Api)
}
#[derive(Debug, Error)]
@@ -95,10 +91,8 @@ pub async fn download_packed(
let view = PackManifestRecordView::new(manifest)?;
// Skip if we already have the whole range (history is contiguous, packfiles are prefixes).
let head = store
.last(view.record.host.id, &RecordTag::History)
.await
.map_err(DownloadError::Store)?;
let head =
store.last(view.record.host.id, &RecordTag::History).await.map_err(DownloadError::Store)?;
if let Some(head) = head
&& head.idx >= view.range().end - 1
{
@@ -110,18 +104,12 @@ pub async fn download_packed(
return Ok(existing.iter().map(|r| r.id).collect());
}
let blob = client
.download_packfile(view.record.id)
.await
.map_err(DownloadError::Api)?;
let blob = client.download_packfile(view.record.id).await.map_err(DownloadError::Api)?;
let records = view.unpack_records(blob, key.clone()).await?;
let ids: Vec<RecordId> = records.iter().map(|record| record.id).collect();
store
.push_batch(records.iter())
.await
.map_err(DownloadError::Store)?;
store.push_batch(records.iter()).await.map_err(DownloadError::Store)?;
Ok(ids)
}
@@ -132,14 +120,13 @@ mod tests {
use atuin_common::encryption::paseto_v4;
use atuin_common::utils::uuid_v7;
use atuin_domain::caps::PackfileCap;
use atuin_domain::record::{DecryptedData, Host, HostId, Record, RecordVersion};
use rstest::*;
use wiremock::matchers::{method, path, path_regex};
use wiremock::{Mock, MockServer, ResponseTemplate};
use super::*;
use atuin_domain::caps::PackfileCap;
use crate::api_client::{AuthToken, Client, caps_client};
use crate::packfile::try_pack;
use crate::record::sqlite_store::SqliteStore;
@@ -155,22 +142,13 @@ mod tests {
/// A [`Client`] pointed at a wiremock server, authenticated with a dummy token.
fn mock_client(addr: &url::Url) -> Client {
let caps = caps_client(addr, &HashMap::new()).unwrap();
Client::new(
addr.clone(),
AuthToken::Token("t".into()),
30,
30,
&HashMap::new(),
caps,
)
.unwrap()
Client::new(addr.clone(), AuthToken::Token("t".into()), 30, 30, &HashMap::new(), caps)
.unwrap()
}
/// A fresh in-memory record store.
async fn memory_store() -> SqliteStore {
SqliteStore::new(":memory:", test_local_timeout())
.await
.unwrap()
SqliteStore::new(":memory:", test_local_timeout()).await.unwrap()
}
/// Push a contiguous run of `count` encrypted HISTORY records (idx `0..count`).
@@ -284,9 +262,7 @@ mod tests {
// `.expect(1)` on all three mocks verifies create_packfile + put_packfile +
// confirm_packfile each fired exactly once.
upload_packed(&manifest, &store, &key, &client)
.await
.unwrap();
upload_packed(&manifest, &store, &key, &client).await.unwrap();
}
#[rstest]
@@ -335,9 +311,7 @@ mod tests {
let addr: url::Url = server.uri().parse().unwrap();
let client = mock_client(&addr);
let ids = download_packed(&manifest, &down, &key, &client)
.await
.unwrap();
let ids = download_packed(&manifest, &down, &key, &client).await.unwrap();
assert_eq!(ids.len(), 5, "all five history records populated");
// History is present locally and decrypts to the same commands.
@@ -391,9 +365,7 @@ mod tests {
)
.unwrap();
let ids = download_packed(&manifest, &down, &key, &client)
.await
.unwrap();
let ids = download_packed(&manifest, &down, &key, &client).await.unwrap();
// Range already present -> no fetch, but the covered ids are still returned so the
// id-driven history.db rebuild can re-index them (see download_packed's doc comment).
@@ -496,11 +468,7 @@ mod tests {
assert_eq!(ids.len(), 3, "the valid manifest's three records expand");
let got = down.next(host, &RecordTag::History, 0, 3).await.unwrap();
assert_eq!(
got.len(),
3,
"the valid packfile's history is present locally"
);
assert_eq!(got.len(), 3, "the valid packfile's history is present locally");
}
/// GUARD: a TRANSIENT/systemic fault (here a connection-refused packfile GET) must still
+4 -6
View File
@@ -40,9 +40,9 @@ impl OfficialPluginRegistry {
OfficialPlugin::new(
"update",
"Update atuin to the latest version",
"The 'atuin update' command is provided by the atuin-update plugin.\n\
It is only installed if you used the install script\n \
If you used a package manager (brew, apt, etc), please continue to use it for updates",
"The 'atuin update' command is provided by the atuin-update plugin.\nIt is only \
installed if you used the install script\n If you used a package manager (brew, \
apt, etc), please continue to use it for updates",
),
);
}
@@ -56,9 +56,7 @@ impl OfficialPluginRegistry {
}
pub fn get_install_message(&self, name: &str) -> Option<&str> {
self.plugins
.get(name)
.map(|plugin| plugin.install_message.as_str())
self.plugins.get(name).map(|plugin| plugin.install_message.as_str())
}
}
+29 -71
View File
@@ -2,25 +2,22 @@
// Multiple stores of multiple types are all stored in one chonky table (for now), and we just index
// by tag/host
use std::path::Path;
use std::str::FromStr;
use std::{path::Path, time::Duration};
use eyre::{Result, eyre};
use fs_err as fs;
use sqlx::{
Row,
sqlite::{
SqliteConnectOptions, SqliteJournalMode, SqlitePool, SqlitePoolOptions, SqliteRow,
SqliteSynchronous,
},
};
use std::time::Duration;
use atuin_common::encryption::paseto_v4;
use atuin_common::utils;
use atuin_domain::record::{
Host, HostId, Record, RecordId, RecordIdx, RecordStatus, RecordTag, RecordVersion,
};
use eyre::{Result, eyre};
use fs_err as fs;
use sqlx::Row;
use sqlx::sqlite::{
SqliteConnectOptions, SqliteJournalMode, SqlitePool, SqlitePoolOptions, SqliteRow,
SqliteSynchronous,
};
use uuid::Uuid;
#[derive(Debug, Clone)]
@@ -36,7 +33,8 @@ impl SqliteStore {
if utils::broken_symlink(path) {
eprintln!(
"Atuin: Sqlite db path ({path:?}) is a broken symlink. Unable to read or create replacement."
"Atuin: Sqlite db path ({path:?}) is a broken symlink. Unable to read or create \
replacement."
);
std::process::exit(1);
}
@@ -118,10 +116,8 @@ impl SqliteStore {
}
async fn load_all(&self) -> Result<Vec<Record<paseto_v4::EncryptedData>>> {
let res = sqlx::query("select * from store ")
.map(Self::query_row)
.fetch_all(&self.pool)
.await?;
let res =
sqlx::query("select * from store ").map(Self::query_row).fetch_all(&self.pool).await?;
Ok(res)
}
@@ -199,9 +195,8 @@ impl SqliteStore {
}
pub async fn len_all(&self) -> Result<u64> {
let res: Result<(i64,), sqlx::Error> = sqlx::query_as("select count(*) from store")
.fetch_one(&self.pool)
.await;
let res: Result<(i64,), sqlx::Error> =
sqlx::query_as("select count(*) from store").fetch_one(&self.pool).await;
match res {
Err(e) => Err(eyre!("failed to fetch local store len: {}", e)),
Ok(v) => Ok(v.0 as u64),
@@ -257,7 +252,8 @@ impl SqliteStore {
limit: u64,
) -> Result<Vec<Record<paseto_v4::EncryptedData>>> {
let res = sqlx::query(
"select * from store where idx >= ?1 and host = ?2 and tag = ?3 order by idx asc limit ?4",
"select * from store where idx >= ?1 and host = ?2 and tag = ?3 order by idx asc \
limit ?4",
)
.bind(idx as i64)
.bind(host.0.as_hyphenated().to_string())
@@ -379,9 +375,7 @@ impl SqliteStore {
pub async fn verify(&self, key: &paseto_v4::Key) -> Result<()> {
let all = self.load_all().await?;
all.into_iter()
.map(|record| record.decrypt(key))
.collect::<Result<Vec<_>>>()?;
all.into_iter().map(|record| record.decrypt(key)).collect::<Result<Vec<_>>>()?;
Ok(())
}
@@ -395,10 +389,7 @@ impl SqliteStore {
match record.clone().decrypt(key) {
Ok(_) => continue,
Err(_) => {
println!(
"Failed to decrypt {}, deleting",
record.id.0.as_hyphenated()
);
println!("Failed to decrypt {}, deleting", record.id.0.as_hyphenated());
self.delete(record.id).await?;
}
@@ -416,15 +407,12 @@ mod tests {
use atuin_domain::record::{DecryptedData, Host, HostId, Record, RecordTag, RecordVersion};
use rstest::{fixture, rstest};
use crate::settings::test_local_timeout;
use super::SqliteStore;
use crate::settings::test_local_timeout;
#[fixture]
async fn store() -> SqliteStore {
SqliteStore::new(":memory:", test_local_timeout())
.await
.unwrap()
SqliteStore::new(":memory:", test_local_timeout()).await.unwrap()
}
#[fixture]
@@ -474,11 +462,7 @@ mod tests {
async fn last(#[future(awt)] store: SqliteStore, record: Record<paseto_v4::EncryptedData>) {
store.push(&record).await.unwrap();
let last = store.last(record.host.id, &record.tag).await.unwrap();
assert_eq!(
last.unwrap().id,
record.id,
"did not get the inserted record"
);
assert_eq!(last.unwrap().id, record.id, "did not get the inserted record");
}
#[rstest]
@@ -486,11 +470,7 @@ mod tests {
async fn first(#[future(awt)] store: SqliteStore, record: Record<paseto_v4::EncryptedData>) {
store.push(&record).await.unwrap();
let first = store.first(record.host.id, &record.tag).await.unwrap();
assert_eq!(
first.unwrap().id,
record.id,
"did not get the inserted record"
);
assert_eq!(first.unwrap().id, record.id, "did not get the inserted record");
}
#[rstest]
@@ -574,11 +554,7 @@ mod tests {
100,
"failed to insert 100 records"
);
assert_eq!(
store.len_tag(&tail.tag).await.unwrap(),
100,
"failed to insert 100 records"
);
assert_eq!(store.len_tag(&tail.tag).await.unwrap(), 100, "failed to insert 100 records");
}
#[rstest]
@@ -618,17 +594,11 @@ mod tests {
.data(DecryptedData(data.clone()))
.build()
.encrypt(&key);
store
.push(&record)
.await
.expect("failed to push encrypted record");
store.push(&record).await.expect("failed to push encrypted record");
}
// the data decrypts with the current key
let all = store
.all_tagged(&RecordTag::Other("test".to_owned()))
.await
.unwrap();
let all = store.all_tagged(&RecordTag::Other("test".to_owned())).await.unwrap();
assert_eq!(all.len(), 10, "failed to fetch all records");
for record in all {
let decrypted = record.decrypt(&key).unwrap();
@@ -637,15 +607,9 @@ mod tests {
// after re-encrypting: the old key fails, the new key works
let new_key = paseto_v4::Key::generate();
store
.re_encrypt(&key, &new_key)
.await
.expect("failed to re-encrypt store");
store.re_encrypt(&key, &new_key).await.expect("failed to re-encrypt store");
let all = store
.all_tagged(&RecordTag::Other("test".to_owned()))
.await
.unwrap();
let all = store.all_tagged(&RecordTag::Other("test".to_owned())).await.unwrap();
for record in all.iter() {
assert!(
record.clone().decrypt(&key).is_err(),
@@ -657,12 +621,6 @@ mod tests {
assert_eq!(decrypted.data.0, data);
}
assert_eq!(
store
.len(host_id, &RecordTag::Other("test".to_owned()))
.await
.unwrap(),
10
);
assert_eq!(store.len(host_id, &RecordTag::Other("test".to_owned())).await.unwrap(), 10);
}
}
+138 -267
View File
@@ -1,20 +1,19 @@
// do a sync :O
use std::{cmp::Ordering, fmt::Write, sync::Arc};
use eyre::Result;
use thiserror::Error;
use super::sqlite_store::SqliteStore;
use crate::{
api_client::{Client, caps_client},
packfile::{download_packed, upload_packed},
settings::Settings,
};
use std::cmp::Ordering;
use std::fmt::Write;
use std::sync::Arc;
use atuin_common::encryption::paseto_v4;
use atuin_domain::caps::{CapClient, PackfileCap};
use atuin_domain::record::{Diff, HostId, RecordId, RecordIdx, RecordStatus, RecordTag};
use eyre::Result;
use indicatif::{ProgressBar, ProgressState, ProgressStyle};
use thiserror::Error;
use super::sqlite_store::SqliteStore;
use crate::api_client::{Client, caps_client};
use crate::packfile::{download_packed, upload_packed};
use crate::settings::Settings;
#[derive(Error, Debug)]
pub enum SyncError {
@@ -22,22 +21,30 @@ pub enum SyncError {
LocalAheadOtherHost,
#[error("an issue with the local database occurred: {msg:?}")]
LocalStoreError { msg: String },
LocalStoreError {
msg: String,
},
#[error("something has gone wrong with the sync logic: {msg:?}")]
SyncLogicError { msg: String },
SyncLogicError {
msg: String,
},
#[error("operational error: {msg:?}")]
OperationalError { msg: String },
OperationalError {
msg: String,
},
#[error("a request to the sync server failed: {msg:?}")]
RemoteRequestError { msg: String },
RemoteRequestError {
msg: String,
},
#[error(
"the encryption key on this machine does not match the data on the server. \
this usually means a new machine was set up without copying the existing key. \
to fix: run `atuin key` on a machine that already syncs correctly, then run \
`atuin store rekey <key>` on this machine with the value from the other machine"
"the encryption key on this machine does not match the data on the server. this usually \
means a new machine was set up without copying the existing key. to fix: run `atuin key` \
on a machine that already syncs correctly, then run `atuin store rekey <key>` on this \
machine with the value from the other machine"
)]
WrongKey,
}
@@ -90,10 +97,8 @@ pub async fn diff(
client: &Client,
store: &SqliteStore,
) -> Result<(Vec<Diff>, RecordStatus), SyncError> {
let local_index = store
.status()
.await
.map_err(|e| SyncError::LocalStoreError { msg: e.to_string() })?;
let local_index =
store.status().await.map_err(|e| SyncError::LocalStoreError { msg: e.to_string() })?;
let remote_index = client
.record_status()
@@ -208,18 +213,20 @@ async fn sync_upload(
let mut progress = 0;
let pb = ProgressBar::new(expected);
pb.set_style(ProgressStyle::with_template("{spinner:.green} [{elapsed_precise}] [{wide_bar:.cyan/blue}] {human_pos}/{human_len} ({eta})")
pb.set_style(
ProgressStyle::with_template(
"{spinner:.green} [{elapsed_precise}] [{wide_bar:.cyan/blue}] {human_pos}/{human_len} \
({eta})",
)
.unwrap()
.with_key("eta", |state: &ProgressState, w: &mut dyn Write| write!(w, "{:.1}s", state.eta().as_secs_f64()).unwrap())
.progress_chars("#>-"));
println!(
"Uploading {} records to {}/{}",
expected,
host.0.as_simple(),
tag
.with_key("eta", |state: &ProgressState, w: &mut dyn Write| {
write!(w, "{:.1}s", state.eta().as_secs_f64()).unwrap()
})
.progress_chars("#>-"),
);
println!("Uploading {} records to {}/{}", expected, host.0.as_simple(), tag);
while progress < expected {
let page = store
.next(host, &tag, first_missing_remote + progress, page_size)
@@ -236,12 +243,10 @@ async fn sync_upload(
if tag == RecordTag::Packfile {
for manifest in &page {
upload_packed(manifest, store, key, client)
.await
.map_err(|e| {
error!("failed to upload packfile: {e}");
SyncError::RemoteRequestError { msg: e.to_string() }
})?;
upload_packed(manifest, store, key, client).await.map_err(|e| {
error!("failed to upload packfile: {e}");
SyncError::RemoteRequestError { msg: e.to_string() }
})?;
}
}
@@ -312,18 +317,20 @@ async fn sync_download(
let mut progress = 0;
let mut ret = Vec::new();
println!(
"Downloading {} records from {}/{}",
expected,
host.0.as_simple(),
tag
);
println!("Downloading {} records from {}/{}", expected, host.0.as_simple(), tag);
let pb = ProgressBar::new(expected);
pb.set_style(ProgressStyle::with_template("{spinner:.green} [{elapsed_precise}] [{wide_bar:.cyan/blue}] {human_pos}/{human_len} ({eta})")
pb.set_style(
ProgressStyle::with_template(
"{spinner:.green} [{elapsed_precise}] [{wide_bar:.cyan/blue}] {human_pos}/{human_len} \
({eta})",
)
.unwrap()
.with_key("eta", |state: &ProgressState, w: &mut dyn Write| write!(w, "{:.1}s", state.eta().as_secs_f64()).unwrap())
.progress_chars("#>-"));
.with_key("eta", |state: &ProgressState, w: &mut dyn Write| {
write!(w, "{:.1}s", state.eta().as_secs_f64()).unwrap()
})
.progress_chars("#>-"),
);
while progress < expected {
let page = client
@@ -397,27 +404,21 @@ pub async fn sync_remote(
} => {
if tag == RecordTag::Packfile && !packfiles_enabled {
debug!(
"server does not advertise PackfileCap; skipping packfile {tag} upload op, loose history covers it"
"server does not advertise PackfileCap; skipping packfile {tag} upload \
op, loose history covers it"
);
continue;
}
uploaded += sync_upload(
local_store,
client,
host,
tag,
local,
remote,
page_size,
key,
)
.await?
uploaded +=
sync_upload(local_store, client, host, tag, local, remote, page_size, key)
.await?
}
Operation::Download { host, tag, remote } => {
if tag == RecordTag::Packfile && !packfiles_enabled {
debug!(
"server does not advertise PackfileCap; skipping packfile {tag} download op, loose history covers it"
"server does not advertise PackfileCap; skipping packfile {tag} download \
op, loose history covers it"
);
continue;
}
@@ -459,9 +460,7 @@ pub async fn check_encryption_key(
return Ok(());
};
record
.decrypt(encryption_key)
.map_err(|_| SyncError::WrongKey)?;
record.decrypt(encryption_key).map_err(|_| SyncError::WrongKey)?;
Ok(())
}
@@ -491,23 +490,15 @@ mod tests {
use pretty_assertions::assert_eq;
use rstest::rstest;
use crate::{
record::{
sqlite_store::SqliteStore,
sync::{self, Operation},
},
settings::test_local_timeout,
};
use crate::record::sqlite_store::SqliteStore;
use crate::record::sync::{self, Operation};
use crate::settings::test_local_timeout;
fn test_record() -> Record<EncryptedData> {
Record::builder()
.host(atuin_domain::record::Host::new(HostId(
atuin_common::utils::uuid_v7(),
)))
.host(atuin_domain::record::Host::new(HostId(atuin_common::utils::uuid_v7())))
.version("v1".into())
.tag(RecordTag::Other(
atuin_common::utils::uuid_v7().simple().to_string(),
))
.tag(RecordTag::Other(atuin_common::utils::uuid_v7().simple().to_string()))
.data(EncryptedData {
raw: String::new(),
cek: String::new(),
@@ -560,15 +551,12 @@ mod tests {
assert_eq!(operations.len(), 1);
assert_eq!(
operations[0],
Operation::Upload {
host: record.host.id,
tag: record.tag.clone(),
local: record.idx,
remote: None,
}
);
assert_eq!(operations[0], Operation::Upload {
host: record.host.id,
tag: record.tag.clone(),
local: record.idx,
remote: None,
});
}
#[rstest]
@@ -592,24 +580,21 @@ mod tests {
assert_eq!(operations.len(), 2);
assert_eq!(
operations,
vec![
// Or in otherwords, local is ahead by one
Operation::Upload {
host: local_ahead.host.id,
tag: local_ahead.tag.clone(),
local: 1,
remote: Some(0),
},
// Or in other words, remote knows of a record in an entirely new store (tag)
Operation::Download {
host: remote_ahead.host.id,
tag: remote_ahead.tag.clone(),
remote: 0,
},
]
);
assert_eq!(operations, vec![
// Or in otherwords, local is ahead by one
Operation::Upload {
host: local_ahead.host.id,
tag: local_ahead.tag.clone(),
local: 1,
remote: Some(0),
},
// Or in other words, remote knows of a record in an entirely new store (tag)
Operation::Download {
host: remote_ahead.host.id,
tag: remote_ahead.tag.clone(),
remote: 0,
},
]);
}
#[rstest]
@@ -630,38 +615,27 @@ mod tests {
let remote_only = test_record();
let remote_only_20 = test_record();
let remote_only_21 = remote_only_20
.append(vec![2, 3, 2])
.encrypt(&[0; 32].into());
let remote_only_22 = remote_only_21
.append(vec![2, 3, 2])
.encrypt(&[0; 32].into());
let remote_only_23 = remote_only_22
.append(vec![2, 3, 2])
.encrypt(&[0; 32].into());
let remote_only_24 = remote_only_23
.append(vec![2, 3, 2])
.encrypt(&[0; 32].into());
let remote_only_21 = remote_only_20.append(vec![2, 3, 2]).encrypt(&[0; 32].into());
let remote_only_22 = remote_only_21.append(vec![2, 3, 2]).encrypt(&[0; 32].into());
let remote_only_23 = remote_only_22.append(vec![2, 3, 2]).encrypt(&[0; 32].into());
let remote_only_24 = remote_only_23.append(vec![2, 3, 2]).encrypt(&[0; 32].into());
let second_shared = test_record();
let second_shared_remote_ahead =
second_shared.append(vec![1, 2, 3]).encrypt(&[0; 32].into());
let second_shared_remote_ahead2 = second_shared_remote_ahead
.append(vec![1, 2, 3])
.encrypt(&[0; 32].into());
let second_shared_remote_ahead2 =
second_shared_remote_ahead.append(vec![1, 2, 3]).encrypt(&[0; 32].into());
let third_shared = test_record();
let third_shared_local_ahead = third_shared.append(vec![1, 2, 3]).encrypt(&[0; 32].into());
let third_shared_local_ahead2 = third_shared_local_ahead
.append(vec![1, 2, 3])
.encrypt(&[0; 32].into());
let third_shared_local_ahead2 =
third_shared_local_ahead.append(vec![1, 2, 3]).encrypt(&[0; 32].into());
let fourth_shared = test_record();
let fourth_shared_remote_ahead =
fourth_shared.append(vec![1, 2, 3]).encrypt(&[0; 32].into());
let fourth_shared_remote_ahead2 = fourth_shared_remote_ahead
.append(vec![1, 2, 3])
.encrypt(&[0; 32].into());
let fourth_shared_remote_ahead2 =
fourth_shared_remote_ahead.append(vec![1, 2, 3]).encrypt(&[0; 32].into());
let local = vec![
shared_record.clone(),
@@ -766,21 +740,20 @@ mod tests {
#[cfg(test)]
mod packfile_download_tests {
use super::*;
use std::collections::HashMap;
use atuin_common::encryption::paseto_v4;
use atuin_common::utils::uuid_v7;
use atuin_domain::record::{DecryptedData, EncryptedData, Host, HostId, Record, RecordId};
use rstest::*;
use wiremock::matchers::{method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
use super::*;
use crate::api_client::{AuthToken, Client, caps_client};
use crate::packfile::PackManifestRecordView;
use crate::packfile::try_pack;
use crate::packfile::{PackManifestRecordView, try_pack};
use crate::record::sqlite_store::SqliteStore;
use crate::settings::test_local_timeout;
use atuin_common::encryption::paseto_v4;
/// A single fixed encryption key. The specific bytes are arbitrary in these tests -- each one
/// packs and unpacks with the same key -- so one shared value keeps setup uniform.
@@ -792,22 +765,13 @@ mod packfile_download_tests {
/// A [`Client`] pointed at a wiremock server, authenticated with a dummy token.
pub(super) fn mock_client(addr: &url::Url) -> Client {
let caps = caps_client(addr, &HashMap::new()).unwrap();
Client::new(
addr.clone(),
AuthToken::Token("t".into()),
30,
30,
&HashMap::new(),
caps,
)
.unwrap()
Client::new(addr.clone(), AuthToken::Token("t".into()), 30, 30, &HashMap::new(), caps)
.unwrap()
}
/// A fresh in-memory record store.
pub(super) async fn memory_store() -> SqliteStore {
SqliteStore::new(":memory:", test_local_timeout())
.await
.unwrap()
SqliteStore::new(":memory:", test_local_timeout()).await.unwrap()
}
/// Push a contiguous run of `count` encrypted HISTORY records (idx `0..count`).
@@ -962,10 +926,7 @@ mod packfile_download_tests {
let err = check_encryption_key(&client, &remote_index, &wrong)
.await
.expect_err("a wrong key on an encrypted record must still be detected");
assert!(
matches!(err, SyncError::WrongKey),
"expected WrongKey, got {err:?}"
);
assert!(matches!(err, SyncError::WrongKey), "expected WrongKey, got {err:?}");
}
#[rstest]
@@ -983,24 +944,11 @@ mod packfile_download_tests {
let addr: url::Url = server.uri().parse().unwrap();
let client = mock_client(&addr);
sync_download(&down, &client, host, RecordTag::Packfile, 1, 100, &key)
.await
.unwrap();
sync_download(&down, &client, host, RecordTag::Packfile, 1, 100, &key).await.unwrap();
// The manifest is stored AND the history it covers was populated.
assert!(
down.last(host, &RecordTag::Packfile)
.await
.unwrap()
.is_some()
);
assert_eq!(
down.next(host, &RecordTag::History, 0, 3)
.await
.unwrap()
.len(),
3
);
assert!(down.last(host, &RecordTag::Packfile).await.unwrap().is_some());
assert_eq!(down.next(host, &RecordTag::History, 0, 3).await.unwrap().len(), 3);
}
#[rstest]
@@ -1017,9 +965,8 @@ mod packfile_download_tests {
let addr: url::Url = server.uri().parse().unwrap();
let client = mock_client(&addr);
let returned = sync_download(&down, &client, host, RecordTag::Packfile, 1, 100, &key)
.await
.unwrap();
let returned =
sync_download(&down, &client, host, RecordTag::Packfile, 1, 100, &key).await.unwrap();
for id in &history_ids {
assert!(
@@ -1078,27 +1025,18 @@ mod packfile_download_tests {
let client = mock_client(&addr);
// Packfile op first (populates history 0..=2), then the history op.
sync_download(&down, &client, host, RecordTag::Packfile, 1, 100, &key)
.await
.unwrap();
sync_download(&down, &client, host, RecordTag::History, 3, 100, &key)
.await
.unwrap();
sync_download(&down, &client, host, RecordTag::Packfile, 1, 100, &key).await.unwrap();
sync_download(&down, &client, host, RecordTag::History, 3, 100, &key).await.unwrap();
// The history download must have started AFTER the packed prefix (idx 2), i.e. never
// requested start=0 for RecordTag::History.
let requests = server.received_requests().await.unwrap();
let requested_history_start_0 = requests.iter().any(|r| {
r.url.path() == "/api/v0/record/next"
&& r.url
.query_pairs()
.any(|(k, v)| k == "tag" && v == RecordTag::History.as_str())
&& r.url.query_pairs().any(|(k, v)| k == "tag" && v == RecordTag::History.as_str())
&& r.url.query_pairs().any(|(k, v)| k == "start" && v == "0")
});
assert!(
!requested_history_start_0,
"packed history range must not be re-requested loose"
);
assert!(!requested_history_start_0, "packed history range must not be re-requested loose");
}
#[rstest]
@@ -1123,23 +1061,14 @@ mod packfile_download_tests {
let client = mock_client(&addr);
// remote (2) is BEHIND the live local head (4) -- must not underflow/panic.
let got = sync_download(&down, &client, host, RecordTag::History, 2, 100, &key)
.await
.unwrap();
assert!(
got.is_empty(),
"nothing to download when local head already exceeds remote"
);
let got =
sync_download(&down, &client, host, RecordTag::History, 2, 100, &key).await.unwrap();
assert!(got.is_empty(), "nothing to download when local head already exceeds remote");
}
}
#[cfg(test)]
mod packfile_capability_tests {
use super::packfile_download_tests::{
memory_store, mock_client, mount_packfile, packed_packfile, seed_history,
};
use super::{Operation, sync_remote};
use atuin_common::encryption::paseto_v4;
use atuin_common::utils::uuid_v7;
use atuin_domain::caps::{CapServer, CapabilitiesCap, PackfileCap};
@@ -1148,6 +1077,11 @@ mod packfile_capability_tests {
use wiremock::matchers::{method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
use super::packfile_download_tests::{
memory_store, mock_client, mount_packfile, packed_packfile, seed_history,
};
use super::{Operation, sync_remote};
/// A single fixed encryption key, matching the sibling packfile tests.
#[fixture]
fn key() -> paseto_v4::Key {
@@ -1205,31 +1139,12 @@ mod packfile_capability_tests {
let addr: url::Url = server.uri().parse().unwrap();
let client = mock_client(&addr);
sync_remote(
&client,
vec![packfile_download_op(host, 3)],
&down,
100,
&key,
)
.await
.unwrap();
sync_remote(&client, vec![packfile_download_op(host, 3)], &down, 100, &key).await.unwrap();
// Cap advertised -> the whole packfile op ran: the manifest was persisted and its history
// was expanded into the store.
assert!(
down.last(host, &RecordTag::Packfile)
.await
.unwrap()
.is_some()
);
assert_eq!(
down.next(host, &RecordTag::History, 0, 3)
.await
.unwrap()
.len(),
3
);
assert!(down.last(host, &RecordTag::Packfile).await.unwrap().is_some());
assert_eq!(down.next(host, &RecordTag::History, 0, 3).await.unwrap().len(), 3);
}
#[rstest]
@@ -1240,9 +1155,7 @@ mod packfile_capability_tests {
let server = MockServer::start().await;
// Caps advertised, but NO PackfileCap -> get_server::<PackfileCap>() == Ok(None).
let caps = CapServer::new()
.add(CapabilitiesCap { version: 1 })
.unwrap();
let caps = CapServer::new().add(CapabilitiesCap { version: 1 }).unwrap();
mount_caps_body(&server, caps.body().to_owned()).await;
// Tag-scoped packfile mocks (not `mount_packfile`, whose start=0 matcher is tag-agnostic and
// would collide with the history op). They exist so that, were the gate broken, the
@@ -1289,14 +1202,11 @@ mod packfile_capability_tests {
sync_remote(
&client,
vec![
packfile_download_op(host, 3),
Operation::Download {
remote: 3,
host,
tag: RecordTag::History,
},
],
vec![packfile_download_op(host, 3), Operation::Download {
remote: 3,
host,
tag: RecordTag::History,
}],
&down,
100,
&key,
@@ -1305,19 +1215,8 @@ mod packfile_capability_tests {
.unwrap();
// Packfile op skipped: manifest not persisted. Loose history still synced (no data loss).
assert!(
down.last(host, &RecordTag::Packfile)
.await
.unwrap()
.is_none()
);
assert_eq!(
down.next(host, &RecordTag::History, 0, 3)
.await
.unwrap()
.len(),
3
);
assert!(down.last(host, &RecordTag::Packfile).await.unwrap().is_none());
assert_eq!(down.next(host, &RecordTag::History, 0, 3).await.unwrap().len(), 3);
}
#[rstest]
@@ -1333,28 +1232,13 @@ mod packfile_capability_tests {
let addr: url::Url = server.uri().parse().unwrap();
let client = mock_client(&addr);
sync_remote(
&client,
vec![packfile_download_op(host, 3)],
&down,
100,
&key,
)
.await
.unwrap();
sync_remote(&client, vec![packfile_download_op(host, 3)], &down, 100, &key).await.unwrap();
// Skipped: manifest not persisted, and no packfile endpoint was ever hit.
assert!(
down.last(host, &RecordTag::Packfile)
.await
.unwrap()
.is_none()
);
assert!(down.last(host, &RecordTag::Packfile).await.unwrap().is_none());
let requests = server.received_requests().await.unwrap();
assert!(
!requests
.iter()
.any(|r| r.url.path().starts_with("/api/v0/packfiles")),
!requests.iter().any(|r| r.url.path().starts_with("/api/v0/packfiles")),
"no /api/v0/packfiles request must be made when packfiles are gated off"
);
}
@@ -1380,21 +1264,8 @@ mod packfile_capability_tests {
let addr: url::Url = server.uri().parse().unwrap();
let client = mock_client(&addr);
sync_remote(
&client,
vec![packfile_download_op(host, 3)],
&down,
100,
&key,
)
.await
.unwrap();
sync_remote(&client, vec![packfile_download_op(host, 3)], &down, 100, &key).await.unwrap();
assert!(
down.last(host, &RecordTag::Packfile)
.await
.unwrap()
.is_none()
);
assert!(down.last(host, &RecordTag::Packfile).await.unwrap().is_none());
}
}
+3 -2
View File
@@ -1,7 +1,8 @@
use atuin_common::encryption::paseto_v4;
use eyre::Result;
use crate::{api_client, settings::Settings};
use atuin_common::encryption::paseto_v4;
use crate::api_client;
use crate::settings::Settings;
pub async fn register_classic(
settings: &Settings,
+7 -2
View File
@@ -1,8 +1,9 @@
// This file will probably trigger a lot of scanners. Sorry.
use regex::RegexSet;
use std::sync::LazyLock;
use regex::RegexSet;
pub enum TestValue<'a> {
Single(&'a str),
Multiple(&'a [&'a str]),
@@ -153,7 +154,11 @@ mod tests {
let re =
Regex::new(regex).unwrap_or_else(|_| panic!("Failed to compile regex for {name}"));
let label = if embed { "embedded test" } else { "test" };
let label = if embed {
"embedded test"
} else {
"test"
};
let wrap = |s: &str| {
if embed {
format!("some random text {s} some more random text")
+56 -95
View File
@@ -1,22 +1,20 @@
use std::collections::HashMap;
use std::io::prelude::*;
use std::path::{Path, PathBuf};
use std::sync::{LazyLock, OnceLock};
use atuin_common::logs::LogLevel;
use atuin_common::utils;
use atuin_domain::record::HostId;
use clap::ValueEnum;
use config::{
Config, ConfigBuilder, Environment, File as ConfigFile, FileFormat, builder::DefaultState,
};
use config::builder::DefaultState;
use config::{Config, ConfigBuilder, Environment, File as ConfigFile, FileFormat};
use eyre::{Context, Result, eyre};
use fs_err::{File, create_dir_all};
use humantime::parse_duration;
use regex::RegexSet;
use semver::Version;
use serde::{Deserialize, Serialize};
use std::{
collections::HashMap,
io::prelude::*,
path::{Path, PathBuf},
sync::{LazyLock, OnceLock},
};
use thiserror::Error;
use time::OffsetDateTime;
use tokio::sync::OnceCell;
@@ -370,17 +368,25 @@ pub enum SyncProtocol {
pub enum SyncAuth {
/// Self-hosted Rust server. Uses `Authorization: Token <session>` and
/// legacy endpoints.
Legacy { token: String },
Legacy {
token: String,
},
/// Hub with a valid Hub API token (`atapi_*`). Uses
/// `Authorization: Bearer <token>` and v0 endpoints.
Hub { token: String },
Hub {
token: String,
},
/// Targeting Hub but only has a CLI session token. Uses
/// `Authorization: Token <session>` against compat/record endpoints.
/// Sync, password change, and account deletion still work, but the user
/// should be nudged to run `atuin login` for full Hub auth.
HubViaCli { token: String },
HubViaCli {
token: String,
},
/// Not authenticated at all. Contains an actionable user-facing message.
NotLoggedIn { reason: String },
NotLoggedIn {
reason: String,
},
}
#[cfg(feature = "sync")]
@@ -1118,8 +1124,7 @@ impl Settings {
}
pub fn search_mode_shell_up_key_binding(&self) -> Option<SearchMode> {
self.requested_search_mode_shell_up_key_binding
.map(Into::into)
self.requested_search_mode_shell_up_key_binding.map(Into::into)
}
/// Return the active search mode depending on whether Atuin was invoked from the "up"
@@ -1136,10 +1141,7 @@ impl Settings {
}
pub(crate) fn effective_data_dir() -> PathBuf {
DATA_DIR
.get()
.cloned()
.unwrap_or_else(atuin_common::utils::data_dir)
DATA_DIR.get().cloned().unwrap_or_else(atuin_common::utils::data_dir)
}
// -- Meta store: lazily initialized on first access --
@@ -1278,8 +1280,8 @@ impl Settings {
return match meta.session_token().await {
Ok(Some(token)) => SyncAuth::Legacy { token },
_ => SyncAuth::NotLoggedIn {
reason: "Not logged in. Run 'atuin login' to authenticate \
with your sync server."
reason: "Not logged in. Run 'atuin login' to authenticate with your sync \
server."
.into(),
},
};
@@ -1310,8 +1312,7 @@ impl Settings {
match meta.session_token().await {
Ok(Some(token)) => SyncAuth::HubViaCli { token },
_ => SyncAuth::NotLoggedIn {
reason: "Not logged in. Run 'atuin login' or 'atuin register' \
to authenticate."
reason: "Not logged in. Run 'atuin login' or 'atuin register' to authenticate."
.into(),
},
}
@@ -1414,9 +1415,7 @@ impl Settings {
}
pub fn builder() -> Result<ConfigBuilder<DefaultState>> {
Ok(Self::builder_with_data_dir(
&atuin_common::utils::data_dir(),
)?)
Ok(Self::builder_with_data_dir(&atuin_common::utils::data_dir())?)
}
fn builder_with_data_dir(
@@ -1517,17 +1516,14 @@ impl Settings {
.set_default("ai.opening.send_cwd", false)?
.set_default("ai.opening.send_last_command", false)?
.set_default("ui.syntax_highlight", true)?
.set_default(
"search.filters",
vec![
"global",
"host",
"session",
"workspace",
"directory",
"session-preload",
],
)?
.set_default("search.filters", vec![
"global",
"host",
"session",
"workspace",
"directory",
"session-preload",
])?
.set_default("theme.name", "default")?
.set_default("theme.debug", None::<bool>)?
.set_default("tmux.enabled", false)?
@@ -1541,11 +1537,7 @@ impl Settings {
.unwrap_or_else(|| config::Value::new(None, config::ValueKind::Boolean(false))),
)?
.set_default("no_mouse", false)?
.add_source(
Environment::with_prefix("atuin")
.prefix_separator("_")
.separator("__"),
))
.add_source(Environment::with_prefix("atuin").prefix_separator("_").separator("__")))
}
pub fn get_config_path() -> Result<PathBuf> {
@@ -1582,17 +1574,12 @@ impl Settings {
data_dir: Option<String>,
}
let config_file_str = config_file
.to_str()
.ok_or_else(|| eyre!("config file path is not valid UTF-8"))?;
let config_file_str =
config_file.to_str().ok_or_else(|| eyre!("config file path is not valid UTF-8"))?;
let partial_config = Config::builder()
.add_source(ConfigFile::new(config_file_str, FileFormat::Toml))
.add_source(
Environment::with_prefix("atuin")
.prefix_separator("_")
.separator("__"),
)
.add_source(Environment::with_prefix("atuin").prefix_separator("_").separator("__"))
.build()
.ok();
@@ -1620,9 +1607,8 @@ impl Settings {
let mut config_builder = Self::builder_with_data_dir(&effective_data_dir)?;
config_builder = if config_file.exists() {
let config_file_str = config_file
.to_str()
.ok_or_else(|| eyre!("config file path is not valid UTF-8"))?;
let config_file_str =
config_file.to_str().ok_or_else(|| eyre!("config file path is not valid UTF-8"))?;
config_builder.add_source(ConfigFile::new(config_file_str, FileFormat::Toml))
} else {
let mut file = File::create(config_file).wrap_err("could not create config file")?;
@@ -1695,9 +1681,8 @@ impl Settings {
);
}
let value: Value = config
.get(key)
.map_err(|e| eyre!("failed to get config value '{}': {}", key, e))?;
let value: Value =
config.get(key).map_err(|e| eyre!("failed to get config value '{}': {}", key, e))?;
Ok(Self::format_resolved_value(&value, key))
}
@@ -1715,10 +1700,8 @@ impl Settings {
ValueKind::Float(f) => f.to_string(),
ValueKind::String(s) => s.clone(),
ValueKind::Array(arr) => {
let items: Vec<String> = arr
.iter()
.map(|v| Self::format_resolved_value(v, ""))
.collect();
let items: Vec<String> =
arr.iter().map(|v| Self::format_resolved_value(v, "")).collect();
format!("[{}]", items.join(", "))
}
ValueKind::Table(map) => {
@@ -1755,17 +1738,14 @@ impl Settings {
pub fn new() -> Result<Self> {
let config = Self::build_config()?;
let settings: Settings = config
.try_deserialize()
.map_err(|e| eyre!("failed to deserialize: {}", e))?;
let settings: Settings =
config.try_deserialize().map_err(|e| eyre!("failed to deserialize: {}", e))?;
// Validate UI settings
settings.ui.validate()?;
// Register meta store config for lazy initialization on first access
META_CONFIG
.set((settings.meta.db_path.clone(), settings.local_timeout))
.ok();
META_CONFIG.set((settings.meta.db_path.clone(), settings.local_timeout)).ok();
Ok(settings)
}
@@ -1862,12 +1842,12 @@ mod tests {
use eyre::Result;
use rstest::rstest;
use url::Url;
use super::{
AiEndpointProtocol, ConfigFile, FileFormat, FilterMode, RequestedSearchMode, SearchMode,
Settings, UtcOffsetSpec,
};
use url::Url;
#[rstest]
#[case::plus_two_digit_hours("+02", (2, 0, 0))]
@@ -1920,10 +1900,7 @@ mod tests {
let mut settings = Settings::default();
settings.ai.endpoint_protocol = protocol;
assert_eq!(
settings.is_hub_ai_endpoint(&Url::parse(endpoint).unwrap()),
expected,
);
assert_eq!(settings.is_hub_ai_endpoint(&Url::parse(endpoint).unwrap()), expected,);
}
/// Forces both `LazyLock`s, so a typo in either constant fails here rather
@@ -1976,21 +1953,12 @@ mod tests {
assert_eq!(db_path, custom_dir.join("history.db").to_str().unwrap());
assert_eq!(key_path, custom_dir.join("key").to_str().unwrap());
assert_eq!(
record_store_path,
custom_dir.join("records.db").to_str().unwrap()
);
assert_eq!(record_store_path, custom_dir.join("records.db").to_str().unwrap());
assert_eq!(kv_db_path, custom_dir.join("kv.db").to_str().unwrap());
assert_eq!(
scripts_db_path,
custom_dir.join("scripts.db").to_str().unwrap()
);
assert_eq!(scripts_db_path, custom_dir.join("scripts.db").to_str().unwrap());
assert_eq!(meta_db_path, custom_dir.join("meta.db").to_str().unwrap());
assert_eq!(daemon_socket_path, None);
assert_eq!(
daemon_pidfile_path,
custom_dir.join("atuin-daemon.pid").to_str().unwrap()
);
assert_eq!(daemon_pidfile_path, custom_dir.join("atuin-daemon.pid").to_str().unwrap());
assert!(!daemon_autostart);
Ok(())
@@ -2014,18 +1982,14 @@ mod tests {
"data_dir"
)]
#[case::more_than_one_expanding_column(
"[ui]\ncolumns = [{ type = \"duration\", expand = true }, { type = \"command\", expand = true }]\n",
"[ui]\ncolumns = [{ type = \"duration\", expand = true }, { type = \"command\", expand = \
true }]\n",
"expand"
)]
fn validate_rejects(#[case] toml: &str, #[case] expected_err: &str) {
let err = Settings::validate_str(toml)
.expect_err("config should not validate")
.to_string();
let err = Settings::validate_str(toml).expect_err("config should not validate").to_string();
assert!(
err.contains(expected_err),
"error should mention `{expected_err}`, got: {err}"
);
assert!(err.contains(expected_err), "error should mention `{expected_err}`, got: {err}");
}
#[test]
@@ -2128,10 +2092,7 @@ mod tests {
settings.requested_search_mode_shell_up_key_binding,
Some(RequestedSearchMode::Skim)
);
assert_eq!(
settings.search_mode_shell_up_key_binding(),
Some(SearchMode::Fuzzy)
);
assert_eq!(settings.search_mode_shell_up_key_binding(), Some(SearchMode::Fuzzy));
}
#[rstest]
+24 -53
View File
@@ -1,8 +1,9 @@
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[cfg(unix)]
use std::{borrow::Cow, path::Path};
use serde::{Deserialize, Serialize};
#[cfg(unix)]
const SOCKET_NAME: &str = "atuin.sock";
@@ -96,9 +97,8 @@ impl Daemon {
fn existing_socket_path_ctx(&self, ctx: impl SocketCtx) -> Cow<'_, Path> {
let mut candidates = self.potential_socket_paths_ctx(ctx);
let primary = candidates
.next()
.expect("there is always at least one potential socket path");
let primary =
candidates.next().expect("there is always at least one potential socket path");
if primary.exists() {
return primary;
@@ -120,24 +120,13 @@ impl Daemon {
(self.default_socket_paths(ctx), usize::MAX)
};
let defaults = defaults
.into_iter()
.take(defaults_limit)
.flatten()
.map(Cow::Owned);
let defaults = defaults.into_iter().take(defaults_limit).flatten().map(Cow::Owned);
self.socket_path
.as_deref()
.map(Cow::Borrowed)
.into_iter()
.chain(defaults)
self.socket_path.as_deref().map(Cow::Borrowed).into_iter().chain(defaults)
}
fn default_socket_paths(&self, ctx: impl SocketCtx) -> [Option<PathBuf>; 3] {
let runtime_path = self
.systemd_socket
.then(|| ctx.runtime_socket_path())
.flatten();
let runtime_path = self.systemd_socket.then(|| ctx.runtime_socket_path()).flatten();
// If `runtime_socket_path` is `Some`, `ctx.legacy_socket_path()` will return the same path
// (both pointing to `$XDG_RUNTIME_DIR`), so don't include the legacy path in that case.
let legacy_path = runtime_path.is_none().then(|| ctx.legacy_socket_path());
@@ -169,9 +158,7 @@ trait SocketCtx: Copy + Sized {
}
fn default_socket_path(&self) -> PathBuf {
self.tmp_dir()
.join(format!("atuin-{}", self.uid()))
.join(SOCKET_NAME)
self.tmp_dir().join(format!("atuin-{}", self.uid())).join(SOCKET_NAME)
}
fn runtime_socket_path(&self) -> Option<PathBuf> {
@@ -179,8 +166,7 @@ trait SocketCtx: Copy + Sized {
}
fn legacy_socket_path(&self) -> PathBuf {
self.runtime_socket_path()
.unwrap_or_else(|| self.data_dir().join(SOCKET_NAME))
self.runtime_socket_path().unwrap_or_else(|| self.data_dir().join(SOCKET_NAME))
}
}
@@ -214,7 +200,10 @@ pub enum CreateSocketDirError {
actual_uid: u32,
},
#[error("{} has incorrect permissions (expected 700, got {permissions:03o})", .path.display())]
WrongPermissions { path: PathBuf, permissions: u32 },
WrongPermissions {
path: PathBuf,
permissions: u32,
},
#[error(transparent)]
Io(#[from] std::io::Error),
}
@@ -233,9 +222,7 @@ impl<'a> SocketPath<'a> {
return Ok(());
};
let dir = path
.parent()
.expect("default socket path always has a parent");
let dir = path.parent().expect("default socket path always has a parent");
match std::fs::DirBuilder::new().mode(0o700).create(dir) {
Err(e) if e.kind() == ErrorKind::AlreadyExists => {}
result => return result.map_err(Into::into),
@@ -310,12 +297,14 @@ impl<'a> From<SocketPath<'a>> for Cow<'a, Path> {
#[cfg(all(unix, test))]
mod unix_tests {
use super::*;
use rstest::*;
use std::fs::Permissions;
use std::os::unix::fs::{MetadataExt, PermissionsExt, symlink};
use rstest::*;
use tempfile::TempDir;
use super::*;
#[fixture]
fn tmp() -> TempDir {
tempfile::tempdir().unwrap()
@@ -432,16 +421,10 @@ mod unix_tests {
};
assert_eq!(
daemon
.potential_socket_paths_ctx(ctx)
.map(Cow::into_owned)
.collect::<Vec<_>>(),
daemon.potential_socket_paths_ctx(ctx).map(Cow::into_owned).collect::<Vec<_>>(),
expected.iter().map(PathBuf::from).collect::<Vec<_>>(),
);
assert_eq!(
daemon.socket_path_ctx(ctx).as_path(),
Path::new(expected[0])
);
assert_eq!(daemon.socket_path_ctx(ctx).as_path(), Path::new(expected[0]));
}
/// The path we connect to is the first one that is actually there, so that a daemon listening
@@ -469,10 +452,7 @@ mod unix_tests {
fs_err::File::create(path).unwrap();
}
assert_eq!(
daemon(None, false).existing_socket_path_ctx(ctx),
scoped(expected)
);
assert_eq!(daemon(None, false).existing_socket_path_ctx(ctx), scoped(expected));
}
#[rstest]
@@ -488,17 +468,11 @@ mod unix_tests {
#[rstest]
fn default_socket_dir_is_created_privately_then_reused(default_socket: DefaultSocket) {
default_socket
.path()
.create_default_dir_if_needed()
.unwrap();
default_socket.path().create_default_dir_if_needed().unwrap();
let mode = fs_err::metadata(&default_socket.dir).unwrap().mode();
assert_eq!(mode & 0o777, 0o700);
default_socket
.path()
.create_default_dir_if_needed()
.unwrap();
default_socket.path().create_default_dir_if_needed().unwrap();
}
#[rstest]
@@ -509,10 +483,7 @@ mod unix_tests {
default_socket: DefaultSocket,
#[case] mode: u32,
) {
default_socket
.path()
.create_default_dir_if_needed()
.unwrap();
default_socket.path().create_default_dir_if_needed().unwrap();
fs_err::set_permissions(&default_socket.dir, Permissions::from_mode(mode)).unwrap();
let Err(CreateSocketDirError::WrongPermissions { permissions, .. }) =
+2 -1
View File
@@ -121,11 +121,12 @@ impl Serialize for Shells {
#[cfg(test)]
mod tests {
use super::Shells;
use atuin_common::filter::{self, OrFilter};
use rstest::rstest;
use serde::Deserialize;
use super::Shells;
fn parse(toml: &str) -> Result<Shells, toml::de::Error> {
Shells::deserialize(toml::de::ValueDeserializer::parse(toml).unwrap())
}
+7 -17
View File
@@ -21,17 +21,13 @@
//! }
//! ```
use std::{
path::PathBuf,
sync::{Arc, OnceLock},
time::Duration,
};
use std::path::PathBuf;
use std::sync::{Arc, OnceLock};
use std::time::Duration;
use eyre::{Result, WrapErr};
use notify::{
Config as NotifyConfig, RecommendedWatcher, RecursiveMode, Watcher,
event::{EventKind, ModifyKind},
};
use notify::event::{EventKind, ModifyKind};
use notify::{Config as NotifyConfig, RecommendedWatcher, RecursiveMode, Watcher};
use tokio::sync::watch;
use tracing::{debug, error, info, warn};
@@ -188,10 +184,7 @@ impl SettingsWatcher {
// Defensive: ensure watch path exists before trying to watch
if !watch_path.exists() {
warn!(
"config directory does not exist, creating it: {:?}",
watch_path
);
warn!("config directory does not exist, creating it: {:?}", watch_path);
std::fs::create_dir_all(watch_path)
.wrap_err_with(|| format!("failed to create config directory: {:?}", watch_path))?;
}
@@ -228,10 +221,7 @@ impl SettingsWatcher {
// Defensive: check if config file exists before reloading
// (handles case where file was deleted - we'll get notified when it's recreated)
if !config_path.exists() {
debug!(
"config file does not exist, skipping reload: {:?}",
config_path
);
debug!("config file does not exist, skipping reload: {:?}", config_path);
continue;
}
+57 -152
View File
@@ -1,12 +1,13 @@
use config::{Config, File as ConfigFile, FileFormat};
use palette::named;
use serde::{Deserialize, Serialize};
use serde_json;
use std::collections::HashMap;
use std::error;
use std::io::{Error, ErrorKind};
use std::path::PathBuf;
use std::sync::LazyLock;
use config::{Config, File as ConfigFile, FileFormat};
use palette::named;
use serde::{Deserialize, Serialize};
use serde_json;
use strum_macros;
static DEFAULT_MAX_DEPTH: u8 = 10;
@@ -138,7 +139,10 @@ impl Theme {
*name,
StyleFactory::from_fg_string(color).unwrap_or_else(|err| {
if debug {
tracing::warn!("Tried to load string as a color unsuccessfully: ({name}={color}) {err}");
tracing::warn!(
"Tried to load string as a color unsuccessfully: ({name}={color}) \
{err}"
);
}
ContentStyle::default()
}),
@@ -268,26 +272,11 @@ static DEFAULT_THEME: LazyLock<Theme> = LazyLock::new(|| {
"default".to_string(),
None,
HashMap::from([
(
Meaning::AlertError,
StyleFactory::from_fg_color(Color::DarkRed),
),
(
Meaning::AlertWarn,
StyleFactory::from_fg_color(Color::DarkYellow),
),
(
Meaning::AlertInfo,
StyleFactory::from_fg_color(Color::DarkGreen),
),
(
Meaning::Annotation,
StyleFactory::from_fg_color(Color::DarkGrey),
),
(
Meaning::Guidance,
StyleFactory::from_fg_color(Color::DarkBlue),
),
(Meaning::AlertError, StyleFactory::from_fg_color(Color::DarkRed)),
(Meaning::AlertWarn, StyleFactory::from_fg_color(Color::DarkYellow)),
(Meaning::AlertInfo, StyleFactory::from_fg_color(Color::DarkGreen)),
(Meaning::Annotation, StyleFactory::from_fg_color(Color::DarkGrey)),
(Meaning::Guidance, StyleFactory::from_fg_color(Color::DarkBlue)),
(
Meaning::Important,
StyleFactory::from_fg_color_and_attributes(
@@ -299,28 +288,13 @@ static DEFAULT_THEME: LazyLock<Theme> = LazyLock::new(|| {
(Meaning::Base, ContentStyle::default()),
// Syntax highlighting uses ANSI palette colors, so they follow
// the user's terminal color scheme out of the box.
(
Meaning::SyntaxCommand,
StyleFactory::from_fg_color(Color::Green),
),
(
Meaning::SyntaxFlag,
StyleFactory::from_fg_color(Color::DarkCyan),
),
(
Meaning::SyntaxString,
StyleFactory::from_fg_color(Color::DarkYellow),
),
(
Meaning::SyntaxVariable,
StyleFactory::from_fg_color(Color::Magenta),
),
(Meaning::SyntaxCommand, StyleFactory::from_fg_color(Color::Green)),
(Meaning::SyntaxFlag, StyleFactory::from_fg_color(Color::DarkCyan)),
(Meaning::SyntaxString, StyleFactory::from_fg_color(Color::DarkYellow)),
(Meaning::SyntaxVariable, StyleFactory::from_fg_color(Color::Magenta)),
// Operators keep the terminal's default foreground
(Meaning::SyntaxOperator, ContentStyle::default()),
(
Meaning::SyntaxComment,
StyleFactory::from_fg_color(Color::DarkGrey),
),
(Meaning::SyntaxComment, StyleFactory::from_fg_color(Color::DarkGrey)),
]),
)
});
@@ -339,42 +313,21 @@ static BUILTIN_THEMES: LazyLock<HashMap<&'static str, Theme>> = LazyLock::new(||
(
"autumn",
HashMap::from([
(
Meaning::AlertError,
StyleFactory::known_fg_string("saddlebrown"),
),
(
Meaning::AlertWarn,
StyleFactory::known_fg_string("darkorange"),
),
(Meaning::AlertError, StyleFactory::known_fg_string("saddlebrown")),
(Meaning::AlertWarn, StyleFactory::known_fg_string("darkorange")),
(Meaning::AlertInfo, StyleFactory::known_fg_string("gold")),
(
Meaning::Annotation,
StyleFactory::from_fg_color(Color::DarkGrey),
),
(Meaning::Annotation, StyleFactory::from_fg_color(Color::DarkGrey)),
(Meaning::Guidance, StyleFactory::known_fg_string("brown")),
]),
),
(
"marine",
HashMap::from([
(
Meaning::AlertError,
StyleFactory::known_fg_string("yellowgreen"),
),
(Meaning::AlertError, StyleFactory::known_fg_string("yellowgreen")),
(Meaning::AlertWarn, StyleFactory::known_fg_string("cyan")),
(
Meaning::AlertInfo,
StyleFactory::known_fg_string("turquoise"),
),
(
Meaning::Annotation,
StyleFactory::known_fg_string("steelblue"),
),
(
Meaning::Base,
StyleFactory::known_fg_string("lightsteelblue"),
),
(Meaning::AlertInfo, StyleFactory::known_fg_string("turquoise")),
(Meaning::Annotation, StyleFactory::known_fg_string("steelblue")),
(Meaning::Base, StyleFactory::known_fg_string("lightsteelblue")),
(Meaning::Guidance, StyleFactory::known_fg_string("teal")),
]),
),
@@ -437,10 +390,8 @@ impl ThemeManager {
let mut config_builder = Config::builder();
config_builder = config_builder.add_source(ConfigFile::new(
theme_file.to_str().unwrap(),
FileFormat::Toml,
));
config_builder = config_builder
.add_source(ConfigFile::new(theme_file.to_str().unwrap(), FileFormat::Toml));
let config = config_builder.build()?;
self.load_theme_from_config(name, config, max_depth)
@@ -520,29 +471,24 @@ impl ThemeManager {
#[cfg(test)]
mod theme_tests {
use super::*;
use atuin_common::test_utils::capture_logs;
use rstest::*;
use super::*;
#[fixture]
fn manager(#[default(false)] debug: bool) -> ThemeManager {
ThemeManager::new(Some(debug), Some("".to_string()))
}
fn theme_config(toml: &str) -> Config {
Config::builder()
.add_source(ConfigFile::from_str(toml, FileFormat::Toml))
.build()
.unwrap()
Config::builder().add_source(ConfigFile::from_str(toml, FileFormat::Toml)).build().unwrap()
}
#[rstest]
fn test_can_load_builtin_theme(mut manager: ThemeManager) {
let theme = manager.load_theme("autumn", None);
assert_eq!(
theme.as_style(Meaning::Guidance).foreground_color,
from_string("brown").ok()
);
assert_eq!(theme.as_style(Meaning::Guidance).foreground_color, from_string("brown").ok());
}
#[rstest]
@@ -550,10 +496,7 @@ mod theme_tests {
let mytheme = Theme::new(
"mytheme".to_string(),
None,
HashMap::from([(
Meaning::AlertError,
StyleFactory::known_fg_string("yellowgreen"),
)]),
HashMap::from([(Meaning::AlertError, StyleFactory::known_fg_string("yellowgreen"))]),
);
manager.loaded_themes.insert("mytheme".to_string(), mytheme);
let theme = manager.load_theme("mytheme", None);
@@ -579,15 +522,10 @@ mod theme_tests {
AlertInfo = \"zomp\"
",
);
let theme = manager
.load_theme_from_config("config_theme", config, 1)
.unwrap();
let theme = manager.load_theme_from_config("config_theme", config, 1).unwrap();
// Correctly picks overridden color.
assert_eq!(
theme.as_style(Meaning::Guidance).foreground_color,
from_string("white").ok()
);
assert_eq!(theme.as_style(Meaning::Guidance).foreground_color, from_string("white").ok());
// Does not fall back to any color.
assert_eq!(theme.as_style(Meaning::AlertInfo).foreground_color, None);
@@ -596,10 +534,7 @@ mod theme_tests {
assert_eq!(theme.as_style(Meaning::Base).foreground_color, None);
// Falls back to red as meaning missing from theme, so picks base default.
assert_eq!(
theme.as_style(Meaning::AlertError).foreground_color,
Some(Color::DarkRed)
);
assert_eq!(theme.as_style(Meaning::AlertError).foreground_color, Some(Color::DarkRed));
// Falls back to Important as Title not available.
assert_eq!(
@@ -617,14 +552,9 @@ mod theme_tests {
AlertInfo = \"zomp\"
",
);
let title_theme = manager
.load_theme_from_config("title_theme", title_config, 1)
.unwrap();
let title_theme = manager.load_theme_from_config("title_theme", title_config, 1).unwrap();
assert_eq!(
title_theme.as_style(Meaning::Title).foreground_color,
Some(Color::White)
);
assert_eq!(title_theme.as_style(Meaning::Title).foreground_color, Some(Color::White));
}
#[test]
@@ -639,19 +569,10 @@ mod theme_tests {
fn test_can_get_colors_via_convenience_functions(#[with(true)] mut manager: ThemeManager) {
let theme = manager.load_theme("default", None);
assert_eq!(theme.get_error().foreground_color.unwrap(), Color::DarkRed);
assert_eq!(
theme.get_warning().foreground_color.unwrap(),
Color::DarkYellow
);
assert_eq!(theme.get_warning().foreground_color.unwrap(), Color::DarkYellow);
assert_eq!(theme.get_info().foreground_color.unwrap(), Color::DarkGreen);
assert_eq!(theme.get_base().foreground_color, None);
assert_eq!(
theme
.get_alert(tracing::Level::ERROR)
.foreground_color
.unwrap(),
Color::DarkRed
)
assert_eq!(theme.get_alert(tracing::Level::ERROR).foreground_color.unwrap(), Color::DarkRed)
}
#[rstest]
@@ -669,14 +590,10 @@ mod theme_tests {
AlertInfo = \"pink\"
",
);
let solarized_theme = manager
.load_theme_from_config("solarized", solarized, 1)
.unwrap();
let solarized_theme = manager.load_theme_from_config("solarized", solarized, 1).unwrap();
assert_eq!(
solarized_theme
.as_style(Meaning::AlertInfo)
.foreground_color,
solarized_theme.as_style(Meaning::AlertInfo).foreground_color,
from_string("pink").ok()
);
@@ -691,23 +608,18 @@ mod theme_tests {
AlertInfo = \"red\"
",
);
let unsolarized_theme = manager
.load_theme_from_config("unsolarized", unsolarized, 1)
.unwrap();
let unsolarized_theme =
manager.load_theme_from_config("unsolarized", unsolarized, 1).unwrap();
// It will take its own values
assert_eq!(
unsolarized_theme
.as_style(Meaning::AlertInfo)
.foreground_color,
unsolarized_theme.as_style(Meaning::AlertInfo).foreground_color,
from_string("red").ok()
);
// ...or fall back to the parent
assert_eq!(
unsolarized_theme
.as_style(Meaning::Guidance)
.foreground_color,
unsolarized_theme.as_style(Meaning::Guidance).foreground_color,
from_string("white").ok()
);
@@ -725,22 +637,17 @@ mod theme_tests {
AlertInfo = \"red\"
",
);
let nunsolarized_theme = manager
.load_theme_from_config("nunsolarized", nunsolarized, 1)
.unwrap();
let nunsolarized_theme =
manager.load_theme_from_config("nunsolarized", nunsolarized, 1).unwrap();
assert_eq!(
nunsolarized_theme
.as_style(Meaning::Guidance)
.foreground_color,
None
);
assert_eq!(nunsolarized_theme.as_style(Meaning::Guidance).foreground_color, None);
let captured_logs = logs.get();
assert_eq!(captured_logs.len(), 1);
assert_eq!(
captured_logs[0].message,
"Could not load theme nonsolarized: Empty theme directory override and could not find theme elsewhere"
"Could not load theme nonsolarized: Empty theme directory override and could not find \
theme elsewhere"
);
assert_eq!(captured_logs[0].level, tracing::Level::WARN)
}
@@ -760,20 +667,20 @@ mod theme_tests {
AlertInfo = \"xinetic\"
",
);
manager
.load_theme_from_config("config_theme", config, 1)
.unwrap();
manager.load_theme_from_config("config_theme", config, 1).unwrap();
let captured_logs = logs.get();
if debug {
assert_eq!(captured_logs.len(), 2);
assert_eq!(
captured_logs[0].message,
"Your theme config name is not the name of your loaded theme config_theme != mytheme"
"Your theme config name is not the name of your loaded theme config_theme != \
mytheme"
);
assert_eq!(captured_logs[0].level, tracing::Level::WARN);
assert_eq!(
captured_logs[1].message,
"Tried to load string as a color unsuccessfully: (AlertInfo=xinetic) No such color in palette"
"Tried to load string as a color unsuccessfully: (AlertInfo=xinetic) No such \
color in palette"
);
assert_eq!(captured_logs[1].level, tracing::Level::WARN);
} else {
@@ -812,9 +719,7 @@ mod theme_tests {
fn rejects_at_prefixed_color_string(#[case] input: &str) {
assert_eq!(
from_string(input),
Err(format!(
"Could not convert color name {input} to Crossterm color"
))
Err(format!("Could not convert color name {input} to Crossterm color"))
);
}
}
+5 -5
View File
@@ -116,10 +116,12 @@ pub fn onlcr<B: Borrow<u8>>(bytes: impl IntoIterator<Item = B>) -> impl Iterator
#[cfg(test)]
mod tests {
use super::*;
use std::num::NonZeroU16;
use proptest::prelude::*;
use rstest::rstest;
use std::num::NonZeroU16;
use super::*;
fn nz(cols: u16) -> NonZeroU16 {
NonZeroU16::new(cols).expect("test column width must be nonzero")
@@ -127,9 +129,7 @@ mod tests {
fn assert_no_terminal_controls(text: &str) {
assert!(
!text
.chars()
.any(|ch| ch.is_control() && ch != '\n' && ch != '\t'),
!text.chars().any(|ch| ch.is_control() && ch != '\n' && ch != '\t'),
"text still contains terminal controls: {text:?}"
);
}
+2 -1
View File
@@ -44,9 +44,10 @@ const fn version_segment(version: &str) -> &str {
#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;
use super::*;
#[rstest]
#[case::stable_patch_zero("18.17.0", "18.17")]
#[case::stable_patch_one("18.17.1", "18.17")]
+13 -20
View File
@@ -1,20 +1,17 @@
//! PASETO v4 / PASERK envelope encryption for atuin records.
//!
//! See [`encrypt_sync`] for the encryption description.
use std::{
array::TryFromSliceError,
fs,
io::{Read, Write},
path::Path,
};
use std::array::TryFromSliceError;
use std::fs;
use std::io::{Read, Write};
use std::path::Path;
use base64::{
Engine,
engine::general_purpose::{STANDARD as B64_STANDARD, URL_SAFE_NO_PAD as B64_URL_SAFE_NO_PAD},
use base64::Engine;
use base64::engine::general_purpose::{
STANDARD as B64_STANDARD, URL_SAFE_NO_PAD as B64_URL_SAFE_NO_PAD,
};
use crypto_secretbox::{KeyInit, XSalsa20Poly1305, aead};
use rusty_paseto::Paseto;
use rusty_paseto::core as rusty_paseto;
use rusty_paseto::{Paseto, core as rusty_paseto};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use zeroize::Zeroize;
@@ -645,9 +642,10 @@ pub fn reencrypt_sync(
#[cfg(test)]
mod test {
use super::*;
use rstest::{fixture, rstest};
use super::*;
#[fixture]
fn key() -> Key {
Key::from([
@@ -726,19 +724,14 @@ mod test {
let old = Key::from([0x11u8; 32]);
let new = Key::from([0x22u8; 32]);
old.try_write_path(&path)
.expect("first write creates the file");
old.try_write_path(&path).expect("first write creates the file");
// try_write_path refuses to replace a *different* key (correct for create-if-missing)...
assert!(matches!(
new.try_write_path(&path),
Err(KeyFileStoringError::AlreadyExists)
));
assert!(matches!(new.try_write_path(&path), Err(KeyFileStoringError::AlreadyExists)));
assert_eq!(Key::try_load_from_path(&path).unwrap(), old);
// ...but overwrite_path deliberately replaces it, as key rotation requires.
new.overwrite_path(&path)
.expect("overwrite replaces the key");
new.overwrite_path(&path).expect("overwrite replaces the key");
assert_eq!(Key::try_load_from_path(&path).unwrap(), new);
let _ = fs::remove_dir_all(&dir);
+14 -31
View File
@@ -1,7 +1,9 @@
use crate::slice::{SortedDedupedSliceComparer, partition_dedup};
use itertools::Itertools;
use std::borrow::Borrow;
use itertools::Itertools;
use crate::slice::{SortedDedupedSliceComparer, partition_dedup};
/// Represents a filter with "or" semantics.
///
/// The filter contains one or more items. An item is allowed through the filter if and only if it
@@ -35,20 +37,10 @@ impl<L: FilterStorage> OrFilter<L> {
/// Create a [`OrFilter`] from a sorted, deduped, non-empty list.
pub fn new_unchecked(sorted_deduped: L) -> Self {
debug_assert!(!sorted_deduped.as_ref().is_empty(), "`sorted` cannot be empty");
debug_assert!(sorted_deduped.as_ref().is_sorted(), "`sorted` must be sorted");
debug_assert!(
!sorted_deduped.as_ref().is_empty(),
"`sorted` cannot be empty"
);
debug_assert!(
sorted_deduped.as_ref().is_sorted(),
"`sorted` must be sorted"
);
debug_assert!(
!sorted_deduped
.as_ref()
.iter()
.tuple_windows()
.any(|(a, b)| a == b),
!sorted_deduped.as_ref().iter().tuple_windows().any(|(a, b)| a == b),
"`sorted` must not contain duplicates",
);
Self {
@@ -117,10 +109,7 @@ impl<L: FilterStorage> OrFilter<L> {
if self.is_all() {
return true;
}
self.inner
.as_ref()
.binary_search_by_key(&item, |item| item.borrow())
.is_ok()
self.inner.as_ref().binary_search_by_key(&item, |item| item.borrow()).is_ok()
}
/// Turn this filter into a list of the items in the filter.
@@ -316,10 +305,12 @@ mod sealed {
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeSet;
use proptest::prelude::*;
use rstest::rstest;
use std::collections::BTreeSet;
use super::*;
/// Build a filter from a nonempty list of items.
fn filter(items: &[&str]) -> OrFilter<Vec<String>> {
@@ -361,10 +352,7 @@ mod tests {
#[test]
fn from_list_sorts_and_dedupes() {
let expected = ["", "bash", "zsh"].map(str::to_owned);
assert_eq!(
filter(&["zsh", "bash", "zsh", ""]).items(),
Items::Some(expected.as_slice())
);
assert_eq!(filter(&["zsh", "bash", "zsh", ""]).items(), Items::Some(expected.as_slice()));
}
#[rstest]
@@ -446,12 +434,7 @@ mod tests {
}
fn sort_dedup(items: &[String]) -> Vec<String> {
items
.iter()
.cloned()
.collect::<BTreeSet<_>>()
.into_iter()
.collect()
items.iter().cloned().collect::<BTreeSet<_>>().into_iter().collect()
}
proptest! {
+2 -1
View File
@@ -1,7 +1,8 @@
use serde::{Deserialize, Serialize};
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
/// Log level for file logging. Maps to tracing's LevelFilter.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
+4 -2
View File
@@ -1,8 +1,9 @@
//! Unix-specific utilities.
use rustix::fs;
use std::path::Path;
use rustix::fs;
/// Get the current UID.
pub fn uid() -> std::ffi::c_uint {
rustix::process::getuid().as_raw()
@@ -30,10 +31,11 @@ pub fn touch_file(path: &Path) -> std::io::Result<()> {
#[cfg(test)]
mod tests {
use super::*;
use std::os::unix::fs::{MetadataExt, symlink};
use std::os::unix::net::UnixListener;
use super::*;
/// Set a file's timestamp to the Unix epoch.
fn backdate(path: &Path) {
let epoch = fs::Timespec {
+10 -35
View File
@@ -135,10 +135,12 @@ impl<T: AsRef<Path> + ?Sized> DisplayRichExt for T {
#[cfg(test)]
mod tests {
use super::DisplayRichExt;
use rstest::rstest;
use std::path::{MAIN_SEPARATOR_STR, Path, PathBuf};
use rstest::rstest;
use super::DisplayRichExt;
/// With no options, output is byte-identical to `Path::display()`.
#[rstest]
#[case(Path::new("relative").join("some").join("dir"))]
@@ -163,41 +165,17 @@ mod tests {
#[case] enabled: bool,
#[case] expected: String,
) {
assert_eq!(
input.display_rich().trailing_slash(enabled).to_string(),
expected
);
assert_eq!(input.display_rich().trailing_slash(enabled).to_string(), expected);
}
/// `display_rich()` is available on every `AsRef<Path>` input type.
#[rstest]
fn works_for_all_asref_path_types() {
let expected = format!("bar{MAIN_SEPARATOR_STR}");
assert_eq!(
"bar".display_rich().trailing_slash(true).to_string(),
expected
);
assert_eq!(
String::from("bar")
.display_rich()
.trailing_slash(true)
.to_string(),
expected
);
assert_eq!(
Path::new("bar")
.display_rich()
.trailing_slash(true)
.to_string(),
expected
);
assert_eq!(
PathBuf::from("bar")
.display_rich()
.trailing_slash(true)
.to_string(),
expected
);
assert_eq!("bar".display_rich().trailing_slash(true).to_string(), expected);
assert_eq!(String::from("bar").display_rich().trailing_slash(true).to_string(), expected);
assert_eq!(Path::new("bar").display_rich().trailing_slash(true).to_string(), expected);
assert_eq!(PathBuf::from("bar").display_rich().trailing_slash(true).to_string(), expected);
}
/// `relative_to(base)` strips `base` when the path is under it (empty when
@@ -253,10 +231,7 @@ mod tests {
fn relative_to_takes_priority_over_tilde() {
let dir = Path::new("home").join("user");
let p = dir.join("proj");
assert_eq!(
p.display_rich().relative_to(&dir).tilde(&dir).to_string(),
"proj"
);
assert_eq!(p.display_rich().relative_to(&dir).tilde(&dir).to_string(), "proj");
}
/// Enrichments compose with `trailing_slash`. The `relative_to`-to-base
+8 -9
View File
@@ -1,10 +1,10 @@
use rmp::Marker;
pub use rmp::decode::bytes::{Bytes, BytesReadError};
pub use rmp::decode::{DecodeStringError, NumValueReadError, RmpRead, RmpReadErr, ValueReadError};
pub use rmp::decode::{read_array_len, read_bin_len, read_map_len, read_str_len};
pub use rmp::decode::{read_bool, read_int, read_str_from_slice};
pub use rmp::decode::{read_i8, read_i16, read_i32, read_i64};
pub use rmp::decode::{read_u8, read_u16, read_u32, read_u64};
pub use rmp::decode::{
DecodeStringError, NumValueReadError, RmpRead, RmpReadErr, ValueReadError, read_array_len,
read_bin_len, read_bool, read_i8, read_i16, read_i32, read_i64, read_int, read_map_len,
read_str_from_slice, read_str_len, read_u8, read_u16, read_u32, read_u64,
};
/// An error encountered while trying to decode a message with [`rmp`].
///
@@ -85,9 +85,7 @@ pub fn read_string<'a>(bytes: &mut Bytes<'a>) -> Result<String, DecodeError<'a>>
if let DecodeStringError::TypeMismatch(_) = e {
// The decode functions in `rmp::decode` consume the marker byte when there's a
// type mismatch; make sure we do that too, as `read_optional` depends on it.
bytes
.read_u8()
.expect("TypeMismatch implies stream contains a marker byte");
bytes.read_u8().expect("TypeMismatch implies stream contains a marker byte");
}
return Err(e.into());
}
@@ -247,7 +245,8 @@ where
#[allow(
unsafe_code,
reason = "Doing this without unsafe code is much less efficient. We would have to create an
reason = "Doing this without unsafe code is much less efficient. We would have to create \
an
`[Option<T>; N]`, which could be up to twice the size, fill in each element, and then
use `array::map` to call `Option::unwrap` on each element. Besides the overhead of
`unwrap`, `array::map` is noted as being inefficient on large arrays."
+7 -15
View File
@@ -1,12 +1,11 @@
use std::convert::Infallible;
use rmp::Marker;
pub use rmp::encode::{ByteBuf, RmpWrite, RmpWriteErr, ValueWriteError};
pub use rmp::encode::{write_array_len, write_bin_len, write_map_len, write_str_len};
pub use rmp::encode::{write_bin, write_bool, write_nil, write_str};
pub use rmp::encode::{write_i8, write_i16, write_i32, write_i64};
pub use rmp::encode::{write_sint, write_uint, write_uint8};
pub use rmp::encode::{write_u8, write_u16, write_u32, write_u64};
pub use rmp::encode::{
ByteBuf, RmpWrite, RmpWriteErr, ValueWriteError, write_array_len, write_bin, write_bin_len,
write_bool, write_i8, write_i16, write_i32, write_i64, write_map_len, write_nil, write_sint,
write_str, write_str_len, write_u8, write_u16, write_u32, write_u64, write_uint, write_uint8,
};
/// An error encountered while trying to encode a message with [`rmp`].
///
@@ -74,11 +73,7 @@ where
writer,
bytes.into_iter().map(Ok::<_, Infallible>),
write_bin_len,
|writer, byte| {
writer
.write_u8(byte)
.map_err(ValueWriteError::InvalidDataWrite)
},
|writer, byte| writer.write_u8(byte).map_err(ValueWriteError::InvalidDataWrite),
)
.map_err(Into::into)
}
@@ -164,9 +159,6 @@ where
write(writer, item).map_err(|e| TryEncodeError::from(e.into()))
})?;
assert_eq!(
len, count,
"programming error: incorrect implementation of ExactSizeIterator"
);
assert_eq!(len, count, "programming error: incorrect implementation of ExactSizeIterator");
Ok(())
}
+7 -8
View File
@@ -1,4 +1,6 @@
use std::{ffi::OsStr, path::Path, process::Command};
use std::ffi::OsStr;
use std::path::Path;
use std::process::Command;
use serde::Serialize;
use sysinfo::{Process, System, get_current_pid};
@@ -52,9 +54,8 @@ impl Shell {
}
pub fn from_env() -> Shell {
std::env::var("ATUIN_SHELL").map_or(Shell::Unknown, |shell| {
Shell::from_string(shell.trim().to_lowercase())
})
std::env::var("ATUIN_SHELL")
.map_or(Shell::Unknown, |shell| Shell::from_string(shell.trim().to_lowercase()))
}
pub fn config_file(&self) -> Option<std::path::PathBuf> {
@@ -83,7 +84,7 @@ impl Shell {
let path = if sys.contains("darwin") {
// This works in my testing so far
Shell::Sh.run_interactive([
"dscl localhost -read \"/Local/Default/Users/$USER\" shell | awk '{print $2}'",
"dscl localhost -read \"/Local/Default/Users/$USER\" shell | awk '{print $2}'"
])?
} else if cfg!(windows) {
return Ok(Shell::Powershell);
@@ -98,9 +99,7 @@ impl Shell {
return Err(ShellError::NotSupported);
}
Ok(Shell::from_string(
shell.unwrap().to_string_lossy().to_string(),
))
Ok(Shell::from_string(shell.unwrap().to_string_lossy().to_string()))
}
pub fn from_string(name: String) -> Shell {
+6 -10
View File
@@ -46,10 +46,7 @@ where
///
/// `sorted` must be sorted and contain no duplicates.
pub fn new(sorted: &'a [T], iter: I) -> Self {
debug_assert!(
sorted.is_sorted_by_key(|s| s.borrow()),
"`sorted` must be sorted",
);
debug_assert!(sorted.is_sorted_by_key(|s| s.borrow()), "`sorted` must be sorted",);
debug_assert_eq!(
{
let mut vec = sorted.iter().collect::<Vec<_>>();
@@ -97,10 +94,12 @@ where
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeSet;
use proptest::prelude::*;
use rstest::rstest;
use std::collections::BTreeSet;
use super::*;
#[rstest]
#[case(&[], &[], &[])]
@@ -157,10 +156,7 @@ mod tests {
)]
_stack_size: [(); STACK_SIZE],
) {
assert_eq!(
SortedDedupedSliceComparer::new(sorted, iter).eq::<STACK_SIZE>(),
expected,
);
assert_eq!(SortedDedupedSliceComparer::new(sorted, iter).eq::<STACK_SIZE>(), expected,);
}
fn sorted_deduped_slice() -> impl Strategy<Value = Vec<u8>> {
-1
View File
@@ -12,7 +12,6 @@ pub use align::{AlignExt, Alignment};
pub use ellipsis::EllipsizeExt;
pub use escape_non_printable_posix_ext::EscapeNonPrintablePosixExt;
pub use non_nul_str::{ContainsNul, NonNulStr};
#[cfg(feature = "unicode")]
use unicode_width::UnicodeWidthStr;
+4 -6
View File
@@ -47,11 +47,12 @@ impl<T: AsRef<str>> AlignExt for T {}
#[cfg(test)]
mod tests {
use super::{AlignExt, Alignment};
use crate::string::Measure;
use pretty_assertions::assert_eq;
use rstest::rstest;
use super::{AlignExt, Alignment};
use crate::string::Measure;
#[rstest]
#[case::start_pads_on_the_end("hi", Measure::Columns(5), Alignment::Start, "hi ")]
#[case::end_pads_on_the_start("hi", Measure::Columns(5), Alignment::End, " hi")]
@@ -87,9 +88,6 @@ mod tests {
#[case] expected_borrowed: bool,
) {
let result = input.pad_to(budget, align);
assert_eq!(
matches!(result, std::borrow::Cow::Borrowed(_)),
expected_borrowed
);
assert_eq!(matches!(result, std::borrow::Cow::Borrowed(_)), expected_borrowed);
}
}

Some files were not shown because too many files have changed in this diff Show More