diff options
| author | Benedikt Peetz <benedikt.peetz@b-peetz.de> | 2026-06-13 00:50:54 +0200 |
|---|---|---|
| committer | Benedikt Peetz <benedikt.peetz@b-peetz.de> | 2026-06-13 00:50:54 +0200 |
| commit | 6723829a3398b3c9dd6dc6ae79124f46000606ee (patch) | |
| tree | a1ec535eddd711a4557e4bcc5b94382c3623504c | |
| parent | chore(treewide): Cleanup themes (diff) | |
| download | atuin-6723829a3398b3c9dd6dc6ae79124f46000606ee.zip | |
chore(treewide): Remove `cargo` warnings to 0
There are still the `clippy` warnings, but they are for a future date.
50 files changed, 1254 insertions, 1945 deletions
@@ -105,15 +105,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] -name = "approx" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" -dependencies = [ - "num-traits", -] - -[[package]] name = "arboard" version = "3.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -189,7 +180,6 @@ name = "atuin" version = "18.16.1" dependencies = [ "arboard", - "argon2", "async-trait", "atuin-nucleo", "atuin-nucleo-matcher", @@ -205,46 +195,30 @@ dependencies = [ "daemonize", "dashmap", "directories", - "divan", "eyre", "fs-err", "fs4", - "futures", - "futures-util", "fuzzy-matcher", - "generic-array", - "getrandom 0.2.17", - "glob-match", - "hex", - "humantime", "hyper-util", - "imara-diff", "indicatif", "interim", "itertools", "lasso", "listenfd", "log", - "memchr", "metrics", "metrics-exporter-prometheus", - "minijinja", "minspan", "norm", "notify", - "open", - "palette", "portable-pty", - "pretty_assertions", "prost", - "prost-types", "protox", "rand 0.8.5", "ratatui", "regex", "reqwest", "rmp", - "rpassword", "runtime-format", "rustix", "rustls", @@ -255,42 +229,30 @@ dependencies = [ "serde_json", "serde_regex", "serde_with", - "sha2", "shellexpand", - "shlex", "signal-hook", "sql-builder", "sqlx", - "strum", - "strum_macros", - "sysinfo", - "tempfile", - "testing_logger", "thiserror 2.0.18", "time", "tokio", "tokio-stream", "toml_edit", "tonic", - "tonic-build", "tonic-prost", "tonic-prost-build", - "tonic-types", "tower", "tower-http", "tracing", "tracing-appender", "tracing-subscriber", - "tracing-tree", "typed-builder", "unicode-segmentation", "unicode-width 0.2.2", "url", - "urlencoding", "uuid", "vt100", "whoami 2.1.1", - "xxhash-rust", ] [[package]] @@ -461,12 +423,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" [[package]] -name = "by_address" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" - -[[package]] name = "bytemuck" version = "1.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -581,7 +537,6 @@ dependencies = [ "anstyle", "clap_lex", "strsim", - "terminal_size", ] [[package]] @@ -680,12 +635,6 @@ dependencies = [ ] [[package]] -name = "condtype" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf0a07a401f374238ab8e2f11a104d2851bf9ce711ec69804834de8af45c7af" - -[[package]] name = "config" version = "0.15.21" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1012,12 +961,6 @@ dependencies = [ ] [[package]] -name = "diff" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" - -[[package]] name = "digest" version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1081,31 +1024,6 @@ dependencies = [ ] [[package]] -name = "divan" -version = "0.1.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a405457ec78b8fe08b0e32b4a3570ab5dff6dd16eb9e76a5ee0a9d9cbd898933" -dependencies = [ - "cfg-if", - "clap", - "condtype", - "divan-macros", - "libc", - "regex-lite", -] - -[[package]] -name = "divan-macros" -version = "0.1.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9556bc800956545d6420a640173e5ba7dfa82f38d3ea5a167eb555bc69ac3323" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] name = "document-features" version = "0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1245,12 +1163,6 @@ dependencies = [ ] [[package]] -name = "fast-srgb8" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1" - -[[package]] name = "fastrand" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1375,21 +1287,6 @@ dependencies = [ ] [[package]] -name = "futures" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" -dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - -[[package]] name = "futures-channel" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1462,7 +1359,6 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ - "futures-channel", "futures-core", "futures-io", "futures-macro", @@ -1488,7 +1384,6 @@ version = "0.14.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ - "serde", "typenum", "version_check", "zeroize", @@ -1541,12 +1436,6 @@ dependencies = [ ] [[package]] -name = "glob-match" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9985c9503b412198aa4197559e9a318524ebc4519c229bfa05a535828c950b9d" - -[[package]] name = "h2" version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1697,12 +1586,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] -name = "humantime" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" - -[[package]] name = "hyper" version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1789,7 +1672,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2", + "windows-core", ] [[package]] @@ -1916,16 +1799,6 @@ dependencies = [ ] [[package]] -name = "imara-diff" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f01d462f766df78ab820dd06f5eb700233c51f0f4c2e846520eaf4ba6aa5c5c" -dependencies = [ - "hashbrown 0.15.5", - "memchr", -] - -[[package]] name = "indenter" version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2054,25 +1927,6 @@ dependencies = [ ] [[package]] -name = "is-docker" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" -dependencies = [ - "once_cell", -] - -[[package]] -name = "is-wsl" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" -dependencies = [ - "is-docker", - "once_cell", -] - -[[package]] name = "is_terminal_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2375,12 +2229,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" [[package]] -name = "memo-map" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38d1115007560874e373613744c6fba374c17688327a71c1476d1a5954cc857b" - -[[package]] name = "memoffset" version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2458,16 +2306,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" [[package]] -name = "minijinja" -version = "2.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2929e494b2280e1e18959bb2e121da03347ae896896fdfaceaab43c88a02803f" -dependencies = [ - "memo-map", - "serde", -] - -[[package]] name = "minimal-lexical" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2579,15 +2417,6 @@ dependencies = [ ] [[package]] -name = "ntapi" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" -dependencies = [ - "winapi", -] - -[[package]] name = "nu-ansi-term" version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2769,17 +2598,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] -name = "open" -version = "5.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc" -dependencies = [ - "is-wsl", - "libc", - "pathdiff", -] - -[[package]] name = "openssl-probe" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2811,31 +2629,6 @@ dependencies = [ ] [[package]] -name = "palette" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cbf71184cc5ecc2e4e1baccdb21026c20e5fc3dcf63028a086131b3ab00b6e6" -dependencies = [ - "approx", - "fast-srgb8", - "palette_derive", - "phf", - "serde", -] - -[[package]] -name = "palette_derive" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5030daf005bface118c096f510ffb781fc28f9ab6a32ab224d8631be6851d30" -dependencies = [ - "by_address", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] name = "parking" version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3130,16 +2923,6 @@ dependencies = [ ] [[package]] -name = "pretty_assertions" -version = "1.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" -dependencies = [ - "diff", - "yansi", -] - -[[package]] name = "prettyplease" version = "0.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3570,12 +3353,6 @@ dependencies = [ ] [[package]] -name = "regex-lite" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" - -[[package]] name = "regex-syntax" version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3644,17 +3421,6 @@ dependencies = [ ] [[package]] -name = "rpassword" -version = "7.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66d4c8b64f049c6721ec8ccec37ddfc3d641c4a7fca57e8f2a89de509c73df39" -dependencies = [ - "libc", - "rtoolbox", - "windows-sys 0.59.0", -] - -[[package]] name = "rsa" version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3675,16 +3441,6 @@ dependencies = [ ] [[package]] -name = "rtoolbox" -version = "0.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7cc970b249fbe527d6e02e0a227762c9108b2f49d81094fe357ffc6d14d7f6f" -dependencies = [ - "libc", - "windows-sys 0.52.0", -] - -[[package]] name = "runtime-format" version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -4524,21 +4280,6 @@ dependencies = [ ] [[package]] -name = "sysinfo" -version = "0.30.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a5b4ddaee55fb2bea2bf0e5000747e5f5c0de765e5a5ff87f4cd106439f4bb3" -dependencies = [ - "cfg-if", - "core-foundation-sys", - "libc", - "ntapi", - "once_cell", - "rayon", - "windows", -] - -[[package]] name = "tempfile" version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -4552,16 +4293,6 @@ dependencies = [ ] [[package]] -name = "terminal_size" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60b8cb979cb11c32ce1603f8137b22262a9d131aaa5c37b5678025f22b8becd0" -dependencies = [ - "rustix", - "windows-sys 0.60.2", -] - -[[package]] name = "terminfo" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -4625,15 +4356,6 @@ dependencies = [ ] [[package]] -name = "testing_logger" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d92b727cb45d33ae956f7f46b966b25f1bc712092aeef9dba5ac798fc89f720" -dependencies = [ - "log", -] - -[[package]] name = "thiserror" version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -4921,17 +4643,6 @@ dependencies = [ ] [[package]] -name = "tonic-types" -version = "0.14.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a875a902255423d34c1f20838ab374126db8eb41625b7947a1d54113b0b7399" -dependencies = [ - "prost", - "prost-types", - "tonic", -] - -[[package]] name = "tower" version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -5069,18 +4780,6 @@ dependencies = [ ] [[package]] -name = "tracing-tree" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac87aa03b6a4d5a7e4810d1a80c19601dbe0f8a837e9177f23af721c7ba7beec" -dependencies = [ - "nu-ansi-term", - "tracing-core", - "tracing-log", - "tracing-subscriber", -] - -[[package]] name = "tree_magic_mini" version = "3.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -5232,12 +4931,6 @@ dependencies = [ ] [[package]] -name = "urlencoding" -version = "2.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" - -[[package]] name = "utf8_iter" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -5727,25 +5420,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] -name = "windows" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be" -dependencies = [ - "windows-core 0.52.0", - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-core" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" -dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] name = "windows-core" version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -6255,18 +5929,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" [[package]] -name = "xxhash-rust" -version = "0.8.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" - -[[package]] -name = "yansi" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" - -[[package]] name = "yoke" version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/crates/turtle/Cargo.toml b/crates/turtle/Cargo.toml index df98aa8f..26ee6771 100644 --- a/crates/turtle/Cargo.toml +++ b/crates/turtle/Cargo.toml @@ -11,24 +11,7 @@ license = { workspace = true } homepage = { workspace = true } repository = { workspace = true } -[features] -default = [ - "clipboard", - "daemon", - "hex", - "sync", - "client", -] - -clipboard = ["arboard"] -daemon = ["pty-proxy"] -pty-proxy = [] -client = [] -hex = ["dep:hex"] -sync = ["urlencoding", "reqwest", "sha2", "hex"] - [dependencies] -argon2 = "0.5" async-trait = "0.1.58" atuin-nucleo-matcher = { workspace = true } atuin-nucleo = { workspace = true } @@ -46,39 +29,24 @@ directories = "6.0.0" eyre = "0.6" fs-err = "3.1" fs4 = "0.13.1" -futures = "0.3" -futures-util = "0.3" fuzzy-matcher = "0.3.7" -generic-array = { version = "0.14", features = ["serde"] } -getrandom = "0.2" -glob-match = "0.2.1" -hex = { version = "0.4", optional = true } -humantime = "2.1.0" hyper-util = "0.1" -imara-diff = "0.2" indicatif = "0.18.0" interim = { version = "0.2.0", features = ["time_0_3"] } itertools = "0.14.0" lasso = { version = "0.7", features = ["multi-threaded"] } log = "0.4" -memchr = "2.7" metrics = "0.24" metrics-exporter-prometheus = { version = "0.18", default-features = false } -minijinja = "2.9.0" minspan = "0.1.5" norm = { version = "0.1.1", features = ["fzf-v2"] } notify = "7" -open = "5" -palette = { version = "0.7.5", features = ["serializing"] } -pretty_assertions = "1.3.0" prost = "0.14" -prost-types = "0.14" rand = { version = "0.8.5", features = ["std"] } ratatui = "0.30.0" regex = "1.10.5" -reqwest = { version = "0.13", optional = true, features = ["json", "rustls-no-provider", "stream"], default-features = false } +reqwest = { version = "0.13", features = ["json", "rustls-no-provider", "stream"], default-features = false } rmp = { version = "0.8.14" } -rpassword = "7.0" runtime-format = "0.1.3" rustix = { version = "1.1.4", features = ["process", "fs"] } rustls = { version = "0.23", default-features = false, features = [ "ring", "std", "tls12", ] } @@ -89,15 +57,9 @@ serde = { version = "1.0.202", features = ["derive"] } serde_json = "1.0.119" serde_regex = "1.1.0" serde_with = "3.8.1" -sha2 = { version = "0.10", optional = true } shellexpand = "3" -shlex = "1.3.0" sql-builder = "3" sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "time", "postgres", "uuid", "sqlite", "regexp"] } -strum = { version = "0.27", features = ["strum_macros"] } -strum_macros = "0.27" -sysinfo = "0.30.7" -tempfile = { version = "3.19" } thiserror = "2" time = { version = "0.3.47", features = [ "serde-human-readable", "macros", "local-offset", "macros", "formatting", "parsing"] } tokio = { version = "1", features = ["full"] } @@ -105,7 +67,6 @@ tokio-stream = { version = "0.1.14", features = ["net"] } toml_edit = "0.25.4" tonic = "0.14" tonic-prost = "0.14" -tonic-types = "0.14" tower = "0.5" tower-http = { version = "0.6", features = ["trace"] } tracing = "0.1" @@ -115,14 +76,12 @@ typed-builder = "0.18.2" unicode-segmentation = "1.11.0" unicode-width = "0.2" url = "2.5.2" -urlencoding = { version = "2.1.0", optional = true } uuid = { version = "1.9", features = ["v4", "v7", "serde"] } vt100 = "0.16" whoami = "2.1.0" -xxhash-rust = { version = "0.8", features = ["xxh3"] } [target.'cfg(target_os = "linux")'.dependencies] -arboard = { version = "3.4", optional = true, default-features = false, features = [ "wayland-data-control", ] } +arboard = { version = "3.4", default-features = false, features = [ "wayland-data-control", ] } listenfd = "1.0.1" [target.'cfg(unix)'.dependencies] @@ -131,14 +90,10 @@ portable-pty = "0.9" signal-hook = "0.3" [dev-dependencies] -tracing-tree = "0.4" -divan = "0.1.14" tokio = { version = "1", features = ["full"] } -testing_logger = "0.1.1" [build-dependencies] protox = "0.9" -tonic-build = "0.14" tonic-prost-build = "0.14" [package.metadata.docs.rs] diff --git a/crates/turtle/build.rs b/crates/turtle/build.rs index 5f26e26c..ad4bc3c8 100644 --- a/crates/turtle/build.rs +++ b/crates/turtle/build.rs @@ -1,6 +1,7 @@ use std::process::Command; use std::{env, fs, path::PathBuf}; +use protox::Compiler; use protox::prost::Message; fn main() -> Result<(), std::io::Error> { @@ -24,16 +25,24 @@ fn main() -> Result<(), std::io::Error> { ]; let proto_include_dirs = ["proto"]; - let file_descriptors = protox::compile(proto_paths, proto_include_dirs).unwrap(); + let file_descriptor_set = Compiler::new(proto_include_dirs) + .map_err(std::io::Error::other)? + .include_source_info(true) + .include_imports(true) + .open_files(proto_paths) + .map_err(std::io::Error::other)? + .file_descriptor_set(); let file_descriptor_path = PathBuf::from(env::var_os("OUT_DIR").expect("OUT_DIR not set")) .join("file_descriptor_set.bin"); - fs::write(&file_descriptor_path, file_descriptors.encode_to_vec()).unwrap(); + fs::write(&file_descriptor_path, file_descriptor_set.encode_to_vec()).unwrap(); tonic_prost_build::configure() .build_server(true) .file_descriptor_set_path(&file_descriptor_path) .skip_protoc_run() - .compile_protos(&proto_paths, &proto_include_dirs) + .compile_protos(&proto_paths, &proto_include_dirs)?; } + + Ok(()) } diff --git a/crates/turtle/src/atuin_client/api_client.rs b/crates/turtle/src/atuin_client/api_client.rs index 15d96d93..bd5bf59e 100644 --- a/crates/turtle/src/atuin_client/api_client.rs +++ b/crates/turtle/src/atuin_client/api_client.rs @@ -19,8 +19,8 @@ static APP_USER_AGENT: &str = concat!("atuin/", env!("CARGO_PKG_VERSION"),); pub(crate) struct Client<'a> { sync_addr: &'a str, - client: reqwest::Client, user_id: Uuid, + inner: reqwest::Client, } fn make_url(address: &str, path: &str, user_id: Uuid) -> Result<String> { @@ -28,7 +28,7 @@ fn make_url(address: &str, path: &str, user_id: Uuid) -> Result<String> { // `join()` expects a trailing `/` in order to join paths // e.g. it treats `http://host:port/subdir` as a file called `subdir` - let address = &format!("{address}/api/v0/{}/", user_id.to_string()); + let address = &format!("{address}/api/v0/{user_id}/"); // passing a path with a leading `/` will cause `join()` to replace the entire URL path let path = path.strip_prefix("/").unwrap_or(path); @@ -81,7 +81,7 @@ async fn handle_resp_error(resp: Response) -> Result<Response> { } if !status.is_success() { - if let Ok(error) = resp.json::<ErrorResponse>().await { + if let Ok(error) = resp.json::<ErrorResponse<'_>>().await { let reason = error.reason; if status.is_client_error() { @@ -117,7 +117,7 @@ impl<'a> Client<'a> { Ok(Client { user_id, sync_addr, - client: reqwest::Client::builder() + inner: reqwest::Client::builder() .user_agent(APP_USER_AGENT) .default_headers(headers) .connect_timeout(Duration::new(connect_timeout, 0)) @@ -130,7 +130,7 @@ impl<'a> Client<'a> { let url = make_url(self.sync_addr, "/store", self.user_id)?; let url = Url::parse(url.as_str())?; - let resp = self.client.delete(url).send().await?; + let resp = self.inner.delete(url).send().await?; handle_resp_error(resp).await?; @@ -143,7 +143,7 @@ impl<'a> Client<'a> { debug!("uploading {} records to {url}", records.len()); - let resp = self.client.post(url).json(records).send().await?; + let resp = self.inner.post(url).json(records).send().await?; handle_resp_error(resp).await?; Ok(()) @@ -169,7 +169,7 @@ impl<'a> Client<'a> { let url = Url::parse(url.as_str())?; - let resp = self.client.get(url).send().await?; + let resp = self.inner.get(url).send().await?; let resp = handle_resp_error(resp).await?; let records = resp.json::<Vec<Record<EncryptedData>>>().await?; @@ -181,7 +181,7 @@ impl<'a> Client<'a> { let url = make_url(self.sync_addr, "/record", self.user_id)?; let url = Url::parse(url.as_str())?; - let resp = self.client.get(url).send().await?; + let resp = self.inner.get(url).send().await?; let resp = handle_resp_error(resp).await?; if !ensure_version(&resp)? { diff --git a/crates/turtle/src/atuin_client/encryption.rs b/crates/turtle/src/atuin_client/encryption.rs index e9c8d7e9..661a6669 100644 --- a/crates/turtle/src/atuin_client/encryption.rs +++ b/crates/turtle/src/atuin_client/encryption.rs @@ -75,7 +75,7 @@ pub(crate) fn decode_key(key: String) -> Result<Key> { if let Ok(key) = <[u8; 32]>::try_from(&*buf) { Ok(key.into()) } else { - let mut bytes = rmp::decode::Bytes::new(&buf); + let mut bytes = decode::Bytes::new(&buf); match Marker::from_u8(buf[0]) { Marker::Bin8 => { @@ -91,7 +91,7 @@ pub(crate) fn decode_key(key: String) -> Result<Key> { let mut key = Key::default(); for i in &mut key { - *i = rmp::decode::read_int(&mut bytes).map_err(|err| eyre!("{err:?}"))?; + *i = decode::read_int(&mut bytes).map_err(|err| eyre!("{err:?}"))?; } Ok(key) } diff --git a/crates/turtle/src/atuin_client/history.rs b/crates/turtle/src/atuin_client/history.rs index 1f89cd71..11d60548 100644 --- a/crates/turtle/src/atuin_client/history.rs +++ b/crates/turtle/src/atuin_client/history.rs @@ -272,7 +272,7 @@ impl History { Ok(History { id: id.to_owned().into(), - timestamp: OffsetDateTime::from_unix_timestamp_nanos(timestamp as i128)?, + timestamp: OffsetDateTime::from_unix_timestamp_nanos(i128::from(timestamp))?, duration, exit, command: command.to_owned(), @@ -282,7 +282,7 @@ impl History { author: Self::author_from_hostname(hostname), intent: None, deleted_at: deleted_at - .map(|t| OffsetDateTime::from_unix_timestamp_nanos(t as i128)) + .map(|t| OffsetDateTime::from_unix_timestamp_nanos(i128::from(t))) .transpose()?, }) } @@ -553,7 +553,7 @@ mod tests { fn disable_secrets() { let settings = Settings { secrets_filter: false, - ..Settings::utc() + ..Settings::new().unwrap() }; let stripe_key: History = History::capture() diff --git a/crates/turtle/src/atuin_client/history/store.rs b/crates/turtle/src/atuin_client/history/store.rs index c6e079f3..9c7771cc 100644 --- a/crates/turtle/src/atuin_client/history/store.rs +++ b/crates/turtle/src/atuin_client/history/store.rs @@ -27,12 +27,12 @@ pub(crate) enum HistoryRecord { } impl HistoryRecord { - /// Serialize a history record, returning DecryptedData + /// Serialize a history record, returning `DecryptedData` /// The record will be of a certain type /// We map those like so: /// - /// HistoryRecord::Create -> 0 - /// HistoryRecord::Delete-> 1 + /// `HistoryRecord::Create` -> 0 + /// `HistoryRecord::Delete`-> 1 /// /// This numeric identifier is then written as the first byte to the buffer. For history, we /// append the serialized history right afterwards, to avoid having to handle serialization @@ -47,7 +47,7 @@ impl HistoryRecord { let mut output = vec![]; match self { - HistoryRecord::Create(history) => { + Self::Create(history) => { // 0 -> a history create encode::write_u8(&mut output, 0)?; @@ -55,12 +55,12 @@ impl HistoryRecord { encode::write_bin(&mut output, &bytes.0)?; } - HistoryRecord::Delete(id) => { + Self::Delete(id) => { // 1 -> a history delete encode::write_u8(&mut output, 1)?; encode::write_str(&mut output, id.0.as_str())?; } - }; + } Ok(DecryptedData(output)) } @@ -81,11 +81,11 @@ impl HistoryRecord { 0 => { // not super useful to us atm, but perhaps in the future // written by write_bin above - let _ = decode::read_bin_len(&mut bytes).map_err(error_report)?; + decode::read_bin_len(&mut bytes).map_err(error_report)?; let record = History::deserialize(bytes.remaining_slice(), version)?; - Ok(HistoryRecord::Create(record)) + Ok(Self::Create(record)) } // 1 -> HistoryRecord::Delete @@ -99,7 +99,7 @@ impl HistoryRecord { ); } - Ok(HistoryRecord::Delete(id.to_string().into())) + Ok(Self::Delete(id.to_string().into())) } n => { @@ -111,7 +111,7 @@ impl HistoryRecord { impl HistoryStore { pub(crate) fn new(store: SqliteStore, host_id: HostId, encryption_key: [u8; 32]) -> Self { - HistoryStore { + Self { store, host_id, encryption_key, @@ -182,7 +182,7 @@ impl HistoryStore { } /// Delete a batch of history entries via the record store. - /// Returns the record IDs so the caller can run incremental_build when ready. + /// Returns the record IDs so the caller can run `incremental_build` when ready. pub(crate) async fn delete_entries( &self, entries: impl IntoIterator<Item = History>, @@ -209,7 +209,7 @@ impl HistoryStore { let records = self.store.all_tagged(HISTORY_TAG).await?; let mut ret = Vec::with_capacity(records.len()); - for record in records.into_iter() { + for record in records { let hist = match record.version.as_str() { HISTORY_VERSION_V0 | HISTORY_VERSION => { let version = record.version.clone(); @@ -266,33 +266,28 @@ impl HistoryStore { for id in ids { let record = self.store.get(*id).await; - let record = match record { - Ok(record) => record, - _ => { + if let Ok(record) = record { + if record.tag != HISTORY_TAG { continue; } - }; - if record.tag != HISTORY_TAG { - continue; - } + let version = record.version.clone(); + let decrypted = record.decrypt::<PASETO_V4>(&self.encryption_key)?; + let record = match version.as_str() { + HISTORY_VERSION_V0 | HISTORY_VERSION => { + HistoryRecord::deserialize(&decrypted.data, version.as_str())? + } + version => bail!("unknown history version {version:?}"), + }; - let version = record.version.clone(); - let decrypted = record.decrypt::<PASETO_V4>(&self.encryption_key)?; - let record = match version.as_str() { - HISTORY_VERSION_V0 | HISTORY_VERSION => { - HistoryRecord::deserialize(&decrypted.data, version.as_str())? - } - version => bail!("unknown history version {version:?}"), - }; - - match record { - HistoryRecord::Create(h) => { - // TODO: benchmark CPU time/memory tradeoff of batch commit vs one at a time - database.save(&h).await?; - } - HistoryRecord::Delete(id) => { - database.delete_rows(&[id]).await?; + match record { + HistoryRecord::Create(h) => { + // TODO: benchmark CPU time/memory tradeoff of batch commit vs one at a time + database.save(&h).await?; + } + HistoryRecord::Delete(id) => { + database.delete_rows(&[id]).await?; + } } } } @@ -306,10 +301,13 @@ impl HistoryStore { pub(crate) async fn history_ids(&self) -> Result<HashSet<HistoryId>> { let history = self.history().await?; - let ret = HashSet::from_iter(history.iter().map(|h| match h { - HistoryRecord::Create(h) => h.id.clone(), - HistoryRecord::Delete(id) => id.clone(), - })); + let ret = history + .iter() + .map(|h| match h { + HistoryRecord::Create(h) => h.id.clone(), + HistoryRecord::Delete(id) => id.clone(), + }) + .collect::<HashSet<_>>(); Ok(ret) } @@ -320,7 +318,7 @@ impl HistoryStore { ProgressStyle::with_template("{spinner:.blue} {msg}") .unwrap() .with_key("eta", |state: &ProgressState, w: &mut dyn Write| { - write!(w, "{:.1}s", state.eta().as_secs_f64()).unwrap() + write!(w, "{:.1}s", state.eta().as_secs_f64()).unwrap(); }) .progress_chars("#>-"), ); diff --git a/crates/turtle/src/atuin_client/record/encryption.rs b/crates/turtle/src/atuin_client/record/encryption.rs index 70723bb7..afac5b02 100644 --- a/crates/turtle/src/atuin_client/record/encryption.rs +++ b/crates/turtle/src/atuin_client/record/encryption.rs @@ -54,16 +54,16 @@ that happens in the background can make the network calls to the HSM impl Encryption for PASETO_V4 { fn re_encrypt( mut data: EncryptedData, - _ad: AdditionalData, + _ad: AdditionalData<'_>, old_key: &[u8; 32], new_key: &[u8; 32], ) -> Result<EncryptedData> { - let cek = Self::decrypt_cek(data.content_encryption_key, old_key)?; + let cek = Self::decrypt_cek(&data.content_encryption_key, old_key)?; data.content_encryption_key = Self::encrypt_cek(cek, new_key); Ok(data) } - fn encrypt(data: DecryptedData, ad: AdditionalData, key: &[u8; 32]) -> EncryptedData { + fn encrypt(data: DecryptedData, ad: AdditionalData<'_>, key: &[u8; 32]) -> EncryptedData { // generate a random key for this entry // aka content-encryption-key (CEK) let random_key = Key::<V4, Local>::new_os_random(); @@ -91,9 +91,13 @@ impl Encryption for PASETO_V4 { } } - fn decrypt(data: EncryptedData, ad: AdditionalData, key: &[u8; 32]) -> Result<DecryptedData> { + fn decrypt( + data: EncryptedData, + ad: AdditionalData<'_>, + key: &[u8; 32], + ) -> Result<DecryptedData> { let token = data.data; - let cek = Self::decrypt_cek(data.content_encryption_key, key)?; + let cek = Self::decrypt_cek(&data.content_encryption_key, key)?; // encode the implicit assertions let assertions = Assertions::from(ad).encode(); @@ -114,12 +118,12 @@ impl Encryption for PASETO_V4 { } impl PASETO_V4 { - fn decrypt_cek(wrapped_cek: String, key: &[u8; 32]) -> Result<Key<V4, Local>> { + fn decrypt_cek(wrapped_cek: &str, key: &[u8; 32]) -> Result<Key<V4, Local>> { let wrapping_key = Key::<V4, Local>::from_bytes(*key); // let wrapping_key = PasetoSymmetricKey::from(Key::from(key)); - let AtuinFooter { kid, wpk } = serde_json::from_str(&wrapped_cek) + let AtuinFooter { kid, wpk } = serde_json::from_str(wrapped_cek) .context("wrapped cek did not contain the correct contents")?; // check that the wrapping key matches the required key to decrypt. @@ -262,7 +266,7 @@ mod tests { let data = DecryptedData(vec![1, 2, 3, 4]); let encrypted = PASETO_V4::encrypt(data, ad, &key.to_bytes()); - let _ = PASETO_V4::decrypt(encrypted, ad, &fake_key.to_bytes()).unwrap_err(); + drop(PASETO_V4::decrypt(encrypted, ad, &fake_key.to_bytes()).unwrap_err()); } #[test] @@ -285,7 +289,7 @@ mod tests { id: &RecordId(uuid_v7()), ..ad }; - let _ = PASETO_V4::decrypt(encrypted, ad, &key.to_bytes()).unwrap_err(); + drop(PASETO_V4::decrypt(encrypted, ad, &key.to_bytes()).unwrap_err()); } #[test] diff --git a/crates/turtle/src/atuin_client/record/sqlite_store.rs b/crates/turtle/src/atuin_client/record/sqlite_store.rs index 24188443..12f9fc4e 100644 --- a/crates/turtle/src/atuin_client/record/sqlite_store.rs +++ b/crates/turtle/src/atuin_client/record/sqlite_store.rs @@ -353,18 +353,17 @@ impl SqliteStore { pub(crate) async fn purge(&self, key: &[u8; 32]) -> Result<()> { let all = self.load_all().await?; - for record in all.iter() { - match record.clone().decrypt::<PASETO_V4>(key) { - Ok(_) => continue, - Err(_) => { - println!( - "Failed to decrypt {}, deleting", - record.id.0.as_hyphenated() - ); - - self.delete(record.id).await?; - } + for record in &all { + if record.clone().decrypt::<PASETO_V4>(key).is_ok() { + continue; } + + println!( + "Failed to decrypt {}, deleting", + record.id.0.as_hyphenated() + ); + + self.delete(record.id).await?; } Ok(()) @@ -379,7 +378,6 @@ mod tests { settings::test_local_timeout, }, atuin_common::{ - self, record::{DecryptedData, EncryptedData, Host, HostId, Record}, utils::uuid_v7, }, @@ -389,9 +387,9 @@ mod tests { fn test_record() -> Record<EncryptedData> { Record::builder() - .host(Host::new(HostId(atuin_common::utils::uuid_v7()))) + .host(Host::new(HostId(uuid_v7()))) .version("v1".into()) - .tag(atuin_common::utils::uuid_v7().simple().to_string()) + .tag(uuid_v7().simple().to_string()) .data(EncryptedData { data: "1234".into(), content_encryption_key: "1234".into(), @@ -507,80 +505,6 @@ mod tests { } #[tokio::test] - async fn len_different_tags() { - let db = SqliteStore::new(":memory:", test_local_timeout()) - .await - .unwrap(); - - // these have different tags, so the len should be the same - // we model multiple stores within one database - // new store = new tag = independent length - let first = test_record(); - let second = test_record(); - - db.push(&first).await.unwrap(); - db.push(&second).await.unwrap(); - - let first_len = db.len(first.host.id, first.tag.as_str()).await.unwrap(); - let second_len = db.len(second.host.id, second.tag.as_str()).await.unwrap(); - - assert_eq!(first_len, 1, "expected length of 1 after insert"); - assert_eq!(second_len, 1, "expected length of 1 after insert"); - } - - #[tokio::test] - async fn append_a_bunch() { - let db = SqliteStore::new(":memory:", test_local_timeout()) - .await - .unwrap(); - - let mut tail = test_record(); - db.push(&tail).await.expect("failed to push record"); - - for _ in 1..100 { - tail = tail.append(vec![1, 2, 3, 4]).encrypt::<PASETO_V4>(&[0; 32]); - db.push(&tail).await.unwrap(); - } - - assert_eq!( - db.len(tail.host.id, tail.tag.as_str()).await.unwrap(), - 100, - "failed to insert 100 records" - ); - - assert_eq!( - db.len_tag(tail.tag.as_str()).await.unwrap(), - 100, - "failed to insert 100 records" - ); - } - - #[tokio::test] - async fn append_a_big_bunch() { - let db = SqliteStore::new(":memory:", test_local_timeout()) - .await - .unwrap(); - - let mut records: Vec<Record<EncryptedData>> = Vec::with_capacity(10000); - - let mut tail = test_record(); - records.push(tail.clone()); - - for _ in 1..10000 { - tail = tail.append(vec![1, 2, 3]).encrypt::<PASETO_V4>(&[0; 32]); - records.push(tail.clone()); - } - - db.push_batch(records.iter()).await.unwrap(); - - assert_eq!( - db.len(tail.host.id, tail.tag.as_str()).await.unwrap(), - 10000, - "failed to insert 10k records" - ); - } - - #[tokio::test] async fn re_encrypt() { let store = SqliteStore::new(":memory:", test_local_timeout()) .await diff --git a/crates/turtle/src/atuin_client/record/sync.rs b/crates/turtle/src/atuin_client/record/sync.rs index a86fc7a9..3057bb10 100644 --- a/crates/turtle/src/atuin_client/record/sync.rs +++ b/crates/turtle/src/atuin_client/record/sync.rs @@ -14,9 +14,6 @@ use indicatif::{ProgressBar, ProgressState, ProgressStyle}; #[derive(Error, Debug)] pub(crate) enum SyncError { - #[error("the local store is ahead of the remote, but for another host. has remote lost data?")] - LocalAheadOtherHost, - #[error("an issue with the local database occurred: {msg:?}")] LocalStoreError { msg: String }, @@ -319,7 +316,7 @@ pub(crate) async fn sync_remote( downloaded.append(&mut d); } - Operation::Noop { .. } => continue, + Operation::Noop { .. } => (), } } @@ -376,14 +373,13 @@ pub(crate) async fn sync( #[cfg(test)] mod tests { + use crate::atuin_client::record::sync::Operation; use crate::atuin_common::record::{Diff, EncryptedData, HostId, Record}; - use pretty_assertions::assert_eq; use crate::atuin_client::{ record::{ - encryption::PASETO_V4, sqlite_store::SqliteStore, - sync::{self, Operation}, + sync::{self}, }, settings::test_local_timeout, }; @@ -442,7 +438,7 @@ mod tests { assert_eq!(diff.len(), 1); - let operations = sync::operations(diff, &store).await.unwrap(); + let operations = sync::operations(diff, &store).unwrap(); assert_eq!(operations.len(), 1); @@ -456,211 +452,4 @@ mod tests { } ); } - - #[tokio::test] - async fn build_two_way_diff() { - // a diff where local is ahead of remote for one, and remote for - // another. One upload, one download - - let shared_record = test_record(); - let remote_ahead = test_record(); - - let local_ahead = shared_record - .append(vec![1, 2, 3]) - .encrypt::<PASETO_V4>(&[0; 32]); - - assert_eq!(local_ahead.idx, 1); - - let local = vec![shared_record.clone(), local_ahead.clone()]; // local knows about the already synced, and something newer in the same store - let remote = vec![shared_record.clone(), remote_ahead.clone()]; // remote knows about the already-synced, and one new record in a new store - - let (store, diff) = build_test_diff(local, remote).await; - let operations = sync::operations(diff, &store).await.unwrap(); - - 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, - 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, - local: None, - remote: 0, - }, - ] - ); - } - - #[tokio::test] - async fn build_complex_diff() { - // One shared, ahead but known only by remote - // One known only by local - // One known only by remote - - let shared_record = test_record(); - let local_only = test_record(); - - let local_only_20 = test_record(); - let local_only_21 = local_only_20 - .append(vec![1, 2, 3]) - .encrypt::<PASETO_V4>(&[0; 32]); - let local_only_22 = local_only_21 - .append(vec![1, 2, 3]) - .encrypt::<PASETO_V4>(&[0; 32]); - let local_only_23 = local_only_22 - .append(vec![1, 2, 3]) - .encrypt::<PASETO_V4>(&[0; 32]); - - let remote_only = test_record(); - - let remote_only_20 = test_record(); - let remote_only_21 = remote_only_20 - .append(vec![2, 3, 2]) - .encrypt::<PASETO_V4>(&[0; 32]); - let remote_only_22 = remote_only_21 - .append(vec![2, 3, 2]) - .encrypt::<PASETO_V4>(&[0; 32]); - let remote_only_23 = remote_only_22 - .append(vec![2, 3, 2]) - .encrypt::<PASETO_V4>(&[0; 32]); - let remote_only_24 = remote_only_23 - .append(vec![2, 3, 2]) - .encrypt::<PASETO_V4>(&[0; 32]); - - let second_shared = test_record(); - let second_shared_remote_ahead = second_shared - .append(vec![1, 2, 3]) - .encrypt::<PASETO_V4>(&[0; 32]); - let second_shared_remote_ahead2 = second_shared_remote_ahead - .append(vec![1, 2, 3]) - .encrypt::<PASETO_V4>(&[0; 32]); - - let third_shared = test_record(); - let third_shared_local_ahead = third_shared - .append(vec![1, 2, 3]) - .encrypt::<PASETO_V4>(&[0; 32]); - let third_shared_local_ahead2 = third_shared_local_ahead - .append(vec![1, 2, 3]) - .encrypt::<PASETO_V4>(&[0; 32]); - - let fourth_shared = test_record(); - let fourth_shared_remote_ahead = fourth_shared - .append(vec![1, 2, 3]) - .encrypt::<PASETO_V4>(&[0; 32]); - let fourth_shared_remote_ahead2 = fourth_shared_remote_ahead - .append(vec![1, 2, 3]) - .encrypt::<PASETO_V4>(&[0; 32]); - - let local = vec![ - shared_record.clone(), - second_shared.clone(), - third_shared.clone(), - fourth_shared.clone(), - fourth_shared_remote_ahead.clone(), - // single store, only local has it - local_only.clone(), - // bigger store, also only known by local - local_only_20.clone(), - local_only_21.clone(), - local_only_22.clone(), - local_only_23.clone(), - // another shared store, but local is ahead on this one - third_shared_local_ahead.clone(), - third_shared_local_ahead2.clone(), - ]; - - let remote = vec![ - remote_only.clone(), - remote_only_20.clone(), - remote_only_21.clone(), - remote_only_22.clone(), - remote_only_23.clone(), - remote_only_24.clone(), - shared_record.clone(), - second_shared.clone(), - third_shared.clone(), - second_shared_remote_ahead.clone(), - second_shared_remote_ahead2.clone(), - fourth_shared.clone(), - fourth_shared_remote_ahead.clone(), - fourth_shared_remote_ahead2.clone(), - ]; // remote knows about the already-synced, and one new record in a new store - - let (store, diff) = build_test_diff(local, remote).await; - let operations = sync::operations(diff, &store).await.unwrap(); - - assert_eq!(operations.len(), 7); - - let mut result_ops = vec![ - // We started with a shared record, but the remote knows of two newer records in the - // same store - Operation::Download { - local: Some(0), - remote: 2, - host: second_shared_remote_ahead.host.id, - tag: second_shared_remote_ahead.tag, - }, - // We have a shared record, local knows of the first two but not the last - Operation::Download { - local: Some(1), - remote: 2, - host: fourth_shared_remote_ahead2.host.id, - tag: fourth_shared_remote_ahead2.tag, - }, - // Remote knows of a store with a single record that local does not have - Operation::Download { - local: None, - remote: 0, - host: remote_only.host.id, - tag: remote_only.tag, - }, - // Remote knows of a store with a bunch of records that local does not have - Operation::Download { - local: None, - remote: 4, - host: remote_only_20.host.id, - tag: remote_only_20.tag, - }, - // Local knows of a record in a store that remote does not have - Operation::Upload { - local: 0, - remote: None, - host: local_only.host.id, - tag: local_only.tag, - }, - // Local knows of 4 records in a store that remote does not have - Operation::Upload { - local: 3, - remote: None, - host: local_only_20.host.id, - tag: local_only_20.tag, - }, - // Local knows of 2 more records in a shared store that remote only has one of - Operation::Upload { - local: 2, - remote: Some(0), - host: third_shared.host.id, - tag: third_shared.tag, - }, - ]; - - result_ops.sort_by_key(|op| match op { - Operation::Noop { host, tag } => (0, *host, tag.clone()), - - Operation::Upload { host, tag, .. } => (1, *host, tag.clone()), - - Operation::Download { host, tag, .. } => (2, *host, tag.clone()), - }); - - assert_eq!(result_ops, operations); - } } diff --git a/crates/turtle/src/atuin_client/secrets.rs b/crates/turtle/src/atuin_client/secrets.rs index 30723890..74d47ea6 100644 --- a/crates/turtle/src/atuin_client/secrets.rs +++ b/crates/turtle/src/atuin_client/secrets.rs @@ -16,7 +16,7 @@ type SpType<'a> = &'a [(&'a str, &'a str, TestValue<'a>)]; type SpType<'a> = &'a [(&'a str, &'a str)]; /// A list of `(name, regex, test)`, where `test` should match against `regex`. -pub(crate) static SECRET_PATTERNS: SpType = &[ +pub(crate) static SECRET_PATTERNS: SpType<'_> = &[ ( "AWS Access Key ID", "A[KS]IA[0-9A-Z]{16}", diff --git a/crates/turtle/src/atuin_client/settings.rs b/crates/turtle/src/atuin_client/settings.rs index c966ba67..074b2634 100644 --- a/crates/turtle/src/atuin_client/settings.rs +++ b/crates/turtle/src/atuin_client/settings.rs @@ -47,7 +47,7 @@ pub(crate) enum SearchMode { } impl SearchMode { - pub(crate) fn as_str(&self) -> &'static str { + pub(crate) fn as_str(self) -> &'static str { match self { SearchMode::Prefix => "PREFIX", SearchMode::FullText => "FULLTXT", @@ -56,7 +56,7 @@ impl SearchMode { SearchMode::DaemonFuzzy => "DAEMON", } } - pub(crate) fn next(&self, settings: &Settings) -> Self { + pub(crate) fn next(self, settings: &Settings) -> Self { match self { SearchMode::Prefix => SearchMode::FullText, // if the user is using skim, we go to skim @@ -94,7 +94,7 @@ pub(crate) enum FilterMode { } impl FilterMode { - pub(crate) fn as_str(&self) -> &'static str { + pub(crate) fn as_str(self) -> &'static str { match self { FilterMode::Global => "GLOBAL", FilterMode::Host => "HOST", @@ -427,7 +427,7 @@ pub(crate) struct Search { pub(crate) frecency_score_multiplier: f64, } -/// Log level for file logging. Maps to tracing's LevelFilter. +/// Log level for file logging. Maps to tracing's [`LevelFilter`]. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize)] #[serde(rename_all = "lowercase")] pub(crate) enum LogLevel { @@ -440,8 +440,8 @@ pub(crate) enum LogLevel { } impl LogLevel { - /// Convert to a tracing directive string for use with EnvFilter. - pub(crate) fn as_directive(&self) -> &'static str { + /// Convert to a tracing directive string for use with [`EnvFilter`]. + pub(crate) fn as_directive(self) -> &'static str { match self { LogLevel::Trace => "trace", LogLevel::Debug => "debug", @@ -478,7 +478,7 @@ pub(crate) struct Logs { pub(crate) dir: String, /// Default log level for file logging. Defaults to "info". - /// Note: ATUIN_LOG environment variable overrides this. + /// Note: [`ATUIN_LOG`] environment variable overrides this. #[serde(default)] pub(crate) level: LogLevel, @@ -641,7 +641,7 @@ pub(crate) enum UiColumnType { impl UiColumnType { /// Returns the default width for this column type (in characters). /// The Command column returns 0 as it expands to fill remaining space. - pub(crate) fn default_width(&self) -> u16 { + pub(crate) fn default_width(self) -> u16 { match self { UiColumnType::Duration => 5, // "814ms" UiColumnType::Time => 9, // "459ms ago" @@ -683,7 +683,7 @@ impl UiColumn { // Custom deserialize to handle both string and object formats: // "duration" or { type = "duration", width = 8, expand = true } -impl<'de> serde::Deserialize<'de> for UiColumn { +impl<'de> Deserialize<'de> for UiColumn { fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> where D: serde::Deserializer<'de>, @@ -695,7 +695,7 @@ impl<'de> serde::Deserialize<'de> for UiColumn { impl<'de> Visitor<'de> for UiColumnVisitor { type Value = UiColumn; - fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter.write_str( "a column type string or an object with 'type' and optional 'width'/'expand'", ) @@ -706,7 +706,7 @@ impl<'de> serde::Deserialize<'de> for UiColumn { E: de::Error, { let column_type: UiColumnType = - serde::Deserialize::deserialize(serde::de::value::StrDeserializer::new(value))?; + Deserialize::deserialize(de::value::StrDeserializer::new(value))?; Ok(UiColumn::new(column_type)) } @@ -730,7 +730,7 @@ impl<'de> serde::Deserialize<'de> for UiColumn { expand = Some(map.next_value()?); } _ => { - let _: serde::de::IgnoredAny = map.next_value()?; + let _: de::IgnoredAny = map.next_value()?; } } } @@ -979,18 +979,19 @@ impl Settings { } pub(crate) fn builder() -> Result<ConfigBuilder<DefaultState>> { - Self::builder_with_data_dir(&crate::atuin_common::utils::data_dir()) + Self::builder_with_data_dir(&utils::data_dir()) } + #[expect(clippy::too_many_lines)] fn builder_with_data_dir(data_dir: &std::path::Path) -> Result<ConfigBuilder<DefaultState>> { let db_path = data_dir.join("history.db"); let record_store_path = data_dir.join("records.db"); let kv_path = data_dir.join("kv.db"); let scripts_path = data_dir.join("scripts.db"); let ai_sessions_path = data_dir.join("ai_sessions.db"); - let socket_path = crate::atuin_common::utils::runtime_dir().join("atuin.sock"); + let socket_path = utils::runtime_dir().join("atuin.sock"); let pidfile_path = data_dir.join("atuin-daemon.pid"); - let logs_dir = crate::atuin_common::utils::logs_dir(); + let logs_dir = utils::logs_dir(); let key_path = data_dir.join("key"); let meta_path = data_dir.join("meta.db"); @@ -1091,10 +1092,10 @@ impl Settings { .set_default("tmux.height", "60%")? .set_default( "prefers_reduced_motion", - std::env::var("NO_MOTION") - .ok() - .map(|_| config::Value::new(None, config::ValueKind::Boolean(true))) - .unwrap_or_else(|| config::Value::new(None, config::ValueKind::Boolean(false))), + std::env::var("NO_MOTION").ok().map_or_else( + || config::Value::new(None, config::ValueKind::Boolean(false)), + |_| config::Value::new(None, config::ValueKind::Boolean(true)), + ), )? .set_default("no_mouse", false)? .add_source( @@ -1105,18 +1106,19 @@ impl Settings { } pub(crate) fn get_config_path() -> Result<PathBuf> { - let config_dir = crate::atuin_common::utils::config_dir(); + let config_dir = utils::config_dir(); create_dir_all(&config_dir) .wrap_err_with(|| format!("could not create dir {}", config_dir.display()))?; - let mut config_file = if let Ok(p) = std::env::var("ATUIN_CONFIG_DIR") { - PathBuf::from(p) - } else { - let mut config_file = PathBuf::new(); - config_file.push(config_dir); - config_file - }; + let mut config_file = std::env::var("ATUIN_CONFIG_DIR").map_or_else( + |_| { + let mut config_file = PathBuf::new(); + config_file.push(config_dir); + config_file + }, + PathBuf::from, + ); config_file.push("config.toml"); @@ -1162,16 +1164,16 @@ impl Settings { .map_err(|e| eyre!("failed to expand data_dir path: {}", e))?; PathBuf::from(expanded.as_ref()) } - None => crate::atuin_common::utils::data_dir(), + None => utils::data_dir(), } } else { - crate::atuin_common::utils::data_dir() + utils::data_dir() }; DATA_DIR.set(effective_data_dir.clone()).ok(); create_dir_all(&effective_data_dir) - .wrap_err_with(|| format!("could not create dir {effective_data_dir:?}"))?; + .wrap_err_with(|| format!("could not create dir {}", effective_data_dir.display()))?; let mut config_builder = Self::builder_with_data_dir(&effective_data_dir)?; @@ -1205,7 +1207,7 @@ impl Settings { ] .iter() .map(|key| (key, built.get_string(key).unwrap_or_default())) - .filter_map(|(key, value)| match Self::expand_path(value) { + .filter_map(|(key, value)| match Self::expand_path(&value) { Ok(expanded) => Some((key, expanded)), Err(e) => { log::warn!("failed to expand path for {key}: {e}"); @@ -1263,7 +1265,7 @@ impl Settings { let full_key = if prefix.is_empty() { k.clone() } else { - format!("{}.{}", prefix, k) + format!("{prefix}.{k}") }; match &v.kind { @@ -1302,7 +1304,7 @@ impl Settings { Ok(settings) } - fn expand_path(path: String) -> Result<String> { + fn expand_path(path: &str) -> Result<String> { shellexpand::full(&path) .map(|p| p.to_string()) .map_err(|e| eyre!("failed to expand path: {}", e)) diff --git a/crates/turtle/src/atuin_client/settings/watcher.rs b/crates/turtle/src/atuin_client/settings/watcher.rs index e280480c..5eec3692 100644 --- a/crates/turtle/src/atuin_client/settings/watcher.rs +++ b/crates/turtle/src/atuin_client/settings/watcher.rs @@ -134,7 +134,7 @@ impl SettingsWatcher { warn!( "config watcher: event has no paths, triggering reload to be safe" ); - let _ = debounce_tx.send(()); + debounce_tx.send(()).expect("should still be active"); return; } @@ -163,7 +163,7 @@ impl SettingsWatcher { ) { debug!("config file event detected: {event:?}"); // Send to debounce channel (ignore send errors - receiver might be gone) - let _ = debounce_tx.send(()); + debounce_tx.send(()).ok(); } } Err(e) => { diff --git a/crates/turtle/src/atuin_common/record.rs b/crates/turtle/src/atuin_common/record.rs index 8a10804c..7b2a1a10 100644 --- a/crates/turtle/src/atuin_common/record.rs +++ b/crates/turtle/src/atuin_common/record.rs @@ -30,7 +30,7 @@ pub(crate) struct Host { impl Host { pub(crate) fn new(id: HostId) -> Self { - Host { + Self { id, name: String::new(), } @@ -117,8 +117,8 @@ impl RecordStatus { self.hosts.entry(host).or_default().insert(tag, tail_id); } - pub(crate) fn get(&self, host: HostId, tag: String) -> Option<RecordIdx> { - self.hosts.get(&host).and_then(|v| v.get(&tag)).copied() + pub(crate) fn get(&self, host: HostId, tag: &str) -> Option<RecordIdx> { + self.hosts.get(&host).and_then(|v| v.get(tag)).copied() } /// Diff this index with another, likely remote index. @@ -134,7 +134,7 @@ impl RecordStatus { // First, we check if other has everything that self has for (host, tag_map) in &self.hosts { for (tag, idx) in tag_map { - match other.get(*host, tag.clone()) { + match other.get(*host, tag) { // The other store is all up to date! No diff. Some(t) if t.eq(idx) => (), @@ -163,7 +163,7 @@ impl RecordStatus { // account for that! for (host, tag_map) in &other.hosts { for (tag, idx) in tag_map { - match self.get(*host, tag.clone()) { + match self.get(*host, tag) { // If we have this host/tag combo, the comparison and diff will have already happened above Some(_) => (), @@ -186,15 +186,19 @@ impl RecordStatus { pub(crate) trait Encryption { fn re_encrypt( data: EncryptedData, - ad: AdditionalData, + ad: AdditionalData<'_>, old_key: &[u8; 32], new_key: &[u8; 32], ) -> Result<EncryptedData> { let data = Self::decrypt(data, ad, old_key)?; Ok(Self::encrypt(data, ad, new_key)) } - fn encrypt(data: DecryptedData, ad: AdditionalData, key: &[u8; 32]) -> EncryptedData; - fn decrypt(data: EncryptedData, ad: AdditionalData, key: &[u8; 32]) -> Result<DecryptedData>; + fn encrypt(data: DecryptedData, ad: AdditionalData<'_>, key: &[u8; 32]) -> EncryptedData; + fn decrypt( + data: EncryptedData, + ad: AdditionalData<'_>, + key: &[u8; 32], + ) -> Result<DecryptedData>; } impl Record<DecryptedData> { @@ -266,8 +270,7 @@ impl Record<EncryptedData> { mod tests { use crate::atuin_common::record::{Host, HostId}; - use super::{DecryptedData, Record, RecordStatus}; - use pretty_assertions::assert_eq; + use super::{DecryptedData, Record}; fn test_record() -> Record<DecryptedData> { Record::builder() @@ -278,55 +281,4 @@ mod tests { .idx(0) .build() } - - #[test] - fn record_index_multi_diff() { - // A much more complex case, with a bunch more checks - let mut index1 = RecordStatus::new(); - let mut index2 = RecordStatus::new(); - - let store1record1 = test_record(); - let store1record2 = store1record1.append(vec![1, 2, 3]); - - let store2record1 = test_record(); - let store2record2 = store2record1.append(vec![1, 2, 3]); - - let store3record1 = test_record(); - - let store4record1 = test_record(); - - // index1 only knows about the first two entries of the first two stores - index1.set(store1record1); - index1.set(store2record1); - - // index2 is fully up to date with the first two stores, and knows of a third - index2.set(store1record2); - index2.set(store2record2); - index2.set(store3record1); - - // index1 knows of a 4th store - index1.set(store4record1); - - let diff1 = index1.diff(&index2); - let diff2 = index2.diff(&index1); - - // both diffs the same length - assert_eq!(4, diff1.len()); - assert_eq!(4, diff2.len()); - - dbg!(&diff1, &diff2); - - // both diffs should be ALMOST the same. They will agree on which hosts and tags - // require updating, but the "other" value will not be the same. - let smol_diff_1: Vec<(HostId, String)> = - diff1.iter().map(|v| (v.host, v.tag.clone())).collect(); - let smol_diff_2: Vec<(HostId, String)> = - diff1.iter().map(|v| (v.host, v.tag.clone())).collect(); - - assert_eq!(smol_diff_1, smol_diff_2); - - // diffing with yourself = no diff - assert_eq!(index1.diff(&index1).len(), 0); - assert_eq!(index2.diff(&index2).len(), 0); - } } diff --git a/crates/turtle/src/atuin_common/utils.rs b/crates/turtle/src/atuin_common/utils.rs index ba0c8eb7..c8c2776e 100644 --- a/crates/turtle/src/atuin_common/utils.rs +++ b/crates/turtle/src/atuin_common/utils.rs @@ -81,19 +81,19 @@ pub(crate) fn home_dir() -> PathBuf { pub(crate) fn config_dir() -> PathBuf { let config_dir = - std::env::var("XDG_CONFIG_HOME").map_or_else(|_| home_dir().join(".config"), PathBuf::from); + env::var("XDG_CONFIG_HOME").map_or_else(|_| home_dir().join(".config"), PathBuf::from); config_dir.join("atuin") } pub(crate) fn data_dir() -> PathBuf { - let data_dir = std::env::var("XDG_DATA_HOME") + let data_dir = env::var("XDG_DATA_HOME") .map_or_else(|_| home_dir().join(".local").join("share"), PathBuf::from); data_dir.join("atuin") } pub(crate) fn runtime_dir() -> PathBuf { - std::env::var("XDG_RUNTIME_DIR").map_or_else(|_| data_dir(), PathBuf::from) + env::var("XDG_RUNTIME_DIR").map_or_else(|_| data_dir(), PathBuf::from) } pub(crate) fn logs_dir() -> PathBuf { @@ -217,7 +217,7 @@ mod tests { fn in_git_repo_regular() { // regular git repo should resolve to the directory containing .git let tmp = std::env::temp_dir().join("atuin-test-regular-git"); - let _ = std::fs::remove_dir_all(&tmp); + drop(std::fs::remove_dir_all(&tmp)); let subdir = tmp.join("src").join("deep"); std::fs::create_dir_all(&subdir).unwrap(); std::fs::create_dir_all(tmp.join(".git")).unwrap(); @@ -234,7 +234,7 @@ mod tests { // worktree .git is a file pointing back to the main repo — // in_git_repo should follow it so all worktrees share a workspace let tmp = std::env::temp_dir().join("atuin-test-worktree-git"); - let _ = std::fs::remove_dir_all(&tmp); + drop(std::fs::remove_dir_all(&tmp)); // main repo at tmp/main with a real .git directory let main_repo = tmp.join("main"); diff --git a/crates/turtle/src/atuin_daemon/client.rs b/crates/turtle/src/atuin_daemon/client.rs index 325b21b8..4ec1a60b 100644 --- a/crates/turtle/src/atuin_daemon/client.rs +++ b/crates/turtle/src/atuin_daemon/client.rs @@ -1,5 +1,3 @@ -use crate::atuin_client::database::Context; -use crate::atuin_client::settings::{FilterMode, Settings}; use eyre::{Context as EyreContext, Result}; use tonic::Code; use tonic::transport::{Channel, Endpoint, Uri}; @@ -9,27 +7,38 @@ use hyper_util::rt::TokioIo; #[cfg(unix)] use tokio::net::UnixStream; - -use crate::atuin_client::history::History; use tracing::{Level, instrument, span}; -use crate::atuin_daemon::control::HistoryRebuiltEvent; -use crate::atuin_daemon::control::{ - ForceSyncEvent, HistoryDeletedEvent, HistoryPrunedEvent, SendEventRequest, - SettingsReloadedEvent, ShutdownEvent, control_client::ControlClient as ControlServiceClient, -}; -use crate::atuin_daemon::events::DaemonEvent; -use crate::atuin_daemon::history::{ - EndHistoryReply, EndHistoryRequest, ShutdownRequest, StartHistoryReply, StartHistoryRequest, - StatusReply, StatusRequest, TailHistoryReply, TailHistoryRequest, - history_client::HistoryClient as HistoryServiceClient, -}; -use crate::atuin_daemon::search::{ - FilterMode as RpcFilterMode, SearchContext as RpcSearchContext, SearchRequest, SearchResponse, - search_client::SearchClient as SearchServiceClient, -}; -use crate::atuin_daemon::semantic::{ - CommandCapture, RecordCommandsReply, semantic_client::SemanticClient as SemanticServiceClient, +use crate::atuin_daemon::generated; +use crate::{ + atuin_client::{ + database::Context, + history::History, + settings::{FilterMode, Settings}, + }, + atuin_daemon::{ + events::DaemonEvent, + generated::{ + control::{ + ForceSyncEvent, HistoryDeletedEvent, HistoryPrunedEvent, HistoryRebuiltEvent, + SendEventRequest, SettingsReloadedEvent, ShutdownEvent, + control_client::ControlClient as ControlServiceClient, + }, + history::{ + EndHistoryReply, EndHistoryRequest, ShutdownRequest, StartHistoryReply, + StartHistoryRequest, StatusReply, StatusRequest, TailHistoryReply, + TailHistoryRequest, history_client::HistoryClient as HistoryServiceClient, + }, + search::{ + FilterMode as RpcFilterMode, SearchContext as RpcSearchContext, SearchRequest, + SearchResponse, search_client::SearchClient as SearchServiceClient, + }, + semantic::{ + CommandCapture, RecordCommandsReply, + semantic_client::SemanticClient as SemanticServiceClient, + }, + }, + }, }; pub(crate) struct HistoryClient { @@ -311,10 +320,8 @@ impl ControlClient { } /// Convert a daemon event to its proto representation. -fn daemon_event_to_proto( - event: DaemonEvent, -) -> crate::atuin_daemon::control::send_event_request::Event { - use crate::atuin_daemon::control::send_event_request::Event; +fn daemon_event_to_proto(event: DaemonEvent) -> generated::control::send_event_request::Event { + use generated::control::send_event_request::Event; match event { DaemonEvent::HistoryPruned => Event::HistoryPruned(HistoryPrunedEvent {}), diff --git a/crates/turtle/src/atuin_daemon/components/history.rs b/crates/turtle/src/atuin_daemon/components/history.rs index b71543c1..b4f91b06 100644 --- a/crates/turtle/src/atuin_daemon/components/history.rs +++ b/crates/turtle/src/atuin_daemon/components/history.rs @@ -18,7 +18,7 @@ use tracing::{Level, instrument}; use crate::atuin_daemon::{ daemon::{Component, DaemonHandle}, events::DaemonEvent, - history::{ + generated::history::{ EndHistoryReply, EndHistoryRequest, HistoryEntry, HistoryEventKind, ShutdownReply, ShutdownRequest, StartHistoryReply, StartHistoryRequest, StatusReply, StatusRequest, TailHistoryReply, TailHistoryRequest, @@ -144,8 +144,8 @@ impl HistorySvc for HistoryGrpcService { ) -> Result<Response<StartHistoryReply>, Status> { let req = request.into_inner(); - let timestamp = - OffsetDateTime::from_unix_timestamp_nanos(req.timestamp as i128).map_err(|_| { + let timestamp = OffsetDateTime::from_unix_timestamp_nanos(i128::from(req.timestamp)) + .map_err(|_| { Status::invalid_argument( "failed to parse timestamp as unix time (expected nanos since epoch)", ) @@ -181,6 +181,7 @@ impl HistorySvc for HistoryGrpcService { } #[instrument(skip_all, level = Level::INFO)] + #[expect(clippy::significant_drop_tightening, reason = "Would be a logic-bug")] async fn end_history( &self, request: Request<EndHistoryRequest>, @@ -216,11 +217,7 @@ impl HistorySvc for HistoryGrpcService { .await .map_err(|e| Status::internal(format!("failed to write to db: {e:?}")))?; - tracing::info!( - id = id.0.to_string(), - duration = history.duration, - "end history" - ); + tracing::info!(id = id.0, duration = history.duration, "end history"); // Push to record store let (record_id, idx) = history_store @@ -247,6 +244,7 @@ impl HistorySvc for HistoryGrpcService { } #[instrument(skip_all, level = Level::INFO)] + #[expect(clippy::significant_drop_tightening, reason = "Would be a logic-bug")] async fn tail_history( &self, _request: Request<TailHistoryRequest>, @@ -265,11 +263,12 @@ impl HistorySvc for HistoryGrpcService { let event = match rx.recv().await { Ok(event) => event, Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => { - let _ = tx - .send(Err(Status::resource_exhausted(format!( + drop( + tx.send(Err(Status::resource_exhausted(format!( "tail stream lagged behind and dropped {skipped} events" )))) - .await; + .await, + ); break; } Err(tokio::sync::broadcast::error::RecvError::Closed) => break, diff --git a/crates/turtle/src/atuin_daemon/components/search.rs b/crates/turtle/src/atuin_daemon/components/search.rs index 832d05d8..39df758b 100644 --- a/crates/turtle/src/atuin_daemon/components/search.rs +++ b/crates/turtle/src/atuin_daemon/components/search.rs @@ -15,10 +15,11 @@ use uuid::Uuid; use crate::atuin_daemon::{ daemon::{Component, DaemonHandle}, events::DaemonEvent, - search::{ - FilterMode, IndexFilterMode, QueryContext, SearchIndex, SearchRequest, SearchResponse, + generated::search::{ + self, FilterMode, SearchRequest, SearchResponse, search_server::{Search as SearchSvc, SearchServer}, }, + search::{IndexFilterMode, QueryContext, SearchIndex}, }; const PAGE_SIZE: usize = 5000; @@ -35,7 +36,7 @@ const FRECENCY_REFRESH_INTERVAL_SECS: u64 = 60; /// - Provides the Search gRPC service pub(crate) struct SearchComponent { index: Arc<RwLock<SearchIndex>>, - handle: tokio::sync::RwLock<Option<DaemonHandle>>, + handle: RwLock<Option<DaemonHandle>>, loader_handle: Option<tokio::task::JoinHandle<()>>, frecency_handle: Option<tokio::task::JoinHandle<()>>, } @@ -45,7 +46,7 @@ impl SearchComponent { pub(crate) fn new() -> Self { Self { index: Arc::new(RwLock::new(SearchIndex::new())), - handle: tokio::sync::RwLock::new(None), + handle: RwLock::new(None), loader_handle: None, frecency_handle: None, } @@ -351,7 +352,7 @@ impl SearchSvc for SearchGrpcService { } } Err(e) => { - let _ = tx.send(Err(e)).await; + drop(tx.send(Err(e)).await); break; } } @@ -367,7 +368,7 @@ impl SearchSvc for SearchGrpcService { /// Convert proto FilterMode and context to IndexFilterMode. fn convert_filter_mode( mode: FilterMode, - context: &Option<crate::atuin_daemon::search::SearchContext>, + context: &Option<search::SearchContext>, ) -> IndexFilterMode { match (mode, context) { (FilterMode::Global, _) => IndexFilterMode::Global, diff --git a/crates/turtle/src/atuin_daemon/components/semantic.rs b/crates/turtle/src/atuin_daemon/components/semantic.rs index 052c2d73..69ffc134 100644 --- a/crates/turtle/src/atuin_daemon/components/semantic.rs +++ b/crates/turtle/src/atuin_daemon/components/semantic.rs @@ -9,6 +9,7 @@ use std::fmt::{Display, Formatter}; use std::sync::Arc; use crate::atuin_client::history::{History, HistoryId}; +use crate::atuin_daemon::generated::semantic; use eyre::Result; use tokio::sync::Mutex; use tonic::{Request, Response, Status, Streaming}; @@ -17,7 +18,7 @@ use tracing::{Level, instrument}; use crate::atuin_daemon::{ daemon::{Component, DaemonHandle}, events::DaemonEvent, - semantic::{ + generated::semantic::{ CommandCapture, CommandOutputReply, CommandOutputRequest, OutputLine, RecordCommandsReply, semantic_server::{Semantic as SemanticSvc, SemanticServer}, }, @@ -244,7 +245,7 @@ impl SemanticState { fn command_output_for_ref( &self, capture_ref: &CaptureRef, - ranges: &[crate::atuin_daemon::semantic::OutputRange], + ranges: &[semantic::OutputRange], ) -> Option<CommandOutputReply> { let stored = self .sessions @@ -534,17 +535,14 @@ fn command_output_not_found() -> CommandOutputReply { } } -fn select_output_ranges( - output: &str, - ranges: &[crate::atuin_daemon::semantic::OutputRange], -) -> Vec<OutputLine> { +fn select_output_ranges(output: &str, ranges: &[semantic::OutputRange]) -> Vec<OutputLine> { let lines: Vec<&str> = output.lines().collect(); if lines.is_empty() { return Vec::new(); } let ranges = if ranges.is_empty() { - vec![crate::atuin_daemon::semantic::OutputRange { start: 0, end: 999 }] + vec![semantic::OutputRange { start: 0, end: 999 }] } else { ranges.to_vec() }; @@ -627,9 +625,20 @@ fn log_record(record: &SemanticCommandRecord, message: &'static str) { #[cfg(test)] mod tests { - use super::*; use time::OffsetDateTime; + use crate::{ + atuin_client::history::{History, HistoryId}, + atuin_daemon::{ + components::semantic::{ + MAX_COMMANDS_PER_SESSION, MAX_SESSIONS, SemanticCommandRecord, SemanticState, + SessionCaptures, SessionId, select_output_ranges, + }, + generated::semantic::{self, CommandOutputReply, CommandOutputRequest, OutputLine}, + }, + atuin_pty_proxy::CommandCapture, + }; + fn history(id: &str, session: &str, command: &str) -> History { History { id: HistoryId(id.to_string()), @@ -819,8 +828,8 @@ mod tests { fn output_ranges_are_line_based_inclusive_and_support_negative_offsets() { let output = "zero\none\ntwo\nthree\nfour"; let ranges = vec![ - crate::atuin_daemon::semantic::OutputRange { start: 1, end: 2 }, - crate::atuin_daemon::semantic::OutputRange { start: -2, end: -1 }, + semantic::OutputRange { start: 1, end: 2 }, + semantic::OutputRange { start: -2, end: -1 }, ]; assert_eq!( @@ -841,8 +850,8 @@ mod tests { .collect::<Vec<_>>() .join("\n"); let ranges = vec![ - crate::atuin_daemon::semantic::OutputRange { start: 0, end: 100 }, - crate::atuin_daemon::semantic::OutputRange { + semantic::OutputRange { start: 0, end: 100 }, + semantic::OutputRange { start: -100, end: -1, }, @@ -859,8 +868,8 @@ mod tests { fn output_ranges_can_leave_gaps_for_client_formatting() { let output = "zero\none\ntwo\nthree\nfour"; let ranges = vec![ - crate::atuin_daemon::semantic::OutputRange { start: 0, end: 1 }, - crate::atuin_daemon::semantic::OutputRange { start: 4, end: 4 }, + semantic::OutputRange { start: 0, end: 1 }, + semantic::OutputRange { start: 4, end: 4 }, ]; assert_eq!( @@ -891,8 +900,8 @@ mod tests { fn output_ranges_skip_ranges_fully_outside_output() { let output = "zero\none\ntwo"; let ranges = vec![ - crate::atuin_daemon::semantic::OutputRange { start: 10, end: 20 }, - crate::atuin_daemon::semantic::OutputRange { + semantic::OutputRange { start: 10, end: 20 }, + semantic::OutputRange { start: -20, end: -10, }, diff --git a/crates/turtle/src/atuin_daemon/components/sync.rs b/crates/turtle/src/atuin_daemon/components/sync.rs index fbfbbd67..8b5b621d 100644 --- a/crates/turtle/src/atuin_daemon/components/sync.rs +++ b/crates/turtle/src/atuin_daemon/components/sync.rs @@ -27,9 +27,9 @@ enum SyncCommand { /// Sync state - tracks whether we're in normal operation or retrying after failure. #[derive(Clone, Copy, PartialEq, Eq)] enum SyncState { - /// Normal operation. Periodic syncs only run if auto_sync is enabled. + /// Normal operation. Periodic syncs only run if [`auto_sync`] is enabled. Idle, - /// Retrying after a sync failure. Retries continue regardless of auto_sync + /// Retrying after a sync failure. Retries continue regardless of [`auto_sync`] /// until the sync succeeds. Retrying, } @@ -39,7 +39,7 @@ enum SyncState { /// This component: /// - Runs a background sync loop on a configurable interval /// - Implements exponential backoff on sync failures -/// - Responds to ForceSync events for immediate sync +/// - Responds to [`ForceSync`] events for immediate sync /// - Emits SyncCompleted/SyncFailed events pub(crate) struct SyncComponent { task_handle: Option<tokio::task::JoinHandle<()>>, @@ -80,22 +80,28 @@ impl Component for SyncComponent { } async fn handle_event(&mut self, event: &DaemonEvent) -> Result<()> { - if let DaemonEvent::ForceSync = event { - tracing::info!("force sync requested"); - if let Some(tx) = &self.command_tx { - let _ = tx.send(SyncCommand::ForceSync).await; + match event { + DaemonEvent::ForceSync => { + tracing::info!("force sync requested"); + if let Some(tx) = &self.command_tx { + drop(tx.send(SyncCommand::ForceSync).await); + } + } + DaemonEvent::SyncFailed { error } => { + tracing::error!(?error, "Sync failed."); } + _ => (), } Ok(()) } async fn stop(&mut self) -> Result<()> { if let Some(tx) = &self.command_tx { - let _ = tx.send(SyncCommand::Stop).await; + drop(tx.send(SyncCommand::Stop).await); } if let Some(handle) = self.task_handle.take() { // Give the task a moment to shut down gracefully - let _ = tokio::time::timeout(std::time::Duration::from_secs(5), handle).await; + drop(time::timeout(Duration::from_secs(5), handle).await); } tracing::info!("sync component stopped"); Ok(()) @@ -126,7 +132,7 @@ async fn sync_loop(handle: DaemonHandle, mut cmd_rx: mpsc::Receiver<SyncCommand> // Don't backoff by more than 30 mins (with a random jitter of up to 1 min) let max_interval: f64 = 60.0 * 30.0 + rand::thread_rng().gen_range(0.0..60.0); - let mut ticker = time::interval(time::Duration::from_secs(settings.daemon.sync_frequency)); + let mut ticker = time::interval(Duration::from_secs(settings.daemon.sync_frequency)); // IMPORTANT: without this, if we miss ticks because a sync takes ages or is otherwise delayed, // we may end up running a lot of syncs in a hot loop. @@ -224,10 +230,10 @@ async fn do_sync_tick( } *ticker = time::interval_at( - tokio::time::Instant::now() + Duration::from_secs(new_interval as u64), - time::Duration::from_secs(new_interval as u64), + time::Instant::now() + Duration::from_secs(new_interval as u64), + Duration::from_secs(new_interval as u64), ); - ticker.reset_after(time::Duration::from_secs(new_interval as u64)); + ticker.reset_after(Duration::from_secs(new_interval as u64)); ticker.set_missed_tick_behavior(MissedTickBehavior::Skip); tracing::error!("backing off, next sync tick in {new_interval}"); @@ -261,9 +267,8 @@ async fn do_sync_tick( // Reset backoff on success if ticker.period().as_secs() != settings.daemon.sync_frequency { *ticker = time::interval_at( - tokio::time::Instant::now() - + Duration::from_secs(settings.daemon.sync_frequency), - time::Duration::from_secs(settings.daemon.sync_frequency), + time::Instant::now() + Duration::from_secs(settings.daemon.sync_frequency), + Duration::from_secs(settings.daemon.sync_frequency), ); ticker.set_missed_tick_behavior(MissedTickBehavior::Skip); } diff --git a/crates/turtle/src/atuin_daemon/control/mod.rs b/crates/turtle/src/atuin_daemon/control/mod.rs index 23068519..7015db5b 100644 --- a/crates/turtle/src/atuin_daemon/control/mod.rs +++ b/crates/turtle/src/atuin_daemon/control/mod.rs @@ -1,12 +1,76 @@ -//! Control module for external event injection. +//! Control service implementation. //! -//! This module provides the gRPC service that allows external processes -//! (like CLI commands) to inject events into the daemon's event bus. +//! This gRPC service allows external processes (like CLI commands) to inject +//! events into the daemon's event bus. -mod service; +use tonic::{Request, Response, Status}; +use tracing::{Level, info, instrument}; -// Include the generated proto code -tonic::include_proto!("control"); +use crate::{ + atuin_client::history::HistoryId, + atuin_daemon::{ + daemon::DaemonHandle, + events::DaemonEvent, + generated::control::{ + SendEventRequest, SendEventResponse, + control_server::{Control, ControlServer}, + send_event_request::Event, + }, + }, +}; -// Re-export the service -pub(crate) use service::ControlService; +/// The Control gRPC service. +/// +/// This service is used by external processes to inject events into the daemon. +/// It's not a component - it's part of the daemon's core infrastructure. +pub(crate) struct ControlService { + handle: DaemonHandle, +} + +impl ControlService { + /// Create a new control service with the given daemon handle. + pub(crate) fn new(handle: DaemonHandle) -> Self { + Self { handle } + } + + /// Get a tonic server for this service. + pub(crate) fn into_server(self) -> ControlServer<Self> { + ControlServer::new(self) + } +} + +#[tonic::async_trait] +impl Control for ControlService { + #[instrument(skip_all, level = Level::INFO, name = "control_send_event")] + async fn send_event( + &self, + request: Request<SendEventRequest>, + ) -> Result<Response<SendEventResponse>, Status> { + let req = request.into_inner(); + + let event = req + .event + .ok_or_else(|| Status::invalid_argument("event is required"))?; + + let daemon_event = proto_event_to_daemon_event(event)?; + + info!(?daemon_event, "received control event"); + self.handle.emit(daemon_event); + + Ok(Response::new(SendEventResponse {})) + } +} + +/// Convert a proto event to a daemon event. +fn proto_event_to_daemon_event(event: Event) -> Result<DaemonEvent, Status> { + match event { + Event::HistoryPruned(_) => Ok(DaemonEvent::HistoryPruned), + Event::HistoryRebuilt(_) => Ok(DaemonEvent::HistoryRebuilt), + Event::HistoryDeleted(e) => Ok(DaemonEvent::HistoryDeleted { + ids: e.ids.into_iter().map(HistoryId).collect(), + }), + Event::ForceSync(_) => Ok(DaemonEvent::ForceSync), + Event::SettingsReloaded(_) => Ok(DaemonEvent::SettingsReloaded), + Event::Shutdown(_) => Ok(DaemonEvent::ShutdownRequested), + } +} diff --git a/crates/turtle/src/atuin_daemon/control/service.rs b/crates/turtle/src/atuin_daemon/control/service.rs deleted file mode 100644 index 8061a3c2..00000000 --- a/crates/turtle/src/atuin_daemon/control/service.rs +++ /dev/null @@ -1,71 +0,0 @@ -//! Control service implementation. -//! -//! This gRPC service allows external processes (like CLI commands) to inject -//! events into the daemon's event bus. - -use crate::atuin_client::history::HistoryId; -use tonic::{Request, Response, Status}; -use tracing::{Level, info, instrument}; - -use super::{ - SendEventRequest, SendEventResponse, - control_server::{Control, ControlServer}, - send_event_request::Event, -}; -use crate::atuin_daemon::{daemon::DaemonHandle, events::DaemonEvent}; - -/// The Control gRPC service. -/// -/// This service is used by external processes to inject events into the daemon. -/// It's not a component - it's part of the daemon's core infrastructure. -pub(crate) struct ControlService { - handle: DaemonHandle, -} - -impl ControlService { - /// Create a new control service with the given daemon handle. - pub(crate) fn new(handle: DaemonHandle) -> Self { - Self { handle } - } - - /// Get a tonic server for this service. - pub(crate) fn into_server(self) -> ControlServer<Self> { - ControlServer::new(self) - } -} - -#[tonic::async_trait] -impl Control for ControlService { - #[instrument(skip_all, level = Level::INFO, name = "control_send_event")] - async fn send_event( - &self, - request: Request<SendEventRequest>, - ) -> Result<Response<SendEventResponse>, Status> { - let req = request.into_inner(); - - let event = req - .event - .ok_or_else(|| Status::invalid_argument("event is required"))?; - - let daemon_event = proto_event_to_daemon_event(event)?; - - info!(?daemon_event, "received control event"); - self.handle.emit(daemon_event); - - Ok(Response::new(SendEventResponse {})) - } -} - -/// Convert a proto event to a daemon event. -fn proto_event_to_daemon_event(event: Event) -> Result<DaemonEvent, Status> { - match event { - Event::HistoryPruned(_) => Ok(DaemonEvent::HistoryPruned), - Event::HistoryRebuilt(_) => Ok(DaemonEvent::HistoryRebuilt), - Event::HistoryDeleted(e) => Ok(DaemonEvent::HistoryDeleted { - ids: e.ids.into_iter().map(HistoryId).collect(), - }), - Event::ForceSync(_) => Ok(DaemonEvent::ForceSync), - Event::SettingsReloaded(_) => Ok(DaemonEvent::SettingsReloaded), - Event::Shutdown(_) => Ok(DaemonEvent::ShutdownRequested), - } -} diff --git a/crates/turtle/src/atuin_daemon/events.rs b/crates/turtle/src/atuin_daemon/events.rs index 09369512..d379277d 100644 --- a/crates/turtle/src/atuin_daemon/events.rs +++ b/crates/turtle/src/atuin_daemon/events.rs @@ -32,8 +32,11 @@ pub(crate) enum DaemonEvent { /// Sync completed successfully. SyncCompleted { /// Number of records uploaded. + #[expect(unused)] uploaded: usize, + /// Number of records downloaded. + #[expect(unused)] downloaded: usize, }, diff --git a/crates/turtle/src/atuin_daemon/generated.rs b/crates/turtle/src/atuin_daemon/generated.rs new file mode 100644 index 00000000..e43f7523 --- /dev/null +++ b/crates/turtle/src/atuin_daemon/generated.rs @@ -0,0 +1,38 @@ +#![allow( + unreachable_pub, + unused_qualifications, + reason = "All of these lints are triggered by the generated code" +)] + +/// Semantic command capture gRPC service types. +pub(crate) mod semantic { + tonic::include_proto!("semantic"); +} + +/// Search module for the daemon gRPC search service. +/// +/// This module provides fuzzy search over command history using Nucleo. +pub(crate) mod search { + // Include the generated proto code + tonic::include_proto!("search"); +} + +/// History module for the daemon gRPC history service. +/// +/// This module contains the proto-generated types for the history gRPC service. +pub(crate) mod history { + // Include the generated proto code + tonic::include_proto!("history"); +} + +/// Control module for external event injection. +/// +/// This module provides the gRPC service that allows external processes +/// (like CLI commands) to inject events into the daemon's event bus. +pub(crate) mod control { + // Include the generated proto code + tonic::include_proto!("control"); + + // Re-export the service + pub(crate) use crate::atuin_daemon::control::ControlService; +} diff --git a/crates/turtle/src/atuin_daemon/history/mod.rs b/crates/turtle/src/atuin_daemon/history/mod.rs deleted file mode 100644 index b71853df..00000000 --- a/crates/turtle/src/atuin_daemon/history/mod.rs +++ /dev/null @@ -1,6 +0,0 @@ -//! History module for the daemon gRPC history service. -//! -//! This module contains the proto-generated types for the history gRPC service. - -// Include the generated proto code -tonic::include_proto!("history"); diff --git a/crates/turtle/src/atuin_daemon/mod.rs b/crates/turtle/src/atuin_daemon/mod.rs index b161b0cc..5f0f489e 100644 --- a/crates/turtle/src/atuin_daemon/mod.rs +++ b/crates/turtle/src/atuin_daemon/mod.rs @@ -8,11 +8,11 @@ pub(crate) mod components; pub(crate) mod control; pub(crate) mod daemon; pub(crate) mod events; -pub(crate) mod history; pub(crate) mod search; -pub(crate) mod semantic; pub(crate) mod server; +pub(crate) mod generated; + // Re-export core daemon types for convenience pub(crate) use daemon::Daemon; pub(crate) use events::DaemonEvent; @@ -91,21 +91,18 @@ pub(crate) async fn boot( signal_handle.shutdown(); }); - // Start the gRPC server in the background server::run_grpc_server( - settings, + &settings, history_service, search_service, semantic_service, control_service.into_server(), handle, - ) - .await?; + )?; - // Run the daemon event loop daemon.run_event_loop().await?; - // Stop all components on shutdown + // After the event loop exited, we shut-down the components. daemon.stop_components().await; tracing::info!("daemon shut down complete"); diff --git a/crates/turtle/src/atuin_daemon/search/index.rs b/crates/turtle/src/atuin_daemon/search/index.rs deleted file mode 100644 index 197a8c1b..00000000 --- a/crates/turtle/src/atuin_daemon/search/index.rs +++ /dev/null @@ -1,678 +0,0 @@ -//! Search index with frecency-based ranking. -//! -//! This module provides a deduplicated search index where each unique command -//! is stored once, with metadata about all its invocations. This enables: -//! -//! - Efficient fuzzy matching (fewer items to match) -//! - Frecency-based ranking (frequency + recency) -//! - Dynamic filtering by directory, host, session, etc. - -use std::{ - collections::{HashMap, HashSet}, - sync::Arc, -}; - -use crate::atuin_client::settings::Search; -use crate::{ - atuin_client::history::History, atuin_daemon::components::search::with_trailing_slash, -}; -use atuin_nucleo::{Injector, Nucleo, pattern}; -use dashmap::DashMap; -use lasso::{Spur, ThreadedRodeo}; -use time::OffsetDateTime; -use tokio::sync::RwLock; -use tracing::{Level, instrument}; -use uuid::Uuid; - -/// Parse a UUID string into a 16-byte array. -/// Returns None if the string is not a valid UUID. -fn parse_uuid_bytes(s: &str) -> Option<[u8; 16]> { - Uuid::parse_str(s).ok().map(|u| *u.as_bytes()) -} - -/// Format a 16-byte array as a UUID string. -fn format_uuid_bytes(bytes: &[u8; 16]) -> String { - Uuid::from_bytes(*bytes).to_string() -} - -/// Pre-computed frecency data for O(1) lookup. -#[derive(Debug, Clone, Default)] -pub(crate) struct FrecencyData { - /// Total number of times this command was used. - pub(crate) count: u32, - /// Most recent usage timestamp (unix seconds). - pub(crate) last_used: i64, -} - -impl FrecencyData { - /// Record a new usage of this command. - pub(crate) fn record_use(&mut self, timestamp: i64) { - self.count += 1; - if timestamp > self.last_used { - self.last_used = timestamp; - } - } - - /// Compute frecency score based on count and recency. - /// - /// Uses a decay function where more recent commands score higher. - /// The formula balances frequency (how often) with recency (how recent). - /// - /// Multipliers allow tuning the relative weights: - /// - `recency_mul`: Multiplier for recency score (default: 1.0) - /// - `frequency_mul`: Multiplier for frequency score (default: 1.0) - /// - /// A multiplier of 0.0 disables that component, 1.0 is unchanged, 2.0 doubles weight. - /// Values like 0.5 reduce weight by half, 1.5 increases by 50%, etc. - #[instrument(level = tracing::Level::TRACE, name = "index_frecency_compute")] - pub(crate) fn compute(&self, now: i64, recency_mul: f64, frequency_mul: f64) -> u32 { - if self.count == 0 { - return 0; - } - - // Time-based decay: score decreases as time passes - let age_seconds = (now - self.last_used).max(0) as u64; - let age_hours = age_seconds / 3600; - - // Decay factor: recent commands get higher scores - // - Last hour: multiplier ~1.0 - // - Last day: multiplier ~0.5 - // - Last week: multiplier ~0.1 - // - Older: multiplier approaches 0 - let recency_score: f64 = match age_hours { - 0 => 100.0, - 1..=6 => 90.0, - 7..=24 => 70.0, - 25..=72 => 50.0, - 73..=168 => 30.0, - 169..=720 => 15.0, - _ => 5.0, - }; - - // Frequency boost: more uses = higher score (with diminishing returns) - let frequency_score = (f64::from(self.count).ln() * 20.0).min(100.0); - - // Apply multipliers and combine scores, then round to u32 - ((recency_score * recency_mul) + (frequency_score * frequency_mul)).round() as u32 - } -} - -/// Data for a unique command. -pub(crate) struct CommandData { - /// History ID of the most recent invocation (16-byte UUID). - most_recent_id: [u8; 16], - /// Timestamp of the most recent invocation. - most_recent_timestamp: i64, - /// Pre-computed global frecency. - pub(crate) global_frecency: FrecencyData, - - // Pre-computed indexes for O(1) filter lookups - // Using HashSet instead of DashSet since CommandData lives inside DashMap (already synchronized) - /// All directories where this command has been run (interned keys). - directories: HashSet<Spur>, - /// All hostnames where this command has been run (interned keys). - hosts: HashSet<Spur>, - /// All sessions where this command has been run (as 16-byte UUIDs). - sessions: HashSet<[u8; 16]>, -} - -impl CommandData { - /// Create a new [`CommandData`] from a history entry. - /// Returns None if the history entry has invalid UUIDs. - pub(crate) fn new(history: &History, interner: &ThreadedRodeo) -> Option<Self> { - let history_id = parse_uuid_bytes(&history.id.0)?; - let session = parse_uuid_bytes(&history.session)?; - let timestamp = history.timestamp.unix_timestamp(); - - let dir_key = interner.get_or_intern(with_trailing_slash(&history.cwd)); - let host_key = interner.get_or_intern(&history.hostname); - - let mut directories = HashSet::new(); - directories.insert(dir_key); - - let mut hosts = HashSet::new(); - hosts.insert(host_key); - - let mut sessions = HashSet::new(); - sessions.insert(session); - - let mut global_frecency = FrecencyData::default(); - global_frecency.record_use(timestamp); - - Some(Self { - most_recent_id: history_id, - most_recent_timestamp: timestamp, - global_frecency, - directories, - hosts, - sessions, - }) - } - - /// Add an invocation from a history entry. - /// Returns false if the history entry has invalid UUIDs. - pub(crate) fn add_invocation(&mut self, history: &History, interner: &ThreadedRodeo) -> bool { - let Some(history_id) = parse_uuid_bytes(&history.id.0) else { - return false; - }; - let Some(session) = parse_uuid_bytes(&history.session) else { - return false; - }; - - let timestamp = history.timestamp.unix_timestamp(); - - // Update global frecency - self.global_frecency.record_use(timestamp); - - // Update pre-computed indexes for O(1) filter lookups - let dir_key = interner.get_or_intern(with_trailing_slash(&history.cwd)); - self.directories.insert(dir_key); - self.hosts.insert(interner.get_or_intern(&history.hostname)); - self.sessions.insert(session); - - // Update most recent if this invocation is newer - if timestamp > self.most_recent_timestamp { - self.most_recent_id = history_id; - self.most_recent_timestamp = timestamp; - } - - true - } - - /// Get the most recent history ID for this command. - pub(crate) fn most_recent_id(&self) -> String { - format_uuid_bytes(&self.most_recent_id) - } - - /// Check if any invocation matches a directory filter (exact match). - /// O(1) lookup using pre-computed index. - pub(crate) fn has_invocation_in_dir(&self, dir: &str, interner: &ThreadedRodeo) -> bool { - interner - .get(dir) - .is_some_and(|spur| self.directories.contains(&spur)) - } - - /// Check if any invocation matches a directory prefix (workspace/git root). - /// O(n) where n = number of unique directories for this command. - pub(crate) fn has_invocation_in_workspace( - &self, - prefix: &str, - interner: &ThreadedRodeo, - ) -> bool { - self.directories - .iter() - .any(|&spur| interner.resolve(&spur).starts_with(prefix)) - } - - /// Check if any invocation matches a hostname. - /// O(1) lookup using pre-computed index. - pub(crate) fn has_invocation_on_host(&self, hostname: &str, interner: &ThreadedRodeo) -> bool { - interner - .get(hostname) - .is_some_and(|spur| self.hosts.contains(&spur)) - } - - /// Check if any invocation matches a session. - /// O(1) lookup using pre-computed index. - pub(crate) fn has_invocation_in_session(&self, session: &str) -> bool { - parse_uuid_bytes(session).is_some_and(|bytes| self.sessions.contains(&bytes)) - } -} - -/// Filter mode for search queries. -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) enum IndexFilterMode { - /// No filtering - search all commands. - Global, - /// Filter to commands run in a specific directory. - Directory(String), - /// Filter to commands run in a workspace (directory prefix). - Workspace(String), - /// Filter to commands run on a specific host. - Host(String), - /// Filter to commands run in a specific session. - Session(String), -} - -/// Context for search queries. -#[derive(Debug, Clone, Default)] -pub(crate) struct QueryContext { - #[expect(dead_code)] - pub(crate) cwd: Option<String>, - #[expect(dead_code)] - pub(crate) git_root: Option<String>, - #[expect(dead_code)] - pub(crate) hostname: Option<String>, - #[expect(dead_code)] - pub(crate) session_id: Option<String>, -} - -/// Shareable frecency map: command -> frecency score. -/// Wrapped in Arc for zero-copy sharing with scorer callbacks. -type FrecencyMap = Arc<HashMap<Arc<str>, u32>>; - -/// A deduplicated search index with frecency-based ranking. -/// -/// Commands are stored by their text, with metadata about all invocations. -/// Nucleo handles fuzzy matching, while frecency is computed via scorer callback. -/// -/// Global frecency is precomputed by a background task and used for scoring. -/// If frecency data is not available, search still works but without frecency ranking; -/// although this should never happen due to precomputing the frecency map. -pub(crate) struct SearchIndex { - /// Map from command text to command data. - /// Using DashMap for concurrent read/write access, wrapped in Arc for sharing with scorer. - /// Keys are Arc<str> to enable zero-copy sharing with frecency_map. - commands: Arc<DashMap<Arc<str>, CommandData>>, - /// Nucleo fuzzy matcher - items are command strings. - nucleo: RwLock<Nucleo<String>>, - /// Injector for adding new commands to Nucleo. - injector: Injector<String>, - /// Precomputed global frecency map. Updated by background task. - frecency_map: RwLock<Option<FrecencyMap>>, - /// String interner for deduplicating cwd, hostname, and directory paths. - interner: Arc<ThreadedRodeo>, -} - -impl SearchIndex { - /// Create a new empty search index. - pub(crate) fn new() -> Self { - let nucleo_config = atuin_nucleo::Config::DEFAULT; - // Single column for command text - let nucleo = Nucleo::<String>::new(nucleo_config, Arc::new(|| {}), None, 1); - let injector = nucleo.injector(); - - Self { - commands: Arc::new(DashMap::new()), - nucleo: RwLock::new(nucleo), - injector, - frecency_map: RwLock::new(None), - interner: Arc::new(ThreadedRodeo::new()), - } - } - - /// Add a history entry to the index. - /// - /// If the command already exists, updates its invocation data. - /// If it's a new command, adds it to both the map and Nucleo. - pub(crate) fn add_history(&self, history: &History) { - let command = history.command.as_str(); - - // DashMap with Arc<str> keys can be looked up with &str via Borrow trait - if let Some(mut entry) = self.commands.get_mut(command) { - // Existing command - just update invocations - entry.add_invocation(history, &self.interner); - } else { - // New command - create Arc<str> once and share it - let Some(data) = CommandData::new(history, &self.interner) else { - return; // Invalid UUIDs, skip this entry - }; - let command_arc: Arc<str> = command.into(); - self.commands.insert(Arc::clone(&command_arc), data); - // Nucleo still needs String (unavoidable copy for fuzzy matching) - self.injector.push(command_arc.to_string(), |cmd, cols| { - cols[0] = cmd.clone().into(); - }); - } - // Note: frecency_map is rebuilt by background task, not invalidated here - } - - /// Add multiple history entries to the index. - pub(crate) fn add_histories(&self, histories: &[History]) { - for history in histories { - self.add_history(history); - } - } - - /// Get the number of unique commands in the index. - pub(crate) fn command_count(&self) -> usize { - self.commands.len() - } - - /// Search for commands matching a query. - /// - /// Returns a list of history IDs (most recent invocation per command). - /// Uses precomputed global frecency for scoring if available. - #[instrument(skip_all, level = tracing::Level::TRACE, name = "index_search", fields(query = %query))] - #[expect( - clippy::significant_drop_tightening, - reason = "The nucleo early drop is a false-positive" - )] - pub(crate) async fn search( - &self, - query: &str, - filter_mode: IndexFilterMode, - // TODO(@bpeetz): Use the query context here <2026-06-12> - #[expect(unused)] context: &QueryContext, - limit: u32, - ) -> Vec<String> { - let mut nucleo = self.nucleo.write().await; - - // Get precomputed frecency map (may be None if not yet computed) - let frecency_map = self.frecency_map.read().await.clone(); - - // Build filter based on mode - let filter = self.build_filter(&filter_mode); - nucleo.set_filter(filter); - - // Build scorer from precomputed frecency (or None if not available) - let scorer = Self::build_scorer(frecency_map); - nucleo.set_scorer(scorer); - - // Update pattern - nucleo.pattern.reparse( - 0, - query, - pattern::CaseMatching::Smart, - pattern::Normalization::Smart, - false, - ); - - tracing::span!(Level::TRACE, "index_search_tick").in_scope(|| { - // Tick until complete - while nucleo.tick(10).running {} - }); - - // Collect results - let snapshot = nucleo.snapshot(); - let matched_count = snapshot.matched_item_count().min(limit); - - tracing::span!(Level::TRACE, "index_search_results").in_scope(|| { - snapshot - .matched_items(..matched_count) - .filter_map(|item| { - let cmd = item.data; - // DashMap<Arc<str>, _>::get accepts &str via Borrow trait - self.commands - .get(cmd.as_str()) - .map(|data| data.most_recent_id()) - }) - .collect() - }) - } - - /// Rebuild the global frecency map. - /// - /// This should be called by a background task periodically. - /// The map is used for scoring search results. - /// - /// Uses multipliers from search settings: - /// - `recency_score_multiplier`: Weight for recency component - /// - `frequency_score_multiplier`: Weight for frequency component - /// - `frecency_score_multiplier`: Overall multiplier for final score - #[instrument(skip_all, level = tracing::Level::DEBUG, name = "rebuild_frecency")] - pub(crate) async fn rebuild_frecency(&self, search_settings: &Search) { - let now = OffsetDateTime::now_utc().unix_timestamp(); - let mut frecency_map: HashMap<Arc<str>, u32> = HashMap::new(); - - // Clamp multipliers to non-negative values to prevent broken frecency ranking - // (negative values would produce unexpected results when cast to u32) - let recency_mul = search_settings.recency_score_multiplier.max(0.0); - let frequency_mul = search_settings.frequency_score_multiplier.max(0.0); - let frecency_mul = search_settings.frecency_score_multiplier.max(0.0); - - for entry in self.commands.iter() { - let frecency = entry - .global_frecency - .compute(now, recency_mul, frequency_mul); - // Apply overall frecency multiplier and round to u32 - let frecency = (frecency as f64 * frecency_mul).round() as u32; - // Arc::clone is cheap - just increments reference count - frecency_map.insert(Arc::clone(entry.key()), frecency); - } - - *self.frecency_map.write().await = Some(Arc::new(frecency_map)); - } - - /// Build filter predicate for the given mode. - fn build_filter(&self, mode: &IndexFilterMode) -> Option<atuin_nucleo::Filter<String>> { - // For Global mode, no filter needed - if matches!(mode, IndexFilterMode::Global) { - return None; - } - - // Pre-compute which commands pass the filter - // Use HashSet<String> for the short-lived filter (simpler than Arc lookup) - let passing_commands: Arc<HashSet<String>> = { - let mut set = HashSet::new(); - for entry in self.commands.iter() { - let passes = match mode { - IndexFilterMode::Global => unreachable!(), - IndexFilterMode::Directory(dir) => { - entry.has_invocation_in_dir(dir, &self.interner) - } - IndexFilterMode::Workspace(prefix) => { - entry.has_invocation_in_workspace(prefix, &self.interner) - } - IndexFilterMode::Host(hostname) => { - entry.has_invocation_on_host(hostname, &self.interner) - } - IndexFilterMode::Session(session) => entry.has_invocation_in_session(session), - }; - if passes { - // Convert Arc<str> to String for filter lookup - set.insert(entry.key().to_string()); - } - } - Arc::new(set) - }; - - Some(Arc::new(move |cmd: &String| passing_commands.contains(cmd))) - } - - /// Build scorer from precomputed frecency map. - /// - /// Returns None if frecency map is not available (search still works, just without frecency ranking). - fn build_scorer(frecency_map: Option<FrecencyMap>) -> Option<atuin_nucleo::Scorer<String>> { - let map = frecency_map?; - Some(Arc::new(move |cmd: &String, fuzzy_score: u32| { - // HashMap<Arc<str>, _>::get accepts &str via Borrow trait - let frecency = map.get(cmd.as_str()).copied().unwrap_or(0); - fuzzy_score + frecency - })) - } -} - -impl Default for SearchIndex { - fn default() -> Self { - Self::new() - } -} - -#[cfg(test)] -mod tests { - use super::*; - use time::macros::datetime; - - #[test] - fn frecency_data_compute() { - let now = 1_000_000i64; - - // Recent command (with default multipliers of 1.0) - let recent = FrecencyData { - count: 5, - last_used: now - 60, // 1 minute ago - }; - assert!(recent.compute(now, 1.0, 1.0) > 100); // High score - - // Old command - let old = FrecencyData { - count: 5, - last_used: now - 86400 * 30, // 30 days ago - }; - assert!(old.compute(now, 1.0, 1.0) < recent.compute(now, 1.0, 1.0)); - - // Frequently used old command - let frequent_old = FrecencyData { - count: 100, - last_used: now - 86400 * 7, // 1 week ago - }; - // Should still have decent score due to frequency - assert!(frequent_old.compute(now, 1.0, 1.0) > 50); - } - - #[test] - fn frecency_data_compute_with_multipliers() { - let now = 1_000_000_i64; - - let data = FrecencyData { - count: 5, - last_used: now - 60, // 1 minute ago (recency_score = 100) - }; - - // Default multipliers (1.0, 1.0) - let default_score = data.compute(now, 1.0, 1.0); - - // Double recency weight - let double_recency = data.compute(now, 2.0, 1.0); - assert!(double_recency > default_score); - - // Double frequency weight - let double_frequency = data.compute(now, 1.0, 2.0); - assert!(double_frequency > default_score); - - // Zero out recency (only frequency counts) - let no_recency = data.compute(now, 0.0, 1.0); - assert!(no_recency < default_score); - - // Zero out frequency (only recency counts) - let no_frequency = data.compute(now, 1.0, 0.0); - assert!(no_frequency < default_score); - - // Zero both (should be zero) - let no_score = data.compute(now, 0.0, 0.0); - assert_eq!(no_score, 0); - - // Fractional multipliers - let half_recency = data.compute(now, 0.5, 1.0); - assert!(half_recency < default_score); - assert!(half_recency > no_recency); - - // 1.5x multiplier - let boost_recency = data.compute(now, 1.5, 1.0); - assert!(boost_recency > default_score); - assert!(boost_recency < double_recency); - } - - #[test] - fn command_data_add_invocation() { - let interner = ThreadedRodeo::new(); - - let (dir1, dir2) = if cfg!(windows) { - ("C:\\Users\\User\\project", "C:\\Users\\User\\other") - } else { - ("/home/user/project", "/home/user/other") - }; - - let history1 = make_history("git status", dir1, datetime!(2024-01-01 10:00 UTC)); - let history2 = make_history("git status", dir2, datetime!(2024-01-01 12:00 UTC)); - - let mut data = CommandData::new(&history1, &interner).unwrap(); - assert_eq!(data.global_frecency.count, 1); - let id1 = data.most_recent_id(); - - data.add_invocation(&history2, &interner); - assert_eq!(data.global_frecency.count, 2); - - // Most recent ID should update to history2 (newer timestamp) - let id2 = data.most_recent_id(); - assert_ne!(id1, id2); - } - - #[test] - fn command_data_filters() { - let interner = ThreadedRodeo::new(); - - let (dir1, dir2) = if cfg!(windows) { - ("C:\\Users\\User\\project", "C:\\Users\\User\\other") - } else { - ("/home/user/project", "/home/user/other") - }; - - let h1 = make_history("git status", dir1, datetime!(2024-01-01 10:00 UTC)); - let h2 = make_history("git status", dir2, datetime!(2024-01-01 12:00 UTC)); - - let mut data = CommandData::new(&h1, &interner).unwrap(); - data.add_invocation(&h2, &interner); - - let (check1, check2, check3) = if cfg!(windows) { - ( - with_trailing_slash("C:\\Users\\User\\project"), - with_trailing_slash("C:\\Users\\User\\other"), - with_trailing_slash("C:\\Users\\User\\missing"), - ) - } else { - ( - with_trailing_slash("/home/user/project"), - with_trailing_slash("/home/user/other"), - with_trailing_slash("/home/user/missing"), - ) - }; - - assert!(data.has_invocation_in_dir(&check1, &interner)); - assert!(data.has_invocation_in_dir(&check2, &interner)); - assert!(!data.has_invocation_in_dir(&check3, &interner)); - - let (check1, check2, check3) = if cfg!(windows) { - ( - with_trailing_slash("C:\\Users\\User"), - with_trailing_slash("C:\\Users"), - with_trailing_slash("C:\\Users\\User\\var"), - ) - } else { - ( - with_trailing_slash("/home/user"), - with_trailing_slash("/home"), - with_trailing_slash("/var"), - ) - }; - - assert!(data.has_invocation_in_workspace(&check1, &interner)); - assert!(data.has_invocation_in_workspace(&check2, &interner)); - assert!(!data.has_invocation_in_workspace(&check3, &interner)); - } - - #[tokio::test] - async fn search_index_add_and_search() { - let index = SearchIndex::new(); - - let h1 = make_history( - "git status", - "/home/user/project", - datetime!(2024-01-01 10:00 UTC), - ); - let h2 = make_history( - "git commit -m 'test'", - "/home/user/project", - datetime!(2024-01-01 10:05 UTC), - ); - let h3 = make_history( - "ls -la", - "/home/user/other", - datetime!(2024-01-01 10:10 UTC), - ); - - index.add_history(&h1); - index.add_history(&h2); - index.add_history(&h3); - - assert_eq!(index.command_count(), 3); - - // Search for "git" - should match 2 commands - let results = index - .search("git", IndexFilterMode::Global, &QueryContext::default(), 10) - .await; - assert_eq!(results.len(), 2); - - // Search with directory filter - let results = index - .search( - "", - IndexFilterMode::Directory(with_trailing_slash("/home/user/project")), - &QueryContext::default(), - 10, - ) - .await; - assert_eq!(results.len(), 2); // git status and git commit - } -} diff --git a/crates/turtle/src/atuin_daemon/search/mod.rs b/crates/turtle/src/atuin_daemon/search/mod.rs index 51b6c6cc..60d923cd 100644 --- a/crates/turtle/src/atuin_daemon/search/mod.rs +++ b/crates/turtle/src/atuin_daemon/search/mod.rs @@ -1,11 +1,678 @@ -//! Search module for the daemon gRPC search service. +//! Search index with frecency-based ranking. //! -//! This module provides fuzzy search over command history using Nucleo. +//! This module provides a deduplicated search index where each unique command +//! is stored once, with metadata about all its invocations. This enables: +//! +//! - Efficient fuzzy matching (fewer items to match) +//! - Frecency-based ranking (frequency + recency) +//! - Dynamic filtering by directory, host, session, etc. + +use std::{ + collections::{HashMap, HashSet}, + sync::Arc, +}; + +use crate::atuin_client::settings::Search; +use crate::{ + atuin_client::history::History, atuin_daemon::components::search::with_trailing_slash, +}; +use atuin_nucleo::{Injector, Nucleo, pattern}; +use dashmap::DashMap; +use lasso::{Spur, ThreadedRodeo}; +use time::OffsetDateTime; +use tokio::sync::RwLock; +use tracing::{Level, instrument}; +use uuid::Uuid; + +/// Parse a UUID string into a 16-byte array. +/// Returns None if the string is not a valid UUID. +fn parse_uuid_bytes(s: &str) -> Option<[u8; 16]> { + Uuid::parse_str(s).ok().map(|u| *u.as_bytes()) +} + +/// Format a 16-byte array as a UUID string. +fn format_uuid_bytes(bytes: &[u8; 16]) -> String { + Uuid::from_bytes(*bytes).to_string() +} + +/// Pre-computed frecency data for O(1) lookup. +#[derive(Debug, Clone, Default)] +pub(crate) struct FrecencyData { + /// Total number of times this command was used. + pub(crate) count: u32, + /// Most recent usage timestamp (unix seconds). + pub(crate) last_used: i64, +} + +impl FrecencyData { + /// Record a new usage of this command. + pub(crate) fn record_use(&mut self, timestamp: i64) { + self.count += 1; + if timestamp > self.last_used { + self.last_used = timestamp; + } + } + + /// Compute frecency score based on count and recency. + /// + /// Uses a decay function where more recent commands score higher. + /// The formula balances frequency (how often) with recency (how recent). + /// + /// Multipliers allow tuning the relative weights: + /// - `recency_mul`: Multiplier for recency score (default: 1.0) + /// - `frequency_mul`: Multiplier for frequency score (default: 1.0) + /// + /// A multiplier of 0.0 disables that component, 1.0 is unchanged, 2.0 doubles weight. + /// Values like 0.5 reduce weight by half, 1.5 increases by 50%, etc. + #[instrument(level = Level::TRACE, name = "index_frecency_compute")] + pub(crate) fn compute(&self, now: i64, recency_mul: f64, frequency_mul: f64) -> u32 { + if self.count == 0 { + return 0; + } + + // Time-based decay: score decreases as time passes + let age_seconds = (now - self.last_used).max(0) as u64; + let age_hours = age_seconds / 3600; + + // Decay factor: recent commands get higher scores + // - Last hour: multiplier ~1.0 + // - Last day: multiplier ~0.5 + // - Last week: multiplier ~0.1 + // - Older: multiplier approaches 0 + let recency_score: f64 = match age_hours { + 0 => 100.0, + 1..=6 => 90.0, + 7..=24 => 70.0, + 25..=72 => 50.0, + 73..=168 => 30.0, + 169..=720 => 15.0, + _ => 5.0, + }; + + // Frequency boost: more uses = higher score (with diminishing returns) + let frequency_score = (f64::from(self.count).ln() * 20.0).min(100.0); + + // Apply multipliers and combine scores, then round to u32 + ((recency_score * recency_mul) + (frequency_score * frequency_mul)).round() as u32 + } +} + +/// Data for a unique command. +pub(crate) struct CommandData { + /// History ID of the most recent invocation (16-byte UUID). + most_recent_id: [u8; 16], + /// Timestamp of the most recent invocation. + most_recent_timestamp: i64, + /// Pre-computed global frecency. + pub(crate) global_frecency: FrecencyData, + + // Pre-computed indexes for O(1) filter lookups + // Using HashSet instead of DashSet since CommandData lives inside DashMap (already synchronized) + /// All directories where this command has been run (interned keys). + directories: HashSet<Spur>, + /// All hostnames where this command has been run (interned keys). + hosts: HashSet<Spur>, + /// All sessions where this command has been run (as 16-byte UUIDs). + sessions: HashSet<[u8; 16]>, +} + +impl CommandData { + /// Create a new [`CommandData`] from a history entry. + /// Returns None if the history entry has invalid UUIDs. + pub(crate) fn new(history: &History, interner: &ThreadedRodeo) -> Option<Self> { + let history_id = parse_uuid_bytes(&history.id.0)?; + let session = parse_uuid_bytes(&history.session)?; + let timestamp = history.timestamp.unix_timestamp(); + + let dir_key = interner.get_or_intern(with_trailing_slash(&history.cwd)); + let host_key = interner.get_or_intern(&history.hostname); + + let mut directories = HashSet::new(); + directories.insert(dir_key); + + let mut hosts = HashSet::new(); + hosts.insert(host_key); + + let mut sessions = HashSet::new(); + sessions.insert(session); + + let mut global_frecency = FrecencyData::default(); + global_frecency.record_use(timestamp); + + Some(Self { + most_recent_id: history_id, + most_recent_timestamp: timestamp, + global_frecency, + directories, + hosts, + sessions, + }) + } + + /// Add an invocation from a history entry. + /// Returns false if the history entry has invalid UUIDs. + pub(crate) fn add_invocation(&mut self, history: &History, interner: &ThreadedRodeo) -> bool { + let Some(history_id) = parse_uuid_bytes(&history.id.0) else { + return false; + }; + let Some(session) = parse_uuid_bytes(&history.session) else { + return false; + }; + + let timestamp = history.timestamp.unix_timestamp(); + + // Update global frecency + self.global_frecency.record_use(timestamp); + + // Update pre-computed indexes for O(1) filter lookups + let dir_key = interner.get_or_intern(with_trailing_slash(&history.cwd)); + self.directories.insert(dir_key); + self.hosts.insert(interner.get_or_intern(&history.hostname)); + self.sessions.insert(session); + + // Update most recent if this invocation is newer + if timestamp > self.most_recent_timestamp { + self.most_recent_id = history_id; + self.most_recent_timestamp = timestamp; + } + + true + } + + /// Get the most recent history ID for this command. + pub(crate) fn most_recent_id(&self) -> String { + format_uuid_bytes(&self.most_recent_id) + } + + /// Check if any invocation matches a directory filter (exact match). + /// O(1) lookup using pre-computed index. + pub(crate) fn has_invocation_in_dir(&self, dir: &str, interner: &ThreadedRodeo) -> bool { + interner + .get(dir) + .is_some_and(|spur| self.directories.contains(&spur)) + } + + /// Check if any invocation matches a directory prefix (workspace/git root). + /// O(n) where n = number of unique directories for this command. + pub(crate) fn has_invocation_in_workspace( + &self, + prefix: &str, + interner: &ThreadedRodeo, + ) -> bool { + self.directories + .iter() + .any(|&spur| interner.resolve(&spur).starts_with(prefix)) + } + + /// Check if any invocation matches a hostname. + /// O(1) lookup using pre-computed index. + pub(crate) fn has_invocation_on_host(&self, hostname: &str, interner: &ThreadedRodeo) -> bool { + interner + .get(hostname) + .is_some_and(|spur| self.hosts.contains(&spur)) + } + + /// Check if any invocation matches a session. + /// O(1) lookup using pre-computed index. + pub(crate) fn has_invocation_in_session(&self, session: &str) -> bool { + parse_uuid_bytes(session).is_some_and(|bytes| self.sessions.contains(&bytes)) + } +} + +/// Filter mode for search queries. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum IndexFilterMode { + /// No filtering - search all commands. + Global, + /// Filter to commands run in a specific directory. + Directory(String), + /// Filter to commands run in a workspace (directory prefix). + Workspace(String), + /// Filter to commands run on a specific host. + Host(String), + /// Filter to commands run in a specific session. + Session(String), +} + +/// Context for search queries. +#[derive(Debug, Clone, Default)] +pub(crate) struct QueryContext { + #[expect(dead_code)] + pub(crate) cwd: Option<String>, + #[expect(dead_code)] + pub(crate) git_root: Option<String>, + #[expect(dead_code)] + pub(crate) hostname: Option<String>, + #[expect(dead_code)] + pub(crate) session_id: Option<String>, +} + +/// Shareable frecency map: command -> frecency score. +/// Wrapped in Arc for zero-copy sharing with scorer callbacks. +type FrecencyMap = Arc<HashMap<Arc<str>, u32>>; + +/// A deduplicated search index with frecency-based ranking. +/// +/// Commands are stored by their text, with metadata about all invocations. +/// Nucleo handles fuzzy matching, while frecency is computed via scorer callback. +/// +/// Global frecency is precomputed by a background task and used for scoring. +/// If frecency data is not available, search still works but without frecency ranking; +/// although this should never happen due to precomputing the frecency map. +pub(crate) struct SearchIndex { + /// Map from command text to command data. + /// Using DashMap for concurrent read/write access, wrapped in Arc for sharing with scorer. + /// Keys are Arc<str> to enable zero-copy sharing with frecency_map. + commands: Arc<DashMap<Arc<str>, CommandData>>, + /// Nucleo fuzzy matcher - items are command strings. + nucleo: RwLock<Nucleo<String>>, + /// Injector for adding new commands to Nucleo. + injector: Injector<String>, + /// Precomputed global frecency map. Updated by background task. + frecency_map: RwLock<Option<FrecencyMap>>, + /// String interner for deduplicating cwd, hostname, and directory paths. + interner: Arc<ThreadedRodeo>, +} + +impl SearchIndex { + /// Create a new empty search index. + pub(crate) fn new() -> Self { + let nucleo_config = atuin_nucleo::Config::DEFAULT; + // Single column for command text + let nucleo = Nucleo::<String>::new(nucleo_config, Arc::new(|| {}), None, 1); + let injector = nucleo.injector(); + + Self { + commands: Arc::new(DashMap::new()), + nucleo: RwLock::new(nucleo), + injector, + frecency_map: RwLock::new(None), + interner: Arc::new(ThreadedRodeo::new()), + } + } + + /// Add a history entry to the index. + /// + /// If the command already exists, updates its invocation data. + /// If it's a new command, adds it to both the map and Nucleo. + pub(crate) fn add_history(&self, history: &History) { + let command = history.command.as_str(); + + // DashMap with Arc<str> keys can be looked up with &str via Borrow trait + if let Some(mut entry) = self.commands.get_mut(command) { + // Existing command - just update invocations + entry.add_invocation(history, &self.interner); + } else { + // New command - create Arc<str> once and share it + let Some(data) = CommandData::new(history, &self.interner) else { + return; // Invalid UUIDs, skip this entry + }; + let command_arc: Arc<str> = command.into(); + self.commands.insert(Arc::clone(&command_arc), data); + // Nucleo still needs String (unavoidable copy for fuzzy matching) + self.injector.push(command_arc.to_string(), |cmd, cols| { + cols[0] = cmd.clone().into(); + }); + } + // Note: frecency_map is rebuilt by background task, not invalidated here + } + + /// Add multiple history entries to the index. + pub(crate) fn add_histories(&self, histories: &[History]) { + for history in histories { + self.add_history(history); + } + } + + /// Get the number of unique commands in the index. + pub(crate) fn command_count(&self) -> usize { + self.commands.len() + } + + /// Search for commands matching a query. + /// + /// Returns a list of history IDs (most recent invocation per command). + /// Uses precomputed global frecency for scoring if available. + #[instrument(skip_all, level = Level::TRACE, name = "index_search", fields(query = %query))] + #[expect( + clippy::significant_drop_tightening, + reason = "The nucleo early drop is a false-positive" + )] + pub(crate) async fn search( + &self, + query: &str, + filter_mode: IndexFilterMode, + // TODO(@bpeetz): Use the query context here <2026-06-12> + #[expect(unused)] context: &QueryContext, + limit: u32, + ) -> Vec<String> { + let mut nucleo = self.nucleo.write().await; + + // Get precomputed frecency map (may be None if not yet computed) + let frecency_map = self.frecency_map.read().await.clone(); + + // Build filter based on mode + let filter = self.build_filter(&filter_mode); + nucleo.set_filter(filter); + + // Build scorer from precomputed frecency (or None if not available) + let scorer = Self::build_scorer(frecency_map); + nucleo.set_scorer(scorer); + + // Update pattern + nucleo.pattern.reparse( + 0, + query, + pattern::CaseMatching::Smart, + pattern::Normalization::Smart, + false, + ); + + tracing::span!(Level::TRACE, "index_search_tick").in_scope(|| { + // Tick until complete + while nucleo.tick(10).running {} + }); + + // Collect results + let snapshot = nucleo.snapshot(); + let matched_count = snapshot.matched_item_count().min(limit); + + tracing::span!(Level::TRACE, "index_search_results").in_scope(|| { + snapshot + .matched_items(..matched_count) + .filter_map(|item| { + let cmd = item.data; + // DashMap<Arc<str>, _>::get accepts &str via Borrow trait + self.commands + .get(cmd.as_str()) + .map(|data| data.most_recent_id()) + }) + .collect() + }) + } + + /// Rebuild the global frecency map. + /// + /// This should be called by a background task periodically. + /// The map is used for scoring search results. + /// + /// Uses multipliers from search settings: + /// - `recency_score_multiplier`: Weight for recency component + /// - `frequency_score_multiplier`: Weight for frequency component + /// - `frecency_score_multiplier`: Overall multiplier for final score + #[instrument(skip_all, level = Level::DEBUG, name = "rebuild_frecency")] + pub(crate) async fn rebuild_frecency(&self, search_settings: &Search) { + let now = OffsetDateTime::now_utc().unix_timestamp(); + let mut frecency_map: HashMap<Arc<str>, u32> = HashMap::new(); + + // Clamp multipliers to non-negative values to prevent broken frecency ranking + // (negative values would produce unexpected results when cast to u32) + let recency_mul = search_settings.recency_score_multiplier.max(0.0); + let frequency_mul = search_settings.frequency_score_multiplier.max(0.0); + let frecency_mul = search_settings.frecency_score_multiplier.max(0.0); + + for entry in self.commands.iter() { + let frecency = entry + .global_frecency + .compute(now, recency_mul, frequency_mul); + // Apply overall frecency multiplier and round to u32 + let frecency = (frecency as f64 * frecency_mul).round() as u32; + // Arc::clone is cheap - just increments reference count + frecency_map.insert(Arc::clone(entry.key()), frecency); + } + + *self.frecency_map.write().await = Some(Arc::new(frecency_map)); + } + + /// Build filter predicate for the given mode. + fn build_filter(&self, mode: &IndexFilterMode) -> Option<atuin_nucleo::Filter<String>> { + // For Global mode, no filter needed + if matches!(mode, IndexFilterMode::Global) { + return None; + } + + // Pre-compute which commands pass the filter + // Use HashSet<String> for the short-lived filter (simpler than Arc lookup) + let passing_commands: Arc<HashSet<String>> = { + let mut set = HashSet::new(); + for entry in self.commands.iter() { + let passes = match mode { + IndexFilterMode::Global => unreachable!(), + IndexFilterMode::Directory(dir) => { + entry.has_invocation_in_dir(dir, &self.interner) + } + IndexFilterMode::Workspace(prefix) => { + entry.has_invocation_in_workspace(prefix, &self.interner) + } + IndexFilterMode::Host(hostname) => { + entry.has_invocation_on_host(hostname, &self.interner) + } + IndexFilterMode::Session(session) => entry.has_invocation_in_session(session), + }; + if passes { + // Convert Arc<str> to String for filter lookup + set.insert(entry.key().to_string()); + } + } + Arc::new(set) + }; + + Some(Arc::new(move |cmd: &String| passing_commands.contains(cmd))) + } + + /// Build scorer from precomputed frecency map. + /// + /// Returns None if frecency map is not available (search still works, just without frecency ranking). + fn build_scorer(frecency_map: Option<FrecencyMap>) -> Option<atuin_nucleo::Scorer<String>> { + let map = frecency_map?; + Some(Arc::new(move |cmd: &String, fuzzy_score: u32| { + // HashMap<Arc<str>, _>::get accepts &str via Borrow trait + let frecency = map.get(cmd.as_str()).copied().unwrap_or(0); + fuzzy_score + frecency + })) + } +} + +impl Default for SearchIndex { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use time::macros::datetime; + + #[test] + fn frecency_data_compute() { + let now = 1_000_000i64; + + // Recent command (with default multipliers of 1.0) + let recent = FrecencyData { + count: 5, + last_used: now - 60, // 1 minute ago + }; + assert!(recent.compute(now, 1.0, 1.0) > 100); // High score + + // Old command + let old = FrecencyData { + count: 5, + last_used: now - 86400 * 30, // 30 days ago + }; + assert!(old.compute(now, 1.0, 1.0) < recent.compute(now, 1.0, 1.0)); + + // Frequently used old command + let frequent_old = FrecencyData { + count: 100, + last_used: now - 86400 * 7, // 1 week ago + }; + // Should still have decent score due to frequency + assert!(frequent_old.compute(now, 1.0, 1.0) > 50); + } + + #[test] + fn frecency_data_compute_with_multipliers() { + let now = 1_000_000_i64; + + let data = FrecencyData { + count: 5, + last_used: now - 60, // 1 minute ago (recency_score = 100) + }; + + // Default multipliers (1.0, 1.0) + let default_score = data.compute(now, 1.0, 1.0); + + // Double recency weight + let double_recency = data.compute(now, 2.0, 1.0); + assert!(double_recency > default_score); + + // Double frequency weight + let double_frequency = data.compute(now, 1.0, 2.0); + assert!(double_frequency > default_score); + + // Zero out recency (only frequency counts) + let no_recency = data.compute(now, 0.0, 1.0); + assert!(no_recency < default_score); + + // Zero out frequency (only recency counts) + let no_frequency = data.compute(now, 1.0, 0.0); + assert!(no_frequency < default_score); + + // Zero both (should be zero) + let no_score = data.compute(now, 0.0, 0.0); + assert_eq!(no_score, 0); + + // Fractional multipliers + let half_recency = data.compute(now, 0.5, 1.0); + assert!(half_recency < default_score); + assert!(half_recency > no_recency); + + // 1.5x multiplier + let boost_recency = data.compute(now, 1.5, 1.0); + assert!(boost_recency > default_score); + assert!(boost_recency < double_recency); + } + + #[test] + fn command_data_add_invocation() { + let interner = ThreadedRodeo::new(); + + let (dir1, dir2) = if cfg!(windows) { + ("C:\\Users\\User\\project", "C:\\Users\\User\\other") + } else { + ("/home/user/project", "/home/user/other") + }; + + let history1 = make_history("git status", dir1, datetime!(2024-01-01 10:00 UTC)); + let history2 = make_history("git status", dir2, datetime!(2024-01-01 12:00 UTC)); + + let mut data = CommandData::new(&history1, &interner).unwrap(); + assert_eq!(data.global_frecency.count, 1); + let id1 = data.most_recent_id(); + + data.add_invocation(&history2, &interner); + assert_eq!(data.global_frecency.count, 2); + + // Most recent ID should update to history2 (newer timestamp) + let id2 = data.most_recent_id(); + assert_ne!(id1, id2); + } + + #[test] + fn command_data_filters() { + let interner = ThreadedRodeo::new(); + + let (dir1, dir2) = if cfg!(windows) { + ("C:\\Users\\User\\project", "C:\\Users\\User\\other") + } else { + ("/home/user/project", "/home/user/other") + }; + + let h1 = make_history("git status", dir1, datetime!(2024-01-01 10:00 UTC)); + let h2 = make_history("git status", dir2, datetime!(2024-01-01 12:00 UTC)); + + let mut data = CommandData::new(&h1, &interner).unwrap(); + data.add_invocation(&h2, &interner); + + let (check1, check2, check3) = if cfg!(windows) { + ( + with_trailing_slash("C:\\Users\\User\\project"), + with_trailing_slash("C:\\Users\\User\\other"), + with_trailing_slash("C:\\Users\\User\\missing"), + ) + } else { + ( + with_trailing_slash("/home/user/project"), + with_trailing_slash("/home/user/other"), + with_trailing_slash("/home/user/missing"), + ) + }; + + assert!(data.has_invocation_in_dir(&check1, &interner)); + assert!(data.has_invocation_in_dir(&check2, &interner)); + assert!(!data.has_invocation_in_dir(&check3, &interner)); + + let (check1, check2, check3) = if cfg!(windows) { + ( + with_trailing_slash("C:\\Users\\User"), + with_trailing_slash("C:\\Users"), + with_trailing_slash("C:\\Users\\User\\var"), + ) + } else { + ( + with_trailing_slash("/home/user"), + with_trailing_slash("/home"), + with_trailing_slash("/var"), + ) + }; + + assert!(data.has_invocation_in_workspace(&check1, &interner)); + assert!(data.has_invocation_in_workspace(&check2, &interner)); + assert!(!data.has_invocation_in_workspace(&check3, &interner)); + } + + #[tokio::test] + async fn search_index_add_and_search() { + let index = SearchIndex::new(); + + let h1 = make_history( + "git status", + "/home/user/project", + datetime!(2024-01-01 10:00 UTC), + ); + let h2 = make_history( + "git commit -m 'test'", + "/home/user/project", + datetime!(2024-01-01 10:05 UTC), + ); + let h3 = make_history( + "ls -la", + "/home/user/other", + datetime!(2024-01-01 10:10 UTC), + ); + + index.add_history(&h1); + index.add_history(&h2); + index.add_history(&h3); -mod index; + assert_eq!(index.command_count(), 3); -// Include the generated proto code -tonic::include_proto!("search"); + // Search for "git" - should match 2 commands + let results = index + .search("git", IndexFilterMode::Global, &QueryContext::default(), 10) + .await; + assert_eq!(results.len(), 2); -// Re-export the service and index -pub(crate) use index::{IndexFilterMode, QueryContext, SearchIndex}; + // Search with directory filter + let results = index + .search( + "", + IndexFilterMode::Directory(with_trailing_slash("/home/user/project")), + &QueryContext::default(), + 10, + ) + .await; + assert_eq!(results.len(), 2); // git status and git commit + } +} diff --git a/crates/turtle/src/atuin_daemon/semantic/mod.rs b/crates/turtle/src/atuin_daemon/semantic/mod.rs deleted file mode 100644 index c3511676..00000000 --- a/crates/turtle/src/atuin_daemon/semantic/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -//! Semantic command capture gRPC service types. - -tonic::include_proto!("semantic"); diff --git a/crates/turtle/src/atuin_daemon/server.rs b/crates/turtle/src/atuin_daemon/server.rs index 65272d2a..36954cca 100644 --- a/crates/turtle/src/atuin_daemon/server.rs +++ b/crates/turtle/src/atuin_daemon/server.rs @@ -1,23 +1,28 @@ use eyre::Result; -use crate::atuin_daemon::components::history::HistoryGrpcService; -use crate::atuin_daemon::components::search::SearchGrpcService; -use crate::atuin_daemon::components::semantic::SemanticGrpcService; -use crate::atuin_daemon::control::{ControlService, control_server::ControlServer}; -use crate::atuin_daemon::daemon::DaemonHandle; -use crate::atuin_daemon::history::history_server::HistoryServer; -use crate::atuin_daemon::search::search_server::SearchServer; -use crate::atuin_daemon::semantic::semantic_server::SemanticServer; - -use crate::atuin_client::settings::Settings; +use crate::{ + atuin_client::settings::Settings, + atuin_daemon::{ + components::{ + history::HistoryGrpcService, search::SearchGrpcService, semantic::SemanticGrpcService, + }, + daemon::DaemonHandle, + generated::{ + control::{ControlService, control_server::ControlServer}, + history::history_server::HistoryServer, + search::search_server::SearchServer, + semantic::semantic_server::SemanticServer, + }, + }, +}; /// Run the gRPC server with the given services. /// /// This starts the gRPC server in the background and returns immediately. -/// The server will shut down when a ShutdownRequested event is received. +/// The server will shut down when a [`ShutdownRequested`] event is received. #[cfg(unix)] -pub(crate) async fn run_grpc_server( - settings: Settings, +pub(crate) fn run_grpc_server( + settings: &Settings, history_service: HistoryServer<HistoryGrpcService>, search_service: SearchServer<SearchGrpcService>, semantic_service: SemanticServer<SemanticGrpcService>, @@ -75,15 +80,16 @@ pub(crate) async fn run_grpc_server( // Create shutdown signal from daemon handle let shutdown_signal = async move { let mut rx = handle.subscribe(); + loop { use crate::atuin_daemon::DaemonEvent; match rx.recv().await { - Ok(DaemonEvent::ShutdownRequested) => break, - Ok(_) => continue, - Err(_) => break, // Channel closed + Err(_) | Ok(DaemonEvent::ShutdownRequested) => break, + Ok(_) => (), } } + if cleanup { eprintln!("Removing socket..."); if let Err(e) = std::fs::remove_file(&socket_path) diff --git a/crates/turtle/src/atuin_history/stats.rs b/crates/turtle/src/atuin_history/stats.rs index 2328eeca..a19d2d0e 100644 --- a/crates/turtle/src/atuin_history/stats.rs +++ b/crates/turtle/src/atuin_history/stats.rs @@ -170,7 +170,7 @@ pub(crate) fn pretty_print(stats: Stats, ngram_size: usize) { let column_widths = stats .top .iter() - .map(|(commands, _)| commands.iter().map(|c| c.len()).collect::<Vec<usize>>()) + .map(|(commands, _)| commands.iter().map(String::len).collect::<Vec<usize>>()) .fold(vec![0; ngram_size], |acc, item| { acc.iter() .zip(item.iter()) @@ -287,10 +287,10 @@ mod tests { #[test] fn ignored_env_vars() { - let settings = Settings::utc(); + let settings = Settings::new().unwrap(); let history: History = History::capture() - .timestamp(time::OffsetDateTime::now_utc()) + .timestamp(OffsetDateTime::now_utc()) .command("FOO='BAR=🚀' echo foo") .cwd("/") .build() @@ -302,7 +302,7 @@ mod tests { #[test] fn ignored_commands() { - let mut settings = Settings::utc(); + let mut settings = Settings::new().unwrap(); settings.stats.ignored_commands.push("cd".to_string()); let history = [ @@ -325,7 +325,7 @@ mod tests { #[test] fn interesting_commands() { - let settings = Settings::utc(); + let settings = Settings::new().unwrap(); assert_eq!(interesting_command(&settings, "cargo"), "cargo"); assert_eq!( @@ -342,7 +342,7 @@ mod tests { // Test with spaces in the common_prefix #[test] fn interesting_commands_spaces() { - let mut settings = Settings::utc(); + let mut settings = Settings::new().unwrap(); settings.stats.common_prefix.push("sudo test".to_string()); assert_eq!(interesting_command(&settings, "sudo test"), "sudo test"); @@ -367,7 +367,7 @@ mod tests { // Test with spaces in the common_subcommand #[test] fn interesting_commands_spaces_subcommand() { - let mut settings = Settings::utc(); + let mut settings = Settings::new().unwrap(); settings .stats .common_subcommands @@ -397,7 +397,7 @@ mod tests { // Test with spaces in the common_prefix and common_subcommand #[test] fn interesting_commands_spaces_both() { - let mut settings = Settings::utc(); + let mut settings = Settings::new().unwrap(); settings.stats.common_prefix.push("sudo test".to_string()); settings .stats diff --git a/crates/turtle/src/atuin_pty_proxy/osc133.rs b/crates/turtle/src/atuin_pty_proxy/osc133.rs index d79166a6..b0cf0f0a 100644 --- a/crates/turtle/src/atuin_pty_proxy/osc133.rs +++ b/crates/turtle/src/atuin_pty_proxy/osc133.rs @@ -1,4 +1,4 @@ -//! Streaming parser for OSC 133 (FinalTerm semantic prompt) escape sequences. +//! Streaming parser for OSC 133 ([`FinalTerm`] semantic prompt) escape sequences. //! //! OSC 133 marks four regions of a shell interaction: //! @@ -24,7 +24,7 @@ //! can ride alongside standard OSC 133 markers. /// Events emitted when an OSC 133 marker is detected. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Copy)] pub(crate) enum Event { /// `ESC ] 133 ; A ST` — the shell is about to display its prompt. PromptStart, @@ -98,7 +98,6 @@ pub(crate) struct LocatedEvent { /// The current semantic zone as determined by the most recent OSC 133 marker. #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] -#[expect(dead_code)] pub(crate) enum Zone { /// No marker seen yet, or after a `D` marker (between commands). #[default] diff --git a/crates/turtle/src/atuin_pty_proxy/runtime.rs b/crates/turtle/src/atuin_pty_proxy/runtime.rs index 37c77eef..69b3a075 100644 --- a/crates/turtle/src/atuin_pty_proxy/runtime.rs +++ b/crates/turtle/src/atuin_pty_proxy/runtime.rs @@ -13,7 +13,7 @@ use crate::atuin_pty_proxy::screen::{self, Msg}; pub(crate) fn main(options: RuntimeOptions) { if let Err(e) = run(options) { - let _ = terminal::disable_raw_mode(); + drop(terminal::disable_raw_mode()); eprintln!("atuin pty-proxy: {e:#}"); std::process::exit(1); } @@ -33,7 +33,7 @@ fn run(options: RuntimeOptions) -> eyre::Result<()> { .map_err(|e| eyre::eyre!("{e:#}"))?; let sock_path = screen::socket_path(); - let _ = std::fs::remove_file(&sock_path); + drop(std::fs::remove_file(&sock_path)); let mut cmd = CommandBuilder::new_default_prog(); cmd.cwd(std::env::current_dir()?); @@ -87,26 +87,26 @@ fn run(options: RuntimeOptions) -> eyre::Result<()> { if let Some(highlighter) = highlighter.as_mut() { let rendered = highlighter.render(&buf[..n]); - let _ = msg_tx.try_send(Msg::Data(rendered.clone())); + drop(msg_tx.try_send(Msg::Data(rendered.clone()))); if stdout.write_all(&rendered).is_err() { break; } } else { - let _ = msg_tx.try_send(Msg::Data(buf[..n].to_vec())); + drop(msg_tx.try_send(Msg::Data(buf[..n].to_vec()))); if stdout.write_all(&buf[..n]).is_err() { break; } } - let _ = stdout.flush(); + drop(stdout.flush()); } } } if highlighter.is_some() { - let _ = stdout.write_all(RESET); - let _ = stdout.flush(); + drop(stdout.write_all(RESET)); + drop(stdout.flush()); } }); @@ -126,10 +126,10 @@ fn run(options: RuntimeOptions) -> eyre::Result<()> { }); let status = child.wait()?; - let _ = stdout_thread.join(); + drop(stdout_thread.join()); - let _ = terminal::disable_raw_mode(); - let _ = std::fs::remove_file(&sock_path); + drop(terminal::disable_raw_mode()); + drop(std::fs::remove_file(&sock_path)); std::process::exit(process_exit_code(status.exit_code())); } @@ -148,13 +148,13 @@ fn spawn_resize_handler( for _ in signals.forever() { if let Ok((cols, rows)) = terminal::size() { current_cols.store(cols.max(1), Ordering::Relaxed); - let _ = master.resize(PtySize { + drop(master.resize(PtySize { rows, cols, pixel_width: 0, pixel_height: 0, - }); - let _ = resize_tx.try_send(Msg::Resize { rows, cols }); + })); + drop(resize_tx.try_send(Msg::Resize { rows, cols })); } } }); diff --git a/crates/turtle/src/atuin_pty_proxy/screen.rs b/crates/turtle/src/atuin_pty_proxy/screen.rs index 5b892e21..c51a0c7d 100644 --- a/crates/turtle/src/atuin_pty_proxy/screen.rs +++ b/crates/turtle/src/atuin_pty_proxy/screen.rs @@ -54,8 +54,8 @@ pub(crate) fn spawn_socket_server(sock_path: PathBuf, screen_tx: SyncSender<Msg> break; } if let Ok(data) = reply_rx.recv() { - let _ = stream.write_all(&data); - let _ = stream.flush(); + drop(stream.write_all(&data)); + drop(stream.flush()); } } }); @@ -98,7 +98,7 @@ fn handle_parser_msg(parser: &mut vt100::Parser, msg: Msg) { Msg::Data(data) => parser.process(&data), Msg::Resize { rows, cols } => parser.screen_mut().set_size(rows, cols), Msg::ScreenRequest(reply_tx) => { - let _ = reply_tx.send(encode_screen(parser)); + drop(reply_tx.send(encode_screen(parser))); } } } diff --git a/crates/turtle/src/atuin_server/database/db/mod.rs b/crates/turtle/src/atuin_server/database/db/mod.rs index 5b3c169b..47dbd6d1 100644 --- a/crates/turtle/src/atuin_server/database/db/mod.rs +++ b/crates/turtle/src/atuin_server/database/db/mod.rs @@ -17,7 +17,7 @@ mod wrappers; const MIN_PG_VERSION: u32 = 14; #[derive(Clone)] -pub struct ServerPostgres { +pub(crate) struct ServerPostgres { pool: sqlx::Pool<sqlx::postgres::Postgres>, /// Optional read replica pool for read-only queries read_pool: Option<sqlx::Pool<sqlx::postgres::Postgres>>, diff --git a/crates/turtle/src/atuin_server/database/db/wrappers.rs b/crates/turtle/src/atuin_server/database/db/wrappers.rs index 8a52d56e..0a7b6ff3 100644 --- a/crates/turtle/src/atuin_server/database/db/wrappers.rs +++ b/crates/turtle/src/atuin_server/database/db/wrappers.rs @@ -1,7 +1,7 @@ use crate::atuin_common::record::{EncryptedData, Host, Record}; use sqlx::{Row, postgres::PgRow}; -pub struct DbRecord(pub Record<EncryptedData>); +pub(crate) struct DbRecord(pub Record<EncryptedData>); impl<'a> ::sqlx::FromRow<'a, PgRow> for DbRecord { fn from_row(row: &'a PgRow) -> ::sqlx::Result<Self> { diff --git a/crates/turtle/src/atuin_server/database/mod.rs b/crates/turtle/src/atuin_server/database/mod.rs index bb64767a..43fe5c3b 100644 --- a/crates/turtle/src/atuin_server/database/mod.rs +++ b/crates/turtle/src/atuin_server/database/mod.rs @@ -14,29 +14,29 @@ pub(crate) enum DbError { impl Display for DbError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - DbError::NotFound => write!(f, "Not found"), - DbError::Other(report) => write!(f, "Other: {report}"), + Self::NotFound => write!(f, "Not found"), + Self::Other(report) => write!(f, "Other: {report}"), } } } impl From<time::error::ComponentRange> for DbError { fn from(error: time::error::ComponentRange) -> Self { - DbError::Other(error.into()) + Self::Other(error.into()) } } impl From<time::error::Error> for DbError { fn from(error: time::error::Error) -> Self { - DbError::Other(error.into()) + Self::Other(error.into()) } } impl From<sqlx::Error> for DbError { fn from(error: sqlx::Error) -> Self { match error { - sqlx::Error::RowNotFound => DbError::NotFound, - error => DbError::Other(error.into()), + sqlx::Error::RowNotFound => Self::NotFound, + error => Self::Other(error.into()), } } } @@ -54,6 +54,7 @@ pub(crate) enum DbType { #[derive(Clone, Deserialize, Serialize)] pub(crate) struct DbSettings { pub(crate) db_uri: String, + /// Optional URI for read replicas. If set, read-only queries will use this connection. pub(crate) read_db_uri: Option<String>, } @@ -69,12 +70,13 @@ impl DbSettings { } fn redact_db_uri(uri: &str) -> String { - url::Url::parse(uri) - .map(|mut url| { - let _ = url.set_password(Some("****")); + url::Url::parse(uri).map_or_else( + |_| uri.to_string(), + |mut url| { + url.set_password(Some("****")).expect("should be possible"); url.to_string() - }) - .unwrap_or_else(|_| uri.to_string()) + }, + ) } // Do our best to redact passwords so they're not logged in the event of an error. diff --git a/crates/turtle/src/command/client.rs b/crates/turtle/src/command/client.rs index 0c97f945..2ec5abe7 100644 --- a/crates/turtle/src/command/client.rs +++ b/crates/turtle/src/command/client.rs @@ -35,7 +35,7 @@ fn cleanup_old_logs(log_dir: &Path, prefix: &str, retention_days: u64) { && let Ok(modified) = metadata.modified() && modified < cutoff { - let _ = fs::remove_file(&path); + drop(fs::remove_file(&path)); } } } @@ -107,7 +107,7 @@ impl Cmd { pub(crate) fn run(self) -> Result<()> { // Daemonize before creating the async runtime – fork() inside a live // tokio runtime corrupts its internal state. - #[cfg(all(unix, feature = "daemon"))] + #[cfg(unix)] if let Self::Daemon(ref cmd) = self && cmd.should_daemonize() { diff --git a/crates/turtle/src/command/client/daemon.rs b/crates/turtle/src/command/client/daemon.rs index 41cb04fe..4960dacd 100644 --- a/crates/turtle/src/command/client/daemon.rs +++ b/crates/turtle/src/command/client/daemon.rs @@ -120,7 +120,7 @@ impl PidfileGuard { impl Drop for PidfileGuard { fn drop(&mut self) { - let _ = self.file.unlock(); + drop(self.file.unlock()); } } @@ -232,7 +232,7 @@ async fn probe(settings: &Settings) -> Probe { async fn request_shutdown(settings: &Settings) { if let Ok(mut client) = connect_client(settings).await { - let _ = client.shutdown().await; + drop(client.shutdown().await); } } @@ -365,7 +365,7 @@ pub(crate) async fn ensure_daemon_running(settings: &Settings) -> Result<()> { remove_stale_socket_if_present(settings)?; spawn_daemon_process()?; - let _ = wait_until_ready(settings, timeout).await?; + drop(wait_until_ready(settings, timeout).await?); drop(startup_lock); Ok(()) @@ -451,7 +451,7 @@ pub(crate) async fn end_history( // End succeeded on the running daemon, so avoid replaying it. // We only restart to make subsequent hook calls target the expected version. - let _ = restart_daemon(settings).await; + drop(restart_daemon(settings).await); return Ok(()); } Err(err) if !settings.daemon.autostart => return Err(err), @@ -679,11 +679,13 @@ fn force_cleanup(settings: &Settings) { #[cfg(unix)] fn kill_process(pid: u32) { // Use kill command to send SIGTERM for graceful shutdown - let _ = Command::new("kill") - .args(["-TERM", &pid.to_string()]) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status(); + drop( + Command::new("kill") + .args(["-TERM", &pid.to_string()]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(), + ); } #[cfg(test)] @@ -725,35 +727,4 @@ mod tests { let lock = daemon_startup_lock_path(pidfile); assert_eq!(lock, PathBuf::from("/tmp/atuin-daemon.pid.startup.lock")); } - - #[test] - fn test_pidfile_guard_acquire_and_drop() { - let tmp = tempfile::tempdir().unwrap(); - let pidfile = tmp.path().join("daemon.pid"); - - { - let _guard = PidfileGuard::acquire(&pidfile).unwrap(); - // Guard holds an exclusive lock — on Windows other handles cannot - // read the file, so we verify contents after the guard is dropped. - } - - let contents = std::fs::read_to_string(&pidfile).unwrap(); - let lines: Vec<&str> = contents.lines().collect(); - assert_eq!(lines.len(), 2); - assert_eq!(lines[0], std::process::id().to_string()); - assert_eq!(lines[1], DAEMON_VERSION); - - // After guard is dropped, lock should be released — acquiring again must succeed. - let _guard2 = PidfileGuard::acquire(&pidfile).unwrap(); - } - - #[test] - fn test_pidfile_guard_prevents_double_acquire() { - let tmp = tempfile::tempdir().unwrap(); - let pidfile = tmp.path().join("daemon.pid"); - - let _guard = PidfileGuard::acquire(&pidfile).unwrap(); - let result = PidfileGuard::acquire(&pidfile); - assert!(result.is_err()); - } } diff --git a/crates/turtle/src/command/client/history.rs b/crates/turtle/src/command/client/history.rs index 2ddcb3a6..e574a2e9 100644 --- a/crates/turtle/src/command/client/history.rs +++ b/crates/turtle/src/command/client/history.rs @@ -14,7 +14,7 @@ use super::daemon as daemon_cmd; use colored::Colorize; use serde::Serialize; -use crate::atuin_daemon::history::{HistoryEventKind, TailHistoryReply}; +use crate::atuin_daemon::generated::history::{HistoryEventKind, TailHistoryReply}; use crate::atuin_client::{ database::{ClientSqlite, current_context}, @@ -166,11 +166,11 @@ pub(crate) enum ListMode { impl ListMode { pub(crate) const fn from_flags(human: bool, cmd_only: bool) -> Self { if human { - ListMode::Human + Self::Human } else if cmd_only { - ListMode::CmdOnly + Self::CmdOnly } else { - ListMode::Regular + Self::Regular } } } @@ -183,7 +183,7 @@ pub(crate) fn print_list( reverse: bool, tz: Timezone, ) { - let w = std::io::stdout(); + let w = io::stdout(); let mut w = w.lock(); let fmt_str = match list_mode { @@ -202,6 +202,7 @@ pub(crate) fn print_list( ListMode::CmdOnly => std::iter::once(ParseSegment::Key("command")).collect(), }; + #[allow(trivial_casts)] let iterator = if reverse { Box::new(h.iter().rev()) as Box<dyn Iterator<Item = &History>> } else { @@ -743,10 +744,10 @@ fn normalize_optional_field(value: &str) -> Option<String> { impl Cmd { async fn handle_tail(settings: &Settings) -> Result<()> { - let tty = std::io::stdout().is_terminal(); + let tty = io::stdout().is_terminal(); let mut client = daemon::tail_client(settings).await?; let mut stream = client.tail_history().await?; - let stdout = std::io::stdout(); + let stdout = io::stdout(); while let Some(reply) = stream.message().await? { let event = TailEvent::from_proto(reply)?; diff --git a/crates/turtle/src/command/client/search.rs b/crates/turtle/src/command/client/search.rs index bba48b8a..5c51caea 100644 --- a/crates/turtle/src/command/client/search.rs +++ b/crates/turtle/src/command/client/search.rs @@ -166,7 +166,7 @@ impl Cmd { |query| { query .split(' ') - .map(std::string::ToString::to_string) + .map(ToString::to_string) .collect() }, ) diff --git a/crates/turtle/src/command/client/search/engines.rs b/crates/turtle/src/command/client/search/engines.rs index 94834221..9fbff278 100644 --- a/crates/turtle/src/command/client/search/engines.rs +++ b/crates/turtle/src/command/client/search/engines.rs @@ -14,9 +14,9 @@ pub(crate) mod skim; pub(crate) fn engine(search_mode: SearchMode, settings: &Settings) -> Box<dyn SearchEngine> { match search_mode { - SearchMode::Skim => Box::new(skim::Search::new()) as Box<_>, - SearchMode::DaemonFuzzy => Box::new(daemon::Search::new(settings)) as Box<_>, - mode => Box::new(db::Search(mode)) as Box<_>, + SearchMode::Skim => Box::new(skim::Search::new()), + SearchMode::DaemonFuzzy => Box::new(daemon::Search::new(settings)), + mode => Box::new(db::Search(mode)), } } diff --git a/crates/turtle/src/command/client/search/engines/db.rs b/crates/turtle/src/command/client/search/engines/db.rs index e6657b17..0eb86878 100644 --- a/crates/turtle/src/command/client/search/engines/db.rs +++ b/crates/turtle/src/command/client/search/engines/db.rs @@ -49,7 +49,7 @@ impl SearchEngine for Search { let mut parser = FzfParser::new(); let query = parser.parse(search_input); let mut ranges: Vec<Range<usize>> = Vec::new(); - let _ = fzf.distance_and_ranges(query, command, &mut ranges); + fzf.distance_and_ranges(query, command, &mut ranges); // convert ranges to all indices ranges.into_iter().flatten().collect() diff --git a/crates/turtle/src/command/client/search/engines/skim.rs b/crates/turtle/src/command/client/search/engines/skim.rs index a6a77573..739e790b 100644 --- a/crates/turtle/src/command/client/search/engines/skim.rs +++ b/crates/turtle/src/command/client/search/engines/skim.rs @@ -117,7 +117,7 @@ async fn fuzzy_search( continue; }; let (seconds, nanos) = timestamp.to_unix(); - let Ok(session_start) = time::OffsetDateTime::from_unix_timestamp_nanos( + let Ok(session_start) = OffsetDateTime::from_unix_timestamp_nanos( i128::from(seconds) * 1_000_000_000 + i128::from(nanos), ) else { warn!( diff --git a/crates/turtle/src/command/client/search/inspector.rs b/crates/turtle/src/command/client/search/inspector.rs index f7b40a26..6c2c59fe 100644 --- a/crates/turtle/src/command/client/search/inspector.rs +++ b/crates/turtle/src/command/client/search/inspector.rs @@ -195,7 +195,7 @@ fn sort_duration_over_time(durations: &[(String, i64)]) -> Vec<(String, i64)> { } fn draw_stats_charts(f: &mut Frame<'_>, parent: Rect, stats: &HistoryStats) { - let exits: Vec<Bar> = stats + let exits: Vec<Bar<'_>> = stats .exits .iter() .map(|(exit, count)| { @@ -219,7 +219,7 @@ fn draw_stats_charts(f: &mut Frame<'_>, parent: Rect, stats: &HistoryStats) { .label_style(Style::default()) .data(BarGroup::default().bars(&exits)); - let day_of_week: Vec<Bar> = stats + let day_of_week: Vec<Bar<'_>> = stats .day_of_week .iter() .map(|(day, count)| { @@ -244,7 +244,7 @@ fn draw_stats_charts(f: &mut Frame<'_>, parent: Rect, stats: &HistoryStats) { .data(BarGroup::default().bars(&day_of_week)); let duration_over_time = sort_duration_over_time(&stats.duration_over_time); - let duration_over_time: Vec<Bar> = duration_over_time + let duration_over_time: Vec<Bar<'_>> = duration_over_time .iter() .map(|(date, duration)| { let d = Duration::from_nanos(u64_or_zero(*duration)); @@ -398,7 +398,7 @@ mod tests { let prev = stats.previous.clone().unwrap(); let next = stats.next.clone().unwrap(); - let _ = terminal.draw(|f| draw_ultracompact(f, chunk, &history, &stats)); + drop(terminal.draw(|f| draw_ultracompact(f, chunk, &history, &stats))); let mut lines = [" "; 5].map(|l| Line::from(l)); for (n, entry) in [prev, history, next].iter().enumerate() { let mut l = lines[n].to_string(); diff --git a/crates/turtle/src/command/client/search/interactive.rs b/crates/turtle/src/command/client/search/interactive.rs index 2c6af8cf..49d483b0 100644 --- a/crates/turtle/src/command/client/search/interactive.rs +++ b/crates/turtle/src/command/client/search/interactive.rs @@ -97,7 +97,7 @@ impl InspectingState { } } -pub(crate) fn to_compactness(f: &Frame, settings: &Settings) -> Compactness { +pub(crate) fn to_compactness(f: &Frame<'_>, settings: &Settings) -> Compactness { if match settings.style { crate::atuin_client::settings::Style::Auto => f.area().height < 14, crate::atuin_client::settings::Style::Compact => true, @@ -232,7 +232,7 @@ impl State { && let Some(style) = cursor_style { self.current_cursor = cursor_style; - let _ = execute!(stdout(), Self::cast_cursor_style(style)); + drop(execute!(stdout(), Self::cast_cursor_style(style))); } } @@ -804,7 +804,7 @@ impl State { fn draw( &mut self, - f: &mut Frame, + f: &mut Frame<'_>, results: &[History], stats: Option<HistoryStats>, inspecting: Option<&History>, @@ -823,7 +823,7 @@ impl State { #[expect(clippy::bool_to_int_with_if)] fn draw_inner( &mut self, - f: &mut Frame, + f: &mut Frame<'_>, area: Rect, results: &[History], stats: Option<HistoryStats>, @@ -1051,12 +1051,12 @@ impl State { #[expect(clippy::cast_possible_truncation, clippy::too_many_arguments)] fn draw_preview( &self, - f: &mut Frame, + f: &mut Frame<'_>, style: StyleState, input_chunk: Rect, compactness: Compactness, preview_chunk: Rect, - preview: Paragraph, + preview: Paragraph<'_>, prefix_width: u16, ) { let input = self.build_input(style, prefix_width); @@ -1088,7 +1088,6 @@ impl State { title.alignment(Alignment::Left) } - #[expect(clippy::unused_self)] fn build_help(&self, settings: &Settings) -> Paragraph<'_> { match self.tab_index { // search @@ -1272,7 +1271,7 @@ impl TerminalWriter { fn new() -> std::io::Result<Self> { let stdout = stdout(); if stdout.is_terminal() { - return Ok(TerminalWriter::Stdout(stdout)); + return Ok(Self::Stdout(stdout)); } // If stdout is not a terminal (e.g., captured by command substitution), @@ -1280,7 +1279,7 @@ impl TerminalWriter { // This allows usage like: VAR=$(atuin search -i) #[cfg(unix)] { - Ok(TerminalWriter::Tty( + Ok(Self::Tty( std::fs::File::options() .read(true) .write(true) @@ -1293,17 +1292,17 @@ impl TerminalWriter { impl Write for TerminalWriter { fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> { match self { - TerminalWriter::Stdout(stdout) => stdout.write(buf), + Self::Stdout(stdout) => stdout.write(buf), #[cfg(unix)] - TerminalWriter::Tty(file) => file.write(buf), + Self::Tty(file) => file.write(buf), } } fn flush(&mut self) -> std::io::Result<()> { match self { - TerminalWriter::Stdout(stdout) => stdout.flush(), + Self::Stdout(stdout) => stdout.flush(), #[cfg(unix)] - TerminalWriter::Tty(file) => file.flush(), + Self::Tty(file) => file.flush(), } } } @@ -1395,27 +1394,32 @@ fn restore_popup_area(saved: &SavedScreen, popup_rect: Rect, scroll_offset: u16) // beforehand. We write `popup_rect.width` spaces instead of // ClearType::CurrentLine so that only the popup area is cleared, not // the entire terminal line. - let _ = execute!( + drop(execute!( stdout, MoveTo(popup_rect.x, target_row), - ratatui::crossterm::style::SetAttribute(ratatui::crossterm::style::Attribute::Reset), - ); - let _ = write!(stdout, "{:width$}", "", width = popup_rect.width as usize); - let _ = execute!(stdout, MoveTo(popup_rect.x, target_row)); + crossterm::style::SetAttribute(crossterm::style::Attribute::Reset), + )); + drop(write!( + stdout, + "{:width$}", + "", + width = popup_rect.width as usize + )); + drop(execute!(stdout, MoveTo(popup_rect.x, target_row))); if let Some(row_bytes) = saved.rows_data.get(source_row) { - let _ = stdout.write_all(row_bytes); + drop(stdout.write_all(row_bytes)); } } - let _ = execute!( + drop(execute!( stdout, MoveTo( saved.cursor_col, saved.cursor_row.saturating_sub(scroll_offset) ) - ); - let _ = stdout.flush(); + )); + drop(stdout.flush()); } struct Stdout { @@ -1534,11 +1538,7 @@ fn compute_popup_placement( // for now, it works. But it'd be great if it were more easily readable, and // modular. I'd like to add some more stats and stuff at some point -#[expect( - clippy::cast_possible_truncation, - clippy::too_many_lines, - clippy::cognitive_complexity -)] +#[expect(clippy::too_many_lines, clippy::cognitive_complexity)] pub(crate) async fn history( query: &[String], settings: &Settings, @@ -1589,11 +1589,11 @@ pub(crate) async fn history( if scroll > 0 { use ratatui::crossterm::cursor::MoveTo; let mut stdout = stdout(); - let _ = execute!(stdout, MoveTo(0, term_rows - 1)); + drop(execute!(stdout, MoveTo(0, term_rows - 1))); for _ in 0..scroll { - let _ = writeln!(stdout); + drop(writeln!(stdout)); } - let _ = stdout.flush(); + drop(stdout.flush()); } (saved, popup_rect, scroll) @@ -1619,20 +1619,20 @@ pub(crate) async fn history( let mut raw_stdout = std::io::stdout(); // Queue all commands without flushing so the terminal receives them // as a single write — no intermediate cursor positions are visible. - let _ = queue!( + drop(queue!( raw_stdout, - ratatui::crossterm::style::SetAttribute(ratatui::crossterm::style::Attribute::Reset) - ); + crossterm::style::SetAttribute(crossterm::style::Attribute::Reset) + )); for row in popup_rect.y..popup_rect.y.saturating_add(popup_rect.height) { - let _ = queue!(raw_stdout, MoveTo(popup_rect.x, row)); - let _ = write!( + drop(queue!(raw_stdout, MoveTo(popup_rect.x, row))); + drop(write!( raw_stdout, "{:width$}", "", width = popup_rect.width as usize - ); + )); } - let _ = raw_stdout.flush(); + drop(raw_stdout.flush()); } let backend = CrosstermBackend::new(stdout); @@ -1996,7 +1996,7 @@ mod tests { strategy: PreviewStrategy::Auto, }, show_preview: true, - ..Settings::now() + ..Settings::new().unwrap() }; let settings_preview_auto_h2 = Settings { @@ -2005,7 +2005,7 @@ mod tests { }, show_preview: true, max_preview_height: 2, - ..Settings::now() + ..Settings::new().unwrap() }; let settings_preview_h4 = Settings { @@ -2014,7 +2014,7 @@ mod tests { }, show_preview: true, max_preview_height: 4, - ..Settings::now() + ..Settings::new().unwrap() }; let settings_preview_fixed = Settings { @@ -2023,25 +2023,25 @@ mod tests { }, show_preview: true, max_preview_height: 15, - ..Settings::now() + ..Settings::new().unwrap() }; let cmd_60: History = History::capture() - .timestamp(time::OffsetDateTime::now_utc()) + .timestamp(OffsetDateTime::now_utc()) .command("for i in $(seq -w 10); do echo \"item number $i - abcd\"; done") .cwd("/") .build() .into(); let cmd_124: History = History::capture() - .timestamp(time::OffsetDateTime::now_utc()) + .timestamp(OffsetDateTime::now_utc()) .command("echo 'Aurea prima sata est aetas, quae vindice nullo, sponte sua, sine lege fidem rectumque colebat. Poena metusque aberant'") .cwd("/") .build() .into(); let cmd_200: History = History::capture() - .timestamp(time::OffsetDateTime::now_utc()) + .timestamp(OffsetDateTime::now_utc()) .command("CREATE USER atuin WITH ENCRYPTED PASSWORD 'supersecretpassword'; CREATE DATABASE atuin WITH OWNER = atuin; \\c atuin; REVOKE ALL PRIVILEGES ON SCHEMA public FROM PUBLIC; echo 'All done. 200 characters'") .cwd("/") .build() @@ -2145,7 +2145,7 @@ mod tests { // Test when there's no results, scrolling up or down doesn't underflow #[test] fn state_scroll_up_underflow() { - let settings = Settings::now(); + let settings = Settings::new().unwrap(); let mut state = State { history_count: 0, results_state: ListState::default(), @@ -2190,7 +2190,7 @@ mod tests { use crate::atuin_client::settings::Keys; use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; - let mut settings = Settings::now(); + let mut settings = Settings::new().unwrap(); settings.keys = Keys { scroll_exits: true, exit_past_line_start: false, @@ -2316,7 +2316,7 @@ mod tests { fn test_vim_gg_multikey_sequence() { use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; - let settings = Settings::now(); + let settings = Settings::new().unwrap(); let mut state = State { history_count: 100, @@ -2374,7 +2374,7 @@ mod tests { fn test_vim_g_key_clears_on_other_input() { use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; - let settings = Settings::now(); + let settings = Settings::new().unwrap(); let mut state = State { history_count: 100, @@ -2428,7 +2428,7 @@ mod tests { fn test_vim_big_g_jump_to_bottom() { use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; - let settings = Settings::now(); + let settings = Settings::new().unwrap(); let mut state = State { history_count: 100, @@ -2475,10 +2475,11 @@ mod tests { } #[test] + #[allow(clippy::similar_names)] fn test_vim_ctrl_u_d_half_page_scroll() { use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; - let settings = Settings::now(); + let settings = Settings::new().unwrap(); let mut state = State { history_count: 100, @@ -2534,10 +2535,11 @@ mod tests { } #[test] + #[allow(clippy::similar_names)] fn test_vim_ctrl_f_b_full_page_scroll() { use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; - let settings = Settings::now(); + let settings = Settings::new().unwrap(); let mut state = State { history_count: 100, @@ -2598,7 +2600,7 @@ mod tests { /// Helper to build a State for executor tests. fn make_executor_state(results_len: usize, selected: usize) -> State { - let settings = Settings::now(); + let settings = Settings::new().unwrap(); let mut state = State { history_count: results_len as i64, results_state: ListState::default(), @@ -2642,7 +2644,7 @@ mod tests { use crate::command::client::search::keybindings::Action; let mut state = make_executor_state(100, 50); - let settings = Settings::now(); + let settings = Settings::new().unwrap(); let result = state.execute_action(&Action::SelectNext, &settings); assert!(matches!(result, super::InputAction::Continue)); // Non-inverted: SelectNext = scroll_down = selected - 1 @@ -2654,7 +2656,7 @@ mod tests { use crate::command::client::search::keybindings::Action; let mut state = make_executor_state(100, 50); - let mut settings = Settings::now(); + let mut settings = Settings::new().unwrap(); settings.invert = true; let result = state.execute_action(&Action::SelectNext, &settings); assert!(matches!(result, super::InputAction::Continue)); @@ -2667,7 +2669,7 @@ mod tests { use crate::command::client::search::keybindings::Action; let mut state = make_executor_state(100, 50); - let settings = Settings::now(); + let settings = Settings::new().unwrap(); let result = state.execute_action(&Action::SelectPrevious, &settings); assert!(matches!(result, super::InputAction::Continue)); // Non-inverted: SelectPrevious = scroll_up = selected + 1 @@ -2679,7 +2681,7 @@ mod tests { use crate::command::client::search::keybindings::Action; let mut state = make_executor_state(100, 0); - let settings = Settings::now(); + let settings = Settings::new().unwrap(); let result = state.execute_action(&Action::VimEnterNormal, &settings); assert!(matches!(result, super::InputAction::Continue)); assert_eq!(state.keymap_mode, KeymapMode::VimNormal); @@ -2691,7 +2693,7 @@ mod tests { let mut state = make_executor_state(100, 0); state.keymap_mode = KeymapMode::VimNormal; - let settings = Settings::now(); + let settings = Settings::new().unwrap(); let result = state.execute_action(&Action::VimEnterInsert, &settings); assert!(matches!(result, super::InputAction::Continue)); assert_eq!(state.keymap_mode, KeymapMode::VimInsert); @@ -2702,7 +2704,7 @@ mod tests { use crate::command::client::search::keybindings::Action; let mut state = make_executor_state(100, 5); - let mut settings = Settings::now(); + let mut settings = Settings::new().unwrap(); settings.enter_accept = true; let result = state.execute_action(&Action::Accept, &settings); assert!(matches!(result, super::InputAction::Accept(5))); @@ -2714,7 +2716,7 @@ mod tests { use crate::command::client::search::keybindings::Action; let mut state = make_executor_state(100, 5); - let settings = Settings::now(); + let settings = Settings::new().unwrap(); let result = state.execute_action(&Action::ReturnSelection, &settings); assert!(matches!(result, super::InputAction::Accept(5))); assert!(!state.accept); @@ -2725,7 +2727,7 @@ mod tests { use crate::command::client::search::keybindings::Action; let mut state = make_executor_state(100, 5); - let settings = Settings::now(); + let settings = Settings::new().unwrap(); let result = state.execute_action(&Action::AcceptNth(3), &settings); assert!(matches!(result, super::InputAction::Accept(8))); } @@ -2735,7 +2737,7 @@ mod tests { use crate::command::client::search::keybindings::Action; let mut state = make_executor_state(100, 50); - let settings = Settings::now(); + let settings = Settings::new().unwrap(); let result = state.execute_action(&Action::ScrollToTop, &settings); assert!(matches!(result, super::InputAction::Continue)); // Non-inverted: visual top = highest index @@ -2747,7 +2749,7 @@ mod tests { use crate::command::client::search::keybindings::Action; let mut state = make_executor_state(100, 50); - let mut settings = Settings::now(); + let mut settings = Settings::new().unwrap(); settings.invert = true; let result = state.execute_action(&Action::ScrollToTop, &settings); assert!(matches!(result, super::InputAction::Continue)); @@ -2760,7 +2762,7 @@ mod tests { use crate::command::client::search::keybindings::Action; let mut state = make_executor_state(100, 50); - let settings = Settings::now(); + let settings = Settings::new().unwrap(); let result = state.execute_action(&Action::ScrollToBottom, &settings); assert!(matches!(result, super::InputAction::Continue)); // Non-inverted: visual bottom = index 0 @@ -2772,7 +2774,7 @@ mod tests { use crate::command::client::search::keybindings::Action; let mut state = make_executor_state(100, 0); - let settings = Settings::now(); + let settings = Settings::new().unwrap(); assert_eq!(state.tab_index, 0); state.execute_action(&Action::ToggleTab, &settings); assert_eq!(state.tab_index, 1); @@ -2785,7 +2787,7 @@ mod tests { use crate::command::client::search::keybindings::Action; let mut state = make_executor_state(100, 0); - let settings = Settings::now(); + let settings = Settings::new().unwrap(); assert!(!state.prefix); state.execute_action(&Action::EnterPrefixMode, &settings); assert!(state.prefix); @@ -2797,7 +2799,7 @@ mod tests { use crate::command::client::search::keybindings::Action; let mut state = make_executor_state(100, 0); - let mut settings = Settings::now(); + let mut settings = Settings::new().unwrap(); settings.exit_mode = ExitMode::ReturnOriginal; let result = state.execute_action(&Action::Exit, &settings); @@ -2813,7 +2815,7 @@ mod tests { use crate::command::client::search::keybindings::Action; let mut state = make_executor_state(100, 0); - let settings = Settings::now(); + let settings = Settings::new().unwrap(); let result = state.execute_action(&Action::ReturnOriginal, &settings); assert!(matches!(result, super::InputAction::ReturnOriginal)); } @@ -2823,7 +2825,7 @@ mod tests { use crate::command::client::search::keybindings::Action; let mut state = make_executor_state(100, 7); - let settings = Settings::now(); + let settings = Settings::new().unwrap(); let result = state.execute_action(&Action::Copy, &settings); assert!(matches!(result, super::InputAction::Copy(7))); } @@ -2833,7 +2835,7 @@ mod tests { use crate::command::client::search::keybindings::Action; let mut state = make_executor_state(100, 7); - let settings = Settings::now(); + let settings = Settings::new().unwrap(); let result = state.execute_action(&Action::Delete, &settings); assert!(matches!(result, super::InputAction::Delete(7))); } @@ -2843,7 +2845,7 @@ mod tests { use crate::command::client::search::keybindings::Action; let mut state = make_executor_state(100, 7); - let settings = Settings::now(); + let settings = Settings::new().unwrap(); let result = state.execute_action(&Action::SwitchContext, &settings); assert!(matches!(result, super::InputAction::SwitchContext(Some(7)))); } @@ -2853,7 +2855,7 @@ mod tests { use crate::command::client::search::keybindings::Action; let mut state = make_executor_state(100, 7); - let settings = Settings::now(); + let settings = Settings::new().unwrap(); let result = state.execute_action(&Action::ClearContext, &settings); assert!(matches!(result, super::InputAction::SwitchContext(None))); } @@ -2863,7 +2865,7 @@ mod tests { use crate::command::client::search::keybindings::Action; let mut state = make_executor_state(100, 50); - let settings = Settings::now(); + let settings = Settings::new().unwrap(); let result = state.execute_action(&Action::Noop, &settings); assert!(matches!(result, super::InputAction::Continue)); assert_eq!(state.results_state.selected(), 50); @@ -2875,7 +2877,7 @@ mod tests { let mut state = make_executor_state(100, 5); state.tab_index = 1; - let settings = Settings::now(); + let settings = Settings::new().unwrap(); let result = state.execute_action(&Action::Accept, &settings); assert!(matches!(result, super::InputAction::AcceptInspecting)); } @@ -2885,7 +2887,7 @@ mod tests { use crate::command::client::search::keybindings::Action; let mut state = make_executor_state(100, 0); - let settings = Settings::now(); + let settings = Settings::new().unwrap(); let original_mode = state.search_mode; let result = state.execute_action(&Action::CycleSearchMode, &settings); assert!(matches!(result, super::InputAction::Continue)); @@ -2901,7 +2903,7 @@ mod tests { state.search.input.insert('h'); state.search.input.insert('i'); state.keymap_mode = KeymapMode::VimNormal; - let settings = Settings::now(); + let settings = Settings::new().unwrap(); let result = state.execute_action(&Action::VimSearchInsert, &settings); assert!(matches!(result, super::InputAction::Continue)); // Should clear input and switch to insert mode @@ -2914,7 +2916,7 @@ mod tests { use crate::command::client::search::keybindings::Action; let mut state = make_executor_state(100, 0); - let settings = Settings::now(); + let settings = Settings::new().unwrap(); // Insert some text state.search.input.insert('h'); @@ -2946,7 +2948,7 @@ mod tests { use crate::command::client::search::keybindings::Action; let mut state = make_executor_state(100, 0); - let settings = Settings::now(); + let settings = Settings::new().unwrap(); // Insert "hello" state.search.input.insert('h'); @@ -2970,7 +2972,7 @@ mod tests { use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use std::collections::HashMap; - let mut settings = Settings::now(); + let mut settings = Settings::new().unwrap(); // Configure tab to return-query settings.keymap.emacs = HashMap::from([( "tab".to_string(), diff --git a/crates/turtle/src/command/client/search/keybindings/defaults.rs b/crates/turtle/src/command/client/search/keybindings/defaults.rs index 6627c84d..82219b33 100644 --- a/crates/turtle/src/command/client/search/keybindings/defaults.rs +++ b/crates/turtle/src/command/client/search/keybindings/defaults.rs @@ -527,7 +527,11 @@ impl KeymapSet { #[cfg(test)] mod tests { - use super::*; + use super::{ + Action, HashMap, KeymapSet, Settings, default_emacs_keymap, default_inspector_keymap, + default_prefix_keymap, default_vim_insert_keymap, default_vim_normal_keymap, key, + parse_binding_config, + }; use crate::command::client::search::keybindings::conditions::EvalContext; fn make_ctx(cursor: usize, width: usize, selected: usize, len: usize) -> EvalContext { @@ -543,7 +547,7 @@ mod tests { } fn default_settings() -> Settings { - Settings::utc() + Settings::new().unwrap() } // -- Emacs keymap tests -- @@ -977,7 +981,7 @@ mod tests { fn parse_simple_binding_config() { use crate::atuin_client::settings::KeyBindingConfig; let cfg = KeyBindingConfig::Simple("accept".to_string()); - let binding = super::parse_binding_config(&cfg).unwrap(); + let binding = parse_binding_config(&cfg).unwrap(); assert_eq!(binding.rules.len(), 1); assert!(binding.rules[0].condition.is_none()); assert_eq!(binding.rules[0].action, Action::Accept); @@ -996,7 +1000,7 @@ mod tests { action: "cursor-left".to_string(), }, ]); - let binding = super::parse_binding_config(&cfg).unwrap(); + let binding = parse_binding_config(&cfg).unwrap(); assert_eq!(binding.rules.len(), 2); assert!(binding.rules[0].condition.is_some()); assert_eq!(binding.rules[0].action, Action::Exit); @@ -1008,7 +1012,7 @@ mod tests { fn parse_binding_config_invalid_action() { use crate::atuin_client::settings::KeyBindingConfig; let cfg = KeyBindingConfig::Simple("not-a-real-action".to_string()); - assert!(super::parse_binding_config(&cfg).is_err()); + assert!(parse_binding_config(&cfg).is_err()); } #[test] @@ -1018,7 +1022,7 @@ mod tests { when: Some("not-a-real-condition".to_string()), action: "exit".to_string(), }]); - assert!(super::parse_binding_config(&cfg).is_err()); + assert!(parse_binding_config(&cfg).is_err()); } #[test] @@ -1213,22 +1217,6 @@ mod tests { } #[test] - fn keys_has_non_default_values_detection() { - use crate::atuin_client::settings::Keys; - - let standard = Keys::standard_defaults(); - assert!(!standard.has_non_default_values()); - - let mut modified = Keys::standard_defaults(); - modified.scroll_exits = false; - assert!(modified.has_non_default_values()); - - let mut modified = Keys::standard_defaults(); - modified.prefix = "x".to_string(); - assert!(modified.has_non_default_values()); - } - - #[test] fn original_input_empty_condition_in_config() { use crate::atuin_client::settings::{KeyBindingConfig, KeyRuleConfig}; use std::collections::HashMap; diff --git a/crates/turtle/src/command/client/search/keybindings/key.rs b/crates/turtle/src/command/client/search/keybindings/key.rs index 35107a24..841656d2 100644 --- a/crates/turtle/src/command/client/search/keybindings/key.rs +++ b/crates/turtle/src/command/client/search/keybindings/key.rs @@ -16,6 +16,10 @@ pub(crate) struct SingleKey { /// The key code portion of a key press. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[expect( + variant_size_differences, + reason = "It's not that much. So should be ok?" +)] pub(crate) enum KeyCodeValue { Char(char), Enter, @@ -59,7 +63,7 @@ impl SingleKey { // we store the uppercase char directly and clear the shift flag // since the case already encodes it. if shift && !ctrl && !alt && !super_key && c.is_ascii_uppercase() { - return Some(SingleKey { + return Some(Self { code: KeyCodeValue::Char(c), ctrl: false, alt: false, @@ -74,7 +78,7 @@ impl SingleKey { KeyCode::Tab => KeyCodeValue::Tab, // BackTab is sent by many terminals for Shift+Tab KeyCode::BackTab => { - return Some(SingleKey { + return Some(Self { code: KeyCodeValue::Tab, ctrl, alt, @@ -98,7 +102,7 @@ impl SingleKey { _ => return None, }; - Some(SingleKey { + Some(Self { code, ctrl, alt, @@ -185,7 +189,7 @@ impl SingleKey { let c = chars[0]; // An uppercase letter implies shift (unless shift already specified) if c.is_ascii_uppercase() && !ctrl && !alt && !super_key { - return Ok(SingleKey { + return Ok(Self { code: KeyCodeValue::Char(c), ctrl: false, alt: false, @@ -200,7 +204,7 @@ impl SingleKey { } }; - Ok(SingleKey { + Ok(Self { code, ctrl, alt, @@ -272,9 +276,9 @@ impl KeyInput { if parts.len() > 1 { let keys: Result<Vec<SingleKey>, String> = parts.iter().map(|p| SingleKey::parse(p)).collect(); - Ok(KeyInput::Sequence(keys?)) + Ok(Self::Sequence(keys?)) } else { - Ok(KeyInput::Single(SingleKey::parse(s)?)) + Ok(Self::Single(SingleKey::parse(s)?)) } } } @@ -282,8 +286,8 @@ impl KeyInput { impl fmt::Display for KeyInput { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - KeyInput::Single(k) => write!(f, "{k}"), - KeyInput::Sequence(keys) => { + Self::Single(k) => write!(f, "{k}"), + Self::Sequence(keys) => { for (i, k) in keys.iter().enumerate() { if i > 0 { write!(f, " ")?; @@ -305,7 +309,7 @@ impl Serialize for KeyInput { impl<'de> Deserialize<'de> for KeyInput { fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> { let s = String::deserialize(deserializer)?; - KeyInput::parse(&s).map_err(serde::de::Error::custom) + Self::parse(&s).map_err(serde::de::Error::custom) } } diff --git a/crates/turtle/src/command/mod.rs b/crates/turtle/src/command/mod.rs index 78de2d03..9a648254 100644 --- a/crates/turtle/src/command/mod.rs +++ b/crates/turtle/src/command/mod.rs @@ -101,11 +101,11 @@ fn semantic_command_capture_sink() -> Option<crate::atuin_pty_proxy::CommandCapt }); Some(Box::new(move |capture| { - let _ = tx.try_send(capture); + drop(tx.try_send(capture)); })) } -#[cfg(all(feature = "daemon", feature = "pty-proxy", unix))] +#[cfg(unix)] #[inline] fn is_truthy_env(name: &str) -> bool { std::env::var(name) @@ -114,14 +114,16 @@ fn is_truthy_env(name: &str) -> bool { .is_some_and(|value| !value.trim().is_empty() && value.trim() != "false") } -#[cfg(all(feature = "daemon", feature = "pty-proxy", unix))] +#[cfg(unix)] async fn send_semantic_command_captures( settings: &crate::atuin_client::settings::Settings, batch: Vec<crate::atuin_pty_proxy::CommandCapture>, ) { + use crate::atuin_daemon::generated; + let captures = batch .into_iter() - .map(|capture| crate::atuin_daemon::semantic::CommandCapture { + .map(|capture| generated::semantic::CommandCapture { prompt: capture.prompt, command: capture.command, output: capture.output, @@ -134,6 +136,6 @@ async fn send_semantic_command_captures( .collect(); if let Ok(mut client) = crate::atuin_daemon::SemanticClient::from_settings(settings).await { - let _ = client.record_commands(captures).await; + drop(client.record_commands(captures).await); } } diff --git a/crates/turtle/src/main.rs b/crates/turtle/src/main.rs index 664cf3a6..7ea08a9b 100644 --- a/crates/turtle/src/main.rs +++ b/crates/turtle/src/main.rs @@ -1,8 +1,15 @@ -#![warn(clippy::pedantic, clippy::nursery)] -#![allow(clippy::use_self, clippy::missing_const_for_fn)] // not 100% reliable -// #![deny(unsafe_code)] #![forbid(unsafe_code)] -#![expect(clippy::redundant_pub_crate)] +#![warn(clippy::pedantic, clippy::nursery)] +#![expect( + clippy::missing_const_for_fn, // not 100% reliable + clippy::redundant_pub_crate, +)] +#![expect( + clippy::cast_possible_wrap, + clippy::cast_sign_loss, + clippy::cast_possible_truncation, + reason = "We should remove all of these. But it's just a lot of work in this code-base" +)] use clap::Parser; use clap::builder::Styles; |
